kopiur_kopia/model.rs
1//! Typed models for kopia `--json` output (kopia 0.23).
2//!
3//! These structs are modeled against the *actual* JSON kopia emits, captured by
4//! round-tripping a filesystem repository. Field names match kopia's keys
5//! exactly via `#[serde(rename_all = "camelCase")]` (plus explicit `rename`s
6//! where kopia diverges, e.g. `uniqueIDHex`). None of these use
7//! `deny_unknown_fields`: kopia adds fields across releases and we must tolerate
8//! them. Times are `chrono::DateTime<Utc>`.
9//!
10//! Note on stdout vs stderr: kopia prints its progress (`Snapshotting ...`,
11//! `Restored N files`) to **stderr** and the machine-readable `--json` result
12//! to **stdout**. The client parses stdout only.
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18/// Strip kopia's `tag:` manifest-key prefix from a snapshot `tags` map, yielding the
19/// tags under the keys the CLI was given (`--tags key:value` → manifest `tag:key`).
20///
21/// kopia splits each `--tags` argument on the FIRST colon and stores the key with a
22/// `tag:` prefix in the manifest (verified against the pinned 0.23.1 binary by
23/// `integration_roundtrip::tag_mechanics_...`). Keys without the prefix are kept
24/// verbatim, so a kopia release that dropped the prefix would degrade gracefully
25/// instead of hiding every tag.
26///
27/// ```
28/// use std::collections::BTreeMap;
29/// let mut m = BTreeMap::new();
30/// m.insert("tag:kopiur-meta".to_string(), "{\"schema\":1}".to_string());
31/// m.insert("bare".to_string(), "kept".to_string());
32/// let u = kopiur_kopia::user_tags(&m);
33/// assert_eq!(u.get("kopiur-meta").map(String::as_str), Some("{\"schema\":1}"));
34/// assert_eq!(u.get("bare").map(String::as_str), Some("kept"));
35/// ```
36pub fn user_tags(tags: &BTreeMap<String, String>) -> BTreeMap<String, String> {
37 tags.iter()
38 .map(|(k, v)| (k.strip_prefix("tag:").unwrap_or(k).to_string(), v.clone()))
39 .collect()
40}
41
42/// Kopia's snapshot identity triple: `userName@host:path`. Present on both
43/// snapshot-create results and snapshot-list entries.
44///
45/// kopia's JSON spells the user component `userName`; the typed field is
46/// `user_name`:
47///
48/// ```
49/// use kopiur_kopia::SnapshotSource;
50///
51/// let src: SnapshotSource =
52/// serde_json::from_str(r#"{"host":"prod","userName":"mydb","path":"/data"}"#).unwrap();
53/// assert_eq!(src.user_name, "mydb");
54/// assert_eq!(src.identity(), "mydb@prod:/data");
55/// ```
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct SnapshotSource {
59 /// The kopia "host" component of identity.
60 pub host: String,
61 /// The kopia "user" component of identity. kopia's JSON key is `userName`.
62 pub user_name: String,
63 /// The absolute source path that was snapshotted.
64 pub path: String,
65}
66
67impl SnapshotSource {
68 /// Render kopia's canonical `user@host:path` identity string.
69 pub fn identity(&self) -> String {
70 format!("{}@{}:{}", self.user_name, self.host, self.path)
71 }
72}
73
74/// Directory summary embedded under a root entry (`summ`). Carries the
75/// aggregate counts kopia computed while walking the tree.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct DirSummary {
79 /// Total logical size in bytes.
80 #[serde(default)]
81 pub size: u64,
82 /// Number of files.
83 #[serde(default)]
84 pub files: u64,
85 /// Number of symlinks.
86 #[serde(default)]
87 pub symlinks: u64,
88 /// Number of directories.
89 #[serde(default)]
90 pub dirs: u64,
91 /// Newest mtime found in the tree.
92 #[serde(default, rename = "maxTime")]
93 pub max_time: Option<DateTime<Utc>>,
94 /// Count of entries that failed during the walk **as fatal errors**. NOTE: this is
95 /// `0` when the failures were *ignored* by an `ignore-file-errors`/`ignore-dir-errors`
96 /// policy — in that case the snapshot still completes (exit 0) but the entries are in
97 /// [`DirSummary::errors`], which is the reliable "what got skipped" signal.
98 #[serde(default, rename = "numFailed")]
99 pub num_failed: u64,
100 /// Per-entry errors kopia hit while walking the tree (e.g. `permission denied`).
101 /// Populated whether the errors were fatal (exit 1) OR ignored by policy (exit 0), so
102 /// this is how an otherwise-silent *incomplete* snapshot is detected.
103 #[serde(default)]
104 pub errors: Vec<EntryError>,
105}
106
107/// One `{path, error}` entry from a snapshot's `rootEntry.summ.errors`. Kopia records the
108/// source-relative path and the full error string for every entry it could not include.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct EntryError {
111 /// Source-relative path of the entry that failed (e.g. `secret_dir`).
112 #[serde(default)]
113 pub path: String,
114 /// The full error string (e.g. `... permission denied`).
115 #[serde(default)]
116 pub error: String,
117}
118
119/// The `rootEntry` of a snapshot — the top directory object plus its summary.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct RootEntry {
123 /// Entry name (basename of the snapshotted path).
124 #[serde(default)]
125 pub name: String,
126 /// Entry type, e.g. "d" for directory.
127 #[serde(default, rename = "type")]
128 pub entry_type: String,
129 /// The kopia object id of the root (the `k...` handle).
130 #[serde(default)]
131 pub obj: String,
132 /// Aggregate directory summary. Optional because non-directory roots omit
133 /// it.
134 #[serde(default, rename = "summ")]
135 pub summary: Option<DirSummary>,
136}
137
138/// Result of `kopia snapshot create <path> --json`.
139///
140/// kopia emits a single JSON object on stdout. The aggregate counts live under
141/// `rootEntry.summ`; the create result itself does not carry a top-level
142/// `stats` block (that appears on snapshot-list entries). We surface
143/// convenience accessors for the common stats the mover reports.
144///
145/// Parse a representative kopia create result and read the convenience
146/// accessors that pull from `rootEntry.summ`:
147///
148/// ```
149/// use kopiur_kopia::SnapshotCreateResult;
150///
151/// let json = r#"{
152/// "id": "k9c0ffee",
153/// "source": {"host": "prod", "userName": "mydb", "path": "/data"},
154/// "startTime": "2026-06-02T03:13:59Z",
155/// "endTime": "2026-06-02T03:14:00Z",
156/// "rootEntry": {
157/// "name": "data", "type": "d", "obj": "k1",
158/// "summ": {"size": 4096, "files": 12, "dirs": 3, "numFailed": 1}
159/// }
160/// }"#;
161/// let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
162/// assert_eq!(r.id, "k9c0ffee");
163/// assert_eq!(r.source.identity(), "mydb@prod:/data");
164/// assert_eq!(r.total_bytes(), 4096);
165/// assert_eq!(r.file_count(), 12);
166/// assert_eq!(r.error_count(), 1);
167/// ```
168///
169/// The accessors return `0` when the root summary is absent rather than
170/// panicking:
171///
172/// ```
173/// use kopiur_kopia::SnapshotCreateResult;
174///
175/// let json = r#"{
176/// "id": "k1",
177/// "source": {"host": "h", "userName": "u", "path": "/p"},
178/// "startTime": "2026-06-02T03:13:59Z",
179/// "endTime": "2026-06-02T03:14:00Z"
180/// }"#;
181/// let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
182/// assert_eq!(r.total_bytes(), 0);
183/// assert_eq!(r.file_count(), 0);
184/// ```
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct SnapshotCreateResult {
188 /// The new snapshot's manifest id.
189 pub id: String,
190 /// Identity of the snapshot.
191 pub source: SnapshotSource,
192 /// Free-form description (usually empty).
193 #[serde(default)]
194 pub description: String,
195 /// When the snapshot started.
196 pub start_time: DateTime<Utc>,
197 /// When the snapshot finished.
198 pub end_time: DateTime<Utc>,
199 /// Root directory entry with its summary.
200 #[serde(default)]
201 pub root_entry: Option<RootEntry>,
202 /// Snapshot tags as stored on the manifest — keys carry kopia's `tag:` prefix
203 /// (strip with [`user_tags`]). Empty for untagged snapshots and on kopia
204 /// versions that omit the field; an empty map is elided on re-serialization
205 /// (these types also ride the mover result wire).
206 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207 pub tags: BTreeMap<String, String>,
208}
209
210/// What a `kopia snapshot create` actually did.
211///
212/// Two outcomes, both successes, and they are NOT interchangeable:
213///
214/// * [`Created`](Self::Created) — a new manifest exists and this run owns it.
215/// * [`Unchanged`](Self::Unchanged) — kopia declined to write one because the
216/// source is byte-identical to the previous snapshot (its retention knob
217/// `ignoreIdenticalSnapshots`). There is **no new manifest**, and crucially
218/// nothing this run may claim: the newest manifest for this identity belongs
219/// to the *previous* `Snapshot` CR, which owns it via a finalizer and will
220/// delete it when retention prunes that CR.
221///
222/// Modelled as an enum rather than `Option<SnapshotCreateResult>` so the
223/// distinction cannot be `unwrap_or_default`-ed away. Collapsing it is exactly
224/// the bug: a deduped run recorded as an ordinary success goes looking for
225/// "its" snapshot, finds its predecessor's, and two CRs end up claiming one
226/// manifest (#351).
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum SnapshotCreateOutcome {
229 /// kopia wrote a new manifest; this run owns it.
230 ///
231 /// Boxed so the enum stays small: `SnapshotCreateResult` is ~320 bytes and
232 /// `Unchanged` is zero-sized, and every caller moves the result out
233 /// immediately.
234 Created(Box<SnapshotCreateResult>),
235 /// kopia deduped: nothing changed, so no manifest was written and this run
236 /// owns nothing. The previous snapshot remains the live restore point.
237 Unchanged,
238}
239
240impl SnapshotCreateResult {
241 /// Total logical bytes in the snapshot, from the root summary (0 if absent).
242 pub fn total_bytes(&self) -> u64 {
243 self.root_entry
244 .as_ref()
245 .and_then(|r| r.summary.as_ref())
246 .map(|s| s.size)
247 .unwrap_or(0)
248 }
249
250 /// Total file count in the snapshot, from the root summary (0 if absent).
251 pub fn file_count(&self) -> u64 {
252 self.root_entry
253 .as_ref()
254 .and_then(|r| r.summary.as_ref())
255 .map(|s| s.files)
256 .unwrap_or(0)
257 }
258
259 /// Number of entries that failed during the walk (0 if absent).
260 pub fn error_count(&self) -> u64 {
261 self.root_entry
262 .as_ref()
263 .and_then(|r| r.summary.as_ref())
264 .map(|s| s.num_failed)
265 .unwrap_or(0)
266 }
267
268 /// The per-entry errors kopia recorded (empty if none / absent). Non-empty even when
269 /// the errors were *ignored* by policy (exit 0) — the canonical signal that a snapshot
270 /// is **incomplete** (some source entries were skipped).
271 pub fn entry_errors(&self) -> &[EntryError] {
272 self.root_entry
273 .as_ref()
274 .and_then(|r| r.summary.as_ref())
275 .map(|s| s.errors.as_slice())
276 .unwrap_or(&[])
277 }
278}
279
280/// The `stats` block present on each `kopia snapshot list --json` entry. These
281/// are the new/modified/unchanged-style counters.
282#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct SnapshotStats {
285 /// Total logical size of all included files.
286 #[serde(default)]
287 pub total_size: u64,
288 /// Size excluded by policy.
289 #[serde(default)]
290 pub excluded_total_size: u64,
291 /// Number of files included.
292 #[serde(default)]
293 pub file_count: u64,
294 /// Files served from cache (unchanged since the prior snapshot).
295 #[serde(default)]
296 pub cached_files: u64,
297 /// Files re-read because they were new or modified.
298 #[serde(default)]
299 pub non_cached_files: u64,
300 /// Number of directories.
301 #[serde(default)]
302 pub dir_count: u64,
303 /// Files excluded by policy.
304 #[serde(default)]
305 pub excluded_file_count: u64,
306 /// Directories excluded by policy.
307 #[serde(default)]
308 pub excluded_dir_count: u64,
309 /// Errors that were ignored (per ignore-error policy).
310 #[serde(default)]
311 pub ignored_error_count: u64,
312 /// Hard errors encountered.
313 #[serde(default)]
314 pub error_count: u64,
315}
316
317/// One entry from `kopia snapshot list --json`.
318///
319/// Unlike the create result, list entries carry a top-level `stats` block and a
320/// `retentionReason` array (the kopia GFS classes keeping the snapshot alive):
321///
322/// ```
323/// use kopiur_kopia::SnapshotListEntry;
324///
325/// let json = r#"{
326/// "id": "k1",
327/// "source": {"host": "prod", "userName": "mydb", "path": "/data"},
328/// "startTime": "2026-06-02T03:13:59Z",
329/// "endTime": "2026-06-02T03:14:00Z",
330/// "stats": {"totalSize": 4096, "fileCount": 12, "errorCount": 0},
331/// "retentionReason": ["latest-1", "daily-1"]
332/// }"#;
333/// let entry: SnapshotListEntry = serde_json::from_str(json).unwrap();
334/// assert_eq!(entry.id, "k1");
335/// assert_eq!(entry.stats.total_size, 4096);
336/// assert_eq!(entry.stats.file_count, 12);
337/// assert_eq!(entry.retention_reason, vec!["latest-1", "daily-1"]);
338/// ```
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "camelCase")]
341pub struct SnapshotListEntry {
342 /// The snapshot manifest id.
343 pub id: String,
344 /// Identity of the snapshot.
345 pub source: SnapshotSource,
346 /// Free-form description.
347 #[serde(default)]
348 pub description: String,
349 /// When the snapshot started.
350 pub start_time: DateTime<Utc>,
351 /// When the snapshot finished.
352 pub end_time: DateTime<Utc>,
353 /// Per-snapshot statistics.
354 #[serde(default)]
355 pub stats: SnapshotStats,
356 /// Root directory entry.
357 #[serde(default)]
358 pub root_entry: Option<RootEntry>,
359 /// Why this snapshot is being retained (kopia GFS reasons such as
360 /// `latest-1`, `daily-1`). Empty for snapshots outside any retention class.
361 #[serde(default)]
362 pub retention_reason: Vec<String>,
363 /// Snapshot tags as stored on the manifest — keys carry kopia's `tag:` prefix
364 /// (strip with [`user_tags`]). Empty for untagged snapshots and on kopia
365 /// versions that omit the field; an empty map is elided on re-serialization
366 /// (these types also ride the mover result wire).
367 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
368 pub tags: BTreeMap<String, String>,
369}
370
371/// One entry of a kopia directory manifest (`kopia show <dir-object-id>`).
372///
373/// Files carry a top-level `size`; directories instead embed an aggregate
374/// [`DirSummaryLite`] under `summ`. All fields beyond name/type/obj are
375/// optional because kopia omits what doesn't apply to the entry type.
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "camelCase")]
378pub struct DirEntry {
379 /// Entry name (a single path component, never a path).
380 pub name: String,
381 /// Entry type: `"d"` (directory), `"f"` (file), `"s"` (symlink).
382 #[serde(rename = "type")]
383 pub entry_type: String,
384 /// The kopia object id backing the entry — a directory's `obj` can be
385 /// `kopia show`n for the next manifest level; a file's streams its bytes.
386 pub obj: String,
387 /// File size in bytes (files only; directories report sizes via `summ`).
388 #[serde(default)]
389 pub size: Option<i64>,
390 /// Modification time as kopia rendered it (RFC3339; kept as a string so an
391 /// unusual kopia rendering can never fail the whole manifest parse).
392 #[serde(default)]
393 pub mtime: Option<String>,
394 /// Unix permission bits as an octal string (e.g. `"0755"`).
395 #[serde(default)]
396 pub mode: Option<String>,
397 /// Aggregate subtree summary (directories only).
398 #[serde(default)]
399 pub summ: Option<DirSummaryLite>,
400}
401
402/// The aggregate subtree counters under a directory entry's `summ` block —
403/// the subset of [`DirSummary`] a directory listing renders. All optional:
404/// kopia may extend or omit counters across releases.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(rename_all = "camelCase")]
407pub struct DirSummaryLite {
408 /// Total logical size of the subtree in bytes.
409 #[serde(default)]
410 pub size: Option<i64>,
411 /// Number of files in the subtree.
412 #[serde(default)]
413 pub files: Option<i64>,
414 /// Number of directories in the subtree.
415 #[serde(default)]
416 pub dirs: Option<i64>,
417}
418
419/// A kopia directory manifest: what `kopia show <dir-object-id>` emits —
420/// `{"stream":"kopia:directory","entries":[…]}`. (`kopia show` on a *file*
421/// object streams the file's raw bytes instead, so callers must check
422/// [`DirEntry::entry_type`] before showing an object as a directory.)
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct DirManifest {
426 /// The manifest stream marker; `"kopia:directory"` for directory objects.
427 pub stream: String,
428 /// The directory's entries, in kopia's on-manifest order.
429 #[serde(default)]
430 pub entries: Vec<DirEntry>,
431}
432
433/// Client identity options reported by `kopia repository status`.
434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct ClientOptions {
437 /// The configured hostname for this client.
438 #[serde(default)]
439 pub hostname: String,
440 /// The configured username for this client.
441 #[serde(default)]
442 pub username: String,
443 /// Human-readable repository description.
444 #[serde(default)]
445 pub description: String,
446 /// Whether snapshot actions are enabled.
447 #[serde(default)]
448 pub enable_actions: bool,
449}
450
451/// Storage backend block from `kopia repository status`. `config` is left as a
452/// raw JSON value because its shape is backend-specific (filesystem path vs S3
453/// bucket/endpoint vs ...).
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455#[serde(rename_all = "camelCase")]
456pub struct StorageInfo {
457 /// Backend type, e.g. "filesystem", "s3", "gcs".
458 #[serde(default, rename = "type")]
459 pub storage_type: String,
460 /// Backend-specific configuration, opaque here.
461 #[serde(default)]
462 pub config: serde_json::Value,
463}
464
465/// Content format block from `kopia repository status`.
466#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct ContentFormat {
469 /// Hash algorithm, e.g. "BLAKE2B-256-128".
470 #[serde(default)]
471 pub hash: String,
472 /// Encryption algorithm, e.g. "AES256-GCM-HMAC-SHA256".
473 #[serde(default)]
474 pub encryption: String,
475 /// Repository format version.
476 #[serde(default)]
477 pub version: u32,
478 /// Epoch-manager parameters, when the repository uses the epoch index (format v2+).
479 /// Absent on older formats, hence `Option`.
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub epoch_parameters: Option<EpochParameters>,
482}
483
484/// kopia's epoch-manager parameters, as reported inside
485/// `repository status --json` → `contentFormat.epochParameters`.
486///
487/// **This block does not follow the `rename_all = "camelCase"` convention the rest of this
488/// module uses** — kopia serializes the Go struct's field names verbatim, so the keys are
489/// PascalCase with no consistent rule between them (`MinEpochDuration` vs
490/// `EpochRefreshFrequency` vs `FullCheckpointFrequency` vs `DeleteParallelism`). Hence the
491/// explicit per-field renames; `crates/kopia/tests/fixtures/repository_status.json` carries
492/// real kopia 0.23 output and pins the shape.
493///
494/// Units are Go-native and not what the flag names suggest:
495/// - durations are `time.Duration` **nanoseconds** (as [`MaintenanceCadence::interval`] is);
496/// - `EpochAdvanceOnTotalSizeBytesThreshold` is **bytes**, and the `--epoch-advance-on-size-mb`
497/// flag that sets it means **MiB** — `7` yields `7340032` (7 × 1048576), even though kopia's
498/// own log line renders it as "7.3 MB".
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
500pub struct EpochParameters {
501 /// Whether the epoch manager is enabled on this repository.
502 #[serde(rename = "Enabled", default)]
503 pub enabled: bool,
504 /// Minimum epoch age before it may advance, in nanoseconds (kopia default 24h).
505 #[serde(rename = "MinEpochDuration", default)]
506 pub min_epoch_duration_ns: i64,
507 /// How often clients re-read epoch state, in nanoseconds (kopia default 20m).
508 #[serde(rename = "EpochRefreshFrequency", default)]
509 pub epoch_refresh_frequency_ns: i64,
510 /// Grace window protecting index blobs a concurrent writer may still need, in
511 /// nanoseconds (kopia default 4h).
512 #[serde(rename = "CleanupSafetyMargin", default)]
513 pub cleanup_safety_margin_ns: i64,
514 /// Index-blob count that triggers an epoch advance (kopia default 20).
515 #[serde(rename = "EpochAdvanceOnCountThreshold", default)]
516 pub advance_on_count: i64,
517 /// Total index size in **bytes** that triggers an epoch advance (kopia default 10 MiB).
518 #[serde(rename = "EpochAdvanceOnTotalSizeBytesThreshold", default)]
519 pub advance_on_total_size_bytes: i64,
520 /// Epochs between full index checkpoints (kopia default 7).
521 #[serde(rename = "FullCheckpointFrequency", default)]
522 pub checkpoint_frequency: i64,
523 /// Parallelism for epoch-cleanup deletions (kopia default 4).
524 #[serde(rename = "DeleteParallelism", default)]
525 pub delete_parallelism: i64,
526}
527
528/// Result of `kopia repository status --json`.
529///
530/// The repository's stable identity is `uniqueIDHex` (kopia's JSON key, hence
531/// the explicit rename). We keep the high-value fields typed and leave the rest
532/// (volume capacity, object format, epoch params) for future expansion without
533/// breaking on unknown fields.
534///
535/// Parse a trimmed status object — note the `uniqueIDHex` key maps to
536/// `unique_id_hex`, and unknown fields (here `extraFutureField`) are tolerated:
537///
538/// ```
539/// use kopiur_kopia::RepositoryStatus;
540///
541/// let json = r#"{
542/// "configFile": "/config/repository.config",
543/// "uniqueIDHex": "deadbeef",
544/// "clientOptions": {"hostname": "prod", "username": "mydb"},
545/// "storage": {"type": "s3", "config": {"bucket": "backups"}},
546/// "contentFormat": {"hash": "BLAKE2B-256-128", "encryption": "AES256-GCM-HMAC-SHA256", "version": 3},
547/// "extraFutureField": 42
548/// }"#;
549/// let status: RepositoryStatus = serde_json::from_str(json).unwrap();
550/// assert_eq!(status.unique_id_hex, "deadbeef");
551/// assert_eq!(status.storage.storage_type, "s3");
552/// assert_eq!(status.content_format.version, 3);
553/// assert_eq!(status.client_options.username, "mydb");
554/// ```
555#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
556#[serde(rename_all = "camelCase")]
557pub struct RepositoryStatus {
558 /// Path to the local repository config file.
559 #[serde(default)]
560 pub config_file: String,
561 /// The repository's stable unique id. kopia's key is `uniqueIDHex`.
562 #[serde(default, rename = "uniqueIDHex")]
563 pub unique_id_hex: String,
564 /// Client identity options.
565 pub client_options: ClientOptions,
566 /// Storage backend info.
567 pub storage: StorageInfo,
568 /// Content format (hash/encryption/version).
569 pub content_format: ContentFormat,
570 /// Object-lock blob retention, from kopia's `kopia.blobcfg` blob.
571 ///
572 /// **Top-level**, unlike the epoch parameters nested under `contentFormat` — do not
573 /// follow that pattern here. `omitempty` on both inner keys means kopia emits `{}` when
574 /// retention is off, so an all-default value is the "disabled" observation, not a
575 /// missing one.
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub blob_retention: Option<BlobRetention>,
578}
579
580/// Blob retention as reported by `kopia repository status --json` → `blobRetention`.
581///
582/// Mirrors kopia's `format.BlobStorageConfiguration`. Unlike the sibling [`EpochParameters`],
583/// the keys here **are** camelCase (kopia gives that struct explicit JSON tags), and unlike
584/// it this type cannot be `Copy` — it carries a `String`.
585///
586/// `retentionPeriod` is a Go `time.Duration`, so the unit is **nanoseconds** (the same
587/// convention as [`MaintenanceCadence::interval`]), not seconds.
588#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
589pub struct BlobRetention {
590 /// `GOVERNANCE`, `COMPLIANCE`, or empty when retention is off.
591 #[serde(rename = "retentionMode", default)]
592 pub mode: String,
593 /// Retention period in **nanoseconds**; `0` when retention is off.
594 #[serde(rename = "retentionPeriod", default)]
595 pub period_ns: i64,
596}
597
598impl BlobRetention {
599 /// Whether retention is actually in force. Mirrors kopia's own `IsRetentionEnabled()`:
600 /// a non-empty mode AND a non-zero period — kopia treats a half-set config as off.
601 pub fn is_enabled(&self) -> bool {
602 !self.mode.is_empty() && self.period_ns != 0
603 }
604}
605
606/// A maintenance cadence block (`quick` / `full`) from `maintenance info`.
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608#[serde(rename_all = "camelCase")]
609pub struct MaintenanceCadence {
610 /// Whether this maintenance class is enabled.
611 #[serde(default)]
612 pub enabled: bool,
613 /// Interval between runs, in nanoseconds (kopia's Go `time.Duration`).
614 #[serde(default)]
615 pub interval: i64,
616}
617
618/// The `schedule` block: when maintenance next runs. The detailed per-task
619/// `runs` history is left as a raw value (its shape is large and unstable).
620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
621#[serde(rename_all = "camelCase")]
622pub struct MaintenanceSchedule {
623 /// Next scheduled full maintenance, if known.
624 #[serde(default)]
625 pub next_full_maintenance: Option<DateTime<Utc>>,
626 /// Next scheduled quick maintenance, if known.
627 #[serde(default)]
628 pub next_quick_maintenance: Option<DateTime<Utc>>,
629}
630
631/// Result of `kopia maintenance info --json`.
632///
633/// `interval` is kopia's Go `time.Duration` in nanoseconds; the `schedule` block
634/// is optional:
635///
636/// ```
637/// use kopiur_kopia::MaintenanceInfo;
638///
639/// let json = r#"{
640/// "owner": "mydb@prod",
641/// "quick": {"enabled": true, "interval": 3600000000000},
642/// "full": {"enabled": false, "interval": 0}
643/// }"#;
644/// let info: MaintenanceInfo = serde_json::from_str(json).unwrap();
645/// assert_eq!(info.owner, "mydb@prod");
646/// assert!(info.quick.enabled);
647/// assert_eq!(info.quick.interval, 3_600_000_000_000); // 1h in nanos
648/// assert!(!info.full.enabled);
649/// assert!(info.schedule.is_none());
650/// ```
651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
652#[serde(rename_all = "camelCase")]
653pub struct MaintenanceInfo {
654 /// The `user@host` that owns the maintenance lease.
655 #[serde(default)]
656 pub owner: String,
657 /// Quick maintenance cadence.
658 pub quick: MaintenanceCadence,
659 /// Full maintenance cadence.
660 pub full: MaintenanceCadence,
661 /// Schedule with next-run timestamps.
662 #[serde(default)]
663 pub schedule: Option<MaintenanceSchedule>,
664}
665
666/// One entry from `kopia index list --json` — a single content-index blob.
667///
668/// kopia's index is a set of these blobs; periodic maintenance compacts them.
669/// When maintenance stops running (e.g. a stale lease owner), the count grows
670/// unbounded and kopia eventually warns "Found too many index blobs (N)". We
671/// only need the COUNT (the length of the array), so we keep just enough fields
672/// to make a meaningful, future-proof model and let serde ignore the rest
673/// (`timestamp`, `Superseded`, …).
674///
675/// ```
676/// use kopiur_kopia::IndexBlobEntry;
677///
678/// let json = r#"{"id":"xn0_5ce…-c1","length":143,"timestamp":"2026-06-15T22:30:50Z","Superseded":null}"#;
679/// let e: IndexBlobEntry = serde_json::from_str(json).unwrap();
680/// assert_eq!(e.length, 143);
681/// ```
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683#[serde(rename_all = "camelCase")]
684pub struct IndexBlobEntry {
685 /// The index blob's id (kopia's `id`, e.g. `xn0_…-c1`).
686 #[serde(default)]
687 pub id: String,
688 /// On-disk length of the index blob in bytes.
689 #[serde(default)]
690 pub length: i64,
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn entry_errors_parse_from_ignored_error_snapshot() {
699 // The exact shape kopia 0.23.1 emits when ignore-file-errors is set: exit 0,
700 // numFailed=0, but summ.errors[] still lists every skipped entry (verified
701 // empirically against a uid-2000 0600 tree snapshotted as uid 65532).
702 let json = r#"{
703 "id": "k1",
704 "source": {"host": "h", "userName": "u", "path": "/pvc"},
705 "startTime": "2026-06-02T03:13:59Z",
706 "endTime": "2026-06-02T03:14:00Z",
707 "rootEntry": {
708 "name": "pvc", "type": "d", "obj": "k1",
709 "summ": {"size": 7, "files": 2, "dirs": 2, "numFailed": 0, "errors": [
710 {"path": "secret_dir", "error": "cannot create iterator: unable to read directory: open /pvc/secret_dir: permission denied"},
711 {"path": "topsecret.txt", "error": "unable to open file: open /pvc/topsecret.txt: permission denied"}
712 ]}
713 }
714 }"#;
715 let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
716 // numFailed is 0 (errors were ignored by policy) — so the COUNT must come from
717 // entry_errors(), not error_count(), or the incomplete snapshot stays silent.
718 assert_eq!(r.error_count(), 0);
719 assert_eq!(r.entry_errors().len(), 2);
720 assert_eq!(r.entry_errors()[0].path, "secret_dir");
721 assert!(r.entry_errors()[1].error.contains("permission denied"));
722 }
723
724 #[test]
725 fn entry_errors_empty_on_clean_snapshot() {
726 let json = r#"{
727 "id": "k1", "source": {"host": "h", "userName": "u", "path": "/p"},
728 "startTime": "2026-06-02T03:13:59Z", "endTime": "2026-06-02T03:14:00Z",
729 "rootEntry": {"name": "p", "type": "d", "obj": "k1", "summ": {"size": 4096, "files": 12}}
730 }"#;
731 let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
732 assert!(r.entry_errors().is_empty());
733 }
734
735 #[test]
736 fn index_blob_list_counts_entries() {
737 // The real shape emitted by `kopia index list --json` (verified against
738 // kopia 0.23.0): a JSON array of entries. The count is the array length.
739 let json = r#"[
740 {"id":"xn0_aaa-c1","length":143,"timestamp":"2026-06-15T22:30:50.3-07:00","Superseded":null},
741 {"id":"xn0_bbb-c1","length":201,"timestamp":"2026-06-15T22:31:01.1-07:00","Superseded":null}
742 ]"#;
743 let entries: Vec<IndexBlobEntry> = serde_json::from_str(json).unwrap();
744 assert_eq!(entries.len(), 2);
745 assert_eq!(entries[0].id, "xn0_aaa-c1");
746 assert_eq!(entries[1].length, 201);
747 }
748
749 #[test]
750 fn index_blob_list_empty_is_zero() {
751 let entries: Vec<IndexBlobEntry> = serde_json::from_str("[]").unwrap();
752 assert_eq!(entries.len(), 0);
753 }
754
755 #[test]
756 fn snapshot_source_identity() {
757 let s = SnapshotSource {
758 host: "h".into(),
759 user_name: "u".into(),
760 path: "/p".into(),
761 };
762 assert_eq!(s.identity(), "u@h:/p");
763 }
764
765 // --- DirManifest: the EXACT `kopia show <dir-oid>` JSON shape, verified
766 // against kopia 0.23. ---
767
768 #[test]
769 fn dir_manifest_parses_the_verified_kopia_show_shape() {
770 let json = r#"{
771 "stream": "kopia:directory",
772 "entries": [
773 {
774 "name": "sub",
775 "type": "d",
776 "mode": "0755",
777 "mtime": "2026-06-10T12:00:00Z",
778 "obj": "kdeadbeef",
779 "summ": {"size": 7, "files": 1, "dirs": 1, "maxTime": "2026-06-10T12:00:00Z"}
780 },
781 {
782 "name": "a.txt",
783 "type": "f",
784 "mode": "0644",
785 "size": 6,
786 "mtime": "2026-06-10T11:59:00Z",
787 "obj": "1f00dcafe"
788 }
789 ],
790 "summary": {"size": 13, "files": 2, "dirs": 2}
791 }"#;
792 let m: DirManifest = serde_json::from_str(json).unwrap();
793 assert_eq!(m.stream, "kopia:directory");
794 assert_eq!(m.entries.len(), 2);
795
796 // Directory entry: no top-level size, aggregate counters under summ.
797 let dir = &m.entries[0];
798 assert_eq!(dir.name, "sub");
799 assert_eq!(dir.entry_type, "d");
800 assert_eq!(dir.obj, "kdeadbeef");
801 assert_eq!(dir.mode.as_deref(), Some("0755"));
802 assert_eq!(dir.size, None);
803 let summ = dir.summ.as_ref().expect("directory summ");
804 assert_eq!(summ.size, Some(7));
805 assert_eq!(summ.files, Some(1));
806 assert_eq!(summ.dirs, Some(1));
807
808 // File entry: top-level size, no summ.
809 let file = &m.entries[1];
810 assert_eq!(file.name, "a.txt");
811 assert_eq!(file.entry_type, "f");
812 assert_eq!(file.obj, "1f00dcafe");
813 assert_eq!(file.size, Some(6));
814 assert_eq!(file.mtime.as_deref(), Some("2026-06-10T11:59:00Z"));
815 assert!(file.summ.is_none());
816 }
817
818 #[test]
819 fn dir_manifest_tolerates_missing_entries_and_unknown_fields() {
820 // An empty directory manifest has no `entries`; future kopia fields
821 // (and the unmapped top-level `summary`) must not fail the parse.
822 let m: DirManifest =
823 serde_json::from_str(r#"{"stream": "kopia:directory", "futureField": 1}"#).unwrap();
824 assert_eq!(m.stream, "kopia:directory");
825 assert!(m.entries.is_empty());
826 }
827}