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}