Skip to main content

canic_cli/restore/
mod.rs

1use crate::{output, version_text};
2use canic_backup::{
3    manifest::FleetBackupManifest,
4    persistence::{BackupLayout, PersistenceError},
5    restore::{
6        RESTORE_RUN_RECEIPT_COMPLETED, RESTORE_RUN_RECEIPT_FAILED,
7        RESTORE_RUN_RECEIPT_RECOVERED_PENDING, RestoreApplyCommandConfig, RestoreApplyDryRun,
8        RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
9        RestoreApplyJournalReport, RestoreApplyJournalStatus, RestoreApplyPendingSummary,
10        RestoreApplyProgressSummary, RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner,
11        RestoreRunOperationReceipt, RestoreRunResponse, RestoreRunnerConfig, RestoreRunnerError,
12        RestoreStatus,
13    },
14};
15use clap::{Arg, ArgAction, Command as ClapCommand};
16use std::{ffi::OsString, fs, path::PathBuf};
17use thiserror::Error as ThisError;
18
19pub use canic_backup::restore::parse_uploaded_snapshot_id;
20
21///
22/// RestoreCommandError
23///
24
25#[derive(Debug, ThisError)]
26pub enum RestoreCommandError {
27    #[error("{0}")]
28    Usage(&'static str),
29
30    #[error("missing required option {0}")]
31    MissingOption(&'static str),
32
33    #[error("use either --manifest or --backup-dir, not both")]
34    ConflictingManifestSources,
35
36    #[error("--require-verified requires --backup-dir")]
37    RequireVerifiedNeedsBackupDir,
38
39    #[error("restore apply currently requires --dry-run")]
40    ApplyRequiresDryRun,
41
42    #[error("restore run requires --dry-run, --execute, or --unclaim-pending")]
43    RestoreRunRequiresMode,
44
45    #[error("use only one restore run mode: --dry-run, --execute, or --unclaim-pending")]
46    RestoreRunConflictingModes,
47
48    #[error("restore run command failed for operation {sequence}: status={status}")]
49    RestoreRunCommandFailed { sequence: usize, status: String },
50
51    #[error("restore apply journal is locked: {lock_path}")]
52    RestoreApplyJournalLocked { lock_path: String },
53
54    #[error("restore run for backup {backup_id} used run_mode={actual}, expected {expected}")]
55    RestoreRunModeMismatch {
56        backup_id: String,
57        expected: String,
58        actual: String,
59    },
60
61    #[error(
62        "restore run for backup {backup_id} stopped for {actual}, expected stopped_reason={expected}"
63    )]
64    RestoreRunStoppedReasonMismatch {
65        backup_id: String,
66        expected: String,
67        actual: String,
68    },
69
70    #[error(
71        "restore run for backup {backup_id} reported next_action={actual}, expected {expected}"
72    )]
73    RestoreRunNextActionMismatch {
74        backup_id: String,
75        expected: String,
76        actual: String,
77    },
78
79    #[error("restore run for backup {backup_id} executed {actual} operations, expected {expected}")]
80    RestoreRunExecutedCountMismatch {
81        backup_id: String,
82        expected: usize,
83        actual: usize,
84    },
85
86    #[error("restore run for backup {backup_id} wrote {actual} receipts, expected {expected}")]
87    RestoreRunReceiptCountMismatch {
88        backup_id: String,
89        expected: usize,
90        actual: usize,
91    },
92
93    #[error(
94        "restore run for backup {backup_id} wrote {actual} {receipt_kind} receipts, expected {expected}"
95    )]
96    RestoreRunReceiptKindCountMismatch {
97        backup_id: String,
98        receipt_kind: &'static str,
99        expected: usize,
100        actual: usize,
101    },
102
103    #[error(
104        "restore run for backup {backup_id} wrote {actual_receipts} receipts with {mismatched_receipts} updated_at mismatches, expected {expected}"
105    )]
106    RestoreRunReceiptUpdatedAtMismatch {
107        backup_id: String,
108        expected: String,
109        actual_receipts: usize,
110        mismatched_receipts: usize,
111    },
112
113    #[error(
114        "restore run for backup {backup_id} reported requested_state_updated_at={actual:?}, expected {expected}"
115    )]
116    RestoreRunStateUpdatedAtMismatch {
117        backup_id: String,
118        expected: String,
119        actual: Option<String>,
120    },
121
122    #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
123    RestoreNotReady {
124        backup_id: String,
125        reasons: Vec<String>,
126    },
127
128    #[error("restore manifest {backup_id} is not design ready")]
129    DesignConformanceNotReady { backup_id: String },
130
131    #[error(
132        "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
133    )]
134    RestoreApplyPending {
135        backup_id: String,
136        pending_operations: usize,
137        next_transition_sequence: Option<usize>,
138    },
139
140    #[error(
141        "restore apply journal for backup {backup_id} has stale or untracked pending work before {cutoff_updated_at}: pending_sequence={pending_sequence:?}, pending_updated_at={pending_updated_at:?}"
142    )]
143    RestoreApplyPendingStale {
144        backup_id: String,
145        cutoff_updated_at: String,
146        pending_sequence: Option<usize>,
147        pending_updated_at: Option<String>,
148    },
149
150    #[error(
151        "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
152    )]
153    RestoreApplyIncomplete {
154        backup_id: String,
155        completed_operations: usize,
156        operation_count: usize,
157    },
158
159    #[error(
160        "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
161    )]
162    RestoreApplyFailed {
163        backup_id: String,
164        failed_operations: usize,
165    },
166
167    #[error("restore apply journal for backup {backup_id} is not ready: reasons={reasons:?}")]
168    RestoreApplyNotReady {
169        backup_id: String,
170        reasons: Vec<String>,
171    },
172
173    #[error("restore apply report for backup {backup_id} requires attention: outcome={outcome:?}")]
174    RestoreApplyReportNeedsAttention {
175        backup_id: String,
176        outcome: canic_backup::restore::RestoreApplyReportOutcome,
177    },
178
179    #[error(
180        "restore apply progress for backup {backup_id} has unexpected {field}: expected={expected}, actual={actual}"
181    )]
182    RestoreApplyProgressMismatch {
183        backup_id: String,
184        field: &'static str,
185        expected: usize,
186        actual: usize,
187    },
188
189    #[error(
190        "restore apply journal for backup {backup_id} has no executable command: operation_available={operation_available}, complete={complete}, blocked_reasons={blocked_reasons:?}"
191    )]
192    RestoreApplyCommandUnavailable {
193        backup_id: String,
194        operation_available: bool,
195        complete: bool,
196        blocked_reasons: Vec<String>,
197    },
198
199    #[error(
200        "restore apply journal next operation changed before claim: expected={expected}, actual={actual:?}"
201    )]
202    RestoreRunClaimSequenceMismatch {
203        expected: usize,
204        actual: Option<usize>,
205    },
206
207    #[error("unknown option {0}")]
208    UnknownOption(String),
209
210    #[error("option --sequence requires a non-negative integer value")]
211    InvalidSequence,
212
213    #[error("option {option} requires a positive integer value")]
214    InvalidPositiveInteger { option: &'static str },
215
216    #[error(transparent)]
217    Io(#[from] std::io::Error),
218
219    #[error(transparent)]
220    Json(#[from] serde_json::Error),
221
222    #[error(transparent)]
223    Persistence(#[from] PersistenceError),
224
225    #[error(transparent)]
226    RestorePlan(#[from] RestorePlanError),
227
228    #[error(transparent)]
229    RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
230
231    #[error(transparent)]
232    RestoreApplyJournal(#[from] RestoreApplyJournalError),
233}
234
235impl From<RestoreRunnerError> for RestoreCommandError {
236    // Preserve the CLI-facing error variants while delegating runner ownership downward.
237    fn from(error: RestoreRunnerError) -> Self {
238        match error {
239            RestoreRunnerError::CommandFailed { sequence, status } => {
240                Self::RestoreRunCommandFailed { sequence, status }
241            }
242            RestoreRunnerError::JournalLocked { lock_path } => {
243                Self::RestoreApplyJournalLocked { lock_path }
244            }
245            RestoreRunnerError::Pending {
246                backup_id,
247                pending_operations,
248                next_transition_sequence,
249            } => Self::RestoreApplyPending {
250                backup_id,
251                pending_operations,
252                next_transition_sequence,
253            },
254            RestoreRunnerError::Failed {
255                backup_id,
256                failed_operations,
257            } => Self::RestoreApplyFailed {
258                backup_id,
259                failed_operations,
260            },
261            RestoreRunnerError::NotReady { backup_id, reasons } => {
262                Self::RestoreApplyNotReady { backup_id, reasons }
263            }
264            RestoreRunnerError::CommandUnavailable {
265                backup_id,
266                operation_available,
267                complete,
268                blocked_reasons,
269            } => Self::RestoreApplyCommandUnavailable {
270                backup_id,
271                operation_available,
272                complete,
273                blocked_reasons,
274            },
275            RestoreRunnerError::ClaimSequenceMismatch { expected, actual } => {
276                Self::RestoreRunClaimSequenceMismatch { expected, actual }
277            }
278            RestoreRunnerError::Io(error) => Self::Io(error),
279            RestoreRunnerError::Json(error) => Self::Json(error),
280            RestoreRunnerError::Journal(error) => Self::RestoreApplyJournal(error),
281        }
282    }
283}
284
285///
286/// RestorePlanOptions
287///
288
289#[derive(Clone, Debug, Eq, PartialEq)]
290pub struct RestorePlanOptions {
291    pub manifest: Option<PathBuf>,
292    pub backup_dir: Option<PathBuf>,
293    pub mapping: Option<PathBuf>,
294    pub out: Option<PathBuf>,
295    pub require_verified: bool,
296    pub require_design_v1: bool,
297    pub require_restore_ready: bool,
298}
299
300impl RestorePlanOptions {
301    /// Parse restore planning options from CLI arguments.
302    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
303    where
304        I: IntoIterator<Item = OsString>,
305    {
306        let matches = restore_plan_command()
307            .try_get_matches_from(std::iter::once(OsString::from("restore-plan")).chain(args))
308            .map_err(|_| RestoreCommandError::Usage(usage()))?;
309
310        let manifest = path_option(&matches, "manifest");
311        let backup_dir = path_option(&matches, "backup-dir");
312        let require_verified = matches.get_flag("require-verified");
313
314        if manifest.is_some() && backup_dir.is_some() {
315            return Err(RestoreCommandError::ConflictingManifestSources);
316        }
317
318        if manifest.is_none() && backup_dir.is_none() {
319            return Err(RestoreCommandError::MissingOption(
320                "--manifest or --backup-dir",
321            ));
322        }
323
324        if require_verified && backup_dir.is_none() {
325            return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
326        }
327
328        Ok(Self {
329            manifest,
330            backup_dir,
331            mapping: path_option(&matches, "mapping"),
332            out: path_option(&matches, "out"),
333            require_verified,
334            require_design_v1: matches.get_flag("require-design"),
335            require_restore_ready: matches.get_flag("require-restore-ready"),
336        })
337    }
338}
339
340// Build the restore plan parser.
341fn restore_plan_command() -> ClapCommand {
342    ClapCommand::new("restore-plan")
343        .disable_help_flag(true)
344        .arg(value_arg("manifest").long("manifest"))
345        .arg(value_arg("backup-dir").long("backup-dir"))
346        .arg(value_arg("mapping").long("mapping"))
347        .arg(value_arg("out").long("out"))
348        .arg(flag_arg("require-verified").long("require-verified"))
349        .arg(flag_arg("require-design").long("require-design"))
350        .arg(flag_arg("require-restore-ready").long("require-restore-ready"))
351}
352
353///
354/// RestoreStatusOptions
355///
356
357#[derive(Clone, Debug, Eq, PartialEq)]
358pub struct RestoreStatusOptions {
359    pub plan: PathBuf,
360    pub out: Option<PathBuf>,
361}
362
363impl RestoreStatusOptions {
364    /// Parse restore status options from CLI arguments.
365    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
366    where
367        I: IntoIterator<Item = OsString>,
368    {
369        let matches = restore_status_command()
370            .try_get_matches_from(std::iter::once(OsString::from("restore-status")).chain(args))
371            .map_err(|_| RestoreCommandError::Usage(usage()))?;
372
373        Ok(Self {
374            plan: path_option(&matches, "plan")
375                .ok_or(RestoreCommandError::MissingOption("--plan"))?,
376            out: path_option(&matches, "out"),
377        })
378    }
379}
380
381// Build the restore status parser.
382fn restore_status_command() -> ClapCommand {
383    ClapCommand::new("restore-status")
384        .disable_help_flag(true)
385        .arg(value_arg("plan").long("plan"))
386        .arg(value_arg("out").long("out"))
387}
388
389///
390/// RestoreApplyOptions
391///
392
393#[derive(Clone, Debug, Eq, PartialEq)]
394pub struct RestoreApplyOptions {
395    pub plan: PathBuf,
396    pub status: Option<PathBuf>,
397    pub backup_dir: Option<PathBuf>,
398    pub out: Option<PathBuf>,
399    pub journal_out: Option<PathBuf>,
400    pub dry_run: bool,
401}
402
403impl RestoreApplyOptions {
404    /// Parse restore apply options from CLI arguments.
405    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
406    where
407        I: IntoIterator<Item = OsString>,
408    {
409        let matches = restore_apply_command()
410            .try_get_matches_from(std::iter::once(OsString::from("restore-apply")).chain(args))
411            .map_err(|_| RestoreCommandError::Usage(usage()))?;
412        let dry_run = matches.get_flag("dry-run");
413
414        if !dry_run {
415            return Err(RestoreCommandError::ApplyRequiresDryRun);
416        }
417
418        Ok(Self {
419            plan: path_option(&matches, "plan")
420                .ok_or(RestoreCommandError::MissingOption("--plan"))?,
421            status: path_option(&matches, "status"),
422            backup_dir: path_option(&matches, "backup-dir"),
423            out: path_option(&matches, "out"),
424            journal_out: path_option(&matches, "journal-out"),
425            dry_run,
426        })
427    }
428}
429
430// Build the restore apply dry-run parser.
431fn restore_apply_command() -> ClapCommand {
432    ClapCommand::new("restore-apply")
433        .disable_help_flag(true)
434        .arg(value_arg("plan").long("plan"))
435        .arg(value_arg("status").long("status"))
436        .arg(value_arg("backup-dir").long("backup-dir"))
437        .arg(value_arg("out").long("out"))
438        .arg(value_arg("journal-out").long("journal-out"))
439        .arg(flag_arg("dry-run").long("dry-run"))
440}
441
442///
443/// RestoreApplyStatusOptions
444///
445
446#[derive(Clone, Debug, Eq, PartialEq)]
447#[expect(
448    clippy::struct_excessive_bools,
449    reason = "CLI status options mirror independent fail-closed guard flags"
450)]
451pub struct RestoreApplyStatusOptions {
452    pub journal: PathBuf,
453    pub require_ready: bool,
454    pub require_no_pending: bool,
455    pub require_no_failed: bool,
456    pub require_complete: bool,
457    pub require_remaining_count: Option<usize>,
458    pub require_attention_count: Option<usize>,
459    pub require_completion_basis_points: Option<usize>,
460    pub require_no_pending_before: Option<String>,
461    pub out: Option<PathBuf>,
462}
463
464impl RestoreApplyStatusOptions {
465    /// Parse restore apply-status options from CLI arguments.
466    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
467    where
468        I: IntoIterator<Item = OsString>,
469    {
470        let matches = restore_apply_status_command()
471            .try_get_matches_from(
472                std::iter::once(OsString::from("restore-apply-status")).chain(args),
473            )
474            .map_err(|_| RestoreCommandError::Usage(usage()))?;
475
476        Ok(Self {
477            journal: path_option(&matches, "journal")
478                .ok_or(RestoreCommandError::MissingOption("--journal"))?,
479            require_ready: matches.get_flag("require-ready"),
480            require_no_pending: matches.get_flag("require-no-pending"),
481            require_no_failed: matches.get_flag("require-no-failed"),
482            require_complete: matches.get_flag("require-complete"),
483            require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
484            require_attention_count: sequence_option(&matches, "require-attention-count")?,
485            require_completion_basis_points: sequence_option(
486                &matches,
487                "require-completion-basis-points",
488            )?,
489            require_no_pending_before: string_option(&matches, "require-no-pending-before"),
490            out: path_option(&matches, "out"),
491        })
492    }
493}
494
495// Build the restore apply-status parser.
496fn restore_apply_status_command() -> ClapCommand {
497    ClapCommand::new("restore-apply-status")
498        .disable_help_flag(true)
499        .arg(value_arg("journal").long("journal"))
500        .arg(flag_arg("require-ready").long("require-ready"))
501        .arg(flag_arg("require-no-pending").long("require-no-pending"))
502        .arg(flag_arg("require-no-failed").long("require-no-failed"))
503        .arg(flag_arg("require-complete").long("require-complete"))
504        .arg(value_arg("require-remaining-count").long("require-remaining-count"))
505        .arg(value_arg("require-attention-count").long("require-attention-count"))
506        .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
507        .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
508        .arg(value_arg("out").long("out"))
509}
510
511///
512/// RestoreApplyReportOptions
513///
514
515#[derive(Clone, Debug, Eq, PartialEq)]
516pub struct RestoreApplyReportOptions {
517    pub journal: PathBuf,
518    pub require_no_attention: bool,
519    pub require_remaining_count: Option<usize>,
520    pub require_attention_count: Option<usize>,
521    pub require_completion_basis_points: Option<usize>,
522    pub require_no_pending_before: Option<String>,
523    pub out: Option<PathBuf>,
524}
525
526impl RestoreApplyReportOptions {
527    /// Parse restore apply-report options from CLI arguments.
528    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
529    where
530        I: IntoIterator<Item = OsString>,
531    {
532        let matches = restore_apply_report_command()
533            .try_get_matches_from(
534                std::iter::once(OsString::from("restore-apply-report")).chain(args),
535            )
536            .map_err(|_| RestoreCommandError::Usage(usage()))?;
537
538        Ok(Self {
539            journal: path_option(&matches, "journal")
540                .ok_or(RestoreCommandError::MissingOption("--journal"))?,
541            require_no_attention: matches.get_flag("require-no-attention"),
542            require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
543            require_attention_count: sequence_option(&matches, "require-attention-count")?,
544            require_completion_basis_points: sequence_option(
545                &matches,
546                "require-completion-basis-points",
547            )?,
548            require_no_pending_before: string_option(&matches, "require-no-pending-before"),
549            out: path_option(&matches, "out"),
550        })
551    }
552}
553
554// Build the restore apply-report parser.
555fn restore_apply_report_command() -> ClapCommand {
556    ClapCommand::new("restore-apply-report")
557        .disable_help_flag(true)
558        .arg(value_arg("journal").long("journal"))
559        .arg(flag_arg("require-no-attention").long("require-no-attention"))
560        .arg(value_arg("require-remaining-count").long("require-remaining-count"))
561        .arg(value_arg("require-attention-count").long("require-attention-count"))
562        .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
563        .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
564        .arg(value_arg("out").long("out"))
565}
566
567///
568/// RestoreRunOptions
569///
570
571#[derive(Clone, Debug, Eq, PartialEq)]
572#[expect(
573    clippy::struct_excessive_bools,
574    reason = "CLI runner options mirror independent mode and fail-closed guard flags"
575)]
576pub struct RestoreRunOptions {
577    pub journal: PathBuf,
578    pub dfx: String,
579    pub network: Option<String>,
580    pub out: Option<PathBuf>,
581    pub dry_run: bool,
582    pub execute: bool,
583    pub unclaim_pending: bool,
584    pub max_steps: Option<usize>,
585    pub updated_at: Option<String>,
586    pub require_complete: bool,
587    pub require_no_attention: bool,
588    pub require_run_mode: Option<String>,
589    pub require_stopped_reason: Option<String>,
590    pub require_next_action: Option<String>,
591    pub require_executed_count: Option<usize>,
592    pub require_receipt_count: Option<usize>,
593    pub require_completed_receipt_count: Option<usize>,
594    pub require_failed_receipt_count: Option<usize>,
595    pub require_recovered_receipt_count: Option<usize>,
596    pub require_receipt_updated_at: Option<String>,
597    pub require_state_updated_at: Option<String>,
598    pub require_remaining_count: Option<usize>,
599    pub require_attention_count: Option<usize>,
600    pub require_completion_basis_points: Option<usize>,
601    pub require_no_pending_before: Option<String>,
602}
603
604impl RestoreRunOptions {
605    /// Parse restore run options from CLI arguments.
606    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
607    where
608        I: IntoIterator<Item = OsString>,
609    {
610        let matches = restore_run_command()
611            .try_get_matches_from(std::iter::once(OsString::from("restore-run")).chain(args))
612            .map_err(|_| RestoreCommandError::Usage(usage()))?;
613
614        let dry_run = matches.get_flag("dry-run");
615        let execute = matches.get_flag("execute");
616        let unclaim_pending = matches.get_flag("unclaim-pending");
617
618        validate_restore_run_mode_selection(dry_run, execute, unclaim_pending)?;
619
620        Ok(Self {
621            journal: path_option(&matches, "journal")
622                .ok_or(RestoreCommandError::MissingOption("--journal"))?,
623            dfx: string_option(&matches, "dfx").unwrap_or_else(|| "dfx".to_string()),
624            network: string_option(&matches, "network"),
625            out: path_option(&matches, "out"),
626            dry_run,
627            execute,
628            unclaim_pending,
629            max_steps: positive_integer_option(&matches, "max-steps", "--max-steps")?,
630            updated_at: string_option(&matches, "updated-at"),
631            require_complete: matches.get_flag("require-complete"),
632            require_no_attention: matches.get_flag("require-no-attention"),
633            require_run_mode: string_option(&matches, "require-run-mode"),
634            require_stopped_reason: string_option(&matches, "require-stopped-reason"),
635            require_next_action: string_option(&matches, "require-next-action"),
636            require_executed_count: sequence_option(&matches, "require-executed-count")?,
637            require_receipt_count: sequence_option(&matches, "require-receipt-count")?,
638            require_completed_receipt_count: sequence_option(
639                &matches,
640                "require-completed-receipt-count",
641            )?,
642            require_failed_receipt_count: sequence_option(
643                &matches,
644                "require-failed-receipt-count",
645            )?,
646            require_recovered_receipt_count: sequence_option(
647                &matches,
648                "require-recovered-receipt-count",
649            )?,
650            require_receipt_updated_at: string_option(&matches, "require-receipt-updated-at"),
651            require_state_updated_at: string_option(&matches, "require-state-updated-at"),
652            require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
653            require_attention_count: sequence_option(&matches, "require-attention-count")?,
654            require_completion_basis_points: sequence_option(
655                &matches,
656                "require-completion-basis-points",
657            )?,
658            require_no_pending_before: string_option(&matches, "require-no-pending-before"),
659        })
660    }
661}
662
663// Build the native restore runner parser.
664fn restore_run_command() -> ClapCommand {
665    ClapCommand::new("restore-run")
666        .disable_help_flag(true)
667        .arg(value_arg("journal").long("journal"))
668        .arg(value_arg("dfx").long("dfx"))
669        .arg(value_arg("network").long("network"))
670        .arg(value_arg("out").long("out"))
671        .arg(flag_arg("dry-run").long("dry-run"))
672        .arg(flag_arg("execute").long("execute"))
673        .arg(flag_arg("unclaim-pending").long("unclaim-pending"))
674        .arg(value_arg("max-steps").long("max-steps"))
675        .arg(value_arg("updated-at").long("updated-at"))
676        .arg(flag_arg("require-complete").long("require-complete"))
677        .arg(flag_arg("require-no-attention").long("require-no-attention"))
678        .arg(value_arg("require-run-mode").long("require-run-mode"))
679        .arg(value_arg("require-stopped-reason").long("require-stopped-reason"))
680        .arg(value_arg("require-next-action").long("require-next-action"))
681        .arg(value_arg("require-executed-count").long("require-executed-count"))
682        .arg(value_arg("require-receipt-count").long("require-receipt-count"))
683        .arg(value_arg("require-completed-receipt-count").long("require-completed-receipt-count"))
684        .arg(value_arg("require-failed-receipt-count").long("require-failed-receipt-count"))
685        .arg(value_arg("require-recovered-receipt-count").long("require-recovered-receipt-count"))
686        .arg(value_arg("require-receipt-updated-at").long("require-receipt-updated-at"))
687        .arg(value_arg("require-state-updated-at").long("require-state-updated-at"))
688        .arg(value_arg("require-remaining-count").long("require-remaining-count"))
689        .arg(value_arg("require-attention-count").long("require-attention-count"))
690        .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
691        .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
692}
693
694// Build one string-valued Clap argument.
695fn value_arg(id: &'static str) -> Arg {
696    Arg::new(id).num_args(1)
697}
698
699// Build one boolean Clap argument.
700fn flag_arg(id: &'static str) -> Arg {
701    Arg::new(id).action(ArgAction::SetTrue)
702}
703
704// Read one string option from Clap matches.
705fn string_option(matches: &clap::ArgMatches, id: &str) -> Option<String> {
706    matches.get_one::<String>(id).cloned()
707}
708
709// Read one path option from Clap matches.
710fn path_option(matches: &clap::ArgMatches, id: &str) -> Option<PathBuf> {
711    string_option(matches, id).map(PathBuf::from)
712}
713
714// Read one usize option from Clap matches.
715fn sequence_option(
716    matches: &clap::ArgMatches,
717    id: &str,
718) -> Result<Option<usize>, RestoreCommandError> {
719    string_option(matches, id).map(parse_sequence).transpose()
720}
721
722// Read one positive integer option from Clap matches.
723fn positive_integer_option(
724    matches: &clap::ArgMatches,
725    id: &str,
726    option: &'static str,
727) -> Result<Option<usize>, RestoreCommandError> {
728    string_option(matches, id)
729        .map(|value| parse_positive_integer(option, value))
730        .transpose()
731}
732
733// Validate that restore run received exactly one execution mode.
734fn validate_restore_run_mode_selection(
735    dry_run: bool,
736    execute: bool,
737    unclaim_pending: bool,
738) -> Result<(), RestoreCommandError> {
739    let mode_count = [dry_run, execute, unclaim_pending]
740        .into_iter()
741        .filter(|enabled| *enabled)
742        .count();
743    if mode_count > 1 {
744        return Err(RestoreCommandError::RestoreRunConflictingModes);
745    }
746
747    if mode_count == 0 {
748        return Err(RestoreCommandError::RestoreRunRequiresMode);
749    }
750
751    Ok(())
752}
753
754/// Run a restore subcommand.
755pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
756where
757    I: IntoIterator<Item = OsString>,
758{
759    let mut args = args.into_iter();
760    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
761        return Err(RestoreCommandError::Usage(usage()));
762    };
763
764    match command.as_str() {
765        "plan" => {
766            let options = RestorePlanOptions::parse(args)?;
767            let plan = plan_restore(&options)?;
768            write_plan(&options, &plan)?;
769            enforce_restore_plan_requirements(&options, &plan)?;
770            Ok(())
771        }
772        "status" => {
773            let options = RestoreStatusOptions::parse(args)?;
774            let status = restore_status(&options)?;
775            write_status(&options, &status)?;
776            Ok(())
777        }
778        "apply" => {
779            let options = RestoreApplyOptions::parse(args)?;
780            let dry_run = restore_apply_dry_run(&options)?;
781            write_apply_dry_run(&options, &dry_run)?;
782            write_apply_journal_if_requested(&options, &dry_run)?;
783            Ok(())
784        }
785        "apply-status" => {
786            let options = RestoreApplyStatusOptions::parse(args)?;
787            let status = restore_apply_status(&options)?;
788            write_apply_status(&options, &status)?;
789            enforce_apply_status_requirements(&options, &status)?;
790            Ok(())
791        }
792        "apply-report" => {
793            let options = RestoreApplyReportOptions::parse(args)?;
794            let report = restore_apply_report(&options)?;
795            write_apply_report(&options, &report)?;
796            enforce_apply_report_requirements(&options, &report)?;
797            Ok(())
798        }
799        "run" => {
800            let options = RestoreRunOptions::parse(args)?;
801            let run = if options.execute {
802                restore_run_execute_result(&options)?
803            } else if options.unclaim_pending {
804                canic_backup::restore::RestoreRunnerOutcome {
805                    response: restore_run_unclaim_pending(&options)?,
806                    error: None,
807                }
808            } else {
809                canic_backup::restore::RestoreRunnerOutcome {
810                    response: restore_run_dry_run(&options)?,
811                    error: None,
812                }
813            };
814            write_restore_run(&options, &run.response)?;
815            if let Some(error) = run.error {
816                return Err(error.into());
817            }
818            enforce_restore_run_requirements(&options, &run.response)?;
819            Ok(())
820        }
821        "help" | "--help" | "-h" => {
822            println!("{}", usage());
823            Ok(())
824        }
825        "version" | "--version" | "-V" => {
826            println!("{}", version_text());
827            Ok(())
828        }
829        _ => Err(RestoreCommandError::UnknownOption(command)),
830    }
831}
832
833/// Build a no-mutation restore plan from a manifest and optional mapping.
834pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
835    verify_backup_layout_if_required(options)?;
836
837    let manifest = read_manifest_source(options)?;
838    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
839
840    RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
841}
842
843/// Build the initial no-mutation restore status from a restore plan.
844pub fn restore_status(
845    options: &RestoreStatusOptions,
846) -> Result<RestoreStatus, RestoreCommandError> {
847    let plan = read_plan(&options.plan)?;
848    Ok(RestoreStatus::from_plan(&plan))
849}
850
851/// Build a no-mutation restore apply dry-run from a restore plan.
852pub fn restore_apply_dry_run(
853    options: &RestoreApplyOptions,
854) -> Result<RestoreApplyDryRun, RestoreCommandError> {
855    let plan = read_plan(&options.plan)?;
856    let status = options.status.as_ref().map(read_status).transpose()?;
857    if let Some(backup_dir) = &options.backup_dir {
858        return RestoreApplyDryRun::try_from_plan_with_artifacts(
859            &plan,
860            status.as_ref(),
861            backup_dir,
862        )
863        .map_err(RestoreCommandError::from);
864    }
865
866    RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
867}
868
869/// Build a compact restore apply status from a journal file.
870pub fn restore_apply_status(
871    options: &RestoreApplyStatusOptions,
872) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
873    let journal = read_apply_journal(&options.journal)?;
874    Ok(journal.status())
875}
876
877/// Build an operator-oriented restore apply report from a journal file.
878pub fn restore_apply_report(
879    options: &RestoreApplyReportOptions,
880) -> Result<RestoreApplyJournalReport, RestoreCommandError> {
881    let journal = read_apply_journal(&options.journal)?;
882    Ok(journal.report())
883}
884
885/// Build a no-mutation native restore runner preview from a journal file.
886pub fn restore_run_dry_run(
887    options: &RestoreRunOptions,
888) -> Result<RestoreRunResponse, RestoreCommandError> {
889    canic_backup::restore::restore_run_dry_run(&restore_runner_config(options))
890        .map_err(RestoreCommandError::from)
891}
892
893/// Recover an interrupted restore runner by unclaiming the pending operation.
894pub fn restore_run_unclaim_pending(
895    options: &RestoreRunOptions,
896) -> Result<RestoreRunResponse, RestoreCommandError> {
897    canic_backup::restore::restore_run_unclaim_pending(&restore_runner_config(options))
898        .map_err(RestoreCommandError::from)
899}
900
901// Execute ready restore apply operations and retain any deferred runner error.
902fn restore_run_execute_result(
903    options: &RestoreRunOptions,
904) -> Result<canic_backup::restore::RestoreRunnerOutcome, RestoreCommandError> {
905    canic_backup::restore::restore_run_execute_result(&restore_runner_config(options))
906        .map_err(RestoreCommandError::from)
907}
908
909// Build command-preview configuration from common dfx/network inputs.
910fn restore_command_config(program: &str, network: Option<&str>) -> RestoreApplyCommandConfig {
911    RestoreApplyCommandConfig {
912        program: program.to_string(),
913        network: network.map(str::to_string),
914    }
915}
916
917// Build the lower-level restore runner configuration from CLI flags.
918fn restore_runner_config(options: &RestoreRunOptions) -> RestoreRunnerConfig {
919    RestoreRunnerConfig {
920        journal: options.journal.clone(),
921        command: restore_command_config(&options.dfx, options.network.as_deref()),
922        max_steps: options.max_steps,
923        updated_at: options.updated_at.clone(),
924    }
925}
926
927// Enforce caller-requested native runner requirements after output is emitted.
928fn enforce_restore_run_requirements(
929    options: &RestoreRunOptions,
930    run: &RestoreRunResponse,
931) -> Result<(), RestoreCommandError> {
932    if options.require_complete && !run.complete {
933        return Err(RestoreCommandError::RestoreApplyIncomplete {
934            backup_id: run.backup_id.clone(),
935            completed_operations: run.completed_operations,
936            operation_count: run.operation_count,
937        });
938    }
939
940    if options.require_no_attention && run.attention_required {
941        return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
942            backup_id: run.backup_id.clone(),
943            outcome: run.outcome.clone(),
944        });
945    }
946
947    if let Some(expected) = &options.require_run_mode
948        && run.run_mode != expected
949    {
950        return Err(RestoreCommandError::RestoreRunModeMismatch {
951            backup_id: run.backup_id.clone(),
952            expected: expected.clone(),
953            actual: run.run_mode.to_string(),
954        });
955    }
956
957    if let Some(expected) = &options.require_stopped_reason
958        && run.stopped_reason != expected
959    {
960        return Err(RestoreCommandError::RestoreRunStoppedReasonMismatch {
961            backup_id: run.backup_id.clone(),
962            expected: expected.clone(),
963            actual: run.stopped_reason.to_string(),
964        });
965    }
966
967    if let Some(expected) = &options.require_next_action
968        && run.next_action != expected
969    {
970        return Err(RestoreCommandError::RestoreRunNextActionMismatch {
971            backup_id: run.backup_id.clone(),
972            expected: expected.clone(),
973            actual: run.next_action.to_string(),
974        });
975    }
976
977    if let Some(expected) = options.require_executed_count {
978        let actual = run.executed_operation_count.unwrap_or(0);
979        if actual != expected {
980            return Err(RestoreCommandError::RestoreRunExecutedCountMismatch {
981                backup_id: run.backup_id.clone(),
982                expected,
983                actual,
984            });
985        }
986    }
987
988    enforce_restore_run_receipt_requirements(options, run)?;
989
990    enforce_progress_requirements(
991        &run.backup_id,
992        &run.progress,
993        options.require_remaining_count,
994        options.require_attention_count,
995        options.require_completion_basis_points,
996    )?;
997    enforce_pending_before_requirement(
998        &run.backup_id,
999        &run.pending_summary,
1000        options.require_no_pending_before.as_deref(),
1001    )?;
1002
1003    Ok(())
1004}
1005
1006// Enforce caller-requested native runner receipt and marker requirements.
1007fn enforce_restore_run_receipt_requirements(
1008    options: &RestoreRunOptions,
1009    run: &RestoreRunResponse,
1010) -> Result<(), RestoreCommandError> {
1011    if let Some(expected) = options.require_receipt_count {
1012        let actual = run.operation_receipt_count.unwrap_or(0);
1013        if actual != expected {
1014            return Err(RestoreCommandError::RestoreRunReceiptCountMismatch {
1015                backup_id: run.backup_id.clone(),
1016                expected,
1017                actual,
1018            });
1019        }
1020    }
1021
1022    enforce_restore_run_receipt_kind_requirement(
1023        &run.backup_id,
1024        RESTORE_RUN_RECEIPT_COMPLETED,
1025        options.require_completed_receipt_count,
1026        run.operation_receipt_summary.command_completed,
1027    )?;
1028    enforce_restore_run_receipt_kind_requirement(
1029        &run.backup_id,
1030        RESTORE_RUN_RECEIPT_FAILED,
1031        options.require_failed_receipt_count,
1032        run.operation_receipt_summary.command_failed,
1033    )?;
1034    enforce_restore_run_receipt_kind_requirement(
1035        &run.backup_id,
1036        RESTORE_RUN_RECEIPT_RECOVERED_PENDING,
1037        options.require_recovered_receipt_count,
1038        run.operation_receipt_summary.pending_recovered,
1039    )?;
1040    enforce_restore_run_receipt_updated_at_requirement(
1041        &run.backup_id,
1042        &run.operation_receipts,
1043        options.require_receipt_updated_at.as_deref(),
1044    )?;
1045    enforce_restore_run_state_updated_at_requirement(
1046        &run.backup_id,
1047        run.requested_state_updated_at.as_deref(),
1048        options.require_state_updated_at.as_deref(),
1049    )?;
1050
1051    Ok(())
1052}
1053
1054// Fail when a runner summary does not echo the requested state marker.
1055fn enforce_restore_run_state_updated_at_requirement(
1056    backup_id: &str,
1057    actual: Option<&str>,
1058    expected: Option<&str>,
1059) -> Result<(), RestoreCommandError> {
1060    if let Some(expected) = expected
1061        && actual != Some(expected)
1062    {
1063        return Err(RestoreCommandError::RestoreRunStateUpdatedAtMismatch {
1064            backup_id: backup_id.to_string(),
1065            expected: expected.to_string(),
1066            actual: actual.map(str::to_string),
1067        });
1068    }
1069
1070    Ok(())
1071}
1072
1073// Fail when emitted runner receipts are missing the requested state marker.
1074fn enforce_restore_run_receipt_updated_at_requirement(
1075    backup_id: &str,
1076    receipts: &[RestoreRunOperationReceipt],
1077    expected: Option<&str>,
1078) -> Result<(), RestoreCommandError> {
1079    let Some(expected) = expected else {
1080        return Ok(());
1081    };
1082
1083    let actual_receipts = receipts.len();
1084    let mismatched_receipts = receipts
1085        .iter()
1086        .filter(|receipt| receipt.updated_at.as_deref() != Some(expected))
1087        .count();
1088    if actual_receipts == 0 || mismatched_receipts > 0 {
1089        return Err(RestoreCommandError::RestoreRunReceiptUpdatedAtMismatch {
1090            backup_id: backup_id.to_string(),
1091            expected: expected.to_string(),
1092            actual_receipts,
1093            mismatched_receipts,
1094        });
1095    }
1096
1097    Ok(())
1098}
1099
1100// Fail when a runner receipt-kind count differs from the requested value.
1101fn enforce_restore_run_receipt_kind_requirement(
1102    backup_id: &str,
1103    receipt_kind: &'static str,
1104    expected: Option<usize>,
1105    actual: usize,
1106) -> Result<(), RestoreCommandError> {
1107    if let Some(expected) = expected
1108        && actual != expected
1109    {
1110        return Err(RestoreCommandError::RestoreRunReceiptKindCountMismatch {
1111            backup_id: backup_id.to_string(),
1112            receipt_kind,
1113            expected,
1114            actual,
1115        });
1116    }
1117
1118    Ok(())
1119}
1120
1121// Enforce caller-requested integer progress requirements after output is emitted.
1122fn enforce_progress_requirements(
1123    backup_id: &str,
1124    progress: &RestoreApplyProgressSummary,
1125    require_remaining_count: Option<usize>,
1126    require_attention_count: Option<usize>,
1127    require_completion_basis_points: Option<usize>,
1128) -> Result<(), RestoreCommandError> {
1129    if let Some(expected) = require_remaining_count
1130        && progress.remaining_operations != expected
1131    {
1132        return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1133            backup_id: backup_id.to_string(),
1134            field: "remaining_operations",
1135            expected,
1136            actual: progress.remaining_operations,
1137        });
1138    }
1139
1140    if let Some(expected) = require_attention_count
1141        && progress.attention_operations != expected
1142    {
1143        return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1144            backup_id: backup_id.to_string(),
1145            field: "attention_operations",
1146            expected,
1147            actual: progress.attention_operations,
1148        });
1149    }
1150
1151    if let Some(expected) = require_completion_basis_points
1152        && progress.completion_basis_points != expected
1153    {
1154        return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1155            backup_id: backup_id.to_string(),
1156            field: "completion_basis_points",
1157            expected,
1158            actual: progress.completion_basis_points,
1159        });
1160    }
1161
1162    Ok(())
1163}
1164
1165// Enforce pending-work freshness using caller-supplied comparable update markers.
1166fn enforce_pending_before_requirement(
1167    backup_id: &str,
1168    pending: &RestoreApplyPendingSummary,
1169    require_no_pending_before: Option<&str>,
1170) -> Result<(), RestoreCommandError> {
1171    let Some(cutoff_updated_at) = require_no_pending_before else {
1172        return Ok(());
1173    };
1174
1175    if pending.pending_operations == 0 {
1176        return Ok(());
1177    }
1178
1179    if pending.pending_updated_at_known
1180        && pending
1181            .pending_updated_at
1182            .as_deref()
1183            .is_some_and(|updated_at| updated_at >= cutoff_updated_at)
1184    {
1185        return Ok(());
1186    }
1187
1188    Err(RestoreCommandError::RestoreApplyPendingStale {
1189        backup_id: backup_id.to_string(),
1190        cutoff_updated_at: cutoff_updated_at.to_string(),
1191        pending_sequence: pending.pending_sequence,
1192        pending_updated_at: pending.pending_updated_at.clone(),
1193    })
1194}
1195
1196// Enforce caller-requested apply report requirements after report output is emitted.
1197fn enforce_apply_report_requirements(
1198    options: &RestoreApplyReportOptions,
1199    report: &RestoreApplyJournalReport,
1200) -> Result<(), RestoreCommandError> {
1201    if options.require_no_attention && report.attention_required {
1202        return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
1203            backup_id: report.backup_id.clone(),
1204            outcome: report.outcome.clone(),
1205        });
1206    }
1207
1208    enforce_progress_requirements(
1209        &report.backup_id,
1210        &report.progress,
1211        options.require_remaining_count,
1212        options.require_attention_count,
1213        options.require_completion_basis_points,
1214    )?;
1215    enforce_pending_before_requirement(
1216        &report.backup_id,
1217        &report.pending_summary,
1218        options.require_no_pending_before.as_deref(),
1219    )
1220}
1221
1222// Enforce caller-requested apply journal requirements after status is emitted.
1223fn enforce_apply_status_requirements(
1224    options: &RestoreApplyStatusOptions,
1225    status: &RestoreApplyJournalStatus,
1226) -> Result<(), RestoreCommandError> {
1227    if options.require_ready && !status.ready {
1228        return Err(RestoreCommandError::RestoreApplyNotReady {
1229            backup_id: status.backup_id.clone(),
1230            reasons: status.blocked_reasons.clone(),
1231        });
1232    }
1233
1234    if options.require_no_pending && status.pending_operations > 0 {
1235        return Err(RestoreCommandError::RestoreApplyPending {
1236            backup_id: status.backup_id.clone(),
1237            pending_operations: status.pending_operations,
1238            next_transition_sequence: status.next_transition_sequence,
1239        });
1240    }
1241
1242    if options.require_no_failed && status.failed_operations > 0 {
1243        return Err(RestoreCommandError::RestoreApplyFailed {
1244            backup_id: status.backup_id.clone(),
1245            failed_operations: status.failed_operations,
1246        });
1247    }
1248
1249    if options.require_complete && !status.complete {
1250        return Err(RestoreCommandError::RestoreApplyIncomplete {
1251            backup_id: status.backup_id.clone(),
1252            completed_operations: status.completed_operations,
1253            operation_count: status.operation_count,
1254        });
1255    }
1256
1257    enforce_progress_requirements(
1258        &status.backup_id,
1259        &status.progress,
1260        options.require_remaining_count,
1261        options.require_attention_count,
1262        options.require_completion_basis_points,
1263    )?;
1264    enforce_pending_before_requirement(
1265        &status.backup_id,
1266        &status.pending_summary,
1267        options.require_no_pending_before.as_deref(),
1268    )?;
1269
1270    Ok(())
1271}
1272
1273// Enforce caller-requested restore plan requirements after the plan is emitted.
1274fn enforce_restore_plan_requirements(
1275    options: &RestorePlanOptions,
1276    plan: &RestorePlan,
1277) -> Result<(), RestoreCommandError> {
1278    if options.require_design_v1 {
1279        let manifest = read_manifest_source(options)?;
1280        if !manifest.design_conformance_report().design_v1_ready {
1281            return Err(RestoreCommandError::DesignConformanceNotReady {
1282                backup_id: plan.backup_id.clone(),
1283            });
1284        }
1285    }
1286
1287    if !options.require_restore_ready || plan.readiness_summary.ready {
1288        return Ok(());
1289    }
1290
1291    Err(RestoreCommandError::RestoreNotReady {
1292        backup_id: plan.backup_id.clone(),
1293        reasons: plan.readiness_summary.reasons.clone(),
1294    })
1295}
1296
1297// Verify backup layout integrity before restore planning when requested.
1298fn verify_backup_layout_if_required(
1299    options: &RestorePlanOptions,
1300) -> Result<(), RestoreCommandError> {
1301    if !options.require_verified {
1302        return Ok(());
1303    }
1304
1305    let Some(dir) = &options.backup_dir else {
1306        return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
1307    };
1308
1309    BackupLayout::new(dir.clone()).verify_integrity()?;
1310    Ok(())
1311}
1312
1313// Read the manifest from a direct path or canonical backup layout.
1314fn read_manifest_source(
1315    options: &RestorePlanOptions,
1316) -> Result<FleetBackupManifest, RestoreCommandError> {
1317    if let Some(path) = &options.manifest {
1318        return read_manifest(path);
1319    }
1320
1321    let Some(dir) = &options.backup_dir else {
1322        return Err(RestoreCommandError::MissingOption(
1323            "--manifest or --backup-dir",
1324        ));
1325    };
1326
1327    BackupLayout::new(dir.clone())
1328        .read_manifest()
1329        .map_err(RestoreCommandError::from)
1330}
1331
1332// Read and decode a fleet backup manifest from disk.
1333fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
1334    let data = fs::read_to_string(path)?;
1335    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1336}
1337
1338// Read and decode an optional source-to-target restore mapping from disk.
1339fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
1340    let data = fs::read_to_string(path)?;
1341    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1342}
1343
1344// Read and decode a restore plan from disk.
1345fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
1346    let data = fs::read_to_string(path)?;
1347    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1348}
1349
1350// Read and decode a restore status from disk.
1351fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
1352    let data = fs::read_to_string(path)?;
1353    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1354}
1355
1356// Read and decode a restore apply journal from disk.
1357fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
1358    let data = fs::read_to_string(path)?;
1359    let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
1360    journal.validate()?;
1361    Ok(journal)
1362}
1363
1364// Parse a restore apply journal operation sequence value.
1365fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
1366    value
1367        .parse::<usize>()
1368        .map_err(|_| RestoreCommandError::InvalidSequence)
1369}
1370
1371// Parse a positive integer CLI value for options where zero is not meaningful.
1372fn parse_positive_integer(
1373    option: &'static str,
1374    value: String,
1375) -> Result<usize, RestoreCommandError> {
1376    let parsed = parse_sequence(value)?;
1377    if parsed == 0 {
1378        return Err(RestoreCommandError::InvalidPositiveInteger { option });
1379    }
1380
1381    Ok(parsed)
1382}
1383
1384// Write the computed plan to stdout or a requested output file.
1385fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
1386    output::write_pretty_json(options.out.as_ref(), plan)
1387}
1388
1389// Write the computed status to stdout or a requested output file.
1390fn write_status(
1391    options: &RestoreStatusOptions,
1392    status: &RestoreStatus,
1393) -> Result<(), RestoreCommandError> {
1394    output::write_pretty_json(options.out.as_ref(), status)
1395}
1396
1397// Write the computed apply dry-run to stdout or a requested output file.
1398fn write_apply_dry_run(
1399    options: &RestoreApplyOptions,
1400    dry_run: &RestoreApplyDryRun,
1401) -> Result<(), RestoreCommandError> {
1402    output::write_pretty_json(options.out.as_ref(), dry_run)
1403}
1404
1405// Write the initial apply journal when the caller requests one.
1406fn write_apply_journal_if_requested(
1407    options: &RestoreApplyOptions,
1408    dry_run: &RestoreApplyDryRun,
1409) -> Result<(), RestoreCommandError> {
1410    let Some(path) = &options.journal_out else {
1411        return Ok(());
1412    };
1413
1414    let journal = RestoreApplyJournal::from_dry_run(dry_run);
1415    let data = serde_json::to_vec_pretty(&journal)?;
1416    fs::write(path, data)?;
1417    Ok(())
1418}
1419
1420// Write the computed apply journal status to stdout or a requested output file.
1421fn write_apply_status(
1422    options: &RestoreApplyStatusOptions,
1423    status: &RestoreApplyJournalStatus,
1424) -> Result<(), RestoreCommandError> {
1425    output::write_pretty_json(options.out.as_ref(), status)
1426}
1427
1428// Write the computed apply journal report to stdout or a requested output file.
1429fn write_apply_report(
1430    options: &RestoreApplyReportOptions,
1431    report: &RestoreApplyJournalReport,
1432) -> Result<(), RestoreCommandError> {
1433    output::write_pretty_json(options.out.as_ref(), report)
1434}
1435
1436// Write the restore runner response to stdout or a requested output file.
1437fn write_restore_run(
1438    options: &RestoreRunOptions,
1439    run: &RestoreRunResponse,
1440) -> Result<(), RestoreCommandError> {
1441    output::write_pretty_json(options.out.as_ref(), run)
1442}
1443
1444// Return restore command usage text.
1445const fn usage() -> &'static str {
1446    "usage: canic restore <command> [<args>]\n\ncommands:\n  plan           Build a no-mutation restore plan.\n  status         Build initial restore status from a plan.\n  apply          Render restore operations and optionally write an apply journal.\n  apply-status   Summarize apply journal state for scripts.\n  apply-report   Write an operator-focused apply journal report.\n  run            Preview, execute, or recover the native restore runner."
1447}
1448
1449#[cfg(test)]
1450mod tests;