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