Skip to main content

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    /// kopia's `IncompleteReason` mirrored into the root summary (`checkpoint`,
106    /// `canceled`, …). See [`SnapshotListEntry::incomplete_reason`].
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub incomplete: Option<String>,
109}
110
111/// One `{path, error}` entry from a snapshot's `rootEntry.summ.errors`. Kopia records the
112/// source-relative path and the full error string for every entry it could not include.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct EntryError {
115    /// Source-relative path of the entry that failed (e.g. `secret_dir`).
116    #[serde(default)]
117    pub path: String,
118    /// The full error string (e.g. `... permission denied`).
119    #[serde(default)]
120    pub error: String,
121}
122
123/// The `rootEntry` of a snapshot — the top directory object plus its summary.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct RootEntry {
127    /// Entry name (basename of the snapshotted path).
128    #[serde(default)]
129    pub name: String,
130    /// Entry type, e.g. "d" for directory.
131    #[serde(default, rename = "type")]
132    pub entry_type: String,
133    /// The kopia object id of the root (the `k...` handle).
134    #[serde(default)]
135    pub obj: String,
136    /// Aggregate directory summary. Optional because non-directory roots omit
137    /// it.
138    #[serde(default, rename = "summ")]
139    pub summary: Option<DirSummary>,
140}
141
142/// Result of `kopia snapshot create <path> --json`.
143///
144/// kopia emits a single JSON object on stdout. The aggregate counts live under
145/// `rootEntry.summ`; the create result itself does not carry a top-level
146/// `stats` block (that appears on snapshot-list entries). We surface
147/// convenience accessors for the common stats the mover reports.
148///
149/// Parse a representative kopia create result and read the convenience
150/// accessors that pull from `rootEntry.summ`:
151///
152/// ```
153/// use kopiur_kopia::SnapshotCreateResult;
154///
155/// let json = r#"{
156///     "id": "k9c0ffee",
157///     "source": {"host": "prod", "userName": "mydb", "path": "/data"},
158///     "startTime": "2026-06-02T03:13:59Z",
159///     "endTime": "2026-06-02T03:14:00Z",
160///     "rootEntry": {
161///         "name": "data", "type": "d", "obj": "k1",
162///         "summ": {"size": 4096, "files": 12, "dirs": 3, "numFailed": 1}
163///     }
164/// }"#;
165/// let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
166/// assert_eq!(r.id, "k9c0ffee");
167/// assert_eq!(r.source.identity(), "mydb@prod:/data");
168/// assert_eq!(r.total_bytes(), 4096);
169/// assert_eq!(r.file_count(), 12);
170/// assert_eq!(r.error_count(), 1);
171/// ```
172///
173/// The accessors return `0` when the root summary is absent rather than
174/// panicking:
175///
176/// ```
177/// use kopiur_kopia::SnapshotCreateResult;
178///
179/// let json = r#"{
180///     "id": "k1",
181///     "source": {"host": "h", "userName": "u", "path": "/p"},
182///     "startTime": "2026-06-02T03:13:59Z",
183///     "endTime": "2026-06-02T03:14:00Z"
184/// }"#;
185/// let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
186/// assert_eq!(r.total_bytes(), 0);
187/// assert_eq!(r.file_count(), 0);
188/// ```
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct SnapshotCreateResult {
192    /// The new snapshot's manifest id.
193    pub id: String,
194    /// Identity of the snapshot.
195    pub source: SnapshotSource,
196    /// Free-form description (usually empty).
197    #[serde(default)]
198    pub description: String,
199    /// When the snapshot started.
200    pub start_time: DateTime<Utc>,
201    /// When the snapshot finished.
202    pub end_time: DateTime<Utc>,
203    /// Root directory entry with its summary.
204    #[serde(default)]
205    pub root_entry: Option<RootEntry>,
206    /// Snapshot tags as stored on the manifest — keys carry kopia's `tag:` prefix
207    /// (strip with [`user_tags`]). Empty for untagged snapshots and on kopia
208    /// versions that omit the field; an empty map is elided on re-serialization
209    /// (these types also ride the mover result wire).
210    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211    pub tags: BTreeMap<String, String>,
212}
213
214/// What a `kopia snapshot create` actually did.
215///
216/// Two outcomes, both successes, and they are NOT interchangeable:
217///
218/// * [`Created`](Self::Created) — a new manifest exists and this run owns it.
219/// * [`Unchanged`](Self::Unchanged) — kopia declined to write one because the
220///   source is byte-identical to the previous snapshot (its retention knob
221///   `ignoreIdenticalSnapshots`). There is **no new manifest**, and crucially
222///   nothing this run may claim: the newest manifest for this identity belongs
223///   to the *previous* `Snapshot` CR, which owns it via a finalizer and will
224///   delete it when retention prunes that CR.
225///
226/// Modelled as an enum rather than `Option<SnapshotCreateResult>` so the
227/// distinction cannot be `unwrap_or_default`-ed away. Collapsing it is exactly
228/// the bug: a deduped run recorded as an ordinary success goes looking for
229/// "its" snapshot, finds its predecessor's, and two CRs end up claiming one
230/// manifest (#351).
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum SnapshotCreateOutcome {
233    /// kopia wrote a new manifest; this run owns it.
234    ///
235    /// Boxed so the enum stays small: `SnapshotCreateResult` is ~320 bytes and
236    /// `Unchanged` is zero-sized, and every caller moves the result out
237    /// immediately.
238    Created(Box<SnapshotCreateResult>),
239    /// kopia deduped: nothing changed, so no manifest was written and this run
240    /// owns nothing. The previous snapshot remains the live restore point.
241    Unchanged,
242}
243
244impl SnapshotCreateResult {
245    /// Total logical bytes in the snapshot, from the root summary (0 if absent).
246    pub fn total_bytes(&self) -> u64 {
247        self.root_entry
248            .as_ref()
249            .and_then(|r| r.summary.as_ref())
250            .map(|s| s.size)
251            .unwrap_or(0)
252    }
253
254    /// Total file count in the snapshot, from the root summary (0 if absent).
255    pub fn file_count(&self) -> u64 {
256        self.root_entry
257            .as_ref()
258            .and_then(|r| r.summary.as_ref())
259            .map(|s| s.files)
260            .unwrap_or(0)
261    }
262
263    /// Number of entries that failed during the walk (0 if absent).
264    pub fn error_count(&self) -> u64 {
265        self.root_entry
266            .as_ref()
267            .and_then(|r| r.summary.as_ref())
268            .map(|s| s.num_failed)
269            .unwrap_or(0)
270    }
271
272    /// The per-entry errors kopia recorded (empty if none / absent). Non-empty even when
273    /// the errors were *ignored* by policy (exit 0) — the canonical signal that a snapshot
274    /// is **incomplete** (some source entries were skipped).
275    pub fn entry_errors(&self) -> &[EntryError] {
276        self.root_entry
277            .as_ref()
278            .and_then(|r| r.summary.as_ref())
279            .map(|s| s.errors.as_slice())
280            .unwrap_or(&[])
281    }
282}
283
284/// The `stats` block present on each `kopia snapshot list --json` entry. These
285/// are the new/modified/unchanged-style counters.
286#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct SnapshotStats {
289    /// Total logical size of all included files.
290    #[serde(default)]
291    pub total_size: u64,
292    /// Size excluded by policy.
293    #[serde(default)]
294    pub excluded_total_size: u64,
295    /// Number of files included.
296    #[serde(default)]
297    pub file_count: u64,
298    /// Files served from cache (unchanged since the prior snapshot).
299    #[serde(default)]
300    pub cached_files: u64,
301    /// Files re-read because they were new or modified.
302    #[serde(default)]
303    pub non_cached_files: u64,
304    /// Number of directories.
305    #[serde(default)]
306    pub dir_count: u64,
307    /// Files excluded by policy.
308    #[serde(default)]
309    pub excluded_file_count: u64,
310    /// Directories excluded by policy.
311    #[serde(default)]
312    pub excluded_dir_count: u64,
313    /// Errors that were ignored (per ignore-error policy).
314    #[serde(default)]
315    pub ignored_error_count: u64,
316    /// Hard errors encountered.
317    #[serde(default)]
318    pub error_count: u64,
319}
320
321/// One entry from `kopia snapshot list --json`.
322///
323/// Unlike the create result, list entries carry a top-level `stats` block and a
324/// `retentionReason` array (the kopia GFS classes keeping the snapshot alive):
325///
326/// ```
327/// use kopiur_kopia::SnapshotListEntry;
328///
329/// let json = r#"{
330///     "id": "k1",
331///     "source": {"host": "prod", "userName": "mydb", "path": "/data"},
332///     "startTime": "2026-06-02T03:13:59Z",
333///     "endTime": "2026-06-02T03:14:00Z",
334///     "stats": {"totalSize": 4096, "fileCount": 12, "errorCount": 0},
335///     "retentionReason": ["latest-1", "daily-1"]
336/// }"#;
337/// let entry: SnapshotListEntry = serde_json::from_str(json).unwrap();
338/// assert_eq!(entry.id, "k1");
339/// assert_eq!(entry.stats.total_size, 4096);
340/// assert_eq!(entry.stats.file_count, 12);
341/// assert_eq!(entry.retention_reason, vec!["latest-1", "daily-1"]);
342/// ```
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct SnapshotListEntry {
346    /// The snapshot manifest id.
347    pub id: String,
348    /// Identity of the snapshot.
349    pub source: SnapshotSource,
350    /// Free-form description.
351    #[serde(default)]
352    pub description: String,
353    /// When the snapshot started.
354    pub start_time: DateTime<Utc>,
355    /// When the snapshot finished.
356    pub end_time: DateTime<Utc>,
357    /// Per-snapshot statistics.
358    #[serde(default)]
359    pub stats: SnapshotStats,
360    /// Root directory entry.
361    #[serde(default)]
362    pub root_entry: Option<RootEntry>,
363    /// Why this snapshot is being retained (kopia GFS reasons such as
364    /// `latest-1`, `daily-1`). Empty for snapshots outside any retention class.
365    #[serde(default)]
366    pub retention_reason: Vec<String>,
367    /// Snapshot tags as stored on the manifest — keys carry kopia's `tag:` prefix
368    /// (strip with [`user_tags`]). Empty for untagged snapshots and on kopia
369    /// versions that omit the field; an empty map is elided on re-serialization
370    /// (these types also ride the mover result wire).
371    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
372    pub tags: BTreeMap<String, String>,
373    /// kopia's `IncompleteReason`: set (`checkpoint`, `canceled`, …) on a
374    /// manifest an interrupted `snapshot create` left behind. kopia's JSON
375    /// listing emits these even WITHOUT `--incomplete` (that flag only filters
376    /// the text output) — read it through [`Self::incomplete_reason`].
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub incomplete: Option<String>,
379}
380
381impl SnapshotListEntry {
382    /// Why this manifest is NOT a complete snapshot, or `None` for a complete
383    /// one. Reads the top-level marker, falling back to `rootEntry.summ`
384    /// (kopia writes both); an empty string is kopia's `omitempty` "complete".
385    /// Deliberately an open string, not an enum: ANY reason means incomplete,
386    /// so a reason kopia adds later can never be mistaken for complete.
387    pub fn incomplete_reason(&self) -> Option<&str> {
388        let summ = self
389            .root_entry
390            .as_ref()
391            .and_then(|r| r.summary.as_ref())
392            .and_then(|s| s.incomplete.as_deref());
393        [self.incomplete.as_deref(), summ]
394            .into_iter()
395            .flatten()
396            .find(|r| !r.is_empty())
397    }
398}
399
400/// Split a raw `snapshot list --json` result into `(complete, incomplete)`,
401/// preserving order. See [`SnapshotListEntry::incomplete_reason`].
402pub fn partition_incomplete(
403    entries: Vec<SnapshotListEntry>,
404) -> (Vec<SnapshotListEntry>, Vec<SnapshotListEntry>) {
405    entries
406        .into_iter()
407        .partition(|e| e.incomplete_reason().is_none())
408}
409
410/// One entry of a kopia directory manifest (`kopia show <dir-object-id>`).
411///
412/// Files carry a top-level `size`; directories instead embed an aggregate
413/// [`DirSummaryLite`] under `summ`. All fields beyond name/type/obj are
414/// optional because kopia omits what doesn't apply to the entry type.
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct DirEntry {
418    /// Entry name (a single path component, never a path).
419    pub name: String,
420    /// Entry type: `"d"` (directory), `"f"` (file), `"s"` (symlink).
421    #[serde(rename = "type")]
422    pub entry_type: String,
423    /// The kopia object id backing the entry — a directory's `obj` can be
424    /// `kopia show`n for the next manifest level; a file's streams its bytes.
425    pub obj: String,
426    /// File size in bytes (files only; directories report sizes via `summ`).
427    #[serde(default)]
428    pub size: Option<i64>,
429    /// Modification time as kopia rendered it (RFC3339; kept as a string so an
430    /// unusual kopia rendering can never fail the whole manifest parse).
431    #[serde(default)]
432    pub mtime: Option<String>,
433    /// Unix permission bits as an octal string (e.g. `"0755"`).
434    #[serde(default)]
435    pub mode: Option<String>,
436    /// Aggregate subtree summary (directories only).
437    #[serde(default)]
438    pub summ: Option<DirSummaryLite>,
439}
440
441/// The aggregate subtree counters under a directory entry's `summ` block —
442/// the subset of [`DirSummary`] a directory listing renders. All optional:
443/// kopia may extend or omit counters across releases.
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct DirSummaryLite {
447    /// Total logical size of the subtree in bytes.
448    #[serde(default)]
449    pub size: Option<i64>,
450    /// Number of files in the subtree.
451    #[serde(default)]
452    pub files: Option<i64>,
453    /// Number of directories in the subtree.
454    #[serde(default)]
455    pub dirs: Option<i64>,
456}
457
458/// A kopia directory manifest: what `kopia show <dir-object-id>` emits —
459/// `{"stream":"kopia:directory","entries":[…]}`. (`kopia show` on a *file*
460/// object streams the file's raw bytes instead, so callers must check
461/// [`DirEntry::entry_type`] before showing an object as a directory.)
462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct DirManifest {
465    /// The manifest stream marker; `"kopia:directory"` for directory objects.
466    pub stream: String,
467    /// The directory's entries, in kopia's on-manifest order.
468    #[serde(default)]
469    pub entries: Vec<DirEntry>,
470}
471
472/// Client identity options reported by `kopia repository status`.
473#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
474#[serde(rename_all = "camelCase")]
475pub struct ClientOptions {
476    /// The configured hostname for this client.
477    #[serde(default)]
478    pub hostname: String,
479    /// The configured username for this client.
480    #[serde(default)]
481    pub username: String,
482    /// Human-readable repository description.
483    #[serde(default)]
484    pub description: String,
485    /// Whether snapshot actions are enabled.
486    #[serde(default)]
487    pub enable_actions: bool,
488}
489
490/// Storage backend block from `kopia repository status`. `config` is left as a
491/// raw JSON value because its shape is backend-specific (filesystem path vs S3
492/// bucket/endpoint vs ...).
493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct StorageInfo {
496    /// Backend type, e.g. "filesystem", "s3", "gcs".
497    #[serde(default, rename = "type")]
498    pub storage_type: String,
499    /// Backend-specific configuration, opaque here.
500    #[serde(default)]
501    pub config: serde_json::Value,
502}
503
504/// Content format block from `kopia repository status`.
505#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct ContentFormat {
508    /// Hash algorithm, e.g. "BLAKE2B-256-128".
509    #[serde(default)]
510    pub hash: String,
511    /// Encryption algorithm, e.g. "AES256-GCM-HMAC-SHA256".
512    #[serde(default)]
513    pub encryption: String,
514    /// Repository format version.
515    #[serde(default)]
516    pub version: u32,
517    /// Epoch-manager parameters, when the repository uses the epoch index (format v2+).
518    /// Absent on older formats, hence `Option`.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub epoch_parameters: Option<EpochParameters>,
521}
522
523/// kopia's epoch-manager parameters, as reported inside
524/// `repository status --json` → `contentFormat.epochParameters`.
525///
526/// **This block does not follow the `rename_all = "camelCase"` convention the rest of this
527/// module uses** — kopia serializes the Go struct's field names verbatim, so the keys are
528/// PascalCase with no consistent rule between them (`MinEpochDuration` vs
529/// `EpochRefreshFrequency` vs `FullCheckpointFrequency` vs `DeleteParallelism`). Hence the
530/// explicit per-field renames; `crates/kopia/tests/fixtures/repository_status.json` carries
531/// real kopia 0.23 output and pins the shape.
532///
533/// Units are Go-native and not what the flag names suggest:
534/// - durations are `time.Duration` **nanoseconds** (as [`MaintenanceCadence::interval`] is);
535/// - `EpochAdvanceOnTotalSizeBytesThreshold` is **bytes**, and the `--epoch-advance-on-size-mb`
536///   flag that sets it means **MiB** — `7` yields `7340032` (7 × 1048576), even though kopia's
537///   own log line renders it as "7.3 MB".
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
539pub struct EpochParameters {
540    /// Whether the epoch manager is enabled on this repository.
541    #[serde(rename = "Enabled", default)]
542    pub enabled: bool,
543    /// Minimum epoch age before it may advance, in nanoseconds (kopia default 24h).
544    #[serde(rename = "MinEpochDuration", default)]
545    pub min_epoch_duration_ns: i64,
546    /// How often clients re-read epoch state, in nanoseconds (kopia default 20m).
547    #[serde(rename = "EpochRefreshFrequency", default)]
548    pub epoch_refresh_frequency_ns: i64,
549    /// Grace window protecting index blobs a concurrent writer may still need, in
550    /// nanoseconds (kopia default 4h).
551    #[serde(rename = "CleanupSafetyMargin", default)]
552    pub cleanup_safety_margin_ns: i64,
553    /// Index-blob count that triggers an epoch advance (kopia default 20).
554    #[serde(rename = "EpochAdvanceOnCountThreshold", default)]
555    pub advance_on_count: i64,
556    /// Total index size in **bytes** that triggers an epoch advance (kopia default 10 MiB).
557    #[serde(rename = "EpochAdvanceOnTotalSizeBytesThreshold", default)]
558    pub advance_on_total_size_bytes: i64,
559    /// Epochs between full index checkpoints (kopia default 7).
560    #[serde(rename = "FullCheckpointFrequency", default)]
561    pub checkpoint_frequency: i64,
562    /// Parallelism for epoch-cleanup deletions (kopia default 4).
563    #[serde(rename = "DeleteParallelism", default)]
564    pub delete_parallelism: i64,
565}
566
567/// Result of `kopia repository status --json`.
568///
569/// The repository's stable identity is `uniqueIDHex` (kopia's JSON key, hence
570/// the explicit rename). We keep the high-value fields typed and leave the rest
571/// (volume capacity, object format, epoch params) for future expansion without
572/// breaking on unknown fields.
573///
574/// Parse a trimmed status object — note the `uniqueIDHex` key maps to
575/// `unique_id_hex`, and unknown fields (here `extraFutureField`) are tolerated:
576///
577/// ```
578/// use kopiur_kopia::RepositoryStatus;
579///
580/// let json = r#"{
581///     "configFile": "/config/repository.config",
582///     "uniqueIDHex": "deadbeef",
583///     "clientOptions": {"hostname": "prod", "username": "mydb"},
584///     "storage": {"type": "s3", "config": {"bucket": "backups"}},
585///     "contentFormat": {"hash": "BLAKE2B-256-128", "encryption": "AES256-GCM-HMAC-SHA256", "version": 3},
586///     "extraFutureField": 42
587/// }"#;
588/// let status: RepositoryStatus = serde_json::from_str(json).unwrap();
589/// assert_eq!(status.unique_id_hex, "deadbeef");
590/// assert_eq!(status.storage.storage_type, "s3");
591/// assert_eq!(status.content_format.version, 3);
592/// assert_eq!(status.client_options.username, "mydb");
593/// ```
594#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
595#[serde(rename_all = "camelCase")]
596pub struct RepositoryStatus {
597    /// Path to the local repository config file.
598    #[serde(default)]
599    pub config_file: String,
600    /// The repository's stable unique id. kopia's key is `uniqueIDHex`.
601    #[serde(default, rename = "uniqueIDHex")]
602    pub unique_id_hex: String,
603    /// Client identity options.
604    pub client_options: ClientOptions,
605    /// Storage backend info.
606    pub storage: StorageInfo,
607    /// Content format (hash/encryption/version).
608    pub content_format: ContentFormat,
609    /// Object-lock blob retention, from kopia's `kopia.blobcfg` blob.
610    ///
611    /// **Top-level**, unlike the epoch parameters nested under `contentFormat` — do not
612    /// follow that pattern here. `omitempty` on both inner keys means kopia emits `{}` when
613    /// retention is off, so an all-default value is the "disabled" observation, not a
614    /// missing one.
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub blob_retention: Option<BlobRetention>,
617}
618
619/// Blob retention as reported by `kopia repository status --json` → `blobRetention`.
620///
621/// Mirrors kopia's `format.BlobStorageConfiguration`. Unlike the sibling [`EpochParameters`],
622/// the keys here **are** camelCase (kopia gives that struct explicit JSON tags), and unlike
623/// it this type cannot be `Copy` — it carries a `String`.
624///
625/// `retentionPeriod` is a Go `time.Duration`, so the unit is **nanoseconds** (the same
626/// convention as [`MaintenanceCadence::interval`]), not seconds.
627#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
628pub struct BlobRetention {
629    /// `GOVERNANCE`, `COMPLIANCE`, or empty when retention is off.
630    #[serde(rename = "retentionMode", default)]
631    pub mode: String,
632    /// Retention period in **nanoseconds**; `0` when retention is off.
633    #[serde(rename = "retentionPeriod", default)]
634    pub period_ns: i64,
635}
636
637impl BlobRetention {
638    /// Whether retention is actually in force. Mirrors kopia's own `IsRetentionEnabled()`:
639    /// a non-empty mode AND a non-zero period — kopia treats a half-set config as off.
640    pub fn is_enabled(&self) -> bool {
641        !self.mode.is_empty() && self.period_ns != 0
642    }
643}
644
645/// A maintenance cadence block (`quick` / `full`) from `maintenance info`.
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647#[serde(rename_all = "camelCase")]
648pub struct MaintenanceCadence {
649    /// Whether this maintenance class is enabled.
650    #[serde(default)]
651    pub enabled: bool,
652    /// Interval between runs, in nanoseconds (kopia's Go `time.Duration`).
653    #[serde(default)]
654    pub interval: i64,
655}
656
657/// The `schedule` block: when maintenance next runs, plus the per-task run
658/// history kopia keeps in the same blob.
659///
660/// `runs` is kopia's `map[TaskType][]RunInfo`
661/// (`repo/maintenance/maintenance_schedule.go`). It carries **no** `omitempty`,
662/// so a repository that has never run maintenance emits `"runs": null` rather
663/// than omitting the key — hence [`Self::runs`] must tolerate an explicit null,
664/// which `#[serde(default)]` alone does NOT (a default applies only to an
665/// ABSENT key). See the field for the deserializer that does.
666///
667/// kopia's `Schedule.ReportRun` **prepends** each new run and truncates the
668/// history to 50 entries per task. Never key a before/after comparison on index
669/// or length: at saturation `len(after) == len(before)`. Compare
670/// [`MaintenanceRun::start`] instead — that is what
671/// [`reclaimed_bytes_since`] does.
672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
673#[serde(rename_all = "camelCase")]
674pub struct MaintenanceSchedule {
675    /// Next scheduled full maintenance, if known.
676    #[serde(default)]
677    pub next_full_maintenance: Option<DateTime<Utc>>,
678    /// Next scheduled quick maintenance, if known.
679    #[serde(default)]
680    pub next_quick_maintenance: Option<DateTime<Utc>>,
681    /// Per-task run history, newest first, capped at 50 entries per task.
682    ///
683    /// The key is kopia's `TaskType` string (`full-delete-blobs`,
684    /// `snapshot-gc`, …) — see the `TASK_*` constants. A `BTreeMap` (not a
685    /// typed enum) on purpose: a newer kopia adding a task must not fail the
686    /// parse of a `maintenance info` the mover's lease decision depends on.
687    ///
688    /// `deserialize_with` is not decoration: `#[serde(default)]` alone rejects
689    /// the explicit `"runs": null` a fresh repository really emits (proven by
690    /// `null_runs_history_decodes_as_empty`), because a default only applies to
691    /// an ABSENT key.
692    #[serde(default, deserialize_with = "null_as_default")]
693    pub runs: BTreeMap<String, Vec<MaintenanceRun>>,
694}
695
696/// Deserialize an explicit JSON `null` as `T::default()` rather than an error.
697///
698/// Needed wherever kopia's Go struct field carries no `omitempty` but its value
699/// is a nil map/slice: `encoding/json` writes `null`, and serde's `default`
700/// covers only a MISSING key.
701fn null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
702where
703    D: serde::Deserializer<'de>,
704    T: Default + Deserialize<'de>,
705{
706    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
707}
708
709/// kopia maintenance task: delete blobs no index references, in a **quick** run.
710/// The `deletedTotalSize` its [`TASK_DELETE_UNREFERENCED_PACKS_STATS`] extra
711/// reports is storage actually freed from the backend.
712pub const TASK_QUICK_DELETE_BLOBS: &str = "quick-delete-blobs";
713/// kopia maintenance task: the [`TASK_QUICK_DELETE_BLOBS`] twin in a **full** run.
714pub const TASK_FULL_DELETE_BLOBS: &str = "full-delete-blobs";
715/// kopia maintenance task: drop index blobs superseded by epoch compaction.
716pub const TASK_DELETE_SUPERSEDED_EPOCH_INDEXES: &str = "delete-superseded-epoch-indexes";
717/// kopia maintenance task: expire old repository log blobs per `logRetention`.
718pub const TASK_CLEANUP_LOGS: &str = "cleanup-logs";
719
720/// `extra[].kind` for [`DeleteUnreferencedPacksStats`].
721pub const TASK_DELETE_UNREFERENCED_PACKS_STATS: &str = "deleteUnreferencedPacksStats";
722/// `extra[].kind` for [`CleanupSupersededIndexesStats`].
723pub const KIND_CLEANUP_SUPERSEDED_INDEXES_STATS: &str = "cleanupSupersededIndexesStats";
724/// `extra[].kind` for [`CleanupLogsStats`].
725pub const KIND_CLEANUP_LOGS_STATS: &str = "cleanupLogsStats";
726
727/// One entry in a per-task maintenance run history (`schedule.runs[task][]`).
728///
729/// Mirrors kopia's `maintenance.RunInfo`. Two serialization details are
730/// load-bearing:
731///
732/// * `success` is `json:"success,omitempty"`, so a **failed** run serializes
733///   with the key *absent*. `#[serde(default)]` therefore decodes a failed run
734///   as `success: false` — the same value kopia meant.
735/// * `extra` is populated only on success, so a failed run contributes no
736///   statistics at all.
737///
738/// `start`/`end` carry no `omitempty` in kopia today, but are modelled as
739/// `Option` anyway: this history feeds a best-effort *metric*, and it must
740/// never be the reason a `maintenance info` — which the mover's lease decision
741/// depends on — fails to parse.
742#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase")]
744pub struct MaintenanceRun {
745    /// When the run started. The only sound key for a before/after comparison
746    /// (see [`MaintenanceSchedule::runs`] on why index and length are not).
747    #[serde(default)]
748    pub start: Option<DateTime<Utc>>,
749    /// When the run finished.
750    #[serde(default)]
751    pub end: Option<DateTime<Utc>>,
752    /// Whether the run succeeded. Absent on the wire means `false`.
753    #[serde(default)]
754    pub success: bool,
755    /// kopia's failure text, empty on success.
756    #[serde(default)]
757    pub error: String,
758    /// Per-task statistics blocks; populated only on success.
759    #[serde(default)]
760    pub extra: Vec<MaintenanceRunExtra>,
761}
762
763impl MaintenanceRun {
764    /// The typed `data` of the first extra with `kind`, or `None` when this run
765    /// carries no such extra (or its `data` does not fit `T` — kopia emits an
766    /// explicit `"data": null` for the statless tasks).
767    fn stats<T: serde::de::DeserializeOwned>(&self, kind: &str) -> Option<T> {
768        self.extra
769            .iter()
770            .find(|e| e.kind == kind)
771            .and_then(|e| serde_json::from_value(e.data.clone()).ok())
772    }
773
774    /// Blobs deleted because no index referenced them — the one statistic that
775    /// reports storage genuinely freed from the backend.
776    pub fn delete_unreferenced_packs_stats(&self) -> Option<DeleteUnreferencedPacksStats> {
777        self.stats(TASK_DELETE_UNREFERENCED_PACKS_STATS)
778    }
779
780    /// Index blobs deleted after epoch compaction superseded them.
781    pub fn cleanup_superseded_indexes_stats(&self) -> Option<CleanupSupersededIndexesStats> {
782        self.stats(KIND_CLEANUP_SUPERSEDED_INDEXES_STATS)
783    }
784
785    /// Repository log blobs deleted per `logRetention`.
786    pub fn cleanup_logs_stats(&self) -> Option<CleanupLogsStats> {
787        self.stats(KIND_CLEANUP_LOGS_STATS)
788    }
789}
790
791/// One statistics block on a [`MaintenanceRun`] — kopia's
792/// `maintenancestats.Extra`, which is `{Kind string; Data json.RawMessage}`.
793///
794/// `data` is a **nested object** whose shape depends on `kind`, so this is
795/// deliberately *not* a `#[serde(tag = ...)]` enum: an unknown `kind` written
796/// by a newer kopia must still decode (and kopia emits `"data": null` for the
797/// tasks that have no statistics, which a tagged enum would also reject). Reach
798/// for the typed accessors on [`MaintenanceRun`] instead of matching on `kind`
799/// by hand.
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
801pub struct MaintenanceRunExtra {
802    /// kopia's statistics-kind discriminator, e.g. `deleteUnreferencedPacksStats`.
803    #[serde(default)]
804    pub kind: String,
805    /// The kind-specific payload; `null` for the statless tasks.
806    #[serde(default)]
807    pub data: serde_json::Value,
808}
809
810/// `deleteUnreferencedPacksStats` — the `quick-delete-blobs` /
811/// `full-delete-blobs` result. **This** is where reclaimed storage is reported:
812/// the task issues real blob deletes against the backend.
813#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
814#[serde(rename_all = "camelCase")]
815pub struct DeleteUnreferencedPacksStats {
816    /// Pack blobs found unreferenced by any index.
817    #[serde(default)]
818    pub unreferenced_pack_count: i64,
819    /// Total bytes of those unreferenced pack blobs.
820    #[serde(default)]
821    pub unreferenced_total_size: i64,
822    /// Pack blobs actually deleted this run.
823    #[serde(default)]
824    pub deleted_pack_count: i64,
825    /// **Bytes actually freed from the backend** this run.
826    #[serde(default)]
827    pub deleted_total_size: i64,
828    /// Pack blobs held back by the run's safety margin (too recent to delete).
829    #[serde(default)]
830    pub retained_pack_count: i64,
831    /// Total bytes of the retained pack blobs.
832    #[serde(default)]
833    pub retained_total_size: i64,
834}
835
836/// `cleanupSupersededIndexesStats` — the `delete-superseded-epoch-indexes`
837/// result. Real blob deletes, so its `deletedTotalSize` is freed storage too.
838#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
839#[serde(rename_all = "camelCase")]
840pub struct CleanupSupersededIndexesStats {
841    /// Index blobs deleted this run.
842    #[serde(default)]
843    pub deleted_blob_count: i64,
844    /// Bytes freed by deleting them.
845    #[serde(default)]
846    pub deleted_total_size: i64,
847}
848
849/// `cleanupLogsStats` — the `cleanup-logs` result. Repository log blobs are real
850/// blobs, so `deletedBlobSize` is freed storage as well (a small figure, but a
851/// true one).
852#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
853#[serde(rename_all = "camelCase")]
854pub struct CleanupLogsStats {
855    /// Log blobs eligible for deletion.
856    #[serde(default)]
857    pub to_delete_blob_count: i64,
858    /// Total bytes of the eligible log blobs.
859    #[serde(default)]
860    pub to_delete_blob_size: i64,
861    /// Log blobs actually deleted this run.
862    #[serde(default)]
863    pub deleted_blob_count: i64,
864    /// Bytes freed by deleting them.
865    #[serde(default)]
866    pub deleted_blob_size: i64,
867    /// Log blobs kept.
868    #[serde(default)]
869    pub retained_blob_count: i64,
870    /// Total bytes of the kept log blobs.
871    #[serde(default)]
872    pub retained_blob_size: i64,
873}
874
875/// Result of `kopia maintenance info --json`.
876///
877/// `interval` is kopia's Go `time.Duration` in nanoseconds; the `schedule` block
878/// is optional:
879///
880/// ```
881/// use kopiur_kopia::MaintenanceInfo;
882///
883/// let json = r#"{
884///     "owner": "mydb@prod",
885///     "quick": {"enabled": true, "interval": 3600000000000},
886///     "full": {"enabled": false, "interval": 0}
887/// }"#;
888/// let info: MaintenanceInfo = serde_json::from_str(json).unwrap();
889/// assert_eq!(info.owner, "mydb@prod");
890/// assert!(info.quick.enabled);
891/// assert_eq!(info.quick.interval, 3_600_000_000_000); // 1h in nanos
892/// assert!(!info.full.enabled);
893/// assert!(info.schedule.is_none());
894/// ```
895#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
896#[serde(rename_all = "camelCase")]
897pub struct MaintenanceInfo {
898    /// The `user@host` that owns the maintenance lease.
899    #[serde(default)]
900    pub owner: String,
901    /// Quick maintenance cadence.
902    pub quick: MaintenanceCadence,
903    /// Full maintenance cadence.
904    pub full: MaintenanceCadence,
905    /// Schedule with next-run timestamps.
906    #[serde(default)]
907    pub schedule: Option<MaintenanceSchedule>,
908}
909
910/// The newest [`MaintenanceRun::start`] recorded for `task`, or `None` when the
911/// schedule has no successful-or-failed entry for it.
912fn newest_run_start(info: &MaintenanceInfo, task: &str) -> Option<DateTime<Utc>> {
913    info.schedule
914        .as_ref()?
915        .runs
916        .get(task)?
917        .iter()
918        .filter_map(|r| r.start)
919        .max()
920}
921
922/// The **successful** runs of `task` present in `after` but not in `before`.
923///
924/// Selected by `start` strictly newer than the newest `start` `before` had for
925/// that task — never by index or length, because kopia prepends and caps the
926/// history at 50 entries per task, so at saturation the two lists are the same
927/// length (see [`MaintenanceSchedule::runs`]). A run with no `start` cannot be
928/// proven new and is skipped.
929fn runs_since<'a>(
930    before: &'a MaintenanceInfo,
931    after: &'a MaintenanceInfo,
932    task: &'a str,
933) -> impl Iterator<Item = &'a MaintenanceRun> {
934    let floor = newest_run_start(before, task);
935    after
936        .schedule
937        .iter()
938        .filter_map(move |s| s.runs.get(task))
939        .flatten()
940        .filter(move |r| r.success && r.start.is_some_and(|start| floor.is_none_or(|f| start > f)))
941}
942
943/// Bytes of backend storage **actually freed** by the maintenance run that
944/// happened between two `kopia maintenance info` observations.
945///
946/// `kopia maintenance run` prints no machine-readable result, so the only way to
947/// learn what a run reclaimed is to diff the per-task run history it appends to
948/// the schedule blob. Returns `None` when no counted task produced a new
949/// successful run — the honest answer for an epoch-enabled repository's *quick*
950/// run, which kopia short-circuits into `compact-single-epoch` + `advance-epoch`
951/// only (`runQuickMaintenance`), neither of which deletes a blob. `Some(0)`
952/// means a counted task *did* run and freed nothing; `None` means unknown. They
953/// are never conflated.
954///
955/// # What is counted, and what deliberately is not
956///
957/// Counted, because each issues real blob deletes against the backend:
958///
959/// | task | statistic |
960/// |---|---|
961/// | `quick-delete-blobs`, `full-delete-blobs` | [`DeleteUnreferencedPacksStats::deleted_total_size`] |
962/// | `delete-superseded-epoch-indexes` | [`CleanupSupersededIndexesStats::deleted_total_size`] |
963/// | `cleanup-logs` | [`CleanupLogsStats::deleted_blob_size`] |
964///
965/// **Not** counted: `snapshotGCStats.deletedContentSize`, however tempting its
966/// name. `snapshot-gc` only *marks* contents deleted in the index
967/// (`snapshot/snapshotgc/gc.go`) and frees no storage at all — the blobs still
968/// occupy the bucket until a later `*-delete-blobs` removes them. Reporting it
969/// would show tens of gigabytes "reclaimed" while the bucket did not shrink by
970/// a byte. `rewriteContentsStats` is excluded for the mirror-image reason: it
971/// *writes* new packs and orphans the old ones, so its bytes are moved, not
972/// freed.
973pub fn reclaimed_bytes_since(before: &MaintenanceInfo, after: &MaintenanceInfo) -> Option<i64> {
974    let mut total = 0i64;
975    let mut measured = false;
976    // `saturating_add`, not `+=`: the summands come from kopia's `extra[].data`,
977    // which is an untyped JSON object this crate does not validate. A release
978    // build would WRAP an overflowing sum into a negative "reclaimed" figure —
979    // a number worse than no number at all on a backup operator.
980    let mut add = |bytes: i64| {
981        measured = true;
982        total = total.saturating_add(bytes);
983    };
984    for task in [TASK_QUICK_DELETE_BLOBS, TASK_FULL_DELETE_BLOBS] {
985        for run in runs_since(before, after, task) {
986            add(run
987                .delete_unreferenced_packs_stats()
988                .map_or(0, |s| s.deleted_total_size));
989        }
990    }
991    for run in runs_since(before, after, TASK_DELETE_SUPERSEDED_EPOCH_INDEXES) {
992        add(run
993            .cleanup_superseded_indexes_stats()
994            .map_or(0, |s| s.deleted_total_size));
995    }
996    for run in runs_since(before, after, TASK_CLEANUP_LOGS) {
997        add(run.cleanup_logs_stats().map_or(0, |s| s.deleted_blob_size));
998    }
999    measured.then_some(total)
1000}
1001
1002/// One entry from `kopia index list --json` — a single content-index blob.
1003///
1004/// kopia's index is a set of these blobs; periodic maintenance compacts them.
1005/// When maintenance stops running (e.g. a stale lease owner), the count grows
1006/// unbounded and kopia eventually warns "Found too many index blobs (N)". We
1007/// only need the COUNT (the length of the array), so we keep just enough fields
1008/// to make a meaningful, future-proof model and let serde ignore the rest
1009/// (`timestamp`, `Superseded`, …).
1010///
1011/// ```
1012/// use kopiur_kopia::IndexBlobEntry;
1013///
1014/// let json = r#"{"id":"xn0_5ce…-c1","length":143,"timestamp":"2026-06-15T22:30:50Z","Superseded":null}"#;
1015/// let e: IndexBlobEntry = serde_json::from_str(json).unwrap();
1016/// assert_eq!(e.length, 143);
1017/// ```
1018#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1019#[serde(rename_all = "camelCase")]
1020pub struct IndexBlobEntry {
1021    /// The index blob's id (kopia's `id`, e.g. `xn0_…-c1`).
1022    #[serde(default)]
1023    pub id: String,
1024    /// On-disk length of the index blob in bytes.
1025    #[serde(default)]
1026    pub length: i64,
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031    use super::*;
1032
1033    #[test]
1034    fn entry_errors_parse_from_ignored_error_snapshot() {
1035        // The exact shape kopia 0.23.1 emits when ignore-file-errors is set: exit 0,
1036        // numFailed=0, but summ.errors[] still lists every skipped entry (verified
1037        // empirically against a uid-2000 0600 tree snapshotted as uid 65532).
1038        let json = r#"{
1039            "id": "k1",
1040            "source": {"host": "h", "userName": "u", "path": "/pvc"},
1041            "startTime": "2026-06-02T03:13:59Z",
1042            "endTime": "2026-06-02T03:14:00Z",
1043            "rootEntry": {
1044                "name": "pvc", "type": "d", "obj": "k1",
1045                "summ": {"size": 7, "files": 2, "dirs": 2, "numFailed": 0, "errors": [
1046                    {"path": "secret_dir", "error": "cannot create iterator: unable to read directory: open /pvc/secret_dir: permission denied"},
1047                    {"path": "topsecret.txt", "error": "unable to open file: open /pvc/topsecret.txt: permission denied"}
1048                ]}
1049            }
1050        }"#;
1051        let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
1052        // numFailed is 0 (errors were ignored by policy) — so the COUNT must come from
1053        // entry_errors(), not error_count(), or the incomplete snapshot stays silent.
1054        assert_eq!(r.error_count(), 0);
1055        assert_eq!(r.entry_errors().len(), 2);
1056        assert_eq!(r.entry_errors()[0].path, "secret_dir");
1057        assert!(r.entry_errors()[1].error.contains("permission denied"));
1058    }
1059
1060    #[test]
1061    fn entry_errors_empty_on_clean_snapshot() {
1062        let json = r#"{
1063            "id": "k1", "source": {"host": "h", "userName": "u", "path": "/p"},
1064            "startTime": "2026-06-02T03:13:59Z", "endTime": "2026-06-02T03:14:00Z",
1065            "rootEntry": {"name": "p", "type": "d", "obj": "k1", "summ": {"size": 4096, "files": 12}}
1066        }"#;
1067        let r: SnapshotCreateResult = serde_json::from_str(json).unwrap();
1068        assert!(r.entry_errors().is_empty());
1069    }
1070
1071    #[test]
1072    fn index_blob_list_counts_entries() {
1073        // The real shape emitted by `kopia index list --json` (verified against
1074        // kopia 0.23.0): a JSON array of entries. The count is the array length.
1075        let json = r#"[
1076            {"id":"xn0_aaa-c1","length":143,"timestamp":"2026-06-15T22:30:50.3-07:00","Superseded":null},
1077            {"id":"xn0_bbb-c1","length":201,"timestamp":"2026-06-15T22:31:01.1-07:00","Superseded":null}
1078        ]"#;
1079        let entries: Vec<IndexBlobEntry> = serde_json::from_str(json).unwrap();
1080        assert_eq!(entries.len(), 2);
1081        assert_eq!(entries[0].id, "xn0_aaa-c1");
1082        assert_eq!(entries[1].length, 201);
1083    }
1084
1085    #[test]
1086    fn index_blob_list_empty_is_zero() {
1087        let entries: Vec<IndexBlobEntry> = serde_json::from_str("[]").unwrap();
1088        assert_eq!(entries.len(), 0);
1089    }
1090
1091    #[test]
1092    fn snapshot_source_identity() {
1093        let s = SnapshotSource {
1094            host: "h".into(),
1095            user_name: "u".into(),
1096            path: "/p".into(),
1097        };
1098        assert_eq!(s.identity(), "u@h:/p");
1099    }
1100
1101    // --- DirManifest: the EXACT `kopia show <dir-oid>` JSON shape, verified
1102    // against kopia 0.23. ---
1103
1104    #[test]
1105    fn dir_manifest_parses_the_verified_kopia_show_shape() {
1106        let json = r#"{
1107            "stream": "kopia:directory",
1108            "entries": [
1109                {
1110                    "name": "sub",
1111                    "type": "d",
1112                    "mode": "0755",
1113                    "mtime": "2026-06-10T12:00:00Z",
1114                    "obj": "kdeadbeef",
1115                    "summ": {"size": 7, "files": 1, "dirs": 1, "maxTime": "2026-06-10T12:00:00Z"}
1116                },
1117                {
1118                    "name": "a.txt",
1119                    "type": "f",
1120                    "mode": "0644",
1121                    "size": 6,
1122                    "mtime": "2026-06-10T11:59:00Z",
1123                    "obj": "1f00dcafe"
1124                }
1125            ],
1126            "summary": {"size": 13, "files": 2, "dirs": 2}
1127        }"#;
1128        let m: DirManifest = serde_json::from_str(json).unwrap();
1129        assert_eq!(m.stream, "kopia:directory");
1130        assert_eq!(m.entries.len(), 2);
1131
1132        // Directory entry: no top-level size, aggregate counters under summ.
1133        let dir = &m.entries[0];
1134        assert_eq!(dir.name, "sub");
1135        assert_eq!(dir.entry_type, "d");
1136        assert_eq!(dir.obj, "kdeadbeef");
1137        assert_eq!(dir.mode.as_deref(), Some("0755"));
1138        assert_eq!(dir.size, None);
1139        let summ = dir.summ.as_ref().expect("directory summ");
1140        assert_eq!(summ.size, Some(7));
1141        assert_eq!(summ.files, Some(1));
1142        assert_eq!(summ.dirs, Some(1));
1143
1144        // File entry: top-level size, no summ.
1145        let file = &m.entries[1];
1146        assert_eq!(file.name, "a.txt");
1147        assert_eq!(file.entry_type, "f");
1148        assert_eq!(file.obj, "1f00dcafe");
1149        assert_eq!(file.size, Some(6));
1150        assert_eq!(file.mtime.as_deref(), Some("2026-06-10T11:59:00Z"));
1151        assert!(file.summ.is_none());
1152    }
1153
1154    #[test]
1155    fn dir_manifest_tolerates_missing_entries_and_unknown_fields() {
1156        // An empty directory manifest has no `entries`; future kopia fields
1157        // (and the unmapped top-level `summary`) must not fail the parse.
1158        let m: DirManifest =
1159            serde_json::from_str(r#"{"stream": "kopia:directory", "futureField": 1}"#).unwrap();
1160        assert_eq!(m.stream, "kopia:directory");
1161        assert!(m.entries.is_empty());
1162    }
1163}