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