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#[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 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#[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 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
340fn 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#[derive(Clone, Debug, Eq, PartialEq)]
358pub struct RestoreStatusOptions {
359 pub plan: PathBuf,
360 pub out: Option<PathBuf>,
361}
362
363impl RestoreStatusOptions {
364 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
381fn 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#[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 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
430fn 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#[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 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
495fn 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#[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 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
554fn 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#[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 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
663fn 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
694fn value_arg(id: &'static str) -> Arg {
696 Arg::new(id).num_args(1)
697}
698
699fn flag_arg(id: &'static str) -> Arg {
701 Arg::new(id).action(ArgAction::SetTrue)
702}
703
704fn string_option(matches: &clap::ArgMatches, id: &str) -> Option<String> {
706 matches.get_one::<String>(id).cloned()
707}
708
709fn path_option(matches: &clap::ArgMatches, id: &str) -> Option<PathBuf> {
711 string_option(matches, id).map(PathBuf::from)
712}
713
714fn 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
722fn 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
733fn 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
754pub 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
833pub 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
843pub 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
851pub 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
869pub 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
877pub 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
885pub 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
893pub 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
901fn 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
909fn 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
917fn 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
927fn 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
1006fn 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
1054fn 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
1073fn 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
1100fn 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
1121fn 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
1165fn 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
1196fn 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
1222fn 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
1273fn 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
1297fn 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
1313fn 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
1332fn 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
1338fn 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
1344fn 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
1350fn 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
1356fn 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
1364fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
1366 value
1367 .parse::<usize>()
1368 .map_err(|_| RestoreCommandError::InvalidSequence)
1369}
1370
1371fn 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
1384fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
1386 output::write_pretty_json(options.out.as_ref(), plan)
1387}
1388
1389fn write_status(
1391 options: &RestoreStatusOptions,
1392 status: &RestoreStatus,
1393) -> Result<(), RestoreCommandError> {
1394 output::write_pretty_json(options.out.as_ref(), status)
1395}
1396
1397fn 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
1405fn 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
1420fn write_apply_status(
1422 options: &RestoreApplyStatusOptions,
1423 status: &RestoreApplyJournalStatus,
1424) -> Result<(), RestoreCommandError> {
1425 output::write_pretty_json(options.out.as_ref(), status)
1426}
1427
1428fn write_apply_report(
1430 options: &RestoreApplyReportOptions,
1431 report: &RestoreApplyJournalReport,
1432) -> Result<(), RestoreCommandError> {
1433 output::write_pretty_json(options.out.as_ref(), report)
1434}
1435
1436fn write_restore_run(
1438 options: &RestoreRunOptions,
1439 run: &RestoreRunResponse,
1440) -> Result<(), RestoreCommandError> {
1441 output::write_pretty_json(options.out.as_ref(), run)
1442}
1443
1444const 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;