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        RestoreApplyCommandConfig, RestoreApplyCommandOutputPair, RestoreApplyCommandPreview,
7        RestoreApplyDryRun, RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
8        RestoreApplyJournalOperation, RestoreApplyJournalReport, RestoreApplyJournalStatus,
9        RestoreApplyOperationKind, RestoreApplyOperationKindCounts, RestoreApplyOperationReceipt,
10        RestoreApplyOperationState, RestoreApplyPendingSummary, RestoreApplyProgressSummary,
11        RestoreApplyReportOperation, RestoreApplyReportOutcome, RestoreApplyRunnerCommand,
12        RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner, RestoreStatus,
13    },
14};
15use clap::{Arg, ArgAction, Command as ClapCommand};
16use serde::Serialize;
17use std::{
18    ffi::OsString,
19    fs,
20    io::{self, Write},
21    path::{Path, PathBuf},
22    process::Command as ProcessCommand,
23};
24use thiserror::Error as ThisError;
25
26///
27/// RestoreCommandError
28///
29
30#[derive(Debug, ThisError)]
31pub enum RestoreCommandError {
32    #[error("{0}")]
33    Usage(&'static str),
34
35    #[error("missing required option {0}")]
36    MissingOption(&'static str),
37
38    #[error("use either --manifest or --backup-dir, not both")]
39    ConflictingManifestSources,
40
41    #[error("--require-verified requires --backup-dir")]
42    RequireVerifiedNeedsBackupDir,
43
44    #[error("restore apply currently requires --dry-run")]
45    ApplyRequiresDryRun,
46
47    #[error("restore run requires --dry-run, --execute, or --unclaim-pending")]
48    RestoreRunRequiresMode,
49
50    #[error("use only one restore run mode: --dry-run, --execute, or --unclaim-pending")]
51    RestoreRunConflictingModes,
52
53    #[error("restore run command failed for operation {sequence}: status={status}")]
54    RestoreRunCommandFailed { sequence: usize, status: String },
55
56    #[error("restore apply journal is locked: {lock_path}")]
57    RestoreApplyJournalLocked { lock_path: String },
58
59    #[error("restore run for backup {backup_id} used run_mode={actual}, expected {expected}")]
60    RestoreRunModeMismatch {
61        backup_id: String,
62        expected: String,
63        actual: String,
64    },
65
66    #[error(
67        "restore run for backup {backup_id} stopped for {actual}, expected stopped_reason={expected}"
68    )]
69    RestoreRunStoppedReasonMismatch {
70        backup_id: String,
71        expected: String,
72        actual: String,
73    },
74
75    #[error(
76        "restore run for backup {backup_id} reported next_action={actual}, expected {expected}"
77    )]
78    RestoreRunNextActionMismatch {
79        backup_id: String,
80        expected: String,
81        actual: String,
82    },
83
84    #[error("restore run for backup {backup_id} executed {actual} operations, expected {expected}")]
85    RestoreRunExecutedCountMismatch {
86        backup_id: String,
87        expected: usize,
88        actual: usize,
89    },
90
91    #[error("restore run for backup {backup_id} wrote {actual} receipts, expected {expected}")]
92    RestoreRunReceiptCountMismatch {
93        backup_id: String,
94        expected: usize,
95        actual: usize,
96    },
97
98    #[error(
99        "restore run for backup {backup_id} wrote {actual} {receipt_kind} receipts, expected {expected}"
100    )]
101    RestoreRunReceiptKindCountMismatch {
102        backup_id: String,
103        receipt_kind: &'static str,
104        expected: usize,
105        actual: usize,
106    },
107
108    #[error(
109        "restore run for backup {backup_id} wrote {actual_receipts} receipts with {mismatched_receipts} updated_at mismatches, expected {expected}"
110    )]
111    RestoreRunReceiptUpdatedAtMismatch {
112        backup_id: String,
113        expected: String,
114        actual_receipts: usize,
115        mismatched_receipts: usize,
116    },
117
118    #[error(
119        "restore run for backup {backup_id} reported requested_state_updated_at={actual:?}, expected {expected}"
120    )]
121    RestoreRunStateUpdatedAtMismatch {
122        backup_id: String,
123        expected: String,
124        actual: Option<String>,
125    },
126
127    #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
128    RestoreNotReady {
129        backup_id: String,
130        reasons: Vec<String>,
131    },
132
133    #[error("restore manifest {backup_id} is not design ready")]
134    DesignConformanceNotReady { backup_id: String },
135
136    #[error(
137        "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
138    )]
139    RestoreApplyPending {
140        backup_id: String,
141        pending_operations: usize,
142        next_transition_sequence: Option<usize>,
143    },
144
145    #[error(
146        "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:?}"
147    )]
148    RestoreApplyPendingStale {
149        backup_id: String,
150        cutoff_updated_at: String,
151        pending_sequence: Option<usize>,
152        pending_updated_at: Option<String>,
153    },
154
155    #[error(
156        "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
157    )]
158    RestoreApplyIncomplete {
159        backup_id: String,
160        completed_operations: usize,
161        operation_count: usize,
162    },
163
164    #[error(
165        "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
166    )]
167    RestoreApplyFailed {
168        backup_id: String,
169        failed_operations: usize,
170    },
171
172    #[error("restore apply journal for backup {backup_id} is not ready: reasons={reasons:?}")]
173    RestoreApplyNotReady {
174        backup_id: String,
175        reasons: Vec<String>,
176    },
177
178    #[error("restore apply report for backup {backup_id} requires attention: outcome={outcome:?}")]
179    RestoreApplyReportNeedsAttention {
180        backup_id: String,
181        outcome: canic_backup::restore::RestoreApplyReportOutcome,
182    },
183
184    #[error(
185        "restore apply progress for backup {backup_id} has unexpected {field}: expected={expected}, actual={actual}"
186    )]
187    RestoreApplyProgressMismatch {
188        backup_id: String,
189        field: &'static str,
190        expected: usize,
191        actual: usize,
192    },
193
194    #[error(
195        "restore apply journal for backup {backup_id} has no executable command: operation_available={operation_available}, complete={complete}, blocked_reasons={blocked_reasons:?}"
196    )]
197    RestoreApplyCommandUnavailable {
198        backup_id: String,
199        operation_available: bool,
200        complete: bool,
201        blocked_reasons: Vec<String>,
202    },
203
204    #[error(
205        "restore apply journal next operation changed before claim: expected={expected}, actual={actual:?}"
206    )]
207    RestoreRunClaimSequenceMismatch {
208        expected: usize,
209        actual: Option<usize>,
210    },
211
212    #[error("unknown option {0}")]
213    UnknownOption(String),
214
215    #[error("option --sequence requires a non-negative integer value")]
216    InvalidSequence,
217
218    #[error("option {option} requires a positive integer value")]
219    InvalidPositiveInteger { option: &'static str },
220
221    #[error(transparent)]
222    Io(#[from] std::io::Error),
223
224    #[error(transparent)]
225    Json(#[from] serde_json::Error),
226
227    #[error(transparent)]
228    Persistence(#[from] PersistenceError),
229
230    #[error(transparent)]
231    RestorePlan(#[from] RestorePlanError),
232
233    #[error(transparent)]
234    RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
235
236    #[error(transparent)]
237    RestoreApplyJournal(#[from] RestoreApplyJournalError),
238}
239
240///
241/// RestorePlanOptions
242///
243
244#[derive(Clone, Debug, Eq, PartialEq)]
245pub struct RestorePlanOptions {
246    pub manifest: Option<PathBuf>,
247    pub backup_dir: Option<PathBuf>,
248    pub mapping: Option<PathBuf>,
249    pub out: Option<PathBuf>,
250    pub require_verified: bool,
251    pub require_design_v1: bool,
252    pub require_restore_ready: bool,
253}
254
255impl RestorePlanOptions {
256    /// Parse restore planning options from CLI arguments.
257    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
258    where
259        I: IntoIterator<Item = OsString>,
260    {
261        let matches = restore_plan_command()
262            .try_get_matches_from(std::iter::once(OsString::from("restore-plan")).chain(args))
263            .map_err(|_| RestoreCommandError::Usage(usage()))?;
264
265        let manifest = path_option(&matches, "manifest");
266        let backup_dir = path_option(&matches, "backup-dir");
267        let require_verified = matches.get_flag("require-verified");
268
269        if manifest.is_some() && backup_dir.is_some() {
270            return Err(RestoreCommandError::ConflictingManifestSources);
271        }
272
273        if manifest.is_none() && backup_dir.is_none() {
274            return Err(RestoreCommandError::MissingOption(
275                "--manifest or --backup-dir",
276            ));
277        }
278
279        if require_verified && backup_dir.is_none() {
280            return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
281        }
282
283        Ok(Self {
284            manifest,
285            backup_dir,
286            mapping: path_option(&matches, "mapping"),
287            out: path_option(&matches, "out"),
288            require_verified,
289            require_design_v1: matches.get_flag("require-design"),
290            require_restore_ready: matches.get_flag("require-restore-ready"),
291        })
292    }
293}
294
295// Build the restore plan parser.
296fn restore_plan_command() -> ClapCommand {
297    ClapCommand::new("restore-plan")
298        .disable_help_flag(true)
299        .arg(value_arg("manifest").long("manifest"))
300        .arg(value_arg("backup-dir").long("backup-dir"))
301        .arg(value_arg("mapping").long("mapping"))
302        .arg(value_arg("out").long("out"))
303        .arg(flag_arg("require-verified").long("require-verified"))
304        .arg(
305            flag_arg("require-design")
306                .long("require-design")
307                .alias("require-design-v1"),
308        )
309        .arg(flag_arg("require-restore-ready").long("require-restore-ready"))
310}
311
312///
313/// RestoreStatusOptions
314///
315
316#[derive(Clone, Debug, Eq, PartialEq)]
317pub struct RestoreStatusOptions {
318    pub plan: PathBuf,
319    pub out: Option<PathBuf>,
320}
321
322impl RestoreStatusOptions {
323    /// Parse restore status options from CLI arguments.
324    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
325    where
326        I: IntoIterator<Item = OsString>,
327    {
328        let matches = restore_status_command()
329            .try_get_matches_from(std::iter::once(OsString::from("restore-status")).chain(args))
330            .map_err(|_| RestoreCommandError::Usage(usage()))?;
331
332        Ok(Self {
333            plan: path_option(&matches, "plan")
334                .ok_or(RestoreCommandError::MissingOption("--plan"))?,
335            out: path_option(&matches, "out"),
336        })
337    }
338}
339
340// Build the restore status parser.
341fn restore_status_command() -> ClapCommand {
342    ClapCommand::new("restore-status")
343        .disable_help_flag(true)
344        .arg(value_arg("plan").long("plan"))
345        .arg(value_arg("out").long("out"))
346}
347
348///
349/// RestoreApplyOptions
350///
351
352#[derive(Clone, Debug, Eq, PartialEq)]
353pub struct RestoreApplyOptions {
354    pub plan: PathBuf,
355    pub status: Option<PathBuf>,
356    pub backup_dir: Option<PathBuf>,
357    pub out: Option<PathBuf>,
358    pub journal_out: Option<PathBuf>,
359    pub dry_run: bool,
360}
361
362impl RestoreApplyOptions {
363    /// Parse restore apply options from CLI arguments.
364    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
365    where
366        I: IntoIterator<Item = OsString>,
367    {
368        let matches = restore_apply_command()
369            .try_get_matches_from(std::iter::once(OsString::from("restore-apply")).chain(args))
370            .map_err(|_| RestoreCommandError::Usage(usage()))?;
371        let dry_run = matches.get_flag("dry-run");
372
373        if !dry_run {
374            return Err(RestoreCommandError::ApplyRequiresDryRun);
375        }
376
377        Ok(Self {
378            plan: path_option(&matches, "plan")
379                .ok_or(RestoreCommandError::MissingOption("--plan"))?,
380            status: path_option(&matches, "status"),
381            backup_dir: path_option(&matches, "backup-dir"),
382            out: path_option(&matches, "out"),
383            journal_out: path_option(&matches, "journal-out"),
384            dry_run,
385        })
386    }
387}
388
389// Build the restore apply dry-run parser.
390fn restore_apply_command() -> ClapCommand {
391    ClapCommand::new("restore-apply")
392        .disable_help_flag(true)
393        .arg(value_arg("plan").long("plan"))
394        .arg(value_arg("status").long("status"))
395        .arg(value_arg("backup-dir").long("backup-dir"))
396        .arg(value_arg("out").long("out"))
397        .arg(value_arg("journal-out").long("journal-out"))
398        .arg(flag_arg("dry-run").long("dry-run"))
399}
400
401///
402/// RestoreApplyStatusOptions
403///
404
405#[derive(Clone, Debug, Eq, PartialEq)]
406#[expect(
407    clippy::struct_excessive_bools,
408    reason = "CLI status options mirror independent fail-closed guard flags"
409)]
410pub struct RestoreApplyStatusOptions {
411    pub journal: PathBuf,
412    pub require_ready: bool,
413    pub require_no_pending: bool,
414    pub require_no_failed: bool,
415    pub require_complete: bool,
416    pub require_remaining_count: Option<usize>,
417    pub require_attention_count: Option<usize>,
418    pub require_completion_basis_points: Option<usize>,
419    pub require_no_pending_before: Option<String>,
420    pub out: Option<PathBuf>,
421}
422
423impl RestoreApplyStatusOptions {
424    /// Parse restore apply-status options from CLI arguments.
425    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
426    where
427        I: IntoIterator<Item = OsString>,
428    {
429        let matches = restore_apply_status_command()
430            .try_get_matches_from(
431                std::iter::once(OsString::from("restore-apply-status")).chain(args),
432            )
433            .map_err(|_| RestoreCommandError::Usage(usage()))?;
434
435        Ok(Self {
436            journal: path_option(&matches, "journal")
437                .ok_or(RestoreCommandError::MissingOption("--journal"))?,
438            require_ready: matches.get_flag("require-ready"),
439            require_no_pending: matches.get_flag("require-no-pending"),
440            require_no_failed: matches.get_flag("require-no-failed"),
441            require_complete: matches.get_flag("require-complete"),
442            require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
443            require_attention_count: sequence_option(&matches, "require-attention-count")?,
444            require_completion_basis_points: sequence_option(
445                &matches,
446                "require-completion-basis-points",
447            )?,
448            require_no_pending_before: string_option(&matches, "require-no-pending-before"),
449            out: path_option(&matches, "out"),
450        })
451    }
452}
453
454// Build the restore apply-status parser.
455fn restore_apply_status_command() -> ClapCommand {
456    ClapCommand::new("restore-apply-status")
457        .disable_help_flag(true)
458        .arg(value_arg("journal").long("journal"))
459        .arg(flag_arg("require-ready").long("require-ready"))
460        .arg(flag_arg("require-no-pending").long("require-no-pending"))
461        .arg(flag_arg("require-no-failed").long("require-no-failed"))
462        .arg(flag_arg("require-complete").long("require-complete"))
463        .arg(value_arg("require-remaining-count").long("require-remaining-count"))
464        .arg(value_arg("require-attention-count").long("require-attention-count"))
465        .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
466        .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
467        .arg(value_arg("out").long("out"))
468}
469
470///
471/// RestoreApplyReportOptions
472///
473
474#[derive(Clone, Debug, Eq, PartialEq)]
475pub struct RestoreApplyReportOptions {
476    pub journal: PathBuf,
477    pub require_no_attention: bool,
478    pub require_remaining_count: Option<usize>,
479    pub require_attention_count: Option<usize>,
480    pub require_completion_basis_points: Option<usize>,
481    pub require_no_pending_before: Option<String>,
482    pub out: Option<PathBuf>,
483}
484
485impl RestoreApplyReportOptions {
486    /// Parse restore apply-report options from CLI arguments.
487    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
488    where
489        I: IntoIterator<Item = OsString>,
490    {
491        let matches = restore_apply_report_command()
492            .try_get_matches_from(
493                std::iter::once(OsString::from("restore-apply-report")).chain(args),
494            )
495            .map_err(|_| RestoreCommandError::Usage(usage()))?;
496
497        Ok(Self {
498            journal: path_option(&matches, "journal")
499                .ok_or(RestoreCommandError::MissingOption("--journal"))?,
500            require_no_attention: matches.get_flag("require-no-attention"),
501            require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
502            require_attention_count: sequence_option(&matches, "require-attention-count")?,
503            require_completion_basis_points: sequence_option(
504                &matches,
505                "require-completion-basis-points",
506            )?,
507            require_no_pending_before: string_option(&matches, "require-no-pending-before"),
508            out: path_option(&matches, "out"),
509        })
510    }
511}
512
513// Build the restore apply-report parser.
514fn restore_apply_report_command() -> ClapCommand {
515    ClapCommand::new("restore-apply-report")
516        .disable_help_flag(true)
517        .arg(value_arg("journal").long("journal"))
518        .arg(flag_arg("require-no-attention").long("require-no-attention"))
519        .arg(value_arg("require-remaining-count").long("require-remaining-count"))
520        .arg(value_arg("require-attention-count").long("require-attention-count"))
521        .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
522        .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
523        .arg(value_arg("out").long("out"))
524}
525
526///
527/// RestoreRunOptions
528///
529
530#[derive(Clone, Debug, Eq, PartialEq)]
531#[expect(
532    clippy::struct_excessive_bools,
533    reason = "CLI runner options mirror independent mode and fail-closed guard flags"
534)]
535pub struct RestoreRunOptions {
536    pub journal: PathBuf,
537    pub dfx: String,
538    pub network: Option<String>,
539    pub out: Option<PathBuf>,
540    pub dry_run: bool,
541    pub execute: bool,
542    pub unclaim_pending: bool,
543    pub max_steps: Option<usize>,
544    pub updated_at: Option<String>,
545    pub require_complete: bool,
546    pub require_no_attention: bool,
547    pub require_run_mode: Option<String>,
548    pub require_stopped_reason: Option<String>,
549    pub require_next_action: Option<String>,
550    pub require_executed_count: Option<usize>,
551    pub require_receipt_count: Option<usize>,
552    pub require_completed_receipt_count: Option<usize>,
553    pub require_failed_receipt_count: Option<usize>,
554    pub require_recovered_receipt_count: Option<usize>,
555    pub require_receipt_updated_at: Option<String>,
556    pub require_state_updated_at: Option<String>,
557    pub require_remaining_count: Option<usize>,
558    pub require_attention_count: Option<usize>,
559    pub require_completion_basis_points: Option<usize>,
560    pub require_no_pending_before: Option<String>,
561}
562
563impl RestoreRunOptions {
564    /// Parse restore run options from CLI arguments.
565    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
566    where
567        I: IntoIterator<Item = OsString>,
568    {
569        let matches = restore_run_command()
570            .try_get_matches_from(std::iter::once(OsString::from("restore-run")).chain(args))
571            .map_err(|_| RestoreCommandError::Usage(usage()))?;
572
573        let dry_run = matches.get_flag("dry-run");
574        let execute = matches.get_flag("execute");
575        let unclaim_pending = matches.get_flag("unclaim-pending");
576
577        validate_restore_run_mode_selection(dry_run, execute, unclaim_pending)?;
578
579        Ok(Self {
580            journal: path_option(&matches, "journal")
581                .ok_or(RestoreCommandError::MissingOption("--journal"))?,
582            dfx: string_option(&matches, "dfx").unwrap_or_else(|| "dfx".to_string()),
583            network: string_option(&matches, "network"),
584            out: path_option(&matches, "out"),
585            dry_run,
586            execute,
587            unclaim_pending,
588            max_steps: positive_integer_option(&matches, "max-steps", "--max-steps")?,
589            updated_at: string_option(&matches, "updated-at"),
590            require_complete: matches.get_flag("require-complete"),
591            require_no_attention: matches.get_flag("require-no-attention"),
592            require_run_mode: string_option(&matches, "require-run-mode"),
593            require_stopped_reason: string_option(&matches, "require-stopped-reason"),
594            require_next_action: string_option(&matches, "require-next-action"),
595            require_executed_count: sequence_option(&matches, "require-executed-count")?,
596            require_receipt_count: sequence_option(&matches, "require-receipt-count")?,
597            require_completed_receipt_count: sequence_option(
598                &matches,
599                "require-completed-receipt-count",
600            )?,
601            require_failed_receipt_count: sequence_option(
602                &matches,
603                "require-failed-receipt-count",
604            )?,
605            require_recovered_receipt_count: sequence_option(
606                &matches,
607                "require-recovered-receipt-count",
608            )?,
609            require_receipt_updated_at: string_option(&matches, "require-receipt-updated-at"),
610            require_state_updated_at: string_option(&matches, "require-state-updated-at"),
611            require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
612            require_attention_count: sequence_option(&matches, "require-attention-count")?,
613            require_completion_basis_points: sequence_option(
614                &matches,
615                "require-completion-basis-points",
616            )?,
617            require_no_pending_before: string_option(&matches, "require-no-pending-before"),
618        })
619    }
620}
621
622// Build the native restore runner parser.
623fn restore_run_command() -> ClapCommand {
624    ClapCommand::new("restore-run")
625        .disable_help_flag(true)
626        .arg(value_arg("journal").long("journal"))
627        .arg(value_arg("dfx").long("dfx"))
628        .arg(value_arg("network").long("network"))
629        .arg(value_arg("out").long("out"))
630        .arg(flag_arg("dry-run").long("dry-run"))
631        .arg(flag_arg("execute").long("execute"))
632        .arg(flag_arg("unclaim-pending").long("unclaim-pending"))
633        .arg(value_arg("max-steps").long("max-steps"))
634        .arg(value_arg("updated-at").long("updated-at"))
635        .arg(flag_arg("require-complete").long("require-complete"))
636        .arg(flag_arg("require-no-attention").long("require-no-attention"))
637        .arg(value_arg("require-run-mode").long("require-run-mode"))
638        .arg(value_arg("require-stopped-reason").long("require-stopped-reason"))
639        .arg(value_arg("require-next-action").long("require-next-action"))
640        .arg(value_arg("require-executed-count").long("require-executed-count"))
641        .arg(value_arg("require-receipt-count").long("require-receipt-count"))
642        .arg(value_arg("require-completed-receipt-count").long("require-completed-receipt-count"))
643        .arg(value_arg("require-failed-receipt-count").long("require-failed-receipt-count"))
644        .arg(value_arg("require-recovered-receipt-count").long("require-recovered-receipt-count"))
645        .arg(value_arg("require-receipt-updated-at").long("require-receipt-updated-at"))
646        .arg(value_arg("require-state-updated-at").long("require-state-updated-at"))
647        .arg(value_arg("require-remaining-count").long("require-remaining-count"))
648        .arg(value_arg("require-attention-count").long("require-attention-count"))
649        .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
650        .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
651}
652
653// Build one string-valued Clap argument.
654fn value_arg(id: &'static str) -> Arg {
655    Arg::new(id).num_args(1)
656}
657
658// Build one boolean Clap argument.
659fn flag_arg(id: &'static str) -> Arg {
660    Arg::new(id).action(ArgAction::SetTrue)
661}
662
663// Read one string option from Clap matches.
664fn string_option(matches: &clap::ArgMatches, id: &str) -> Option<String> {
665    matches.get_one::<String>(id).cloned()
666}
667
668// Read one path option from Clap matches.
669fn path_option(matches: &clap::ArgMatches, id: &str) -> Option<PathBuf> {
670    string_option(matches, id).map(PathBuf::from)
671}
672
673// Read one usize option from Clap matches.
674fn sequence_option(
675    matches: &clap::ArgMatches,
676    id: &str,
677) -> Result<Option<usize>, RestoreCommandError> {
678    string_option(matches, id).map(parse_sequence).transpose()
679}
680
681// Read one positive integer option from Clap matches.
682fn positive_integer_option(
683    matches: &clap::ArgMatches,
684    id: &str,
685    option: &'static str,
686) -> Result<Option<usize>, RestoreCommandError> {
687    string_option(matches, id)
688        .map(|value| parse_positive_integer(option, value))
689        .transpose()
690}
691
692// Validate that restore run received exactly one execution mode.
693fn validate_restore_run_mode_selection(
694    dry_run: bool,
695    execute: bool,
696    unclaim_pending: bool,
697) -> Result<(), RestoreCommandError> {
698    let mode_count = [dry_run, execute, unclaim_pending]
699        .into_iter()
700        .filter(|enabled| *enabled)
701        .count();
702    if mode_count > 1 {
703        return Err(RestoreCommandError::RestoreRunConflictingModes);
704    }
705
706    if mode_count == 0 {
707        return Err(RestoreCommandError::RestoreRunRequiresMode);
708    }
709
710    Ok(())
711}
712
713///
714/// RestoreRunResult
715///
716
717struct RestoreRunResult {
718    response: RestoreRunResponse,
719    error: Option<RestoreCommandError>,
720}
721
722impl RestoreRunResult {
723    // Build a successful runner response with no deferred error.
724    const fn ok(response: RestoreRunResponse) -> Self {
725        Self {
726            response,
727            error: None,
728        }
729    }
730}
731
732const RESTORE_RUN_MODE_DRY_RUN: &str = "dry-run";
733const RESTORE_RUN_MODE_EXECUTE: &str = "execute";
734const RESTORE_RUN_MODE_UNCLAIM_PENDING: &str = "unclaim-pending";
735
736const RESTORE_RUN_STOPPED_BLOCKED: &str = "blocked";
737const RESTORE_RUN_STOPPED_COMMAND_FAILED: &str = "command-failed";
738const RESTORE_RUN_STOPPED_COMPLETE: &str = "complete";
739const RESTORE_RUN_STOPPED_MAX_STEPS: &str = "max-steps-reached";
740const RESTORE_RUN_STOPPED_PENDING: &str = "pending";
741const RESTORE_RUN_STOPPED_PREVIEW: &str = "preview";
742const RESTORE_RUN_STOPPED_READY: &str = "ready";
743const RESTORE_RUN_STOPPED_RECOVERED_PENDING: &str = "recovered-pending";
744
745const RESTORE_RUN_ACTION_DONE: &str = "done";
746const RESTORE_RUN_ACTION_FIX_BLOCKED: &str = "fix-blocked-journal";
747const RESTORE_RUN_ACTION_INSPECT_FAILED: &str = "inspect-failed-operation";
748const RESTORE_RUN_ACTION_RERUN: &str = "rerun";
749const RESTORE_RUN_ACTION_UNCLAIM_PENDING: &str = "unclaim-pending";
750
751const RESTORE_RUN_EXECUTED_COMPLETED: &str = "completed";
752const RESTORE_RUN_EXECUTED_FAILED: &str = "failed";
753const RESTORE_RUN_RECEIPT_COMPLETED: &str = "command-completed";
754const RESTORE_RUN_RECEIPT_FAILED: &str = "command-failed";
755const RESTORE_RUN_RECEIPT_RECOVERED_PENDING: &str = "pending-recovered";
756const RESTORE_RUN_RECEIPT_STATE_READY: &str = "ready";
757const RESTORE_RUN_COMMAND_EXIT_PREFIX: &str = "runner-command-exit";
758const RESTORE_RUN_RESPONSE_VERSION: u16 = 1;
759const RESTORE_RUN_OUTPUT_RECEIPT_LIMIT: usize = 64 * 1024;
760
761///
762/// RestoreRunResponse
763///
764
765#[derive(Clone, Debug, Serialize)]
766#[expect(
767    clippy::struct_excessive_bools,
768    reason = "Runner response exposes stable JSON status flags for operators and CI"
769)]
770pub struct RestoreRunResponse {
771    run_version: u16,
772    backup_id: String,
773    run_mode: &'static str,
774    dry_run: bool,
775    execute: bool,
776    unclaim_pending: bool,
777    stopped_reason: &'static str,
778    next_action: &'static str,
779    #[serde(skip_serializing_if = "Option::is_none")]
780    requested_state_updated_at: Option<String>,
781    #[serde(skip_serializing_if = "Option::is_none")]
782    max_steps_reached: Option<bool>,
783    #[serde(default, skip_serializing_if = "Vec::is_empty")]
784    executed_operations: Vec<RestoreRunExecutedOperation>,
785    #[serde(default, skip_serializing_if = "Vec::is_empty")]
786    operation_receipts: Vec<RestoreRunOperationReceipt>,
787    #[serde(skip_serializing_if = "Option::is_none")]
788    operation_receipt_count: Option<usize>,
789    operation_receipt_summary: RestoreRunReceiptSummary,
790    #[serde(skip_serializing_if = "Option::is_none")]
791    executed_operation_count: Option<usize>,
792    #[serde(skip_serializing_if = "Option::is_none")]
793    recovered_operation: Option<RestoreApplyJournalOperation>,
794    batch_summary: RestoreRunBatchSummary,
795    ready: bool,
796    complete: bool,
797    attention_required: bool,
798    outcome: RestoreApplyReportOutcome,
799    operation_count: usize,
800    operation_counts: RestoreApplyOperationKindCounts,
801    operation_counts_supplied: bool,
802    progress: RestoreApplyProgressSummary,
803    pending_summary: RestoreApplyPendingSummary,
804    pending_operations: usize,
805    ready_operations: usize,
806    blocked_operations: usize,
807    completed_operations: usize,
808    failed_operations: usize,
809    blocked_reasons: Vec<String>,
810    next_transition: Option<RestoreApplyReportOperation>,
811    #[serde(skip_serializing_if = "Option::is_none")]
812    operation_available: Option<bool>,
813    #[serde(skip_serializing_if = "Option::is_none")]
814    command_available: Option<bool>,
815    #[serde(skip_serializing_if = "Option::is_none")]
816    command: Option<RestoreApplyRunnerCommand>,
817}
818
819impl RestoreRunResponse {
820    // Build the shared native runner response fields from an apply journal report.
821    fn from_report(
822        backup_id: String,
823        report: RestoreApplyJournalReport,
824        mode: RestoreRunResponseMode,
825    ) -> Self {
826        Self {
827            run_version: RESTORE_RUN_RESPONSE_VERSION,
828            backup_id,
829            run_mode: mode.run_mode,
830            dry_run: mode.dry_run,
831            execute: mode.execute,
832            unclaim_pending: mode.unclaim_pending,
833            stopped_reason: mode.stopped_reason,
834            next_action: mode.next_action,
835            requested_state_updated_at: None,
836            max_steps_reached: None,
837            executed_operations: Vec::new(),
838            operation_receipts: Vec::new(),
839            operation_receipt_count: Some(0),
840            operation_receipt_summary: RestoreRunReceiptSummary::default(),
841            executed_operation_count: None,
842            recovered_operation: None,
843            batch_summary: RestoreRunBatchSummary::from_counts(
844                RestoreRunBatchStart::new(
845                    None,
846                    report.ready_operations,
847                    report.progress.remaining_operations,
848                ),
849                0,
850                report.ready_operations,
851                report.progress.remaining_operations,
852                false,
853                report.complete,
854            ),
855            ready: report.ready,
856            complete: report.complete,
857            attention_required: report.attention_required,
858            outcome: report.outcome,
859            operation_count: report.operation_count,
860            operation_counts: report.operation_counts,
861            operation_counts_supplied: report.operation_counts_supplied,
862            progress: report.progress,
863            pending_summary: report.pending_summary,
864            pending_operations: report.pending_operations,
865            ready_operations: report.ready_operations,
866            blocked_operations: report.blocked_operations,
867            completed_operations: report.completed_operations,
868            failed_operations: report.failed_operations,
869            blocked_reasons: report.blocked_reasons,
870            next_transition: report.next_transition,
871            operation_available: None,
872            command_available: None,
873            command: None,
874        }
875    }
876
877    // Replace the detailed receipt stream and refresh the compact counters.
878    fn set_operation_receipts(&mut self, receipts: Vec<RestoreRunOperationReceipt>) {
879        self.operation_receipt_summary = RestoreRunReceiptSummary::from_receipts(&receipts);
880        self.operation_receipt_count = Some(receipts.len());
881        self.operation_receipts = receipts;
882    }
883
884    // Echo the caller-provided state marker for receipt-free runner summaries.
885    fn set_requested_state_updated_at(&mut self, updated_at: Option<&String>) {
886        self.requested_state_updated_at = updated_at.cloned();
887    }
888
889    // Refresh batch counters after a dry-run, recovery, or execute pass.
890    const fn set_batch_summary(
891        &mut self,
892        batch_start: RestoreRunBatchStart,
893        executed_operations: usize,
894        stopped_by_max_steps: bool,
895    ) {
896        self.batch_summary = RestoreRunBatchSummary::from_counts(
897            batch_start,
898            executed_operations,
899            self.ready_operations,
900            self.progress.remaining_operations,
901            stopped_by_max_steps,
902            self.complete,
903        );
904    }
905}
906
907///
908/// RestoreRunBatchStart
909///
910
911#[derive(Clone, Copy, Debug)]
912struct RestoreRunBatchStart {
913    requested_max_steps: Option<usize>,
914    initial_ready_operations: usize,
915    initial_remaining_operations: usize,
916}
917
918impl RestoreRunBatchStart {
919    // Capture the runner counters observed before a dry-run, recovery, or execute pass.
920    const fn new(
921        requested_max_steps: Option<usize>,
922        initial_ready_operations: usize,
923        initial_remaining_operations: usize,
924    ) -> Self {
925        Self {
926            requested_max_steps,
927            initial_ready_operations,
928            initial_remaining_operations,
929        }
930    }
931}
932
933///
934/// RestoreRunBatchSummary
935///
936
937#[derive(Clone, Debug, Serialize)]
938struct RestoreRunBatchSummary {
939    requested_max_steps: Option<usize>,
940    initial_ready_operations: usize,
941    initial_remaining_operations: usize,
942    executed_operations: usize,
943    remaining_ready_operations: usize,
944    remaining_operations: usize,
945    ready_operations_delta: isize,
946    remaining_operations_delta: isize,
947    stopped_by_max_steps: bool,
948    complete: bool,
949}
950
951impl RestoreRunBatchSummary {
952    // Build the compact batch counters shown in every native runner response.
953    const fn from_counts(
954        batch_start: RestoreRunBatchStart,
955        executed_operations: usize,
956        remaining_ready_operations: usize,
957        remaining_operations: usize,
958        stopped_by_max_steps: bool,
959        complete: bool,
960    ) -> Self {
961        Self {
962            requested_max_steps: batch_start.requested_max_steps,
963            initial_ready_operations: batch_start.initial_ready_operations,
964            initial_remaining_operations: batch_start.initial_remaining_operations,
965            executed_operations,
966            remaining_ready_operations,
967            remaining_operations,
968            ready_operations_delta: remaining_ready_operations.cast_signed()
969                - batch_start.initial_ready_operations.cast_signed(),
970            remaining_operations_delta: remaining_operations.cast_signed()
971                - batch_start.initial_remaining_operations.cast_signed(),
972            stopped_by_max_steps,
973            complete,
974        }
975    }
976}
977
978///
979/// RestoreRunReceiptSummary
980///
981
982#[derive(Clone, Debug, Default, Serialize)]
983struct RestoreRunReceiptSummary {
984    total_receipts: usize,
985    command_completed: usize,
986    command_failed: usize,
987    pending_recovered: usize,
988}
989
990impl RestoreRunReceiptSummary {
991    // Count restore runner receipt classes for script-friendly summaries.
992    fn from_receipts(receipts: &[RestoreRunOperationReceipt]) -> Self {
993        let mut summary = Self {
994            total_receipts: receipts.len(),
995            ..Self::default()
996        };
997
998        for receipt in receipts {
999            match receipt.event {
1000                RESTORE_RUN_RECEIPT_COMPLETED => summary.command_completed += 1,
1001                RESTORE_RUN_RECEIPT_FAILED => summary.command_failed += 1,
1002                RESTORE_RUN_RECEIPT_RECOVERED_PENDING => summary.pending_recovered += 1,
1003                _ => {}
1004            }
1005        }
1006
1007        summary
1008    }
1009}
1010
1011///
1012/// RestoreRunOperationReceipt
1013///
1014
1015#[derive(Clone, Debug, Serialize)]
1016struct RestoreRunOperationReceipt {
1017    event: &'static str,
1018    sequence: usize,
1019    operation: RestoreApplyOperationKind,
1020    target_canister: String,
1021    state: &'static str,
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    updated_at: Option<String>,
1024    #[serde(skip_serializing_if = "Option::is_none")]
1025    command: Option<RestoreApplyRunnerCommand>,
1026    #[serde(skip_serializing_if = "Option::is_none")]
1027    status: Option<String>,
1028}
1029
1030impl RestoreRunOperationReceipt {
1031    // Build a receipt for a completed runner command.
1032    fn completed(
1033        operation: RestoreApplyJournalOperation,
1034        command: RestoreApplyRunnerCommand,
1035        status: String,
1036        updated_at: Option<String>,
1037    ) -> Self {
1038        Self::from_operation(
1039            RESTORE_RUN_RECEIPT_COMPLETED,
1040            operation,
1041            RESTORE_RUN_EXECUTED_COMPLETED,
1042            updated_at,
1043            Some(command),
1044            Some(status),
1045        )
1046    }
1047
1048    // Build a receipt for a failed runner command.
1049    fn failed(
1050        operation: RestoreApplyJournalOperation,
1051        command: RestoreApplyRunnerCommand,
1052        status: String,
1053        updated_at: Option<String>,
1054    ) -> Self {
1055        Self::from_operation(
1056            RESTORE_RUN_RECEIPT_FAILED,
1057            operation,
1058            RESTORE_RUN_EXECUTED_FAILED,
1059            updated_at,
1060            Some(command),
1061            Some(status),
1062        )
1063    }
1064
1065    // Build a receipt for a recovered pending operation.
1066    fn recovered_pending(
1067        operation: RestoreApplyJournalOperation,
1068        updated_at: Option<String>,
1069    ) -> Self {
1070        Self::from_operation(
1071            RESTORE_RUN_RECEIPT_RECOVERED_PENDING,
1072            operation,
1073            RESTORE_RUN_RECEIPT_STATE_READY,
1074            updated_at,
1075            None,
1076            None,
1077        )
1078    }
1079
1080    // Map one operation event into a compact audit receipt.
1081    fn from_operation(
1082        event: &'static str,
1083        operation: RestoreApplyJournalOperation,
1084        state: &'static str,
1085        updated_at: Option<String>,
1086        command: Option<RestoreApplyRunnerCommand>,
1087        status: Option<String>,
1088    ) -> Self {
1089        Self {
1090            event,
1091            sequence: operation.sequence,
1092            operation: operation.operation,
1093            target_canister: operation.target_canister,
1094            state,
1095            updated_at,
1096            command,
1097            status,
1098        }
1099    }
1100}
1101
1102///
1103/// RestoreRunExecutedOperation
1104///
1105
1106#[derive(Clone, Debug, Serialize)]
1107struct RestoreRunExecutedOperation {
1108    sequence: usize,
1109    operation: RestoreApplyOperationKind,
1110    target_canister: String,
1111    command: RestoreApplyRunnerCommand,
1112    status: String,
1113    state: &'static str,
1114}
1115
1116impl RestoreRunExecutedOperation {
1117    // Build a completed executed-operation summary row from a runner operation.
1118    fn completed(
1119        operation: RestoreApplyJournalOperation,
1120        command: RestoreApplyRunnerCommand,
1121        status: String,
1122    ) -> Self {
1123        Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_COMPLETED)
1124    }
1125
1126    // Build a failed executed-operation summary row from a runner operation.
1127    fn failed(
1128        operation: RestoreApplyJournalOperation,
1129        command: RestoreApplyRunnerCommand,
1130        status: String,
1131    ) -> Self {
1132        Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_FAILED)
1133    }
1134
1135    // Map a journal operation into the compact runner execution row.
1136    fn from_operation(
1137        operation: RestoreApplyJournalOperation,
1138        command: RestoreApplyRunnerCommand,
1139        status: String,
1140        state: &'static str,
1141    ) -> Self {
1142        Self {
1143            sequence: operation.sequence,
1144            operation: operation.operation,
1145            target_canister: operation.target_canister,
1146            command,
1147            status,
1148            state,
1149        }
1150    }
1151}
1152
1153///
1154/// RestoreRunResponseMode
1155///
1156
1157struct RestoreRunResponseMode {
1158    run_mode: &'static str,
1159    dry_run: bool,
1160    execute: bool,
1161    unclaim_pending: bool,
1162    stopped_reason: &'static str,
1163    next_action: &'static str,
1164}
1165
1166impl RestoreRunResponseMode {
1167    // Build a response mode from the stable JSON mode flags and action labels.
1168    const fn new(
1169        run_mode: &'static str,
1170        dry_run: bool,
1171        execute: bool,
1172        unclaim_pending: bool,
1173        stopped_reason: &'static str,
1174        next_action: &'static str,
1175    ) -> Self {
1176        Self {
1177            run_mode,
1178            dry_run,
1179            execute,
1180            unclaim_pending,
1181            stopped_reason,
1182            next_action,
1183        }
1184    }
1185
1186    // Build a dry-run response mode with a computed stop reason and action.
1187    const fn dry_run(stopped_reason: &'static str, next_action: &'static str) -> Self {
1188        Self::new(
1189            RESTORE_RUN_MODE_DRY_RUN,
1190            true,
1191            false,
1192            false,
1193            stopped_reason,
1194            next_action,
1195        )
1196    }
1197
1198    // Build an execute response mode with a computed stop reason and action.
1199    const fn execute(stopped_reason: &'static str, next_action: &'static str) -> Self {
1200        Self::new(
1201            RESTORE_RUN_MODE_EXECUTE,
1202            false,
1203            true,
1204            false,
1205            stopped_reason,
1206            next_action,
1207        )
1208    }
1209
1210    // Build the pending-operation recovery response mode.
1211    const fn unclaim_pending(next_action: &'static str) -> Self {
1212        Self::new(
1213            RESTORE_RUN_MODE_UNCLAIM_PENDING,
1214            false,
1215            false,
1216            true,
1217            RESTORE_RUN_STOPPED_RECOVERED_PENDING,
1218            next_action,
1219        )
1220    }
1221}
1222
1223/// Run a restore subcommand.
1224pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
1225where
1226    I: IntoIterator<Item = OsString>,
1227{
1228    let mut args = args.into_iter();
1229    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
1230        return Err(RestoreCommandError::Usage(usage()));
1231    };
1232
1233    match command.as_str() {
1234        "plan" => {
1235            let options = RestorePlanOptions::parse(args)?;
1236            let plan = plan_restore(&options)?;
1237            write_plan(&options, &plan)?;
1238            enforce_restore_plan_requirements(&options, &plan)?;
1239            Ok(())
1240        }
1241        "status" => {
1242            let options = RestoreStatusOptions::parse(args)?;
1243            let status = restore_status(&options)?;
1244            write_status(&options, &status)?;
1245            Ok(())
1246        }
1247        "apply" => {
1248            let options = RestoreApplyOptions::parse(args)?;
1249            let dry_run = restore_apply_dry_run(&options)?;
1250            write_apply_dry_run(&options, &dry_run)?;
1251            write_apply_journal_if_requested(&options, &dry_run)?;
1252            Ok(())
1253        }
1254        "apply-status" => {
1255            let options = RestoreApplyStatusOptions::parse(args)?;
1256            let status = restore_apply_status(&options)?;
1257            write_apply_status(&options, &status)?;
1258            enforce_apply_status_requirements(&options, &status)?;
1259            Ok(())
1260        }
1261        "apply-report" => {
1262            let options = RestoreApplyReportOptions::parse(args)?;
1263            let report = restore_apply_report(&options)?;
1264            write_apply_report(&options, &report)?;
1265            enforce_apply_report_requirements(&options, &report)?;
1266            Ok(())
1267        }
1268        "run" => {
1269            let options = RestoreRunOptions::parse(args)?;
1270            let run = if options.execute {
1271                restore_run_execute_result(&options)?
1272            } else if options.unclaim_pending {
1273                RestoreRunResult::ok(restore_run_unclaim_pending(&options)?)
1274            } else {
1275                RestoreRunResult::ok(restore_run_dry_run(&options)?)
1276            };
1277            write_restore_run(&options, &run.response)?;
1278            if let Some(error) = run.error {
1279                return Err(error);
1280            }
1281            enforce_restore_run_requirements(&options, &run.response)?;
1282            Ok(())
1283        }
1284        "help" | "--help" | "-h" => {
1285            println!("{}", usage());
1286            Ok(())
1287        }
1288        "version" | "--version" | "-V" => {
1289            println!("{}", version_text());
1290            Ok(())
1291        }
1292        _ => Err(RestoreCommandError::UnknownOption(command)),
1293    }
1294}
1295
1296/// Build a no-mutation restore plan from a manifest and optional mapping.
1297pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
1298    verify_backup_layout_if_required(options)?;
1299
1300    let manifest = read_manifest_source(options)?;
1301    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
1302
1303    RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
1304}
1305
1306/// Build the initial no-mutation restore status from a restore plan.
1307pub fn restore_status(
1308    options: &RestoreStatusOptions,
1309) -> Result<RestoreStatus, RestoreCommandError> {
1310    let plan = read_plan(&options.plan)?;
1311    Ok(RestoreStatus::from_plan(&plan))
1312}
1313
1314/// Build a no-mutation restore apply dry-run from a restore plan.
1315pub fn restore_apply_dry_run(
1316    options: &RestoreApplyOptions,
1317) -> Result<RestoreApplyDryRun, RestoreCommandError> {
1318    let plan = read_plan(&options.plan)?;
1319    let status = options.status.as_ref().map(read_status).transpose()?;
1320    if let Some(backup_dir) = &options.backup_dir {
1321        return RestoreApplyDryRun::try_from_plan_with_artifacts(
1322            &plan,
1323            status.as_ref(),
1324            backup_dir,
1325        )
1326        .map_err(RestoreCommandError::from);
1327    }
1328
1329    RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
1330}
1331
1332/// Build a compact restore apply status from a journal file.
1333pub fn restore_apply_status(
1334    options: &RestoreApplyStatusOptions,
1335) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
1336    let journal = read_apply_journal(&options.journal)?;
1337    Ok(journal.status())
1338}
1339
1340/// Build an operator-oriented restore apply report from a journal file.
1341pub fn restore_apply_report(
1342    options: &RestoreApplyReportOptions,
1343) -> Result<RestoreApplyJournalReport, RestoreCommandError> {
1344    let journal = read_apply_journal(&options.journal)?;
1345    Ok(journal.report())
1346}
1347
1348/// Build a no-mutation native restore runner preview from a journal file.
1349pub fn restore_run_dry_run(
1350    options: &RestoreRunOptions,
1351) -> Result<RestoreRunResponse, RestoreCommandError> {
1352    let journal = read_apply_journal(&options.journal)?;
1353    let report = journal.report();
1354    let initial_ready_operations = report.ready_operations;
1355    let initial_remaining_operations = report.progress.remaining_operations;
1356    let preview = journal.next_command_preview_with_config(&restore_run_command_config(options));
1357    let stopped_reason = restore_run_stopped_reason(&report, false, false);
1358    let next_action = restore_run_next_action(&report, false);
1359
1360    let mut response = RestoreRunResponse::from_report(
1361        journal.backup_id,
1362        report,
1363        RestoreRunResponseMode::dry_run(stopped_reason, next_action),
1364    );
1365    response.set_requested_state_updated_at(options.updated_at.as_ref());
1366    response.set_batch_summary(
1367        RestoreRunBatchStart::new(
1368            options.max_steps,
1369            initial_ready_operations,
1370            initial_remaining_operations,
1371        ),
1372        0,
1373        false,
1374    );
1375    response.operation_available = Some(preview.operation_available);
1376    response.command_available = Some(preview.command_available);
1377    response.command = preview.command;
1378    Ok(response)
1379}
1380
1381/// Recover an interrupted restore runner by unclaiming the pending operation.
1382pub fn restore_run_unclaim_pending(
1383    options: &RestoreRunOptions,
1384) -> Result<RestoreRunResponse, RestoreCommandError> {
1385    let _lock = RestoreJournalLock::acquire(&options.journal)?;
1386    let mut journal = read_apply_journal(&options.journal)?;
1387    let initial_report = journal.report();
1388    let initial_ready_operations = initial_report.ready_operations;
1389    let initial_remaining_operations = initial_report.progress.remaining_operations;
1390    let recovered_operation = journal
1391        .next_transition_operation()
1392        .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
1393        .cloned()
1394        .ok_or(RestoreApplyJournalError::NoPendingOperation)?;
1395
1396    let recovered_updated_at = state_updated_at(options.updated_at.as_ref());
1397    journal.mark_next_operation_ready_at(Some(recovered_updated_at.clone()))?;
1398    write_apply_journal_file(&options.journal, &journal)?;
1399
1400    let report = journal.report();
1401    let next_action = restore_run_next_action(&report, true);
1402    let mut response = RestoreRunResponse::from_report(
1403        journal.backup_id,
1404        report,
1405        RestoreRunResponseMode::unclaim_pending(next_action),
1406    );
1407    response.set_requested_state_updated_at(options.updated_at.as_ref());
1408    response.set_batch_summary(
1409        RestoreRunBatchStart::new(
1410            options.max_steps,
1411            initial_ready_operations,
1412            initial_remaining_operations,
1413        ),
1414        0,
1415        false,
1416    );
1417    response.set_operation_receipts(vec![RestoreRunOperationReceipt::recovered_pending(
1418        recovered_operation.clone(),
1419        Some(recovered_updated_at),
1420    )]);
1421    response.recovered_operation = Some(recovered_operation);
1422    Ok(response)
1423}
1424
1425/// Execute ready restore apply journal operations through generated runner commands.
1426pub fn restore_run_execute(
1427    options: &RestoreRunOptions,
1428) -> Result<RestoreRunResponse, RestoreCommandError> {
1429    let run = restore_run_execute_result(options)?;
1430    if let Some(error) = run.error {
1431        return Err(error);
1432    }
1433
1434    Ok(run.response)
1435}
1436
1437// Execute ready restore apply operations and retain any deferred runner error.
1438#[expect(
1439    clippy::too_many_lines,
1440    reason = "runner execution keeps claim, command, receipt, and journal commit steps together"
1441)]
1442fn restore_run_execute_result(
1443    options: &RestoreRunOptions,
1444) -> Result<RestoreRunResult, RestoreCommandError> {
1445    let _lock = RestoreJournalLock::acquire(&options.journal)?;
1446    let mut journal = read_apply_journal(&options.journal)?;
1447    let initial_report = journal.report();
1448    let batch_start = RestoreRunBatchStart::new(
1449        options.max_steps,
1450        initial_report.ready_operations,
1451        initial_report.progress.remaining_operations,
1452    );
1453    let mut executed_operations = Vec::new();
1454    let mut operation_receipts = Vec::new();
1455    let config = restore_run_command_config(options);
1456
1457    loop {
1458        let report = journal.report();
1459        let max_steps_reached =
1460            restore_run_max_steps_reached(options, executed_operations.len(), &report);
1461        if report.complete || max_steps_reached {
1462            return Ok(RestoreRunResult::ok(restore_run_execute_summary(
1463                &journal,
1464                executed_operations,
1465                operation_receipts,
1466                max_steps_reached,
1467                options.updated_at.as_ref(),
1468                batch_start,
1469            )));
1470        }
1471
1472        enforce_restore_run_executable(&journal, &report)?;
1473        let preview = journal.next_command_preview_with_config(&config);
1474        enforce_restore_run_command_available(&preview)?;
1475
1476        let operation = preview
1477            .operation
1478            .clone()
1479            .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1480        let command = preview
1481            .command
1482            .clone()
1483            .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1484        let sequence = operation.sequence;
1485        let attempt = journal
1486            .operation_receipts
1487            .iter()
1488            .filter(|receipt| receipt.sequence == sequence)
1489            .count()
1490            + 1;
1491
1492        enforce_apply_claim_sequence(sequence, &journal)?;
1493        journal.mark_operation_pending_at(
1494            sequence,
1495            Some(state_updated_at(options.updated_at.as_ref())),
1496        )?;
1497        write_apply_journal_file(&options.journal, &journal)?;
1498
1499        let output = ProcessCommand::new(&command.program)
1500            .args(&command.args)
1501            .output()?;
1502        let status_label = exit_status_label(output.status);
1503        let output_pair = RestoreApplyCommandOutputPair::from_bytes(
1504            &output.stdout,
1505            &output.stderr,
1506            RESTORE_RUN_OUTPUT_RECEIPT_LIMIT,
1507        );
1508        if output.status.success() {
1509            let uploaded_snapshot_id =
1510                parse_uploaded_snapshot_id(&String::from_utf8_lossy(&output.stdout));
1511            let completed_updated_at = state_updated_at(options.updated_at.as_ref());
1512            journal.mark_operation_completed_at(sequence, Some(completed_updated_at.clone()))?;
1513            if operation.operation != RestoreApplyOperationKind::UploadSnapshot
1514                || uploaded_snapshot_id.is_some()
1515            {
1516                journal.record_operation_receipt(
1517                    RestoreApplyOperationReceipt::command_completed(
1518                        &operation,
1519                        command.clone(),
1520                        status_label.clone(),
1521                        Some(completed_updated_at.clone()),
1522                        output_pair.clone(),
1523                        attempt,
1524                        uploaded_snapshot_id,
1525                    ),
1526                )?;
1527            }
1528            write_apply_journal_file(&options.journal, &journal)?;
1529            executed_operations.push(RestoreRunExecutedOperation::completed(
1530                operation.clone(),
1531                command.clone(),
1532                status_label.clone(),
1533            ));
1534            operation_receipts.push(RestoreRunOperationReceipt::completed(
1535                operation,
1536                command,
1537                status_label,
1538                Some(completed_updated_at),
1539            ));
1540            continue;
1541        }
1542
1543        let failed_updated_at = state_updated_at(options.updated_at.as_ref());
1544        let failure_reason = format!("{RESTORE_RUN_COMMAND_EXIT_PREFIX}-{status_label}");
1545        journal.mark_operation_failed_at(
1546            sequence,
1547            failure_reason.clone(),
1548            Some(failed_updated_at.clone()),
1549        )?;
1550        journal.record_operation_receipt(RestoreApplyOperationReceipt::command_failed(
1551            &operation,
1552            command.clone(),
1553            status_label.clone(),
1554            Some(failed_updated_at.clone()),
1555            output_pair,
1556            attempt,
1557            failure_reason,
1558        ))?;
1559        write_apply_journal_file(&options.journal, &journal)?;
1560        executed_operations.push(RestoreRunExecutedOperation::failed(
1561            operation.clone(),
1562            command.clone(),
1563            status_label.clone(),
1564        ));
1565        operation_receipts.push(RestoreRunOperationReceipt::failed(
1566            operation,
1567            command,
1568            status_label.clone(),
1569            Some(failed_updated_at),
1570        ));
1571        let response = restore_run_execute_summary(
1572            &journal,
1573            executed_operations,
1574            operation_receipts,
1575            false,
1576            options.updated_at.as_ref(),
1577            batch_start,
1578        );
1579        return Ok(RestoreRunResult {
1580            response,
1581            error: Some(RestoreCommandError::RestoreRunCommandFailed {
1582                sequence,
1583                status: status_label,
1584            }),
1585        });
1586    }
1587}
1588
1589// Build the shared runner command-preview configuration from CLI options.
1590fn restore_run_command_config(options: &RestoreRunOptions) -> RestoreApplyCommandConfig {
1591    restore_command_config(&options.dfx, options.network.as_deref())
1592}
1593
1594// Build command-preview configuration from common dfx/network inputs.
1595fn restore_command_config(program: &str, network: Option<&str>) -> RestoreApplyCommandConfig {
1596    RestoreApplyCommandConfig {
1597        program: program.to_string(),
1598        network: network.map(str::to_string),
1599    }
1600}
1601
1602// Check whether execute mode has reached its requested operation batch size.
1603fn restore_run_max_steps_reached(
1604    options: &RestoreRunOptions,
1605    executed_operation_count: usize,
1606    report: &RestoreApplyJournalReport,
1607) -> bool {
1608    options.max_steps == Some(executed_operation_count) && !report.complete
1609}
1610
1611// Build the final native runner execution summary.
1612fn restore_run_execute_summary(
1613    journal: &RestoreApplyJournal,
1614    executed_operations: Vec<RestoreRunExecutedOperation>,
1615    operation_receipts: Vec<RestoreRunOperationReceipt>,
1616    max_steps_reached: bool,
1617    requested_state_updated_at: Option<&String>,
1618    batch_start: RestoreRunBatchStart,
1619) -> RestoreRunResponse {
1620    let report = journal.report();
1621    let executed_operation_count = executed_operations.len();
1622    let stopped_reason = restore_run_stopped_reason(&report, max_steps_reached, true);
1623    let next_action = restore_run_next_action(&report, false);
1624
1625    let mut response = RestoreRunResponse::from_report(
1626        journal.backup_id.clone(),
1627        report,
1628        RestoreRunResponseMode::execute(stopped_reason, next_action),
1629    );
1630    response.set_requested_state_updated_at(requested_state_updated_at);
1631    response.set_batch_summary(batch_start, executed_operation_count, max_steps_reached);
1632    response.max_steps_reached = Some(max_steps_reached);
1633    response.executed_operation_count = Some(executed_operation_count);
1634    response.executed_operations = executed_operations;
1635    response.set_operation_receipts(operation_receipts);
1636    response
1637}
1638
1639// Classify why the native runner stopped for operator summaries.
1640const fn restore_run_stopped_reason(
1641    report: &RestoreApplyJournalReport,
1642    max_steps_reached: bool,
1643    executed: bool,
1644) -> &'static str {
1645    if report.complete {
1646        return RESTORE_RUN_STOPPED_COMPLETE;
1647    }
1648    if report.failed_operations > 0 {
1649        return RESTORE_RUN_STOPPED_COMMAND_FAILED;
1650    }
1651    if report.pending_operations > 0 {
1652        return RESTORE_RUN_STOPPED_PENDING;
1653    }
1654    if !report.ready || report.blocked_operations > 0 {
1655        return RESTORE_RUN_STOPPED_BLOCKED;
1656    }
1657    if max_steps_reached {
1658        return RESTORE_RUN_STOPPED_MAX_STEPS;
1659    }
1660    if executed {
1661        return RESTORE_RUN_STOPPED_READY;
1662    }
1663    RESTORE_RUN_STOPPED_PREVIEW
1664}
1665
1666// Recommend the next operator action for the native runner summary.
1667const fn restore_run_next_action(
1668    report: &RestoreApplyJournalReport,
1669    recovered_pending: bool,
1670) -> &'static str {
1671    if report.complete {
1672        return RESTORE_RUN_ACTION_DONE;
1673    }
1674    if report.failed_operations > 0 {
1675        return RESTORE_RUN_ACTION_INSPECT_FAILED;
1676    }
1677    if report.pending_operations > 0 {
1678        return RESTORE_RUN_ACTION_UNCLAIM_PENDING;
1679    }
1680    if !report.ready || report.blocked_operations > 0 {
1681        return RESTORE_RUN_ACTION_FIX_BLOCKED;
1682    }
1683    if recovered_pending {
1684        return RESTORE_RUN_ACTION_RERUN;
1685    }
1686    RESTORE_RUN_ACTION_RERUN
1687}
1688
1689// Ensure the journal can be advanced by the native restore runner.
1690fn enforce_restore_run_executable(
1691    journal: &RestoreApplyJournal,
1692    report: &RestoreApplyJournalReport,
1693) -> Result<(), RestoreCommandError> {
1694    if report.pending_operations > 0 {
1695        return Err(RestoreCommandError::RestoreApplyPending {
1696            backup_id: report.backup_id.clone(),
1697            pending_operations: report.pending_operations,
1698            next_transition_sequence: report
1699                .next_transition
1700                .as_ref()
1701                .map(|operation| operation.sequence),
1702        });
1703    }
1704
1705    if report.failed_operations > 0 {
1706        return Err(RestoreCommandError::RestoreApplyFailed {
1707            backup_id: report.backup_id.clone(),
1708            failed_operations: report.failed_operations,
1709        });
1710    }
1711
1712    if report.ready {
1713        return Ok(());
1714    }
1715
1716    Err(RestoreCommandError::RestoreApplyNotReady {
1717        backup_id: journal.backup_id.clone(),
1718        reasons: report.blocked_reasons.clone(),
1719    })
1720}
1721
1722// Convert an unavailable native runner command into the shared fail-closed error.
1723fn enforce_restore_run_command_available(
1724    preview: &RestoreApplyCommandPreview,
1725) -> Result<(), RestoreCommandError> {
1726    if preview.command_available {
1727        return Ok(());
1728    }
1729
1730    Err(restore_command_unavailable_error(preview))
1731}
1732
1733// Build a shared command-unavailable error from a preview.
1734fn restore_command_unavailable_error(preview: &RestoreApplyCommandPreview) -> RestoreCommandError {
1735    RestoreCommandError::RestoreApplyCommandUnavailable {
1736        backup_id: preview.backup_id.clone(),
1737        operation_available: preview.operation_available,
1738        complete: preview.complete,
1739        blocked_reasons: preview.blocked_reasons.clone(),
1740    }
1741}
1742
1743// Render process exit status without relying on platform-specific internals.
1744fn exit_status_label(status: std::process::ExitStatus) -> String {
1745    status
1746        .code()
1747        .map_or_else(|| "signal".to_string(), |code| code.to_string())
1748}
1749
1750// Extract the uploaded target snapshot ID from dfx upload output.
1751fn parse_uploaded_snapshot_id(output: &str) -> Option<String> {
1752    output
1753        .lines()
1754        .filter_map(|line| line.split_once(':').map(|(_, value)| value.trim()))
1755        .find(|value| !value.is_empty())
1756        .map(str::to_string)
1757}
1758
1759// Enforce caller-requested native runner requirements after output is emitted.
1760fn enforce_restore_run_requirements(
1761    options: &RestoreRunOptions,
1762    run: &RestoreRunResponse,
1763) -> Result<(), RestoreCommandError> {
1764    if options.require_complete && !run.complete {
1765        return Err(RestoreCommandError::RestoreApplyIncomplete {
1766            backup_id: run.backup_id.clone(),
1767            completed_operations: run.completed_operations,
1768            operation_count: run.operation_count,
1769        });
1770    }
1771
1772    if options.require_no_attention && run.attention_required {
1773        return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
1774            backup_id: run.backup_id.clone(),
1775            outcome: run.outcome.clone(),
1776        });
1777    }
1778
1779    if let Some(expected) = &options.require_run_mode
1780        && run.run_mode != expected
1781    {
1782        return Err(RestoreCommandError::RestoreRunModeMismatch {
1783            backup_id: run.backup_id.clone(),
1784            expected: expected.clone(),
1785            actual: run.run_mode.to_string(),
1786        });
1787    }
1788
1789    if let Some(expected) = &options.require_stopped_reason
1790        && run.stopped_reason != expected
1791    {
1792        return Err(RestoreCommandError::RestoreRunStoppedReasonMismatch {
1793            backup_id: run.backup_id.clone(),
1794            expected: expected.clone(),
1795            actual: run.stopped_reason.to_string(),
1796        });
1797    }
1798
1799    if let Some(expected) = &options.require_next_action
1800        && run.next_action != expected
1801    {
1802        return Err(RestoreCommandError::RestoreRunNextActionMismatch {
1803            backup_id: run.backup_id.clone(),
1804            expected: expected.clone(),
1805            actual: run.next_action.to_string(),
1806        });
1807    }
1808
1809    if let Some(expected) = options.require_executed_count {
1810        let actual = run.executed_operation_count.unwrap_or(0);
1811        if actual != expected {
1812            return Err(RestoreCommandError::RestoreRunExecutedCountMismatch {
1813                backup_id: run.backup_id.clone(),
1814                expected,
1815                actual,
1816            });
1817        }
1818    }
1819
1820    enforce_restore_run_receipt_requirements(options, run)?;
1821
1822    enforce_progress_requirements(
1823        &run.backup_id,
1824        &run.progress,
1825        options.require_remaining_count,
1826        options.require_attention_count,
1827        options.require_completion_basis_points,
1828    )?;
1829    enforce_pending_before_requirement(
1830        &run.backup_id,
1831        &run.pending_summary,
1832        options.require_no_pending_before.as_deref(),
1833    )?;
1834
1835    Ok(())
1836}
1837
1838// Enforce caller-requested native runner receipt and marker requirements.
1839fn enforce_restore_run_receipt_requirements(
1840    options: &RestoreRunOptions,
1841    run: &RestoreRunResponse,
1842) -> Result<(), RestoreCommandError> {
1843    if let Some(expected) = options.require_receipt_count {
1844        let actual = run.operation_receipt_count.unwrap_or(0);
1845        if actual != expected {
1846            return Err(RestoreCommandError::RestoreRunReceiptCountMismatch {
1847                backup_id: run.backup_id.clone(),
1848                expected,
1849                actual,
1850            });
1851        }
1852    }
1853
1854    enforce_restore_run_receipt_kind_requirement(
1855        &run.backup_id,
1856        RESTORE_RUN_RECEIPT_COMPLETED,
1857        options.require_completed_receipt_count,
1858        run.operation_receipt_summary.command_completed,
1859    )?;
1860    enforce_restore_run_receipt_kind_requirement(
1861        &run.backup_id,
1862        RESTORE_RUN_RECEIPT_FAILED,
1863        options.require_failed_receipt_count,
1864        run.operation_receipt_summary.command_failed,
1865    )?;
1866    enforce_restore_run_receipt_kind_requirement(
1867        &run.backup_id,
1868        RESTORE_RUN_RECEIPT_RECOVERED_PENDING,
1869        options.require_recovered_receipt_count,
1870        run.operation_receipt_summary.pending_recovered,
1871    )?;
1872    enforce_restore_run_receipt_updated_at_requirement(
1873        &run.backup_id,
1874        &run.operation_receipts,
1875        options.require_receipt_updated_at.as_deref(),
1876    )?;
1877    enforce_restore_run_state_updated_at_requirement(
1878        &run.backup_id,
1879        run.requested_state_updated_at.as_deref(),
1880        options.require_state_updated_at.as_deref(),
1881    )?;
1882
1883    Ok(())
1884}
1885
1886// Fail when a runner summary does not echo the requested state marker.
1887fn enforce_restore_run_state_updated_at_requirement(
1888    backup_id: &str,
1889    actual: Option<&str>,
1890    expected: Option<&str>,
1891) -> Result<(), RestoreCommandError> {
1892    if let Some(expected) = expected
1893        && actual != Some(expected)
1894    {
1895        return Err(RestoreCommandError::RestoreRunStateUpdatedAtMismatch {
1896            backup_id: backup_id.to_string(),
1897            expected: expected.to_string(),
1898            actual: actual.map(str::to_string),
1899        });
1900    }
1901
1902    Ok(())
1903}
1904
1905// Fail when emitted runner receipts are missing the requested state marker.
1906fn enforce_restore_run_receipt_updated_at_requirement(
1907    backup_id: &str,
1908    receipts: &[RestoreRunOperationReceipt],
1909    expected: Option<&str>,
1910) -> Result<(), RestoreCommandError> {
1911    let Some(expected) = expected else {
1912        return Ok(());
1913    };
1914
1915    let actual_receipts = receipts.len();
1916    let mismatched_receipts = receipts
1917        .iter()
1918        .filter(|receipt| receipt.updated_at.as_deref() != Some(expected))
1919        .count();
1920    if actual_receipts == 0 || mismatched_receipts > 0 {
1921        return Err(RestoreCommandError::RestoreRunReceiptUpdatedAtMismatch {
1922            backup_id: backup_id.to_string(),
1923            expected: expected.to_string(),
1924            actual_receipts,
1925            mismatched_receipts,
1926        });
1927    }
1928
1929    Ok(())
1930}
1931
1932// Fail when a runner receipt-kind count differs from the requested value.
1933fn enforce_restore_run_receipt_kind_requirement(
1934    backup_id: &str,
1935    receipt_kind: &'static str,
1936    expected: Option<usize>,
1937    actual: usize,
1938) -> Result<(), RestoreCommandError> {
1939    if let Some(expected) = expected
1940        && actual != expected
1941    {
1942        return Err(RestoreCommandError::RestoreRunReceiptKindCountMismatch {
1943            backup_id: backup_id.to_string(),
1944            receipt_kind,
1945            expected,
1946            actual,
1947        });
1948    }
1949
1950    Ok(())
1951}
1952
1953// Enforce caller-requested integer progress requirements after output is emitted.
1954fn enforce_progress_requirements(
1955    backup_id: &str,
1956    progress: &RestoreApplyProgressSummary,
1957    require_remaining_count: Option<usize>,
1958    require_attention_count: Option<usize>,
1959    require_completion_basis_points: Option<usize>,
1960) -> Result<(), RestoreCommandError> {
1961    if let Some(expected) = require_remaining_count
1962        && progress.remaining_operations != expected
1963    {
1964        return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1965            backup_id: backup_id.to_string(),
1966            field: "remaining_operations",
1967            expected,
1968            actual: progress.remaining_operations,
1969        });
1970    }
1971
1972    if let Some(expected) = require_attention_count
1973        && progress.attention_operations != expected
1974    {
1975        return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1976            backup_id: backup_id.to_string(),
1977            field: "attention_operations",
1978            expected,
1979            actual: progress.attention_operations,
1980        });
1981    }
1982
1983    if let Some(expected) = require_completion_basis_points
1984        && progress.completion_basis_points != expected
1985    {
1986        return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1987            backup_id: backup_id.to_string(),
1988            field: "completion_basis_points",
1989            expected,
1990            actual: progress.completion_basis_points,
1991        });
1992    }
1993
1994    Ok(())
1995}
1996
1997// Enforce pending-work freshness using caller-supplied comparable update markers.
1998fn enforce_pending_before_requirement(
1999    backup_id: &str,
2000    pending: &RestoreApplyPendingSummary,
2001    require_no_pending_before: Option<&str>,
2002) -> Result<(), RestoreCommandError> {
2003    let Some(cutoff_updated_at) = require_no_pending_before else {
2004        return Ok(());
2005    };
2006
2007    if pending.pending_operations == 0 {
2008        return Ok(());
2009    }
2010
2011    if pending.pending_updated_at_known
2012        && pending
2013            .pending_updated_at
2014            .as_deref()
2015            .is_some_and(|updated_at| updated_at >= cutoff_updated_at)
2016    {
2017        return Ok(());
2018    }
2019
2020    Err(RestoreCommandError::RestoreApplyPendingStale {
2021        backup_id: backup_id.to_string(),
2022        cutoff_updated_at: cutoff_updated_at.to_string(),
2023        pending_sequence: pending.pending_sequence,
2024        pending_updated_at: pending.pending_updated_at.clone(),
2025    })
2026}
2027
2028// Enforce caller-requested apply report requirements after report output is emitted.
2029fn enforce_apply_report_requirements(
2030    options: &RestoreApplyReportOptions,
2031    report: &RestoreApplyJournalReport,
2032) -> Result<(), RestoreCommandError> {
2033    if options.require_no_attention && report.attention_required {
2034        return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
2035            backup_id: report.backup_id.clone(),
2036            outcome: report.outcome.clone(),
2037        });
2038    }
2039
2040    enforce_progress_requirements(
2041        &report.backup_id,
2042        &report.progress,
2043        options.require_remaining_count,
2044        options.require_attention_count,
2045        options.require_completion_basis_points,
2046    )?;
2047    enforce_pending_before_requirement(
2048        &report.backup_id,
2049        &report.pending_summary,
2050        options.require_no_pending_before.as_deref(),
2051    )
2052}
2053
2054// Enforce caller-requested apply journal requirements after status is emitted.
2055fn enforce_apply_status_requirements(
2056    options: &RestoreApplyStatusOptions,
2057    status: &RestoreApplyJournalStatus,
2058) -> Result<(), RestoreCommandError> {
2059    if options.require_ready && !status.ready {
2060        return Err(RestoreCommandError::RestoreApplyNotReady {
2061            backup_id: status.backup_id.clone(),
2062            reasons: status.blocked_reasons.clone(),
2063        });
2064    }
2065
2066    if options.require_no_pending && status.pending_operations > 0 {
2067        return Err(RestoreCommandError::RestoreApplyPending {
2068            backup_id: status.backup_id.clone(),
2069            pending_operations: status.pending_operations,
2070            next_transition_sequence: status.next_transition_sequence,
2071        });
2072    }
2073
2074    if options.require_no_failed && status.failed_operations > 0 {
2075        return Err(RestoreCommandError::RestoreApplyFailed {
2076            backup_id: status.backup_id.clone(),
2077            failed_operations: status.failed_operations,
2078        });
2079    }
2080
2081    if options.require_complete && !status.complete {
2082        return Err(RestoreCommandError::RestoreApplyIncomplete {
2083            backup_id: status.backup_id.clone(),
2084            completed_operations: status.completed_operations,
2085            operation_count: status.operation_count,
2086        });
2087    }
2088
2089    enforce_progress_requirements(
2090        &status.backup_id,
2091        &status.progress,
2092        options.require_remaining_count,
2093        options.require_attention_count,
2094        options.require_completion_basis_points,
2095    )?;
2096    enforce_pending_before_requirement(
2097        &status.backup_id,
2098        &status.pending_summary,
2099        options.require_no_pending_before.as_deref(),
2100    )?;
2101
2102    Ok(())
2103}
2104
2105// Ensure a runner claim still matches the operation it previewed.
2106fn enforce_apply_claim_sequence(
2107    expected: usize,
2108    journal: &RestoreApplyJournal,
2109) -> Result<(), RestoreCommandError> {
2110    let actual = journal
2111        .next_transition_operation()
2112        .map(|operation| operation.sequence);
2113
2114    if actual == Some(expected) {
2115        return Ok(());
2116    }
2117
2118    Err(RestoreCommandError::RestoreRunClaimSequenceMismatch { expected, actual })
2119}
2120
2121// Enforce caller-requested restore plan requirements after the plan is emitted.
2122fn enforce_restore_plan_requirements(
2123    options: &RestorePlanOptions,
2124    plan: &RestorePlan,
2125) -> Result<(), RestoreCommandError> {
2126    if options.require_design_v1 {
2127        let manifest = read_manifest_source(options)?;
2128        if !manifest.design_conformance_report().design_v1_ready {
2129            return Err(RestoreCommandError::DesignConformanceNotReady {
2130                backup_id: plan.backup_id.clone(),
2131            });
2132        }
2133    }
2134
2135    if !options.require_restore_ready || plan.readiness_summary.ready {
2136        return Ok(());
2137    }
2138
2139    Err(RestoreCommandError::RestoreNotReady {
2140        backup_id: plan.backup_id.clone(),
2141        reasons: plan.readiness_summary.reasons.clone(),
2142    })
2143}
2144
2145// Verify backup layout integrity before restore planning when requested.
2146fn verify_backup_layout_if_required(
2147    options: &RestorePlanOptions,
2148) -> Result<(), RestoreCommandError> {
2149    if !options.require_verified {
2150        return Ok(());
2151    }
2152
2153    let Some(dir) = &options.backup_dir else {
2154        return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
2155    };
2156
2157    BackupLayout::new(dir.clone()).verify_integrity()?;
2158    Ok(())
2159}
2160
2161// Read the manifest from a direct path or canonical backup layout.
2162fn read_manifest_source(
2163    options: &RestorePlanOptions,
2164) -> Result<FleetBackupManifest, RestoreCommandError> {
2165    if let Some(path) = &options.manifest {
2166        return read_manifest(path);
2167    }
2168
2169    let Some(dir) = &options.backup_dir else {
2170        return Err(RestoreCommandError::MissingOption(
2171            "--manifest or --backup-dir",
2172        ));
2173    };
2174
2175    BackupLayout::new(dir.clone())
2176        .read_manifest()
2177        .map_err(RestoreCommandError::from)
2178}
2179
2180// Read and decode a fleet backup manifest from disk.
2181fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
2182    let data = fs::read_to_string(path)?;
2183    serde_json::from_str(&data).map_err(RestoreCommandError::from)
2184}
2185
2186// Read and decode an optional source-to-target restore mapping from disk.
2187fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
2188    let data = fs::read_to_string(path)?;
2189    serde_json::from_str(&data).map_err(RestoreCommandError::from)
2190}
2191
2192// Read and decode a restore plan from disk.
2193fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
2194    let data = fs::read_to_string(path)?;
2195    serde_json::from_str(&data).map_err(RestoreCommandError::from)
2196}
2197
2198// Read and decode a restore status from disk.
2199fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
2200    let data = fs::read_to_string(path)?;
2201    serde_json::from_str(&data).map_err(RestoreCommandError::from)
2202}
2203
2204// Read and decode a restore apply journal from disk.
2205fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
2206    let data = fs::read_to_string(path)?;
2207    let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
2208    journal.validate()?;
2209    Ok(journal)
2210}
2211
2212// Parse a restore apply journal operation sequence value.
2213fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
2214    value
2215        .parse::<usize>()
2216        .map_err(|_| RestoreCommandError::InvalidSequence)
2217}
2218
2219// Parse a positive integer CLI value for options where zero is not meaningful.
2220fn parse_positive_integer(
2221    option: &'static str,
2222    value: String,
2223) -> Result<usize, RestoreCommandError> {
2224    let parsed = parse_sequence(value)?;
2225    if parsed == 0 {
2226        return Err(RestoreCommandError::InvalidPositiveInteger { option });
2227    }
2228
2229    Ok(parsed)
2230}
2231
2232// Return the caller-supplied journal update marker or the current placeholder.
2233fn state_updated_at(updated_at: Option<&String>) -> String {
2234    updated_at.cloned().unwrap_or_else(timestamp_placeholder)
2235}
2236
2237// Return a placeholder timestamp until the CLI owns a clock abstraction.
2238fn timestamp_placeholder() -> String {
2239    "unknown".to_string()
2240}
2241
2242// Write the computed plan to stdout or a requested output file.
2243fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
2244    output::write_pretty_json(options.out.as_ref(), plan)
2245}
2246
2247// Write the computed status to stdout or a requested output file.
2248fn write_status(
2249    options: &RestoreStatusOptions,
2250    status: &RestoreStatus,
2251) -> Result<(), RestoreCommandError> {
2252    output::write_pretty_json(options.out.as_ref(), status)
2253}
2254
2255// Write the computed apply dry-run to stdout or a requested output file.
2256fn write_apply_dry_run(
2257    options: &RestoreApplyOptions,
2258    dry_run: &RestoreApplyDryRun,
2259) -> Result<(), RestoreCommandError> {
2260    output::write_pretty_json(options.out.as_ref(), dry_run)
2261}
2262
2263// Write the initial apply journal when the caller requests one.
2264fn write_apply_journal_if_requested(
2265    options: &RestoreApplyOptions,
2266    dry_run: &RestoreApplyDryRun,
2267) -> Result<(), RestoreCommandError> {
2268    let Some(path) = &options.journal_out else {
2269        return Ok(());
2270    };
2271
2272    let journal = RestoreApplyJournal::from_dry_run(dry_run);
2273    let data = serde_json::to_vec_pretty(&journal)?;
2274    fs::write(path, data)?;
2275    Ok(())
2276}
2277
2278// Write the computed apply journal status to stdout or a requested output file.
2279fn write_apply_status(
2280    options: &RestoreApplyStatusOptions,
2281    status: &RestoreApplyJournalStatus,
2282) -> Result<(), RestoreCommandError> {
2283    output::write_pretty_json(options.out.as_ref(), status)
2284}
2285
2286// Write the computed apply journal report to stdout or a requested output file.
2287fn write_apply_report(
2288    options: &RestoreApplyReportOptions,
2289    report: &RestoreApplyJournalReport,
2290) -> Result<(), RestoreCommandError> {
2291    output::write_pretty_json(options.out.as_ref(), report)
2292}
2293
2294// Write the restore runner response to stdout or a requested output file.
2295fn write_restore_run(
2296    options: &RestoreRunOptions,
2297    run: &RestoreRunResponse,
2298) -> Result<(), RestoreCommandError> {
2299    output::write_pretty_json(options.out.as_ref(), run)
2300}
2301
2302// Persist the restore apply journal to its canonical runner path.
2303fn write_apply_journal_file(
2304    path: &PathBuf,
2305    journal: &RestoreApplyJournal,
2306) -> Result<(), RestoreCommandError> {
2307    let data = serde_json::to_vec_pretty(journal)?;
2308    fs::write(path, data)?;
2309    Ok(())
2310}
2311
2312///
2313/// RestoreJournalLock
2314///
2315
2316struct RestoreJournalLock {
2317    path: PathBuf,
2318}
2319
2320impl RestoreJournalLock {
2321    // Acquire an atomic sidecar lock for mutating restore runner operations.
2322    fn acquire(journal_path: &Path) -> Result<Self, RestoreCommandError> {
2323        let path = journal_lock_path(journal_path);
2324        match fs::OpenOptions::new()
2325            .write(true)
2326            .create_new(true)
2327            .open(&path)
2328        {
2329            Ok(mut file) => {
2330                writeln!(file, "pid={}", std::process::id())?;
2331                Ok(Self { path })
2332            }
2333            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
2334                Err(RestoreCommandError::RestoreApplyJournalLocked {
2335                    lock_path: path.to_string_lossy().to_string(),
2336                })
2337            }
2338            Err(error) => Err(error.into()),
2339        }
2340    }
2341}
2342
2343impl Drop for RestoreJournalLock {
2344    // Release the sidecar lock when the mutating command completes or fails.
2345    fn drop(&mut self) {
2346        let _ = fs::remove_file(&self.path);
2347    }
2348}
2349
2350// Derive the sidecar lock path for one apply journal.
2351fn journal_lock_path(path: &Path) -> PathBuf {
2352    let mut lock_path = path.as_os_str().to_os_string();
2353    lock_path.push(".lock");
2354    PathBuf::from(lock_path)
2355}
2356
2357// Return restore command usage text.
2358const fn usage() -> &'static str {
2359    "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."
2360}
2361
2362#[cfg(test)]
2363mod tests;