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}