Skip to main content

canic_cli/restore/
mod.rs

1use canic_backup::{
2    manifest::FleetBackupManifest,
3    persistence::{BackupLayout, PersistenceError},
4    restore::{
5        RestoreApplyCommandConfig, RestoreApplyCommandPreview, RestoreApplyDryRun,
6        RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
7        RestoreApplyJournalOperation, RestoreApplyJournalReport, RestoreApplyJournalStatus,
8        RestoreApplyNextOperation, RestoreApplyOperationKind, RestoreApplyOperationKindCounts,
9        RestoreApplyOperationState, RestoreApplyReportOperation, RestoreApplyReportOutcome,
10        RestoreApplyRunnerCommand, RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner,
11        RestoreStatus,
12    },
13};
14use serde::Serialize;
15use std::{
16    ffi::OsString,
17    fs,
18    io::{self, Write},
19    path::PathBuf,
20    process::Command,
21};
22use thiserror::Error as ThisError;
23
24///
25/// RestoreCommandError
26///
27
28#[derive(Debug, ThisError)]
29pub enum RestoreCommandError {
30    #[error("{0}")]
31    Usage(&'static str),
32
33    #[error("missing required option {0}")]
34    MissingOption(&'static str),
35
36    #[error("use either --manifest or --backup-dir, not both")]
37    ConflictingManifestSources,
38
39    #[error("--require-verified requires --backup-dir")]
40    RequireVerifiedNeedsBackupDir,
41
42    #[error("restore apply currently requires --dry-run")]
43    ApplyRequiresDryRun,
44
45    #[error("restore run requires --dry-run, --execute, or --unclaim-pending")]
46    RestoreRunRequiresMode,
47
48    #[error("use only one restore run mode: --dry-run, --execute, or --unclaim-pending")]
49    RestoreRunConflictingModes,
50
51    #[error("restore run command failed for operation {sequence}: status={status}")]
52    RestoreRunCommandFailed { sequence: usize, status: 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 plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
87    RestoreNotReady {
88        backup_id: String,
89        reasons: Vec<String>,
90    },
91
92    #[error(
93        "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
94    )]
95    RestoreApplyPending {
96        backup_id: String,
97        pending_operations: usize,
98        next_transition_sequence: Option<usize>,
99    },
100
101    #[error(
102        "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
103    )]
104    RestoreApplyIncomplete {
105        backup_id: String,
106        completed_operations: usize,
107        operation_count: usize,
108    },
109
110    #[error(
111        "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
112    )]
113    RestoreApplyFailed {
114        backup_id: String,
115        failed_operations: usize,
116    },
117
118    #[error("restore apply journal for backup {backup_id} is not ready: reasons={reasons:?}")]
119    RestoreApplyNotReady {
120        backup_id: String,
121        reasons: Vec<String>,
122    },
123
124    #[error("restore apply report for backup {backup_id} requires attention: outcome={outcome:?}")]
125    RestoreApplyReportNeedsAttention {
126        backup_id: String,
127        outcome: canic_backup::restore::RestoreApplyReportOutcome,
128    },
129
130    #[error(
131        "restore apply journal for backup {backup_id} has no executable command: operation_available={operation_available}, complete={complete}, blocked_reasons={blocked_reasons:?}"
132    )]
133    RestoreApplyCommandUnavailable {
134        backup_id: String,
135        operation_available: bool,
136        complete: bool,
137        blocked_reasons: Vec<String>,
138    },
139
140    #[error(
141        "restore apply journal operation {sequence} must be pending before apply-mark: state={state:?}"
142    )]
143    RestoreApplyMarkRequiresPending {
144        sequence: usize,
145        state: RestoreApplyOperationState,
146    },
147
148    #[error(
149        "restore apply journal next operation changed before claim: expected={expected}, actual={actual:?}"
150    )]
151    RestoreApplyClaimSequenceMismatch {
152        expected: usize,
153        actual: Option<usize>,
154    },
155
156    #[error(
157        "restore apply journal pending operation changed before unclaim: expected={expected}, actual={actual:?}"
158    )]
159    RestoreApplyUnclaimSequenceMismatch {
160        expected: usize,
161        actual: Option<usize>,
162    },
163
164    #[error("unknown option {0}")]
165    UnknownOption(String),
166
167    #[error("option {0} requires a value")]
168    MissingValue(&'static str),
169
170    #[error("option --sequence requires a non-negative integer value")]
171    InvalidSequence,
172
173    #[error("unsupported apply-mark state {0}; use completed or failed")]
174    InvalidApplyMarkState(String),
175
176    #[error(transparent)]
177    Io(#[from] std::io::Error),
178
179    #[error(transparent)]
180    Json(#[from] serde_json::Error),
181
182    #[error(transparent)]
183    Persistence(#[from] PersistenceError),
184
185    #[error(transparent)]
186    RestorePlan(#[from] RestorePlanError),
187
188    #[error(transparent)]
189    RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
190
191    #[error(transparent)]
192    RestoreApplyJournal(#[from] RestoreApplyJournalError),
193}
194
195///
196/// RestorePlanOptions
197///
198
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct RestorePlanOptions {
201    pub manifest: Option<PathBuf>,
202    pub backup_dir: Option<PathBuf>,
203    pub mapping: Option<PathBuf>,
204    pub out: Option<PathBuf>,
205    pub require_verified: bool,
206    pub require_restore_ready: bool,
207}
208
209impl RestorePlanOptions {
210    /// Parse restore planning options from CLI arguments.
211    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
212    where
213        I: IntoIterator<Item = OsString>,
214    {
215        let mut manifest = None;
216        let mut backup_dir = None;
217        let mut mapping = None;
218        let mut out = None;
219        let mut require_verified = false;
220        let mut require_restore_ready = false;
221
222        let mut args = args.into_iter();
223        while let Some(arg) = args.next() {
224            let arg = arg
225                .into_string()
226                .map_err(|_| RestoreCommandError::Usage(usage()))?;
227            match arg.as_str() {
228                "--manifest" => {
229                    manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
230                }
231                "--backup-dir" => {
232                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
233                }
234                "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
235                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
236                "--require-verified" => require_verified = true,
237                "--require-restore-ready" => require_restore_ready = true,
238                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
239                _ => return Err(RestoreCommandError::UnknownOption(arg)),
240            }
241        }
242
243        if manifest.is_some() && backup_dir.is_some() {
244            return Err(RestoreCommandError::ConflictingManifestSources);
245        }
246
247        if manifest.is_none() && backup_dir.is_none() {
248            return Err(RestoreCommandError::MissingOption(
249                "--manifest or --backup-dir",
250            ));
251        }
252
253        if require_verified && backup_dir.is_none() {
254            return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
255        }
256
257        Ok(Self {
258            manifest,
259            backup_dir,
260            mapping,
261            out,
262            require_verified,
263            require_restore_ready,
264        })
265    }
266}
267
268///
269/// RestoreStatusOptions
270///
271
272#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct RestoreStatusOptions {
274    pub plan: PathBuf,
275    pub out: Option<PathBuf>,
276}
277
278impl RestoreStatusOptions {
279    /// Parse restore status options from CLI arguments.
280    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
281    where
282        I: IntoIterator<Item = OsString>,
283    {
284        let mut plan = None;
285        let mut out = None;
286
287        let mut args = args.into_iter();
288        while let Some(arg) = args.next() {
289            let arg = arg
290                .into_string()
291                .map_err(|_| RestoreCommandError::Usage(usage()))?;
292            match arg.as_str() {
293                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
294                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
295                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
296                _ => return Err(RestoreCommandError::UnknownOption(arg)),
297            }
298        }
299
300        Ok(Self {
301            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
302            out,
303        })
304    }
305}
306
307///
308/// RestoreApplyOptions
309///
310
311#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct RestoreApplyOptions {
313    pub plan: PathBuf,
314    pub status: Option<PathBuf>,
315    pub backup_dir: Option<PathBuf>,
316    pub out: Option<PathBuf>,
317    pub journal_out: Option<PathBuf>,
318    pub dry_run: bool,
319}
320
321impl RestoreApplyOptions {
322    /// Parse restore apply options from CLI arguments.
323    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
324    where
325        I: IntoIterator<Item = OsString>,
326    {
327        let mut plan = None;
328        let mut status = None;
329        let mut backup_dir = None;
330        let mut out = None;
331        let mut journal_out = None;
332        let mut dry_run = false;
333
334        let mut args = args.into_iter();
335        while let Some(arg) = args.next() {
336            let arg = arg
337                .into_string()
338                .map_err(|_| RestoreCommandError::Usage(usage()))?;
339            match arg.as_str() {
340                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
341                "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
342                "--backup-dir" => {
343                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
344                }
345                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
346                "--journal-out" => {
347                    journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
348                }
349                "--dry-run" => dry_run = true,
350                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
351                _ => return Err(RestoreCommandError::UnknownOption(arg)),
352            }
353        }
354
355        if !dry_run {
356            return Err(RestoreCommandError::ApplyRequiresDryRun);
357        }
358
359        Ok(Self {
360            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
361            status,
362            backup_dir,
363            out,
364            journal_out,
365            dry_run,
366        })
367    }
368}
369
370///
371/// RestoreApplyStatusOptions
372///
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375#[expect(
376    clippy::struct_excessive_bools,
377    reason = "CLI status options mirror independent fail-closed guard flags"
378)]
379pub struct RestoreApplyStatusOptions {
380    pub journal: PathBuf,
381    pub require_ready: bool,
382    pub require_no_pending: bool,
383    pub require_no_failed: bool,
384    pub require_complete: bool,
385    pub out: Option<PathBuf>,
386}
387
388impl RestoreApplyStatusOptions {
389    /// Parse restore apply-status options from CLI arguments.
390    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
391    where
392        I: IntoIterator<Item = OsString>,
393    {
394        let mut journal = None;
395        let mut require_ready = false;
396        let mut require_no_pending = false;
397        let mut require_no_failed = false;
398        let mut require_complete = false;
399        let mut out = None;
400
401        let mut args = args.into_iter();
402        while let Some(arg) = args.next() {
403            let arg = arg
404                .into_string()
405                .map_err(|_| RestoreCommandError::Usage(usage()))?;
406            match arg.as_str() {
407                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
408                "--require-ready" => require_ready = true,
409                "--require-no-pending" => require_no_pending = true,
410                "--require-no-failed" => require_no_failed = true,
411                "--require-complete" => require_complete = true,
412                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
413                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
414                _ => return Err(RestoreCommandError::UnknownOption(arg)),
415            }
416        }
417
418        Ok(Self {
419            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
420            require_ready,
421            require_no_pending,
422            require_no_failed,
423            require_complete,
424            out,
425        })
426    }
427}
428
429///
430/// RestoreApplyReportOptions
431///
432
433#[derive(Clone, Debug, Eq, PartialEq)]
434pub struct RestoreApplyReportOptions {
435    pub journal: PathBuf,
436    pub require_no_attention: bool,
437    pub out: Option<PathBuf>,
438}
439
440impl RestoreApplyReportOptions {
441    /// Parse restore apply-report options from CLI arguments.
442    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
443    where
444        I: IntoIterator<Item = OsString>,
445    {
446        let mut journal = None;
447        let mut require_no_attention = false;
448        let mut out = None;
449
450        let mut args = args.into_iter();
451        while let Some(arg) = args.next() {
452            let arg = arg
453                .into_string()
454                .map_err(|_| RestoreCommandError::Usage(usage()))?;
455            match arg.as_str() {
456                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
457                "--require-no-attention" => require_no_attention = true,
458                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
459                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
460                _ => return Err(RestoreCommandError::UnknownOption(arg)),
461            }
462        }
463
464        Ok(Self {
465            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
466            require_no_attention,
467            out,
468        })
469    }
470}
471
472///
473/// RestoreRunOptions
474///
475
476#[derive(Clone, Debug, Eq, PartialEq)]
477#[expect(
478    clippy::struct_excessive_bools,
479    reason = "CLI runner options mirror independent mode and fail-closed guard flags"
480)]
481pub struct RestoreRunOptions {
482    pub journal: PathBuf,
483    pub dfx: String,
484    pub network: Option<String>,
485    pub out: Option<PathBuf>,
486    pub dry_run: bool,
487    pub execute: bool,
488    pub unclaim_pending: bool,
489    pub max_steps: Option<usize>,
490    pub require_complete: bool,
491    pub require_no_attention: bool,
492    pub require_run_mode: Option<String>,
493    pub require_stopped_reason: Option<String>,
494    pub require_next_action: Option<String>,
495    pub require_executed_count: Option<usize>,
496}
497
498impl RestoreRunOptions {
499    /// Parse restore run options from CLI arguments.
500    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
501    where
502        I: IntoIterator<Item = OsString>,
503    {
504        let mut journal = None;
505        let mut dfx = "dfx".to_string();
506        let mut network = None;
507        let mut out = None;
508        let mut dry_run = false;
509        let mut execute = false;
510        let mut unclaim_pending = false;
511        let mut max_steps = None;
512        let mut require_complete = false;
513        let mut require_no_attention = false;
514        let mut require_run_mode = None;
515        let mut require_stopped_reason = None;
516        let mut require_next_action = None;
517        let mut require_executed_count = None;
518
519        let mut args = args.into_iter();
520        while let Some(arg) = args.next() {
521            let arg = arg
522                .into_string()
523                .map_err(|_| RestoreCommandError::Usage(usage()))?;
524            match arg.as_str() {
525                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
526                "--dfx" => dfx = next_value(&mut args, "--dfx")?,
527                "--network" => network = Some(next_value(&mut args, "--network")?),
528                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
529                "--dry-run" => dry_run = true,
530                "--execute" => execute = true,
531                "--unclaim-pending" => unclaim_pending = true,
532                "--max-steps" => {
533                    max_steps = Some(parse_sequence(next_value(&mut args, "--max-steps")?)?);
534                }
535                "--require-complete" => require_complete = true,
536                "--require-no-attention" => require_no_attention = true,
537                "--require-run-mode" => {
538                    require_run_mode = Some(next_value(&mut args, "--require-run-mode")?);
539                }
540                "--require-stopped-reason" => {
541                    require_stopped_reason =
542                        Some(next_value(&mut args, "--require-stopped-reason")?);
543                }
544                "--require-next-action" => {
545                    require_next_action = Some(next_value(&mut args, "--require-next-action")?);
546                }
547                "--require-executed-count" => {
548                    require_executed_count = Some(parse_sequence(next_value(
549                        &mut args,
550                        "--require-executed-count",
551                    )?)?);
552                }
553                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
554                _ => return Err(RestoreCommandError::UnknownOption(arg)),
555            }
556        }
557
558        let mode_count = [dry_run, execute, unclaim_pending]
559            .into_iter()
560            .filter(|enabled| *enabled)
561            .count();
562        if mode_count > 1 {
563            return Err(RestoreCommandError::RestoreRunConflictingModes);
564        }
565
566        if mode_count == 0 {
567            return Err(RestoreCommandError::RestoreRunRequiresMode);
568        }
569
570        Ok(Self {
571            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
572            dfx,
573            network,
574            out,
575            dry_run,
576            execute,
577            unclaim_pending,
578            max_steps,
579            require_complete,
580            require_no_attention,
581            require_run_mode,
582            require_stopped_reason,
583            require_next_action,
584            require_executed_count,
585        })
586    }
587}
588
589///
590/// RestoreRunResult
591///
592
593struct RestoreRunResult {
594    response: RestoreRunResponse,
595    error: Option<RestoreCommandError>,
596}
597
598impl RestoreRunResult {
599    // Build a successful runner response with no deferred error.
600    const fn ok(response: RestoreRunResponse) -> Self {
601        Self {
602            response,
603            error: None,
604        }
605    }
606}
607
608const RESTORE_RUN_MODE_DRY_RUN: &str = "dry-run";
609const RESTORE_RUN_MODE_EXECUTE: &str = "execute";
610const RESTORE_RUN_MODE_UNCLAIM_PENDING: &str = "unclaim-pending";
611
612const RESTORE_RUN_STOPPED_BLOCKED: &str = "blocked";
613const RESTORE_RUN_STOPPED_COMMAND_FAILED: &str = "command-failed";
614const RESTORE_RUN_STOPPED_COMPLETE: &str = "complete";
615const RESTORE_RUN_STOPPED_MAX_STEPS: &str = "max-steps-reached";
616const RESTORE_RUN_STOPPED_PENDING: &str = "pending";
617const RESTORE_RUN_STOPPED_PREVIEW: &str = "preview";
618const RESTORE_RUN_STOPPED_READY: &str = "ready";
619const RESTORE_RUN_STOPPED_RECOVERED_PENDING: &str = "recovered-pending";
620
621const RESTORE_RUN_ACTION_DONE: &str = "done";
622const RESTORE_RUN_ACTION_FIX_BLOCKED: &str = "fix-blocked-journal";
623const RESTORE_RUN_ACTION_INSPECT_FAILED: &str = "inspect-failed-operation";
624const RESTORE_RUN_ACTION_RERUN: &str = "rerun";
625const RESTORE_RUN_ACTION_UNCLAIM_PENDING: &str = "unclaim-pending";
626
627const RESTORE_RUN_EXECUTED_COMPLETED: &str = "completed";
628const RESTORE_RUN_EXECUTED_FAILED: &str = "failed";
629const RESTORE_RUN_COMMAND_EXIT_PREFIX: &str = "runner-command-exit";
630const RESTORE_RUN_RESPONSE_VERSION: u16 = 1;
631
632///
633/// RestoreRunResponse
634///
635
636#[derive(Clone, Debug, Serialize)]
637#[expect(
638    clippy::struct_excessive_bools,
639    reason = "Runner response exposes stable JSON status flags for operators and CI"
640)]
641pub struct RestoreRunResponse {
642    run_version: u16,
643    backup_id: String,
644    run_mode: &'static str,
645    dry_run: bool,
646    execute: bool,
647    unclaim_pending: bool,
648    stopped_reason: &'static str,
649    next_action: &'static str,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    max_steps_reached: Option<bool>,
652    #[serde(default, skip_serializing_if = "Vec::is_empty")]
653    executed_operations: Vec<RestoreRunExecutedOperation>,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    executed_operation_count: Option<usize>,
656    #[serde(skip_serializing_if = "Option::is_none")]
657    recovered_operation: Option<RestoreApplyJournalOperation>,
658    ready: bool,
659    complete: bool,
660    attention_required: bool,
661    outcome: RestoreApplyReportOutcome,
662    operation_count: usize,
663    operation_counts: RestoreApplyOperationKindCounts,
664    operation_counts_supplied: bool,
665    progress: canic_backup::restore::RestoreApplyProgressSummary,
666    pending_operations: usize,
667    ready_operations: usize,
668    blocked_operations: usize,
669    completed_operations: usize,
670    failed_operations: usize,
671    blocked_reasons: Vec<String>,
672    next_transition: Option<RestoreApplyReportOperation>,
673    #[serde(skip_serializing_if = "Option::is_none")]
674    operation_available: Option<bool>,
675    #[serde(skip_serializing_if = "Option::is_none")]
676    command_available: Option<bool>,
677    #[serde(skip_serializing_if = "Option::is_none")]
678    command: Option<RestoreApplyRunnerCommand>,
679}
680
681impl RestoreRunResponse {
682    // Build the shared native runner response fields from an apply journal report.
683    fn from_report(
684        backup_id: String,
685        report: RestoreApplyJournalReport,
686        mode: RestoreRunResponseMode,
687    ) -> Self {
688        Self {
689            run_version: RESTORE_RUN_RESPONSE_VERSION,
690            backup_id,
691            run_mode: mode.run_mode,
692            dry_run: mode.dry_run,
693            execute: mode.execute,
694            unclaim_pending: mode.unclaim_pending,
695            stopped_reason: mode.stopped_reason,
696            next_action: mode.next_action,
697            max_steps_reached: None,
698            executed_operations: Vec::new(),
699            executed_operation_count: None,
700            recovered_operation: None,
701            ready: report.ready,
702            complete: report.complete,
703            attention_required: report.attention_required,
704            outcome: report.outcome,
705            operation_count: report.operation_count,
706            operation_counts: report.operation_counts,
707            operation_counts_supplied: report.operation_counts_supplied,
708            progress: report.progress,
709            pending_operations: report.pending_operations,
710            ready_operations: report.ready_operations,
711            blocked_operations: report.blocked_operations,
712            completed_operations: report.completed_operations,
713            failed_operations: report.failed_operations,
714            blocked_reasons: report.blocked_reasons,
715            next_transition: report.next_transition,
716            operation_available: None,
717            command_available: None,
718            command: None,
719        }
720    }
721}
722
723///
724/// RestoreRunExecutedOperation
725///
726
727#[derive(Clone, Debug, Serialize)]
728struct RestoreRunExecutedOperation {
729    sequence: usize,
730    operation: RestoreApplyOperationKind,
731    target_canister: String,
732    command: RestoreApplyRunnerCommand,
733    status: String,
734    state: &'static str,
735}
736
737impl RestoreRunExecutedOperation {
738    // Build a completed executed-operation summary row from a runner operation.
739    fn completed(
740        operation: RestoreApplyJournalOperation,
741        command: RestoreApplyRunnerCommand,
742        status: String,
743    ) -> Self {
744        Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_COMPLETED)
745    }
746
747    // Build a failed executed-operation summary row from a runner operation.
748    fn failed(
749        operation: RestoreApplyJournalOperation,
750        command: RestoreApplyRunnerCommand,
751        status: String,
752    ) -> Self {
753        Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_FAILED)
754    }
755
756    // Map a journal operation into the compact runner execution row.
757    fn from_operation(
758        operation: RestoreApplyJournalOperation,
759        command: RestoreApplyRunnerCommand,
760        status: String,
761        state: &'static str,
762    ) -> Self {
763        Self {
764            sequence: operation.sequence,
765            operation: operation.operation,
766            target_canister: operation.target_canister,
767            command,
768            status,
769            state,
770        }
771    }
772}
773
774///
775/// RestoreRunResponseMode
776///
777
778struct RestoreRunResponseMode {
779    run_mode: &'static str,
780    dry_run: bool,
781    execute: bool,
782    unclaim_pending: bool,
783    stopped_reason: &'static str,
784    next_action: &'static str,
785}
786
787impl RestoreRunResponseMode {
788    // Build a response mode from the stable JSON mode flags and action labels.
789    const fn new(
790        run_mode: &'static str,
791        dry_run: bool,
792        execute: bool,
793        unclaim_pending: bool,
794        stopped_reason: &'static str,
795        next_action: &'static str,
796    ) -> Self {
797        Self {
798            run_mode,
799            dry_run,
800            execute,
801            unclaim_pending,
802            stopped_reason,
803            next_action,
804        }
805    }
806
807    // Build a dry-run response mode with a computed stop reason and action.
808    const fn dry_run(stopped_reason: &'static str, next_action: &'static str) -> Self {
809        Self::new(
810            RESTORE_RUN_MODE_DRY_RUN,
811            true,
812            false,
813            false,
814            stopped_reason,
815            next_action,
816        )
817    }
818
819    // Build an execute response mode with a computed stop reason and action.
820    const fn execute(stopped_reason: &'static str, next_action: &'static str) -> Self {
821        Self::new(
822            RESTORE_RUN_MODE_EXECUTE,
823            false,
824            true,
825            false,
826            stopped_reason,
827            next_action,
828        )
829    }
830
831    // Build the pending-operation recovery response mode.
832    const fn unclaim_pending(next_action: &'static str) -> Self {
833        Self::new(
834            RESTORE_RUN_MODE_UNCLAIM_PENDING,
835            false,
836            false,
837            true,
838            RESTORE_RUN_STOPPED_RECOVERED_PENDING,
839            next_action,
840        )
841    }
842}
843
844///
845/// RestoreApplyNextOptions
846///
847
848#[derive(Clone, Debug, Eq, PartialEq)]
849pub struct RestoreApplyNextOptions {
850    pub journal: PathBuf,
851    pub out: Option<PathBuf>,
852}
853
854impl RestoreApplyNextOptions {
855    /// Parse restore apply-next options from CLI arguments.
856    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
857    where
858        I: IntoIterator<Item = OsString>,
859    {
860        let mut journal = None;
861        let mut out = None;
862
863        let mut args = args.into_iter();
864        while let Some(arg) = args.next() {
865            let arg = arg
866                .into_string()
867                .map_err(|_| RestoreCommandError::Usage(usage()))?;
868            match arg.as_str() {
869                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
870                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
871                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
872                _ => return Err(RestoreCommandError::UnknownOption(arg)),
873            }
874        }
875
876        Ok(Self {
877            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
878            out,
879        })
880    }
881}
882
883///
884/// RestoreApplyCommandOptions
885///
886
887#[derive(Clone, Debug, Eq, PartialEq)]
888pub struct RestoreApplyCommandOptions {
889    pub journal: PathBuf,
890    pub dfx: String,
891    pub network: Option<String>,
892    pub out: Option<PathBuf>,
893    pub require_command: bool,
894}
895
896impl RestoreApplyCommandOptions {
897    /// Parse restore apply-command options from CLI arguments.
898    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
899    where
900        I: IntoIterator<Item = OsString>,
901    {
902        let mut journal = None;
903        let mut dfx = "dfx".to_string();
904        let mut network = None;
905        let mut out = None;
906        let mut require_command = false;
907
908        let mut args = args.into_iter();
909        while let Some(arg) = args.next() {
910            let arg = arg
911                .into_string()
912                .map_err(|_| RestoreCommandError::Usage(usage()))?;
913            match arg.as_str() {
914                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
915                "--dfx" => dfx = next_value(&mut args, "--dfx")?,
916                "--network" => network = Some(next_value(&mut args, "--network")?),
917                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
918                "--require-command" => require_command = true,
919                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
920                _ => return Err(RestoreCommandError::UnknownOption(arg)),
921            }
922        }
923
924        Ok(Self {
925            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
926            dfx,
927            network,
928            out,
929            require_command,
930        })
931    }
932}
933
934///
935/// RestoreApplyClaimOptions
936///
937
938#[derive(Clone, Debug, Eq, PartialEq)]
939pub struct RestoreApplyClaimOptions {
940    pub journal: PathBuf,
941    pub sequence: Option<usize>,
942    pub updated_at: Option<String>,
943    pub out: Option<PathBuf>,
944}
945
946impl RestoreApplyClaimOptions {
947    /// Parse restore apply-claim options from CLI arguments.
948    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
949    where
950        I: IntoIterator<Item = OsString>,
951    {
952        let mut journal = None;
953        let mut sequence = None;
954        let mut updated_at = None;
955        let mut out = None;
956
957        let mut args = args.into_iter();
958        while let Some(arg) = args.next() {
959            let arg = arg
960                .into_string()
961                .map_err(|_| RestoreCommandError::Usage(usage()))?;
962            match arg.as_str() {
963                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
964                "--sequence" => {
965                    sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
966                }
967                "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
968                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
969                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
970                _ => return Err(RestoreCommandError::UnknownOption(arg)),
971            }
972        }
973
974        Ok(Self {
975            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
976            sequence,
977            updated_at,
978            out,
979        })
980    }
981}
982
983///
984/// RestoreApplyMarkOptions
985///
986
987#[derive(Clone, Debug, Eq, PartialEq)]
988pub struct RestoreApplyUnclaimOptions {
989    pub journal: PathBuf,
990    pub sequence: Option<usize>,
991    pub updated_at: Option<String>,
992    pub out: Option<PathBuf>,
993}
994
995impl RestoreApplyUnclaimOptions {
996    /// Parse restore apply-unclaim options from CLI arguments.
997    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
998    where
999        I: IntoIterator<Item = OsString>,
1000    {
1001        let mut journal = None;
1002        let mut sequence = None;
1003        let mut updated_at = None;
1004        let mut out = None;
1005
1006        let mut args = args.into_iter();
1007        while let Some(arg) = args.next() {
1008            let arg = arg
1009                .into_string()
1010                .map_err(|_| RestoreCommandError::Usage(usage()))?;
1011            match arg.as_str() {
1012                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
1013                "--sequence" => {
1014                    sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
1015                }
1016                "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
1017                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
1018                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
1019                _ => return Err(RestoreCommandError::UnknownOption(arg)),
1020            }
1021        }
1022
1023        Ok(Self {
1024            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
1025            sequence,
1026            updated_at,
1027            out,
1028        })
1029    }
1030}
1031
1032///
1033/// RestoreApplyMarkOptions
1034///
1035
1036#[derive(Clone, Debug, Eq, PartialEq)]
1037pub struct RestoreApplyMarkOptions {
1038    pub journal: PathBuf,
1039    pub sequence: usize,
1040    pub state: RestoreApplyMarkState,
1041    pub reason: Option<String>,
1042    pub updated_at: Option<String>,
1043    pub out: Option<PathBuf>,
1044    pub require_pending: bool,
1045}
1046
1047impl RestoreApplyMarkOptions {
1048    /// Parse restore apply-mark options from CLI arguments.
1049    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
1050    where
1051        I: IntoIterator<Item = OsString>,
1052    {
1053        let mut journal = None;
1054        let mut sequence = None;
1055        let mut state = None;
1056        let mut reason = None;
1057        let mut updated_at = None;
1058        let mut out = None;
1059        let mut require_pending = false;
1060
1061        let mut args = args.into_iter();
1062        while let Some(arg) = args.next() {
1063            let arg = arg
1064                .into_string()
1065                .map_err(|_| RestoreCommandError::Usage(usage()))?;
1066            match arg.as_str() {
1067                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
1068                "--sequence" => {
1069                    sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
1070                }
1071                "--state" => {
1072                    state = Some(RestoreApplyMarkState::parse(next_value(
1073                        &mut args, "--state",
1074                    )?)?);
1075                }
1076                "--reason" => reason = Some(next_value(&mut args, "--reason")?),
1077                "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
1078                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
1079                "--require-pending" => require_pending = true,
1080                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
1081                _ => return Err(RestoreCommandError::UnknownOption(arg)),
1082            }
1083        }
1084
1085        Ok(Self {
1086            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
1087            sequence: sequence.ok_or(RestoreCommandError::MissingOption("--sequence"))?,
1088            state: state.ok_or(RestoreCommandError::MissingOption("--state"))?,
1089            reason,
1090            updated_at,
1091            out,
1092            require_pending,
1093        })
1094    }
1095}
1096
1097///
1098/// RestoreApplyMarkState
1099///
1100
1101#[derive(Clone, Debug, Eq, PartialEq)]
1102pub enum RestoreApplyMarkState {
1103    Completed,
1104    Failed,
1105}
1106
1107impl RestoreApplyMarkState {
1108    // Parse the restricted operation states accepted by apply-mark.
1109    fn parse(value: String) -> Result<Self, RestoreCommandError> {
1110        match value.as_str() {
1111            "completed" => Ok(Self::Completed),
1112            "failed" => Ok(Self::Failed),
1113            _ => Err(RestoreCommandError::InvalidApplyMarkState(value)),
1114        }
1115    }
1116}
1117
1118/// Run a restore subcommand.
1119pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
1120where
1121    I: IntoIterator<Item = OsString>,
1122{
1123    let mut args = args.into_iter();
1124    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
1125        return Err(RestoreCommandError::Usage(usage()));
1126    };
1127
1128    match command.as_str() {
1129        "plan" => {
1130            let options = RestorePlanOptions::parse(args)?;
1131            let plan = plan_restore(&options)?;
1132            write_plan(&options, &plan)?;
1133            enforce_restore_plan_requirements(&options, &plan)?;
1134            Ok(())
1135        }
1136        "status" => {
1137            let options = RestoreStatusOptions::parse(args)?;
1138            let status = restore_status(&options)?;
1139            write_status(&options, &status)?;
1140            Ok(())
1141        }
1142        "apply" => {
1143            let options = RestoreApplyOptions::parse(args)?;
1144            let dry_run = restore_apply_dry_run(&options)?;
1145            write_apply_dry_run(&options, &dry_run)?;
1146            write_apply_journal_if_requested(&options, &dry_run)?;
1147            Ok(())
1148        }
1149        "apply-status" => {
1150            let options = RestoreApplyStatusOptions::parse(args)?;
1151            let status = restore_apply_status(&options)?;
1152            write_apply_status(&options, &status)?;
1153            enforce_apply_status_requirements(&options, &status)?;
1154            Ok(())
1155        }
1156        "apply-report" => {
1157            let options = RestoreApplyReportOptions::parse(args)?;
1158            let report = restore_apply_report(&options)?;
1159            write_apply_report(&options, &report)?;
1160            enforce_apply_report_requirements(&options, &report)?;
1161            Ok(())
1162        }
1163        "run" => {
1164            let options = RestoreRunOptions::parse(args)?;
1165            let run = if options.execute {
1166                restore_run_execute_result(&options)?
1167            } else if options.unclaim_pending {
1168                RestoreRunResult::ok(restore_run_unclaim_pending(&options)?)
1169            } else {
1170                RestoreRunResult::ok(restore_run_dry_run(&options)?)
1171            };
1172            write_restore_run(&options, &run.response)?;
1173            if let Some(error) = run.error {
1174                return Err(error);
1175            }
1176            enforce_restore_run_requirements(&options, &run.response)?;
1177            Ok(())
1178        }
1179        "apply-next" => {
1180            let options = RestoreApplyNextOptions::parse(args)?;
1181            let next = restore_apply_next(&options)?;
1182            write_apply_next(&options, &next)?;
1183            Ok(())
1184        }
1185        "apply-command" => {
1186            let options = RestoreApplyCommandOptions::parse(args)?;
1187            let preview = restore_apply_command(&options)?;
1188            write_apply_command(&options, &preview)?;
1189            enforce_apply_command_requirements(&options, &preview)?;
1190            Ok(())
1191        }
1192        "apply-claim" => {
1193            let options = RestoreApplyClaimOptions::parse(args)?;
1194            let journal = restore_apply_claim(&options)?;
1195            write_apply_claim(&options, &journal)?;
1196            Ok(())
1197        }
1198        "apply-unclaim" => {
1199            let options = RestoreApplyUnclaimOptions::parse(args)?;
1200            let journal = restore_apply_unclaim(&options)?;
1201            write_apply_unclaim(&options, &journal)?;
1202            Ok(())
1203        }
1204        "apply-mark" => {
1205            let options = RestoreApplyMarkOptions::parse(args)?;
1206            let journal = restore_apply_mark(&options)?;
1207            write_apply_mark(&options, &journal)?;
1208            Ok(())
1209        }
1210        "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
1211        _ => Err(RestoreCommandError::UnknownOption(command)),
1212    }
1213}
1214
1215/// Build a no-mutation restore plan from a manifest and optional mapping.
1216pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
1217    verify_backup_layout_if_required(options)?;
1218
1219    let manifest = read_manifest_source(options)?;
1220    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
1221
1222    RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
1223}
1224
1225/// Build the initial no-mutation restore status from a restore plan.
1226pub fn restore_status(
1227    options: &RestoreStatusOptions,
1228) -> Result<RestoreStatus, RestoreCommandError> {
1229    let plan = read_plan(&options.plan)?;
1230    Ok(RestoreStatus::from_plan(&plan))
1231}
1232
1233/// Build a no-mutation restore apply dry-run from a restore plan.
1234pub fn restore_apply_dry_run(
1235    options: &RestoreApplyOptions,
1236) -> Result<RestoreApplyDryRun, RestoreCommandError> {
1237    let plan = read_plan(&options.plan)?;
1238    let status = options.status.as_ref().map(read_status).transpose()?;
1239    if let Some(backup_dir) = &options.backup_dir {
1240        return RestoreApplyDryRun::try_from_plan_with_artifacts(
1241            &plan,
1242            status.as_ref(),
1243            backup_dir,
1244        )
1245        .map_err(RestoreCommandError::from);
1246    }
1247
1248    RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
1249}
1250
1251/// Build a compact restore apply status from a journal file.
1252pub fn restore_apply_status(
1253    options: &RestoreApplyStatusOptions,
1254) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
1255    let journal = read_apply_journal(&options.journal)?;
1256    Ok(journal.status())
1257}
1258
1259/// Build an operator-oriented restore apply report from a journal file.
1260pub fn restore_apply_report(
1261    options: &RestoreApplyReportOptions,
1262) -> Result<RestoreApplyJournalReport, RestoreCommandError> {
1263    let journal = read_apply_journal(&options.journal)?;
1264    Ok(journal.report())
1265}
1266
1267/// Build a no-mutation native restore runner preview from a journal file.
1268pub fn restore_run_dry_run(
1269    options: &RestoreRunOptions,
1270) -> Result<RestoreRunResponse, RestoreCommandError> {
1271    let journal = read_apply_journal(&options.journal)?;
1272    let report = journal.report();
1273    let preview = journal.next_command_preview_with_config(&restore_run_command_config(options));
1274    let stopped_reason = restore_run_stopped_reason(&report, false, false);
1275    let next_action = restore_run_next_action(&report, false);
1276
1277    let mut response = RestoreRunResponse::from_report(
1278        journal.backup_id,
1279        report,
1280        RestoreRunResponseMode::dry_run(stopped_reason, next_action),
1281    );
1282    response.operation_available = Some(preview.operation_available);
1283    response.command_available = Some(preview.command_available);
1284    response.command = preview.command;
1285    Ok(response)
1286}
1287
1288/// Recover an interrupted restore runner by unclaiming the pending operation.
1289pub fn restore_run_unclaim_pending(
1290    options: &RestoreRunOptions,
1291) -> Result<RestoreRunResponse, RestoreCommandError> {
1292    let mut journal = read_apply_journal(&options.journal)?;
1293    let recovered_operation = journal
1294        .next_transition_operation()
1295        .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
1296        .cloned()
1297        .ok_or(RestoreApplyJournalError::NoPendingOperation)?;
1298
1299    journal.mark_next_operation_ready_at(Some(timestamp_placeholder()))?;
1300    write_apply_journal_file(&options.journal, &journal)?;
1301
1302    let report = journal.report();
1303    let next_action = restore_run_next_action(&report, true);
1304    let mut response = RestoreRunResponse::from_report(
1305        journal.backup_id,
1306        report,
1307        RestoreRunResponseMode::unclaim_pending(next_action),
1308    );
1309    response.recovered_operation = Some(recovered_operation);
1310    Ok(response)
1311}
1312
1313/// Execute ready restore apply journal operations through generated runner commands.
1314pub fn restore_run_execute(
1315    options: &RestoreRunOptions,
1316) -> Result<RestoreRunResponse, RestoreCommandError> {
1317    let run = restore_run_execute_result(options)?;
1318    if let Some(error) = run.error {
1319        return Err(error);
1320    }
1321
1322    Ok(run.response)
1323}
1324
1325// Execute ready restore apply operations and retain any deferred runner error.
1326fn restore_run_execute_result(
1327    options: &RestoreRunOptions,
1328) -> Result<RestoreRunResult, RestoreCommandError> {
1329    let mut journal = read_apply_journal(&options.journal)?;
1330    let mut executed_operations = Vec::new();
1331    let config = restore_run_command_config(options);
1332
1333    loop {
1334        let report = journal.report();
1335        let max_steps_reached =
1336            restore_run_max_steps_reached(options, executed_operations.len(), &report);
1337        if report.complete || max_steps_reached {
1338            return Ok(RestoreRunResult::ok(restore_run_execute_summary(
1339                &journal,
1340                executed_operations,
1341                max_steps_reached,
1342            )));
1343        }
1344
1345        enforce_restore_run_executable(&journal, &report)?;
1346        let preview = journal.next_command_preview_with_config(&config);
1347        enforce_restore_run_command_available(&preview)?;
1348
1349        let operation = preview
1350            .operation
1351            .clone()
1352            .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1353        let command = preview
1354            .command
1355            .clone()
1356            .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1357        let sequence = operation.sequence;
1358
1359        enforce_apply_claim_sequence(sequence, &journal)?;
1360        journal.mark_operation_pending_at(sequence, Some(timestamp_placeholder()))?;
1361        write_apply_journal_file(&options.journal, &journal)?;
1362
1363        let status = Command::new(&command.program)
1364            .args(&command.args)
1365            .status()?;
1366        let status_label = exit_status_label(status);
1367        if status.success() {
1368            journal.mark_operation_completed_at(sequence, Some(timestamp_placeholder()))?;
1369            write_apply_journal_file(&options.journal, &journal)?;
1370            executed_operations.push(RestoreRunExecutedOperation::completed(
1371                operation,
1372                command,
1373                status_label,
1374            ));
1375            continue;
1376        }
1377
1378        journal.mark_operation_failed_at(
1379            sequence,
1380            format!("{RESTORE_RUN_COMMAND_EXIT_PREFIX}-{status_label}"),
1381            Some(timestamp_placeholder()),
1382        )?;
1383        write_apply_journal_file(&options.journal, &journal)?;
1384        executed_operations.push(RestoreRunExecutedOperation::failed(
1385            operation,
1386            command,
1387            status_label.clone(),
1388        ));
1389        let response = restore_run_execute_summary(&journal, executed_operations, false);
1390        return Ok(RestoreRunResult {
1391            response,
1392            error: Some(RestoreCommandError::RestoreRunCommandFailed {
1393                sequence,
1394                status: status_label,
1395            }),
1396        });
1397    }
1398}
1399
1400// Build the shared runner command-preview configuration from CLI options.
1401fn restore_run_command_config(options: &RestoreRunOptions) -> RestoreApplyCommandConfig {
1402    restore_command_config(&options.dfx, options.network.as_deref())
1403}
1404
1405// Build the shared apply-command preview configuration from CLI options.
1406fn restore_apply_command_config(options: &RestoreApplyCommandOptions) -> RestoreApplyCommandConfig {
1407    restore_command_config(&options.dfx, options.network.as_deref())
1408}
1409
1410// Build command-preview configuration from common dfx/network inputs.
1411fn restore_command_config(program: &str, network: Option<&str>) -> RestoreApplyCommandConfig {
1412    RestoreApplyCommandConfig {
1413        program: program.to_string(),
1414        network: network.map(str::to_string),
1415    }
1416}
1417
1418// Check whether execute mode has reached its requested operation batch size.
1419fn restore_run_max_steps_reached(
1420    options: &RestoreRunOptions,
1421    executed_operation_count: usize,
1422    report: &RestoreApplyJournalReport,
1423) -> bool {
1424    options.max_steps == Some(executed_operation_count) && !report.complete
1425}
1426
1427// Build the final native runner execution summary.
1428fn restore_run_execute_summary(
1429    journal: &RestoreApplyJournal,
1430    executed_operations: Vec<RestoreRunExecutedOperation>,
1431    max_steps_reached: bool,
1432) -> RestoreRunResponse {
1433    let report = journal.report();
1434    let executed_operation_count = executed_operations.len();
1435    let stopped_reason = restore_run_stopped_reason(&report, max_steps_reached, true);
1436    let next_action = restore_run_next_action(&report, false);
1437
1438    let mut response = RestoreRunResponse::from_report(
1439        journal.backup_id.clone(),
1440        report,
1441        RestoreRunResponseMode::execute(stopped_reason, next_action),
1442    );
1443    response.max_steps_reached = Some(max_steps_reached);
1444    response.executed_operation_count = Some(executed_operation_count);
1445    response.executed_operations = executed_operations;
1446    response
1447}
1448
1449// Classify why the native runner stopped for operator summaries.
1450const fn restore_run_stopped_reason(
1451    report: &RestoreApplyJournalReport,
1452    max_steps_reached: bool,
1453    executed: bool,
1454) -> &'static str {
1455    if report.complete {
1456        return RESTORE_RUN_STOPPED_COMPLETE;
1457    }
1458    if report.failed_operations > 0 {
1459        return RESTORE_RUN_STOPPED_COMMAND_FAILED;
1460    }
1461    if report.pending_operations > 0 {
1462        return RESTORE_RUN_STOPPED_PENDING;
1463    }
1464    if !report.ready || report.blocked_operations > 0 {
1465        return RESTORE_RUN_STOPPED_BLOCKED;
1466    }
1467    if max_steps_reached {
1468        return RESTORE_RUN_STOPPED_MAX_STEPS;
1469    }
1470    if executed {
1471        return RESTORE_RUN_STOPPED_READY;
1472    }
1473    RESTORE_RUN_STOPPED_PREVIEW
1474}
1475
1476// Recommend the next operator action for the native runner summary.
1477const fn restore_run_next_action(
1478    report: &RestoreApplyJournalReport,
1479    recovered_pending: bool,
1480) -> &'static str {
1481    if report.complete {
1482        return RESTORE_RUN_ACTION_DONE;
1483    }
1484    if report.failed_operations > 0 {
1485        return RESTORE_RUN_ACTION_INSPECT_FAILED;
1486    }
1487    if report.pending_operations > 0 {
1488        return RESTORE_RUN_ACTION_UNCLAIM_PENDING;
1489    }
1490    if !report.ready || report.blocked_operations > 0 {
1491        return RESTORE_RUN_ACTION_FIX_BLOCKED;
1492    }
1493    if recovered_pending {
1494        return RESTORE_RUN_ACTION_RERUN;
1495    }
1496    RESTORE_RUN_ACTION_RERUN
1497}
1498
1499// Ensure the journal can be advanced by the native restore runner.
1500fn enforce_restore_run_executable(
1501    journal: &RestoreApplyJournal,
1502    report: &RestoreApplyJournalReport,
1503) -> Result<(), RestoreCommandError> {
1504    if report.pending_operations > 0 {
1505        return Err(RestoreCommandError::RestoreApplyPending {
1506            backup_id: report.backup_id.clone(),
1507            pending_operations: report.pending_operations,
1508            next_transition_sequence: report
1509                .next_transition
1510                .as_ref()
1511                .map(|operation| operation.sequence),
1512        });
1513    }
1514
1515    if report.failed_operations > 0 {
1516        return Err(RestoreCommandError::RestoreApplyFailed {
1517            backup_id: report.backup_id.clone(),
1518            failed_operations: report.failed_operations,
1519        });
1520    }
1521
1522    if report.ready {
1523        return Ok(());
1524    }
1525
1526    Err(RestoreCommandError::RestoreApplyNotReady {
1527        backup_id: journal.backup_id.clone(),
1528        reasons: report.blocked_reasons.clone(),
1529    })
1530}
1531
1532// Convert an unavailable native runner command into the shared fail-closed error.
1533fn enforce_restore_run_command_available(
1534    preview: &RestoreApplyCommandPreview,
1535) -> Result<(), RestoreCommandError> {
1536    if preview.command_available {
1537        return Ok(());
1538    }
1539
1540    Err(restore_command_unavailable_error(preview))
1541}
1542
1543// Build a shared command-unavailable error from a preview.
1544fn restore_command_unavailable_error(preview: &RestoreApplyCommandPreview) -> RestoreCommandError {
1545    RestoreCommandError::RestoreApplyCommandUnavailable {
1546        backup_id: preview.backup_id.clone(),
1547        operation_available: preview.operation_available,
1548        complete: preview.complete,
1549        blocked_reasons: preview.blocked_reasons.clone(),
1550    }
1551}
1552
1553// Render process exit status without relying on platform-specific internals.
1554fn exit_status_label(status: std::process::ExitStatus) -> String {
1555    status
1556        .code()
1557        .map_or_else(|| "signal".to_string(), |code| code.to_string())
1558}
1559
1560// Enforce caller-requested native runner requirements after output is emitted.
1561fn enforce_restore_run_requirements(
1562    options: &RestoreRunOptions,
1563    run: &RestoreRunResponse,
1564) -> Result<(), RestoreCommandError> {
1565    if options.require_complete && !run.complete {
1566        return Err(RestoreCommandError::RestoreApplyIncomplete {
1567            backup_id: run.backup_id.clone(),
1568            completed_operations: run.completed_operations,
1569            operation_count: run.operation_count,
1570        });
1571    }
1572
1573    if options.require_no_attention && run.attention_required {
1574        return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
1575            backup_id: run.backup_id.clone(),
1576            outcome: run.outcome.clone(),
1577        });
1578    }
1579
1580    if let Some(expected) = &options.require_run_mode
1581        && run.run_mode != expected
1582    {
1583        return Err(RestoreCommandError::RestoreRunModeMismatch {
1584            backup_id: run.backup_id.clone(),
1585            expected: expected.clone(),
1586            actual: run.run_mode.to_string(),
1587        });
1588    }
1589
1590    if let Some(expected) = &options.require_stopped_reason
1591        && run.stopped_reason != expected
1592    {
1593        return Err(RestoreCommandError::RestoreRunStoppedReasonMismatch {
1594            backup_id: run.backup_id.clone(),
1595            expected: expected.clone(),
1596            actual: run.stopped_reason.to_string(),
1597        });
1598    }
1599
1600    if let Some(expected) = &options.require_next_action
1601        && run.next_action != expected
1602    {
1603        return Err(RestoreCommandError::RestoreRunNextActionMismatch {
1604            backup_id: run.backup_id.clone(),
1605            expected: expected.clone(),
1606            actual: run.next_action.to_string(),
1607        });
1608    }
1609
1610    if let Some(expected) = options.require_executed_count {
1611        let actual = run.executed_operation_count.unwrap_or(0);
1612        if actual != expected {
1613            return Err(RestoreCommandError::RestoreRunExecutedCountMismatch {
1614                backup_id: run.backup_id.clone(),
1615                expected,
1616                actual,
1617            });
1618        }
1619    }
1620
1621    Ok(())
1622}
1623
1624// Enforce caller-requested apply report requirements after report output is emitted.
1625fn enforce_apply_report_requirements(
1626    options: &RestoreApplyReportOptions,
1627    report: &RestoreApplyJournalReport,
1628) -> Result<(), RestoreCommandError> {
1629    if !options.require_no_attention || !report.attention_required {
1630        return Ok(());
1631    }
1632
1633    Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
1634        backup_id: report.backup_id.clone(),
1635        outcome: report.outcome.clone(),
1636    })
1637}
1638
1639// Enforce caller-requested apply journal requirements after status is emitted.
1640fn enforce_apply_status_requirements(
1641    options: &RestoreApplyStatusOptions,
1642    status: &RestoreApplyJournalStatus,
1643) -> Result<(), RestoreCommandError> {
1644    if options.require_ready && !status.ready {
1645        return Err(RestoreCommandError::RestoreApplyNotReady {
1646            backup_id: status.backup_id.clone(),
1647            reasons: status.blocked_reasons.clone(),
1648        });
1649    }
1650
1651    if options.require_no_pending && status.pending_operations > 0 {
1652        return Err(RestoreCommandError::RestoreApplyPending {
1653            backup_id: status.backup_id.clone(),
1654            pending_operations: status.pending_operations,
1655            next_transition_sequence: status.next_transition_sequence,
1656        });
1657    }
1658
1659    if options.require_no_failed && status.failed_operations > 0 {
1660        return Err(RestoreCommandError::RestoreApplyFailed {
1661            backup_id: status.backup_id.clone(),
1662            failed_operations: status.failed_operations,
1663        });
1664    }
1665
1666    if options.require_complete && !status.complete {
1667        return Err(RestoreCommandError::RestoreApplyIncomplete {
1668            backup_id: status.backup_id.clone(),
1669            completed_operations: status.completed_operations,
1670            operation_count: status.operation_count,
1671        });
1672    }
1673
1674    Ok(())
1675}
1676
1677/// Build the next restore apply operation response from a journal file.
1678pub fn restore_apply_next(
1679    options: &RestoreApplyNextOptions,
1680) -> Result<RestoreApplyNextOperation, RestoreCommandError> {
1681    let journal = read_apply_journal(&options.journal)?;
1682    Ok(journal.next_operation())
1683}
1684
1685/// Build the next restore apply command preview from a journal file.
1686pub fn restore_apply_command(
1687    options: &RestoreApplyCommandOptions,
1688) -> Result<RestoreApplyCommandPreview, RestoreCommandError> {
1689    let journal = read_apply_journal(&options.journal)?;
1690    Ok(journal.next_command_preview_with_config(&restore_apply_command_config(options)))
1691}
1692
1693// Enforce caller-requested command preview requirements after preview output is emitted.
1694fn enforce_apply_command_requirements(
1695    options: &RestoreApplyCommandOptions,
1696    preview: &RestoreApplyCommandPreview,
1697) -> Result<(), RestoreCommandError> {
1698    if !options.require_command || preview.command_available {
1699        return Ok(());
1700    }
1701
1702    Err(restore_command_unavailable_error(preview))
1703}
1704
1705/// Mark the next restore apply journal operation pending.
1706pub fn restore_apply_claim(
1707    options: &RestoreApplyClaimOptions,
1708) -> Result<RestoreApplyJournal, RestoreCommandError> {
1709    let mut journal = read_apply_journal(&options.journal)?;
1710    let updated_at = Some(state_updated_at(options.updated_at.as_ref()));
1711
1712    if let Some(sequence) = options.sequence {
1713        enforce_apply_claim_sequence(sequence, &journal)?;
1714        journal.mark_operation_pending_at(sequence, updated_at)?;
1715        return Ok(journal);
1716    }
1717
1718    journal.mark_next_operation_pending_at(updated_at)?;
1719    Ok(journal)
1720}
1721
1722// Ensure a runner claim still matches the operation it previewed.
1723fn enforce_apply_claim_sequence(
1724    expected: usize,
1725    journal: &RestoreApplyJournal,
1726) -> Result<(), RestoreCommandError> {
1727    let actual = journal
1728        .next_transition_operation()
1729        .map(|operation| operation.sequence);
1730
1731    if actual == Some(expected) {
1732        return Ok(());
1733    }
1734
1735    Err(RestoreCommandError::RestoreApplyClaimSequenceMismatch { expected, actual })
1736}
1737
1738/// Mark the current pending restore apply journal operation ready again.
1739pub fn restore_apply_unclaim(
1740    options: &RestoreApplyUnclaimOptions,
1741) -> Result<RestoreApplyJournal, RestoreCommandError> {
1742    let mut journal = read_apply_journal(&options.journal)?;
1743    if let Some(sequence) = options.sequence {
1744        enforce_apply_unclaim_sequence(sequence, &journal)?;
1745    }
1746
1747    journal.mark_next_operation_ready_at(Some(state_updated_at(options.updated_at.as_ref())))?;
1748    Ok(journal)
1749}
1750
1751// Ensure a runner unclaim still matches the pending operation it recovered.
1752fn enforce_apply_unclaim_sequence(
1753    expected: usize,
1754    journal: &RestoreApplyJournal,
1755) -> Result<(), RestoreCommandError> {
1756    let actual = journal
1757        .next_transition_operation()
1758        .map(|operation| operation.sequence);
1759
1760    if actual == Some(expected) {
1761        return Ok(());
1762    }
1763
1764    Err(RestoreCommandError::RestoreApplyUnclaimSequenceMismatch { expected, actual })
1765}
1766
1767/// Mark one restore apply journal operation completed or failed.
1768pub fn restore_apply_mark(
1769    options: &RestoreApplyMarkOptions,
1770) -> Result<RestoreApplyJournal, RestoreCommandError> {
1771    let mut journal = read_apply_journal(&options.journal)?;
1772    enforce_apply_mark_pending_requirement(options, &journal)?;
1773
1774    match options.state {
1775        RestoreApplyMarkState::Completed => {
1776            journal.mark_operation_completed_at(
1777                options.sequence,
1778                Some(state_updated_at(options.updated_at.as_ref())),
1779            )?;
1780        }
1781        RestoreApplyMarkState::Failed => {
1782            let reason =
1783                options
1784                    .reason
1785                    .clone()
1786                    .ok_or(RestoreApplyJournalError::FailureReasonRequired(
1787                        options.sequence,
1788                    ))?;
1789            journal.mark_operation_failed_at(
1790                options.sequence,
1791                reason,
1792                Some(state_updated_at(options.updated_at.as_ref())),
1793            )?;
1794        }
1795    }
1796
1797    Ok(journal)
1798}
1799
1800// Enforce that apply-mark only records an already claimed operation when requested.
1801fn enforce_apply_mark_pending_requirement(
1802    options: &RestoreApplyMarkOptions,
1803    journal: &RestoreApplyJournal,
1804) -> Result<(), RestoreCommandError> {
1805    if !options.require_pending {
1806        return Ok(());
1807    }
1808
1809    let state = journal
1810        .operations
1811        .iter()
1812        .find(|operation| operation.sequence == options.sequence)
1813        .map(|operation| operation.state.clone())
1814        .ok_or(RestoreApplyJournalError::OperationNotFound(
1815            options.sequence,
1816        ))?;
1817
1818    if state == RestoreApplyOperationState::Pending {
1819        return Ok(());
1820    }
1821
1822    Err(RestoreCommandError::RestoreApplyMarkRequiresPending {
1823        sequence: options.sequence,
1824        state,
1825    })
1826}
1827
1828// Enforce caller-requested restore plan requirements after the plan is emitted.
1829fn enforce_restore_plan_requirements(
1830    options: &RestorePlanOptions,
1831    plan: &RestorePlan,
1832) -> Result<(), RestoreCommandError> {
1833    if !options.require_restore_ready || plan.readiness_summary.ready {
1834        return Ok(());
1835    }
1836
1837    Err(RestoreCommandError::RestoreNotReady {
1838        backup_id: plan.backup_id.clone(),
1839        reasons: plan.readiness_summary.reasons.clone(),
1840    })
1841}
1842
1843// Verify backup layout integrity before restore planning when requested.
1844fn verify_backup_layout_if_required(
1845    options: &RestorePlanOptions,
1846) -> Result<(), RestoreCommandError> {
1847    if !options.require_verified {
1848        return Ok(());
1849    }
1850
1851    let Some(dir) = &options.backup_dir else {
1852        return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
1853    };
1854
1855    BackupLayout::new(dir.clone()).verify_integrity()?;
1856    Ok(())
1857}
1858
1859// Read the manifest from a direct path or canonical backup layout.
1860fn read_manifest_source(
1861    options: &RestorePlanOptions,
1862) -> Result<FleetBackupManifest, RestoreCommandError> {
1863    if let Some(path) = &options.manifest {
1864        return read_manifest(path);
1865    }
1866
1867    let Some(dir) = &options.backup_dir else {
1868        return Err(RestoreCommandError::MissingOption(
1869            "--manifest or --backup-dir",
1870        ));
1871    };
1872
1873    BackupLayout::new(dir.clone())
1874        .read_manifest()
1875        .map_err(RestoreCommandError::from)
1876}
1877
1878// Read and decode a fleet backup manifest from disk.
1879fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
1880    let data = fs::read_to_string(path)?;
1881    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1882}
1883
1884// Read and decode an optional source-to-target restore mapping from disk.
1885fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
1886    let data = fs::read_to_string(path)?;
1887    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1888}
1889
1890// Read and decode a restore plan from disk.
1891fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
1892    let data = fs::read_to_string(path)?;
1893    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1894}
1895
1896// Read and decode a restore status from disk.
1897fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
1898    let data = fs::read_to_string(path)?;
1899    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1900}
1901
1902// Read and decode a restore apply journal from disk.
1903fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
1904    let data = fs::read_to_string(path)?;
1905    let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
1906    journal.validate()?;
1907    Ok(journal)
1908}
1909
1910// Parse a restore apply journal operation sequence value.
1911fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
1912    value
1913        .parse::<usize>()
1914        .map_err(|_| RestoreCommandError::InvalidSequence)
1915}
1916
1917// Return the caller-supplied journal update marker or the current placeholder.
1918fn state_updated_at(updated_at: Option<&String>) -> String {
1919    updated_at.cloned().unwrap_or_else(timestamp_placeholder)
1920}
1921
1922// Return a placeholder timestamp until the CLI owns a clock abstraction.
1923fn timestamp_placeholder() -> String {
1924    "unknown".to_string()
1925}
1926
1927// Write the computed plan to stdout or a requested output file.
1928fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
1929    if let Some(path) = &options.out {
1930        let data = serde_json::to_vec_pretty(plan)?;
1931        fs::write(path, data)?;
1932        return Ok(());
1933    }
1934
1935    let stdout = io::stdout();
1936    let mut handle = stdout.lock();
1937    serde_json::to_writer_pretty(&mut handle, plan)?;
1938    writeln!(handle)?;
1939    Ok(())
1940}
1941
1942// Write the computed status to stdout or a requested output file.
1943fn write_status(
1944    options: &RestoreStatusOptions,
1945    status: &RestoreStatus,
1946) -> Result<(), RestoreCommandError> {
1947    if let Some(path) = &options.out {
1948        let data = serde_json::to_vec_pretty(status)?;
1949        fs::write(path, data)?;
1950        return Ok(());
1951    }
1952
1953    let stdout = io::stdout();
1954    let mut handle = stdout.lock();
1955    serde_json::to_writer_pretty(&mut handle, status)?;
1956    writeln!(handle)?;
1957    Ok(())
1958}
1959
1960// Write the computed apply dry-run to stdout or a requested output file.
1961fn write_apply_dry_run(
1962    options: &RestoreApplyOptions,
1963    dry_run: &RestoreApplyDryRun,
1964) -> Result<(), RestoreCommandError> {
1965    if let Some(path) = &options.out {
1966        let data = serde_json::to_vec_pretty(dry_run)?;
1967        fs::write(path, data)?;
1968        return Ok(());
1969    }
1970
1971    let stdout = io::stdout();
1972    let mut handle = stdout.lock();
1973    serde_json::to_writer_pretty(&mut handle, dry_run)?;
1974    writeln!(handle)?;
1975    Ok(())
1976}
1977
1978// Write the initial apply journal when the caller requests one.
1979fn write_apply_journal_if_requested(
1980    options: &RestoreApplyOptions,
1981    dry_run: &RestoreApplyDryRun,
1982) -> Result<(), RestoreCommandError> {
1983    let Some(path) = &options.journal_out else {
1984        return Ok(());
1985    };
1986
1987    let journal = RestoreApplyJournal::from_dry_run(dry_run);
1988    let data = serde_json::to_vec_pretty(&journal)?;
1989    fs::write(path, data)?;
1990    Ok(())
1991}
1992
1993// Write the computed apply journal status to stdout or a requested output file.
1994fn write_apply_status(
1995    options: &RestoreApplyStatusOptions,
1996    status: &RestoreApplyJournalStatus,
1997) -> Result<(), RestoreCommandError> {
1998    if let Some(path) = &options.out {
1999        let data = serde_json::to_vec_pretty(status)?;
2000        fs::write(path, data)?;
2001        return Ok(());
2002    }
2003
2004    let stdout = io::stdout();
2005    let mut handle = stdout.lock();
2006    serde_json::to_writer_pretty(&mut handle, status)?;
2007    writeln!(handle)?;
2008    Ok(())
2009}
2010
2011// Write the computed apply journal report to stdout or a requested output file.
2012fn write_apply_report(
2013    options: &RestoreApplyReportOptions,
2014    report: &RestoreApplyJournalReport,
2015) -> Result<(), RestoreCommandError> {
2016    if let Some(path) = &options.out {
2017        let data = serde_json::to_vec_pretty(report)?;
2018        fs::write(path, data)?;
2019        return Ok(());
2020    }
2021
2022    let stdout = io::stdout();
2023    let mut handle = stdout.lock();
2024    serde_json::to_writer_pretty(&mut handle, report)?;
2025    writeln!(handle)?;
2026    Ok(())
2027}
2028
2029// Write the restore runner response to stdout or a requested output file.
2030fn write_restore_run(
2031    options: &RestoreRunOptions,
2032    run: &RestoreRunResponse,
2033) -> Result<(), RestoreCommandError> {
2034    if let Some(path) = &options.out {
2035        let data = serde_json::to_vec_pretty(run)?;
2036        fs::write(path, data)?;
2037        return Ok(());
2038    }
2039
2040    let stdout = io::stdout();
2041    let mut handle = stdout.lock();
2042    serde_json::to_writer_pretty(&mut handle, run)?;
2043    writeln!(handle)?;
2044    Ok(())
2045}
2046
2047// Persist the restore apply journal to its canonical runner path.
2048fn write_apply_journal_file(
2049    path: &PathBuf,
2050    journal: &RestoreApplyJournal,
2051) -> Result<(), RestoreCommandError> {
2052    let data = serde_json::to_vec_pretty(journal)?;
2053    fs::write(path, data)?;
2054    Ok(())
2055}
2056
2057// Write the computed apply next-operation response to stdout or a requested output file.
2058fn write_apply_next(
2059    options: &RestoreApplyNextOptions,
2060    next: &RestoreApplyNextOperation,
2061) -> Result<(), RestoreCommandError> {
2062    if let Some(path) = &options.out {
2063        let data = serde_json::to_vec_pretty(next)?;
2064        fs::write(path, data)?;
2065        return Ok(());
2066    }
2067
2068    let stdout = io::stdout();
2069    let mut handle = stdout.lock();
2070    serde_json::to_writer_pretty(&mut handle, next)?;
2071    writeln!(handle)?;
2072    Ok(())
2073}
2074
2075// Write the computed apply command preview to stdout or a requested output file.
2076fn write_apply_command(
2077    options: &RestoreApplyCommandOptions,
2078    preview: &RestoreApplyCommandPreview,
2079) -> Result<(), RestoreCommandError> {
2080    if let Some(path) = &options.out {
2081        let data = serde_json::to_vec_pretty(preview)?;
2082        fs::write(path, data)?;
2083        return Ok(());
2084    }
2085
2086    let stdout = io::stdout();
2087    let mut handle = stdout.lock();
2088    serde_json::to_writer_pretty(&mut handle, preview)?;
2089    writeln!(handle)?;
2090    Ok(())
2091}
2092
2093// Write the claimed apply journal to stdout or a requested output file.
2094fn write_apply_claim(
2095    options: &RestoreApplyClaimOptions,
2096    journal: &RestoreApplyJournal,
2097) -> Result<(), RestoreCommandError> {
2098    if let Some(path) = &options.out {
2099        let data = serde_json::to_vec_pretty(journal)?;
2100        fs::write(path, data)?;
2101        return Ok(());
2102    }
2103
2104    let stdout = io::stdout();
2105    let mut handle = stdout.lock();
2106    serde_json::to_writer_pretty(&mut handle, journal)?;
2107    writeln!(handle)?;
2108    Ok(())
2109}
2110
2111// Write the unclaimed apply journal to stdout or a requested output file.
2112fn write_apply_unclaim(
2113    options: &RestoreApplyUnclaimOptions,
2114    journal: &RestoreApplyJournal,
2115) -> Result<(), RestoreCommandError> {
2116    if let Some(path) = &options.out {
2117        let data = serde_json::to_vec_pretty(journal)?;
2118        fs::write(path, data)?;
2119        return Ok(());
2120    }
2121
2122    let stdout = io::stdout();
2123    let mut handle = stdout.lock();
2124    serde_json::to_writer_pretty(&mut handle, journal)?;
2125    writeln!(handle)?;
2126    Ok(())
2127}
2128
2129// Write the updated apply journal to stdout or a requested output file.
2130fn write_apply_mark(
2131    options: &RestoreApplyMarkOptions,
2132    journal: &RestoreApplyJournal,
2133) -> Result<(), RestoreCommandError> {
2134    if let Some(path) = &options.out {
2135        let data = serde_json::to_vec_pretty(journal)?;
2136        fs::write(path, data)?;
2137        return Ok(());
2138    }
2139
2140    let stdout = io::stdout();
2141    let mut handle = stdout.lock();
2142    serde_json::to_writer_pretty(&mut handle, journal)?;
2143    writeln!(handle)?;
2144    Ok(())
2145}
2146
2147// Read the next required option value.
2148fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
2149where
2150    I: Iterator<Item = OsString>,
2151{
2152    args.next()
2153        .and_then(|value| value.into_string().ok())
2154        .ok_or(RestoreCommandError::MissingValue(option))
2155}
2156
2157// Return restore command usage text.
2158const fn usage() -> &'static str {
2159    "usage: canic restore plan (--manifest <file> | --backup-dir <dir>) [--mapping <file>] [--out <file>] [--require-verified] [--require-restore-ready]\n       canic restore status --plan <file> [--out <file>]\n       canic restore apply --plan <file> [--status <file>] [--backup-dir <dir>] --dry-run [--out <file>] [--journal-out <file>]\n       canic restore apply-status --journal <file> [--out <file>] [--require-ready] [--require-no-pending] [--require-no-failed] [--require-complete]\n       canic restore apply-report --journal <file> [--out <file>] [--require-no-attention]\n       canic restore run --journal <file> (--dry-run | --execute | --unclaim-pending) [--dfx <path>] [--network <name>] [--max-steps <n>] [--out <file>] [--require-complete] [--require-no-attention] [--require-run-mode <text>] [--require-stopped-reason <text>] [--require-next-action <text>] [--require-executed-count <n>]\n       canic restore apply-next --journal <file> [--out <file>]\n       canic restore apply-command --journal <file> [--dfx <path>] [--network <name>] [--out <file>] [--require-command]\n       canic restore apply-claim --journal <file> [--sequence <n>] [--updated-at <text>] [--out <file>]\n       canic restore apply-unclaim --journal <file> [--sequence <n>] [--updated-at <text>] [--out <file>]\n       canic restore apply-mark --journal <file> --sequence <n> --state completed|failed [--reason <text>] [--updated-at <text>] [--out <file>] [--require-pending]"
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164    use super::*;
2165    use canic_backup::restore::RestoreApplyOperationState;
2166    use canic_backup::{
2167        artifacts::ArtifactChecksum,
2168        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
2169        manifest::{
2170            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
2171            FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
2172            VerificationCheck, VerificationPlan,
2173        },
2174    };
2175    use serde_json::json;
2176    use std::{
2177        path::Path,
2178        time::{SystemTime, UNIX_EPOCH},
2179    };
2180
2181    const ROOT: &str = "aaaaa-aa";
2182    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
2183    const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
2184    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2185
2186    // Ensure restore plan options parse the intended no-mutation command.
2187    #[test]
2188    fn parses_restore_plan_options() {
2189        let options = RestorePlanOptions::parse([
2190            OsString::from("--manifest"),
2191            OsString::from("manifest.json"),
2192            OsString::from("--mapping"),
2193            OsString::from("mapping.json"),
2194            OsString::from("--out"),
2195            OsString::from("plan.json"),
2196            OsString::from("--require-restore-ready"),
2197        ])
2198        .expect("parse options");
2199
2200        assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
2201        assert_eq!(options.backup_dir, None);
2202        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
2203        assert_eq!(options.out, Some(PathBuf::from("plan.json")));
2204        assert!(!options.require_verified);
2205        assert!(options.require_restore_ready);
2206    }
2207
2208    // Ensure verified restore plan options parse with the canonical backup source.
2209    #[test]
2210    fn parses_verified_restore_plan_options() {
2211        let options = RestorePlanOptions::parse([
2212            OsString::from("--backup-dir"),
2213            OsString::from("backups/run"),
2214            OsString::from("--require-verified"),
2215        ])
2216        .expect("parse verified options");
2217
2218        assert_eq!(options.manifest, None);
2219        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
2220        assert_eq!(options.mapping, None);
2221        assert_eq!(options.out, None);
2222        assert!(options.require_verified);
2223        assert!(!options.require_restore_ready);
2224    }
2225
2226    // Ensure restore status options parse the intended no-mutation command.
2227    #[test]
2228    fn parses_restore_status_options() {
2229        let options = RestoreStatusOptions::parse([
2230            OsString::from("--plan"),
2231            OsString::from("restore-plan.json"),
2232            OsString::from("--out"),
2233            OsString::from("restore-status.json"),
2234        ])
2235        .expect("parse status options");
2236
2237        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
2238        assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
2239    }
2240
2241    // Ensure restore apply options require the explicit dry-run mode.
2242    #[test]
2243    fn parses_restore_apply_dry_run_options() {
2244        let options = RestoreApplyOptions::parse([
2245            OsString::from("--plan"),
2246            OsString::from("restore-plan.json"),
2247            OsString::from("--status"),
2248            OsString::from("restore-status.json"),
2249            OsString::from("--backup-dir"),
2250            OsString::from("backups/run"),
2251            OsString::from("--dry-run"),
2252            OsString::from("--out"),
2253            OsString::from("restore-apply-dry-run.json"),
2254            OsString::from("--journal-out"),
2255            OsString::from("restore-apply-journal.json"),
2256        ])
2257        .expect("parse apply options");
2258
2259        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
2260        assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
2261        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
2262        assert_eq!(
2263            options.out,
2264            Some(PathBuf::from("restore-apply-dry-run.json"))
2265        );
2266        assert_eq!(
2267            options.journal_out,
2268            Some(PathBuf::from("restore-apply-journal.json"))
2269        );
2270        assert!(options.dry_run);
2271    }
2272
2273    // Ensure restore apply-status options parse the intended journal command.
2274    #[test]
2275    fn parses_restore_apply_status_options() {
2276        let options = RestoreApplyStatusOptions::parse([
2277            OsString::from("--journal"),
2278            OsString::from("restore-apply-journal.json"),
2279            OsString::from("--out"),
2280            OsString::from("restore-apply-status.json"),
2281            OsString::from("--require-ready"),
2282            OsString::from("--require-no-pending"),
2283            OsString::from("--require-no-failed"),
2284            OsString::from("--require-complete"),
2285        ])
2286        .expect("parse apply-status options");
2287
2288        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2289        assert!(options.require_ready);
2290        assert!(options.require_no_pending);
2291        assert!(options.require_no_failed);
2292        assert!(options.require_complete);
2293        assert_eq!(
2294            options.out,
2295            Some(PathBuf::from("restore-apply-status.json"))
2296        );
2297    }
2298
2299    // Ensure restore apply-report options parse the intended journal command.
2300    #[test]
2301    fn parses_restore_apply_report_options() {
2302        let options = RestoreApplyReportOptions::parse([
2303            OsString::from("--journal"),
2304            OsString::from("restore-apply-journal.json"),
2305            OsString::from("--out"),
2306            OsString::from("restore-apply-report.json"),
2307            OsString::from("--require-no-attention"),
2308        ])
2309        .expect("parse apply-report options");
2310
2311        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2312        assert!(options.require_no_attention);
2313        assert_eq!(
2314            options.out,
2315            Some(PathBuf::from("restore-apply-report.json"))
2316        );
2317    }
2318
2319    // Ensure restore run options parse the native runner dry-run command.
2320    #[test]
2321    fn parses_restore_run_dry_run_options() {
2322        let options = RestoreRunOptions::parse([
2323            OsString::from("--journal"),
2324            OsString::from("restore-apply-journal.json"),
2325            OsString::from("--dry-run"),
2326            OsString::from("--dfx"),
2327            OsString::from("/tmp/dfx"),
2328            OsString::from("--network"),
2329            OsString::from("local"),
2330            OsString::from("--out"),
2331            OsString::from("restore-run-dry-run.json"),
2332            OsString::from("--max-steps"),
2333            OsString::from("1"),
2334            OsString::from("--require-complete"),
2335            OsString::from("--require-no-attention"),
2336            OsString::from("--require-run-mode"),
2337            OsString::from("dry-run"),
2338            OsString::from("--require-stopped-reason"),
2339            OsString::from("preview"),
2340            OsString::from("--require-next-action"),
2341            OsString::from("rerun"),
2342            OsString::from("--require-executed-count"),
2343            OsString::from("0"),
2344        ])
2345        .expect("parse restore run options");
2346
2347        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2348        assert_eq!(options.dfx, "/tmp/dfx");
2349        assert_eq!(options.network.as_deref(), Some("local"));
2350        assert_eq!(options.out, Some(PathBuf::from("restore-run-dry-run.json")));
2351        assert!(options.dry_run);
2352        assert!(!options.execute);
2353        assert!(!options.unclaim_pending);
2354        assert_eq!(options.max_steps, Some(1));
2355        assert!(options.require_complete);
2356        assert!(options.require_no_attention);
2357        assert_eq!(options.require_run_mode.as_deref(), Some("dry-run"));
2358        assert_eq!(options.require_stopped_reason.as_deref(), Some("preview"));
2359        assert_eq!(options.require_next_action.as_deref(), Some("rerun"));
2360        assert_eq!(options.require_executed_count, Some(0));
2361    }
2362
2363    // Ensure restore run options parse the native execute command.
2364    #[test]
2365    fn parses_restore_run_execute_options() {
2366        let options = RestoreRunOptions::parse([
2367            OsString::from("--journal"),
2368            OsString::from("restore-apply-journal.json"),
2369            OsString::from("--execute"),
2370            OsString::from("--dfx"),
2371            OsString::from("/bin/true"),
2372            OsString::from("--max-steps"),
2373            OsString::from("4"),
2374        ])
2375        .expect("parse restore run execute options");
2376
2377        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2378        assert_eq!(options.dfx, "/bin/true");
2379        assert_eq!(options.network, None);
2380        assert_eq!(options.out, None);
2381        assert!(!options.dry_run);
2382        assert!(options.execute);
2383        assert!(!options.unclaim_pending);
2384        assert_eq!(options.max_steps, Some(4));
2385        assert!(!options.require_complete);
2386        assert!(!options.require_no_attention);
2387        assert_eq!(options.require_run_mode, None);
2388        assert_eq!(options.require_stopped_reason, None);
2389        assert_eq!(options.require_next_action, None);
2390        assert_eq!(options.require_executed_count, None);
2391    }
2392
2393    // Ensure restore run options parse the native pending-operation recovery mode.
2394    #[test]
2395    fn parses_restore_run_unclaim_pending_options() {
2396        let options = RestoreRunOptions::parse([
2397            OsString::from("--journal"),
2398            OsString::from("restore-apply-journal.json"),
2399            OsString::from("--unclaim-pending"),
2400            OsString::from("--out"),
2401            OsString::from("restore-run.json"),
2402        ])
2403        .expect("parse restore run unclaim options");
2404
2405        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2406        assert_eq!(options.out, Some(PathBuf::from("restore-run.json")));
2407        assert!(!options.dry_run);
2408        assert!(!options.execute);
2409        assert!(options.unclaim_pending);
2410    }
2411
2412    // Ensure restore apply-next options parse the intended journal command.
2413    #[test]
2414    fn parses_restore_apply_next_options() {
2415        let options = RestoreApplyNextOptions::parse([
2416            OsString::from("--journal"),
2417            OsString::from("restore-apply-journal.json"),
2418            OsString::from("--out"),
2419            OsString::from("restore-apply-next.json"),
2420        ])
2421        .expect("parse apply-next options");
2422
2423        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2424        assert_eq!(options.out, Some(PathBuf::from("restore-apply-next.json")));
2425    }
2426
2427    // Ensure restore apply-command options parse the intended preview command.
2428    #[test]
2429    fn parses_restore_apply_command_options() {
2430        let options = RestoreApplyCommandOptions::parse([
2431            OsString::from("--journal"),
2432            OsString::from("restore-apply-journal.json"),
2433            OsString::from("--dfx"),
2434            OsString::from("/tmp/dfx"),
2435            OsString::from("--network"),
2436            OsString::from("local"),
2437            OsString::from("--out"),
2438            OsString::from("restore-apply-command.json"),
2439            OsString::from("--require-command"),
2440        ])
2441        .expect("parse apply-command options");
2442
2443        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2444        assert_eq!(options.dfx, "/tmp/dfx");
2445        assert_eq!(options.network.as_deref(), Some("local"));
2446        assert!(options.require_command);
2447        assert_eq!(
2448            options.out,
2449            Some(PathBuf::from("restore-apply-command.json"))
2450        );
2451    }
2452
2453    // Ensure restore apply-claim options parse the intended journal command.
2454    #[test]
2455    fn parses_restore_apply_claim_options() {
2456        let options = RestoreApplyClaimOptions::parse([
2457            OsString::from("--journal"),
2458            OsString::from("restore-apply-journal.json"),
2459            OsString::from("--sequence"),
2460            OsString::from("0"),
2461            OsString::from("--updated-at"),
2462            OsString::from("2026-05-04T12:00:00Z"),
2463            OsString::from("--out"),
2464            OsString::from("restore-apply-journal.claimed.json"),
2465        ])
2466        .expect("parse apply-claim options");
2467
2468        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2469        assert_eq!(options.sequence, Some(0));
2470        assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:00:00Z"));
2471        assert_eq!(
2472            options.out,
2473            Some(PathBuf::from("restore-apply-journal.claimed.json"))
2474        );
2475    }
2476
2477    // Ensure restore apply-unclaim options parse the intended journal command.
2478    #[test]
2479    fn parses_restore_apply_unclaim_options() {
2480        let options = RestoreApplyUnclaimOptions::parse([
2481            OsString::from("--journal"),
2482            OsString::from("restore-apply-journal.json"),
2483            OsString::from("--sequence"),
2484            OsString::from("0"),
2485            OsString::from("--updated-at"),
2486            OsString::from("2026-05-04T12:01:00Z"),
2487            OsString::from("--out"),
2488            OsString::from("restore-apply-journal.unclaimed.json"),
2489        ])
2490        .expect("parse apply-unclaim options");
2491
2492        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2493        assert_eq!(options.sequence, Some(0));
2494        assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:01:00Z"));
2495        assert_eq!(
2496            options.out,
2497            Some(PathBuf::from("restore-apply-journal.unclaimed.json"))
2498        );
2499    }
2500
2501    // Ensure restore apply-mark options parse the intended journal update command.
2502    #[test]
2503    fn parses_restore_apply_mark_options() {
2504        let options = RestoreApplyMarkOptions::parse([
2505            OsString::from("--journal"),
2506            OsString::from("restore-apply-journal.json"),
2507            OsString::from("--sequence"),
2508            OsString::from("4"),
2509            OsString::from("--state"),
2510            OsString::from("failed"),
2511            OsString::from("--reason"),
2512            OsString::from("dfx-load-failed"),
2513            OsString::from("--updated-at"),
2514            OsString::from("2026-05-04T12:02:00Z"),
2515            OsString::from("--out"),
2516            OsString::from("restore-apply-journal.updated.json"),
2517            OsString::from("--require-pending"),
2518        ])
2519        .expect("parse apply-mark options");
2520
2521        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2522        assert_eq!(options.sequence, 4);
2523        assert_eq!(options.state, RestoreApplyMarkState::Failed);
2524        assert_eq!(options.reason.as_deref(), Some("dfx-load-failed"));
2525        assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:02:00Z"));
2526        assert!(options.require_pending);
2527        assert_eq!(
2528            options.out,
2529            Some(PathBuf::from("restore-apply-journal.updated.json"))
2530        );
2531    }
2532
2533    // Ensure restore apply refuses non-dry-run execution while apply is scaffolded.
2534    #[test]
2535    fn restore_apply_requires_dry_run() {
2536        let err = RestoreApplyOptions::parse([
2537            OsString::from("--plan"),
2538            OsString::from("restore-plan.json"),
2539        ])
2540        .expect_err("apply without dry-run should fail");
2541
2542        assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
2543    }
2544
2545    // Ensure restore run refuses mutation while native execution is scaffolded.
2546    #[test]
2547    fn restore_run_requires_mode() {
2548        let err = RestoreRunOptions::parse([
2549            OsString::from("--journal"),
2550            OsString::from("restore-apply-journal.json"),
2551        ])
2552        .expect_err("restore run without dry-run should fail");
2553
2554        assert!(matches!(err, RestoreCommandError::RestoreRunRequiresMode));
2555    }
2556
2557    // Ensure restore run rejects ambiguous execution modes.
2558    #[test]
2559    fn restore_run_rejects_conflicting_modes() {
2560        let err = RestoreRunOptions::parse([
2561            OsString::from("--journal"),
2562            OsString::from("restore-apply-journal.json"),
2563            OsString::from("--dry-run"),
2564            OsString::from("--execute"),
2565            OsString::from("--unclaim-pending"),
2566        ])
2567        .expect_err("restore run should reject conflicting modes");
2568
2569        assert!(matches!(
2570            err,
2571            RestoreCommandError::RestoreRunConflictingModes
2572        ));
2573    }
2574
2575    // Ensure backup-dir restore planning reads the canonical layout manifest.
2576    #[test]
2577    fn plan_restore_reads_manifest_from_backup_dir() {
2578        let root = temp_dir("canic-cli-restore-plan-layout");
2579        let layout = BackupLayout::new(root.clone());
2580        layout
2581            .write_manifest(&valid_manifest())
2582            .expect("write manifest");
2583
2584        let options = RestorePlanOptions {
2585            manifest: None,
2586            backup_dir: Some(root.clone()),
2587            mapping: None,
2588            out: None,
2589            require_verified: false,
2590            require_restore_ready: false,
2591        };
2592
2593        let plan = plan_restore(&options).expect("plan restore");
2594
2595        fs::remove_dir_all(root).expect("remove temp root");
2596        assert_eq!(plan.backup_id, "backup-test");
2597        assert_eq!(plan.member_count, 2);
2598    }
2599
2600    // Ensure restore planning has exactly one manifest source.
2601    #[test]
2602    fn parse_rejects_conflicting_manifest_sources() {
2603        let err = RestorePlanOptions::parse([
2604            OsString::from("--manifest"),
2605            OsString::from("manifest.json"),
2606            OsString::from("--backup-dir"),
2607            OsString::from("backups/run"),
2608        ])
2609        .expect_err("conflicting sources should fail");
2610
2611        assert!(matches!(
2612            err,
2613            RestoreCommandError::ConflictingManifestSources
2614        ));
2615    }
2616
2617    // Ensure verified planning requires the canonical backup layout source.
2618    #[test]
2619    fn parse_rejects_require_verified_with_manifest_source() {
2620        let err = RestorePlanOptions::parse([
2621            OsString::from("--manifest"),
2622            OsString::from("manifest.json"),
2623            OsString::from("--require-verified"),
2624        ])
2625        .expect_err("verification should require a backup layout");
2626
2627        assert!(matches!(
2628            err,
2629            RestoreCommandError::RequireVerifiedNeedsBackupDir
2630        ));
2631    }
2632
2633    // Ensure restore planning can require manifest, journal, and artifact integrity.
2634    #[test]
2635    fn plan_restore_requires_verified_backup_layout() {
2636        let root = temp_dir("canic-cli-restore-plan-verified");
2637        let layout = BackupLayout::new(root.clone());
2638        let manifest = valid_manifest();
2639        write_verified_layout(&root, &layout, &manifest);
2640
2641        let options = RestorePlanOptions {
2642            manifest: None,
2643            backup_dir: Some(root.clone()),
2644            mapping: None,
2645            out: None,
2646            require_verified: true,
2647            require_restore_ready: false,
2648        };
2649
2650        let plan = plan_restore(&options).expect("plan verified restore");
2651
2652        fs::remove_dir_all(root).expect("remove temp root");
2653        assert_eq!(plan.backup_id, "backup-test");
2654        assert_eq!(plan.member_count, 2);
2655    }
2656
2657    // Ensure required verification fails before planning when the layout is incomplete.
2658    #[test]
2659    fn plan_restore_rejects_unverified_backup_layout() {
2660        let root = temp_dir("canic-cli-restore-plan-unverified");
2661        let layout = BackupLayout::new(root.clone());
2662        layout
2663            .write_manifest(&valid_manifest())
2664            .expect("write manifest");
2665
2666        let options = RestorePlanOptions {
2667            manifest: None,
2668            backup_dir: Some(root.clone()),
2669            mapping: None,
2670            out: None,
2671            require_verified: true,
2672            require_restore_ready: false,
2673        };
2674
2675        let err = plan_restore(&options).expect_err("missing journal should fail");
2676
2677        fs::remove_dir_all(root).expect("remove temp root");
2678        assert!(matches!(err, RestoreCommandError::Persistence(_)));
2679    }
2680
2681    // Ensure the CLI planning path validates manifests and applies mappings.
2682    #[test]
2683    fn plan_restore_reads_manifest_and_mapping() {
2684        let root = temp_dir("canic-cli-restore-plan");
2685        fs::create_dir_all(&root).expect("create temp root");
2686        let manifest_path = root.join("manifest.json");
2687        let mapping_path = root.join("mapping.json");
2688
2689        fs::write(
2690            &manifest_path,
2691            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
2692        )
2693        .expect("write manifest");
2694        fs::write(
2695            &mapping_path,
2696            json!({
2697                "members": [
2698                    {"source_canister": ROOT, "target_canister": ROOT},
2699                    {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
2700                ]
2701            })
2702            .to_string(),
2703        )
2704        .expect("write mapping");
2705
2706        let options = RestorePlanOptions {
2707            manifest: Some(manifest_path),
2708            backup_dir: None,
2709            mapping: Some(mapping_path),
2710            out: None,
2711            require_verified: false,
2712            require_restore_ready: false,
2713        };
2714
2715        let plan = plan_restore(&options).expect("plan restore");
2716
2717        fs::remove_dir_all(root).expect("remove temp root");
2718        let members = plan.ordered_members();
2719        assert_eq!(members.len(), 2);
2720        assert_eq!(members[0].source_canister, ROOT);
2721        assert_eq!(members[1].target_canister, MAPPED_CHILD);
2722    }
2723
2724    // Ensure restore-readiness gating happens after writing the plan artifact.
2725    #[test]
2726    fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
2727        let root = temp_dir("canic-cli-restore-plan-require-ready");
2728        fs::create_dir_all(&root).expect("create temp root");
2729        let manifest_path = root.join("manifest.json");
2730        let out_path = root.join("plan.json");
2731
2732        fs::write(
2733            &manifest_path,
2734            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
2735        )
2736        .expect("write manifest");
2737
2738        let err = run([
2739            OsString::from("plan"),
2740            OsString::from("--manifest"),
2741            OsString::from(manifest_path.as_os_str()),
2742            OsString::from("--out"),
2743            OsString::from(out_path.as_os_str()),
2744            OsString::from("--require-restore-ready"),
2745        ])
2746        .expect_err("restore readiness should be enforced");
2747
2748        assert!(out_path.exists());
2749        let plan: RestorePlan =
2750            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
2751
2752        fs::remove_dir_all(root).expect("remove temp root");
2753        assert!(!plan.readiness_summary.ready);
2754        assert!(matches!(
2755            err,
2756            RestoreCommandError::RestoreNotReady {
2757                reasons,
2758                ..
2759            } if reasons == [
2760                "missing-module-hash",
2761                "missing-wasm-hash",
2762                "missing-snapshot-checksum"
2763            ]
2764        ));
2765    }
2766
2767    // Ensure restore-readiness gating accepts plans with complete provenance.
2768    #[test]
2769    fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
2770        let root = temp_dir("canic-cli-restore-plan-ready");
2771        fs::create_dir_all(&root).expect("create temp root");
2772        let manifest_path = root.join("manifest.json");
2773        let out_path = root.join("plan.json");
2774
2775        fs::write(
2776            &manifest_path,
2777            serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
2778        )
2779        .expect("write manifest");
2780
2781        run([
2782            OsString::from("plan"),
2783            OsString::from("--manifest"),
2784            OsString::from(manifest_path.as_os_str()),
2785            OsString::from("--out"),
2786            OsString::from(out_path.as_os_str()),
2787            OsString::from("--require-restore-ready"),
2788        ])
2789        .expect("restore-ready plan should pass");
2790
2791        let plan: RestorePlan =
2792            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
2793
2794        fs::remove_dir_all(root).expect("remove temp root");
2795        assert!(plan.readiness_summary.ready);
2796        assert!(plan.readiness_summary.reasons.is_empty());
2797    }
2798
2799    // Ensure restore status writes the initial planned execution journal.
2800    #[test]
2801    fn run_restore_status_writes_planned_status() {
2802        let root = temp_dir("canic-cli-restore-status");
2803        fs::create_dir_all(&root).expect("create temp root");
2804        let plan_path = root.join("restore-plan.json");
2805        let out_path = root.join("restore-status.json");
2806        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2807
2808        fs::write(
2809            &plan_path,
2810            serde_json::to_vec(&plan).expect("serialize plan"),
2811        )
2812        .expect("write plan");
2813
2814        run([
2815            OsString::from("status"),
2816            OsString::from("--plan"),
2817            OsString::from(plan_path.as_os_str()),
2818            OsString::from("--out"),
2819            OsString::from(out_path.as_os_str()),
2820        ])
2821        .expect("write restore status");
2822
2823        let status: RestoreStatus =
2824            serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
2825                .expect("decode restore status");
2826        let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
2827
2828        fs::remove_dir_all(root).expect("remove temp root");
2829        assert_eq!(status.status_version, 1);
2830        assert_eq!(status.backup_id.as_str(), "backup-test");
2831        assert!(status.ready);
2832        assert!(status.readiness_reasons.is_empty());
2833        assert_eq!(status.member_count, 2);
2834        assert_eq!(status.phase_count, 1);
2835        assert_eq!(status.planned_snapshot_uploads, 2);
2836        assert_eq!(status.planned_snapshot_loads, 2);
2837        assert_eq!(status.planned_code_reinstalls, 2);
2838        assert_eq!(status.planned_verification_checks, 2);
2839        assert_eq!(status.planned_operations, 8);
2840        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
2841        assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
2842    }
2843
2844    // Ensure restore apply dry-run writes ordered operations from plan and status.
2845    #[test]
2846    fn run_restore_apply_dry_run_writes_operations() {
2847        let root = temp_dir("canic-cli-restore-apply-dry-run");
2848        fs::create_dir_all(&root).expect("create temp root");
2849        let plan_path = root.join("restore-plan.json");
2850        let status_path = root.join("restore-status.json");
2851        let out_path = root.join("restore-apply-dry-run.json");
2852        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2853        let status = RestoreStatus::from_plan(&plan);
2854
2855        fs::write(
2856            &plan_path,
2857            serde_json::to_vec(&plan).expect("serialize plan"),
2858        )
2859        .expect("write plan");
2860        fs::write(
2861            &status_path,
2862            serde_json::to_vec(&status).expect("serialize status"),
2863        )
2864        .expect("write status");
2865
2866        run([
2867            OsString::from("apply"),
2868            OsString::from("--plan"),
2869            OsString::from(plan_path.as_os_str()),
2870            OsString::from("--status"),
2871            OsString::from(status_path.as_os_str()),
2872            OsString::from("--dry-run"),
2873            OsString::from("--out"),
2874            OsString::from(out_path.as_os_str()),
2875        ])
2876        .expect("write apply dry-run");
2877
2878        let dry_run: RestoreApplyDryRun =
2879            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
2880                .expect("decode dry-run");
2881        let dry_run_json: serde_json::Value =
2882            serde_json::to_value(&dry_run).expect("encode dry-run");
2883
2884        fs::remove_dir_all(root).expect("remove temp root");
2885        assert_eq!(dry_run.dry_run_version, 1);
2886        assert_eq!(dry_run.backup_id.as_str(), "backup-test");
2887        assert!(dry_run.ready);
2888        assert!(dry_run.status_supplied);
2889        assert_eq!(dry_run.member_count, 2);
2890        assert_eq!(dry_run.phase_count, 1);
2891        assert_eq!(dry_run.planned_snapshot_uploads, 2);
2892        assert_eq!(dry_run.planned_operations, 8);
2893        assert_eq!(dry_run.rendered_operations, 8);
2894        assert_eq!(dry_run_json["operation_counts"]["snapshot_uploads"], 2);
2895        assert_eq!(dry_run_json["operation_counts"]["snapshot_loads"], 2);
2896        assert_eq!(dry_run_json["operation_counts"]["code_reinstalls"], 2);
2897        assert_eq!(dry_run_json["operation_counts"]["member_verifications"], 2);
2898        assert_eq!(dry_run_json["operation_counts"]["fleet_verifications"], 0);
2899        assert_eq!(
2900            dry_run_json["operation_counts"]["verification_operations"],
2901            2
2902        );
2903        assert_eq!(
2904            dry_run_json["phases"][0]["operations"][0]["operation"],
2905            "upload-snapshot"
2906        );
2907        assert_eq!(
2908            dry_run_json["phases"][0]["operations"][3]["operation"],
2909            "verify-member"
2910        );
2911        assert_eq!(
2912            dry_run_json["phases"][0]["operations"][3]["verification_kind"],
2913            "status"
2914        );
2915        assert_eq!(
2916            dry_run_json["phases"][0]["operations"][3]["verification_method"],
2917            serde_json::Value::Null
2918        );
2919    }
2920
2921    // Ensure restore apply dry-run can validate artifacts under a backup directory.
2922    #[test]
2923    fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
2924        let root = temp_dir("canic-cli-restore-apply-artifacts");
2925        fs::create_dir_all(&root).expect("create temp root");
2926        let plan_path = root.join("restore-plan.json");
2927        let out_path = root.join("restore-apply-dry-run.json");
2928        let journal_path = root.join("restore-apply-journal.json");
2929        let status_path = root.join("restore-apply-status.json");
2930        let mut manifest = restore_ready_manifest();
2931        write_manifest_artifacts(&root, &mut manifest);
2932        let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
2933
2934        fs::write(
2935            &plan_path,
2936            serde_json::to_vec(&plan).expect("serialize plan"),
2937        )
2938        .expect("write plan");
2939
2940        run([
2941            OsString::from("apply"),
2942            OsString::from("--plan"),
2943            OsString::from(plan_path.as_os_str()),
2944            OsString::from("--backup-dir"),
2945            OsString::from(root.as_os_str()),
2946            OsString::from("--dry-run"),
2947            OsString::from("--out"),
2948            OsString::from(out_path.as_os_str()),
2949            OsString::from("--journal-out"),
2950            OsString::from(journal_path.as_os_str()),
2951        ])
2952        .expect("write apply dry-run");
2953        run([
2954            OsString::from("apply-status"),
2955            OsString::from("--journal"),
2956            OsString::from(journal_path.as_os_str()),
2957            OsString::from("--out"),
2958            OsString::from(status_path.as_os_str()),
2959        ])
2960        .expect("write apply status");
2961
2962        let dry_run: RestoreApplyDryRun =
2963            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
2964                .expect("decode dry-run");
2965        let validation = dry_run
2966            .artifact_validation
2967            .expect("artifact validation should be present");
2968        let journal_json: serde_json::Value =
2969            serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
2970                .expect("decode journal");
2971        let status_json: serde_json::Value =
2972            serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
2973                .expect("decode apply status");
2974
2975        fs::remove_dir_all(root).expect("remove temp root");
2976        assert_eq!(validation.checked_members, 2);
2977        assert!(validation.artifacts_present);
2978        assert!(validation.checksums_verified);
2979        assert_eq!(validation.members_with_expected_checksums, 2);
2980        assert_eq!(journal_json["ready"], true);
2981        assert_eq!(journal_json["operation_count"], 8);
2982        assert_eq!(journal_json["operation_counts"]["snapshot_uploads"], 2);
2983        assert_eq!(journal_json["operation_counts"]["snapshot_loads"], 2);
2984        assert_eq!(journal_json["operation_counts"]["code_reinstalls"], 2);
2985        assert_eq!(journal_json["operation_counts"]["member_verifications"], 2);
2986        assert_eq!(journal_json["operation_counts"]["fleet_verifications"], 0);
2987        assert_eq!(
2988            journal_json["operation_counts"]["verification_operations"],
2989            2
2990        );
2991        assert_eq!(journal_json["ready_operations"], 8);
2992        assert_eq!(journal_json["blocked_operations"], 0);
2993        assert_eq!(journal_json["operations"][0]["state"], "ready");
2994        assert_eq!(status_json["ready"], true);
2995        assert_eq!(status_json["operation_count"], 8);
2996        assert_eq!(status_json["operation_counts"]["snapshot_uploads"], 2);
2997        assert_eq!(status_json["operation_counts"]["snapshot_loads"], 2);
2998        assert_eq!(status_json["operation_counts"]["code_reinstalls"], 2);
2999        assert_eq!(status_json["operation_counts"]["member_verifications"], 2);
3000        assert_eq!(status_json["operation_counts"]["fleet_verifications"], 0);
3001        assert_eq!(
3002            status_json["operation_counts"]["verification_operations"],
3003            2
3004        );
3005        assert_eq!(status_json["operation_counts_supplied"], true);
3006        assert_eq!(status_json["progress"]["operation_count"], 8);
3007        assert_eq!(status_json["progress"]["completed_operations"], 0);
3008        assert_eq!(status_json["progress"]["remaining_operations"], 8);
3009        assert_eq!(status_json["progress"]["transitionable_operations"], 8);
3010        assert_eq!(status_json["progress"]["attention_operations"], 0);
3011        assert_eq!(status_json["progress"]["completion_basis_points"], 0);
3012        assert_eq!(status_json["next_ready_sequence"], 0);
3013        assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
3014    }
3015
3016    // Ensure apply-status rejects structurally inconsistent journals.
3017    #[test]
3018    fn run_restore_apply_status_rejects_invalid_journal() {
3019        let root = temp_dir("canic-cli-restore-apply-status-invalid");
3020        fs::create_dir_all(&root).expect("create temp root");
3021        let journal_path = root.join("restore-apply-journal.json");
3022        let out_path = root.join("restore-apply-status.json");
3023        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
3024        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
3025        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3026        journal.operation_count += 1;
3027
3028        fs::write(
3029            &journal_path,
3030            serde_json::to_vec(&journal).expect("serialize journal"),
3031        )
3032        .expect("write journal");
3033
3034        let err = run([
3035            OsString::from("apply-status"),
3036            OsString::from("--journal"),
3037            OsString::from(journal_path.as_os_str()),
3038            OsString::from("--out"),
3039            OsString::from(out_path.as_os_str()),
3040        ])
3041        .expect_err("invalid journal should fail");
3042
3043        assert!(!out_path.exists());
3044        fs::remove_dir_all(root).expect("remove temp root");
3045        assert!(matches!(
3046            err,
3047            RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
3048                field: "operation_count",
3049                ..
3050            })
3051        ));
3052    }
3053
3054    // Ensure apply-status can fail closed after writing status for pending work.
3055    #[test]
3056    fn run_restore_apply_status_require_no_pending_writes_status_then_fails() {
3057        let root = temp_dir("canic-cli-restore-apply-status-pending");
3058        fs::create_dir_all(&root).expect("create temp root");
3059        let journal_path = root.join("restore-apply-journal.json");
3060        let out_path = root.join("restore-apply-status.json");
3061        let mut journal = ready_apply_journal();
3062        journal
3063            .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
3064            .expect("claim operation");
3065
3066        fs::write(
3067            &journal_path,
3068            serde_json::to_vec(&journal).expect("serialize journal"),
3069        )
3070        .expect("write journal");
3071
3072        let err = run([
3073            OsString::from("apply-status"),
3074            OsString::from("--journal"),
3075            OsString::from(journal_path.as_os_str()),
3076            OsString::from("--out"),
3077            OsString::from(out_path.as_os_str()),
3078            OsString::from("--require-no-pending"),
3079        ])
3080        .expect_err("pending operation should fail requirement");
3081
3082        assert!(out_path.exists());
3083        let status: RestoreApplyJournalStatus =
3084            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3085                .expect("decode apply status");
3086
3087        fs::remove_dir_all(root).expect("remove temp root");
3088        assert_eq!(status.pending_operations, 1);
3089        assert_eq!(status.next_transition_sequence, Some(0));
3090        assert_eq!(
3091            status.next_transition_updated_at.as_deref(),
3092            Some("2026-05-04T12:00:00Z")
3093        );
3094        assert!(matches!(
3095            err,
3096            RestoreCommandError::RestoreApplyPending {
3097                pending_operations: 1,
3098                next_transition_sequence: Some(0),
3099                ..
3100            }
3101        ));
3102    }
3103
3104    // Ensure apply-status can fail closed after writing status for unready work.
3105    #[test]
3106    fn run_restore_apply_status_require_ready_writes_status_then_fails() {
3107        let root = temp_dir("canic-cli-restore-apply-status-ready");
3108        fs::create_dir_all(&root).expect("create temp root");
3109        let journal_path = root.join("restore-apply-journal.json");
3110        let out_path = root.join("restore-apply-status.json");
3111        let plan = RestorePlanner::plan(&valid_manifest(), None).expect("build plan");
3112        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
3113        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3114
3115        fs::write(
3116            &journal_path,
3117            serde_json::to_vec(&journal).expect("serialize journal"),
3118        )
3119        .expect("write journal");
3120
3121        let err = run([
3122            OsString::from("apply-status"),
3123            OsString::from("--journal"),
3124            OsString::from(journal_path.as_os_str()),
3125            OsString::from("--out"),
3126            OsString::from(out_path.as_os_str()),
3127            OsString::from("--require-ready"),
3128        ])
3129        .expect_err("unready journal should fail requirement");
3130
3131        let status: RestoreApplyJournalStatus =
3132            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3133                .expect("decode apply status");
3134
3135        fs::remove_dir_all(root).expect("remove temp root");
3136        assert!(!status.ready);
3137        assert_eq!(status.blocked_operations, status.operation_count);
3138        assert!(
3139            status
3140                .blocked_reasons
3141                .contains(&"missing-snapshot-checksum".to_string())
3142        );
3143        assert!(matches!(
3144            err,
3145            RestoreCommandError::RestoreApplyNotReady { reasons, .. }
3146                if reasons.contains(&"missing-snapshot-checksum".to_string())
3147        ));
3148    }
3149
3150    // Ensure apply-report writes the operator-focused journal summary.
3151    #[test]
3152    fn run_restore_apply_report_writes_attention_summary() {
3153        let root = temp_dir("canic-cli-restore-apply-report");
3154        fs::create_dir_all(&root).expect("create temp root");
3155        let journal_path = root.join("restore-apply-journal.json");
3156        let out_path = root.join("restore-apply-report.json");
3157        let mut journal = ready_apply_journal();
3158        journal
3159            .mark_operation_failed_at(
3160                0,
3161                "dfx-upload-failed".to_string(),
3162                Some("2026-05-05T12:00:00Z".to_string()),
3163            )
3164            .expect("mark failed operation");
3165        journal
3166            .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3167            .expect("mark pending operation");
3168
3169        fs::write(
3170            &journal_path,
3171            serde_json::to_vec(&journal).expect("serialize journal"),
3172        )
3173        .expect("write journal");
3174
3175        run([
3176            OsString::from("apply-report"),
3177            OsString::from("--journal"),
3178            OsString::from(journal_path.as_os_str()),
3179            OsString::from("--out"),
3180            OsString::from(out_path.as_os_str()),
3181        ])
3182        .expect("write apply report");
3183
3184        let report: RestoreApplyJournalReport =
3185            serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
3186                .expect("decode apply report");
3187        let report_json: serde_json::Value =
3188            serde_json::to_value(&report).expect("encode apply report");
3189
3190        fs::remove_dir_all(root).expect("remove temp root");
3191        assert_eq!(report.backup_id, "backup-test");
3192        assert!(report.attention_required);
3193        assert_eq!(report.failed_operations, 1);
3194        assert_eq!(report.pending_operations, 1);
3195        assert_eq!(report.operation_counts.snapshot_uploads, 2);
3196        assert_eq!(report.operation_counts.snapshot_loads, 2);
3197        assert_eq!(report.operation_counts.code_reinstalls, 2);
3198        assert_eq!(report.operation_counts.member_verifications, 2);
3199        assert_eq!(report.operation_counts.fleet_verifications, 0);
3200        assert_eq!(report.operation_counts.verification_operations, 2);
3201        assert!(report.operation_counts_supplied);
3202        assert_eq!(report.progress.operation_count, 8);
3203        assert_eq!(report.progress.completed_operations, 0);
3204        assert_eq!(report.progress.remaining_operations, 8);
3205        assert_eq!(report.progress.transitionable_operations, 7);
3206        assert_eq!(report.progress.attention_operations, 2);
3207        assert_eq!(report.progress.completion_basis_points, 0);
3208        assert_eq!(report.failed.len(), 1);
3209        assert_eq!(report.pending.len(), 1);
3210        assert_eq!(report.failed[0].sequence, 0);
3211        assert_eq!(report.pending[0].sequence, 1);
3212        assert_eq!(
3213            report.next_transition.as_ref().map(|op| op.sequence),
3214            Some(1)
3215        );
3216        assert_eq!(report_json["outcome"], "failed");
3217        assert_eq!(report_json["failed"][0]["reasons"][0], "dfx-upload-failed");
3218    }
3219
3220    // Ensure restore run writes a native no-mutation runner preview.
3221    #[test]
3222    fn run_restore_run_dry_run_writes_native_runner_preview() {
3223        let root = temp_dir("canic-cli-restore-run-dry-run");
3224        fs::create_dir_all(&root).expect("create temp root");
3225        let journal_path = root.join("restore-apply-journal.json");
3226        let out_path = root.join("restore-run-dry-run.json");
3227        let journal = ready_apply_journal();
3228
3229        fs::write(
3230            &journal_path,
3231            serde_json::to_vec(&journal).expect("serialize journal"),
3232        )
3233        .expect("write journal");
3234
3235        run([
3236            OsString::from("run"),
3237            OsString::from("--journal"),
3238            OsString::from(journal_path.as_os_str()),
3239            OsString::from("--dry-run"),
3240            OsString::from("--dfx"),
3241            OsString::from("/tmp/dfx"),
3242            OsString::from("--network"),
3243            OsString::from("local"),
3244            OsString::from("--out"),
3245            OsString::from(out_path.as_os_str()),
3246        ])
3247        .expect("write restore run dry-run");
3248
3249        let dry_run: serde_json::Value =
3250            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
3251                .expect("decode dry-run");
3252
3253        fs::remove_dir_all(root).expect("remove temp root");
3254        assert_eq!(dry_run["run_version"], 1);
3255        assert_eq!(dry_run["backup_id"], "backup-test");
3256        assert_eq!(dry_run["run_mode"], "dry-run");
3257        assert_eq!(dry_run["dry_run"], true);
3258        assert_eq!(dry_run["ready"], true);
3259        assert_eq!(dry_run["complete"], false);
3260        assert_eq!(dry_run["attention_required"], false);
3261        assert_eq!(dry_run["operation_counts"]["snapshot_uploads"], 2);
3262        assert_eq!(dry_run["operation_counts"]["snapshot_loads"], 2);
3263        assert_eq!(dry_run["operation_counts"]["code_reinstalls"], 2);
3264        assert_eq!(dry_run["operation_counts"]["member_verifications"], 2);
3265        assert_eq!(dry_run["operation_counts"]["fleet_verifications"], 0);
3266        assert_eq!(dry_run["operation_counts"]["verification_operations"], 2);
3267        assert_eq!(dry_run["operation_counts_supplied"], true);
3268        assert_eq!(dry_run["progress"]["operation_count"], 8);
3269        assert_eq!(dry_run["progress"]["completed_operations"], 0);
3270        assert_eq!(dry_run["progress"]["remaining_operations"], 8);
3271        assert_eq!(dry_run["progress"]["transitionable_operations"], 8);
3272        assert_eq!(dry_run["progress"]["attention_operations"], 0);
3273        assert_eq!(dry_run["progress"]["completion_basis_points"], 0);
3274        assert_eq!(dry_run["stopped_reason"], "preview");
3275        assert_eq!(dry_run["next_action"], "rerun");
3276        assert_eq!(dry_run["operation_available"], true);
3277        assert_eq!(dry_run["command_available"], true);
3278        assert_eq!(dry_run["next_transition"]["sequence"], 0);
3279        assert_eq!(dry_run["command"]["program"], "/tmp/dfx");
3280        assert_eq!(
3281            dry_run["command"]["args"],
3282            json!([
3283                "canister",
3284                "--network",
3285                "local",
3286                "snapshot",
3287                "upload",
3288                "--dir",
3289                "artifacts/root",
3290                ROOT
3291            ])
3292        );
3293        assert_eq!(dry_run["command"]["mutates"], true);
3294    }
3295
3296    // Ensure restore run can recover one interrupted pending operation.
3297    #[test]
3298    fn run_restore_run_unclaim_pending_marks_operation_ready() {
3299        let root = temp_dir("canic-cli-restore-run-unclaim-pending");
3300        fs::create_dir_all(&root).expect("create temp root");
3301        let journal_path = root.join("restore-apply-journal.json");
3302        let out_path = root.join("restore-run.json");
3303        let mut journal = ready_apply_journal();
3304        journal
3305            .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3306            .expect("mark pending operation");
3307
3308        fs::write(
3309            &journal_path,
3310            serde_json::to_vec(&journal).expect("serialize journal"),
3311        )
3312        .expect("write journal");
3313
3314        run([
3315            OsString::from("run"),
3316            OsString::from("--journal"),
3317            OsString::from(journal_path.as_os_str()),
3318            OsString::from("--unclaim-pending"),
3319            OsString::from("--out"),
3320            OsString::from(out_path.as_os_str()),
3321        ])
3322        .expect("unclaim pending operation");
3323
3324        let run_summary: serde_json::Value =
3325            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3326                .expect("decode run summary");
3327        let updated: RestoreApplyJournal =
3328            serde_json::from_slice(&fs::read(&journal_path).expect("read updated journal"))
3329                .expect("decode updated journal");
3330
3331        fs::remove_dir_all(root).expect("remove temp root");
3332        assert_eq!(run_summary["run_mode"], "unclaim-pending");
3333        assert_eq!(run_summary["unclaim_pending"], true);
3334        assert_eq!(run_summary["stopped_reason"], "recovered-pending");
3335        assert_eq!(run_summary["next_action"], "rerun");
3336        assert_eq!(run_summary["recovered_operation"]["sequence"], 0);
3337        assert_eq!(run_summary["recovered_operation"]["state"], "pending");
3338        assert_eq!(run_summary["pending_operations"], 0);
3339        assert_eq!(run_summary["ready_operations"], 8);
3340        assert_eq!(run_summary["attention_required"], false);
3341        assert_eq!(updated.pending_operations, 0);
3342        assert_eq!(updated.ready_operations, 8);
3343        assert_eq!(
3344            updated.operations[0].state,
3345            RestoreApplyOperationState::Ready
3346        );
3347    }
3348
3349    // Ensure restore run execute claims and completes one generated command.
3350    #[test]
3351    fn run_restore_run_execute_marks_completed_operation() {
3352        let root = temp_dir("canic-cli-restore-run-execute");
3353        fs::create_dir_all(&root).expect("create temp root");
3354        let journal_path = root.join("restore-apply-journal.json");
3355        let out_path = root.join("restore-run.json");
3356        let journal = ready_apply_journal();
3357
3358        fs::write(
3359            &journal_path,
3360            serde_json::to_vec(&journal).expect("serialize journal"),
3361        )
3362        .expect("write journal");
3363
3364        run([
3365            OsString::from("run"),
3366            OsString::from("--journal"),
3367            OsString::from(journal_path.as_os_str()),
3368            OsString::from("--execute"),
3369            OsString::from("--dfx"),
3370            OsString::from("/bin/true"),
3371            OsString::from("--max-steps"),
3372            OsString::from("1"),
3373            OsString::from("--out"),
3374            OsString::from(out_path.as_os_str()),
3375        ])
3376        .expect("execute one restore run step");
3377
3378        let run_summary: serde_json::Value =
3379            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3380                .expect("decode run summary");
3381        let updated: RestoreApplyJournal =
3382            serde_json::from_slice(&fs::read(&journal_path).expect("read updated journal"))
3383                .expect("decode updated journal");
3384
3385        fs::remove_dir_all(root).expect("remove temp root");
3386        assert_eq!(run_summary["run_mode"], "execute");
3387        assert_eq!(run_summary["execute"], true);
3388        assert_eq!(run_summary["dry_run"], false);
3389        assert_eq!(run_summary["max_steps_reached"], true);
3390        assert_eq!(run_summary["stopped_reason"], "max-steps-reached");
3391        assert_eq!(run_summary["next_action"], "rerun");
3392        assert_eq!(run_summary["executed_operation_count"], 1);
3393        assert_eq!(run_summary["executed_operations"][0]["sequence"], 0);
3394        assert_eq!(
3395            run_summary["executed_operations"][0]["command"]["program"],
3396            "/bin/true"
3397        );
3398        assert_eq!(updated.completed_operations, 1);
3399        assert_eq!(updated.pending_operations, 0);
3400        assert_eq!(updated.failed_operations, 0);
3401        assert_eq!(
3402            updated.operations[0].state,
3403            RestoreApplyOperationState::Completed
3404        );
3405    }
3406
3407    // Ensure restore run can fail closed after writing an incomplete summary.
3408    #[test]
3409    fn run_restore_run_require_complete_writes_summary_then_fails() {
3410        let root = temp_dir("canic-cli-restore-run-require-complete");
3411        fs::create_dir_all(&root).expect("create temp root");
3412        let journal_path = root.join("restore-apply-journal.json");
3413        let out_path = root.join("restore-run.json");
3414        let journal = ready_apply_journal();
3415
3416        fs::write(
3417            &journal_path,
3418            serde_json::to_vec(&journal).expect("serialize journal"),
3419        )
3420        .expect("write journal");
3421
3422        let err = run([
3423            OsString::from("run"),
3424            OsString::from("--journal"),
3425            OsString::from(journal_path.as_os_str()),
3426            OsString::from("--execute"),
3427            OsString::from("--dfx"),
3428            OsString::from("/bin/true"),
3429            OsString::from("--max-steps"),
3430            OsString::from("1"),
3431            OsString::from("--out"),
3432            OsString::from(out_path.as_os_str()),
3433            OsString::from("--require-complete"),
3434        ])
3435        .expect_err("incomplete run should fail requirement");
3436
3437        let run_summary: serde_json::Value =
3438            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3439                .expect("decode run summary");
3440
3441        fs::remove_dir_all(root).expect("remove temp root");
3442        assert_eq!(run_summary["executed_operation_count"], 1);
3443        assert_eq!(run_summary["complete"], false);
3444        assert!(matches!(
3445            err,
3446            RestoreCommandError::RestoreApplyIncomplete {
3447                completed_operations: 1,
3448                operation_count: 8,
3449                ..
3450            }
3451        ));
3452    }
3453
3454    // Ensure restore run execute records failed command exits in the journal.
3455    #[test]
3456    fn run_restore_run_execute_marks_failed_operation() {
3457        let root = temp_dir("canic-cli-restore-run-execute-failed");
3458        fs::create_dir_all(&root).expect("create temp root");
3459        let journal_path = root.join("restore-apply-journal.json");
3460        let out_path = root.join("restore-run.json");
3461        let journal = ready_apply_journal();
3462
3463        fs::write(
3464            &journal_path,
3465            serde_json::to_vec(&journal).expect("serialize journal"),
3466        )
3467        .expect("write journal");
3468
3469        let err = run([
3470            OsString::from("run"),
3471            OsString::from("--journal"),
3472            OsString::from(journal_path.as_os_str()),
3473            OsString::from("--execute"),
3474            OsString::from("--dfx"),
3475            OsString::from("/bin/false"),
3476            OsString::from("--max-steps"),
3477            OsString::from("1"),
3478            OsString::from("--out"),
3479            OsString::from(out_path.as_os_str()),
3480        ])
3481        .expect_err("failing runner command should fail");
3482
3483        let run_summary: serde_json::Value =
3484            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3485                .expect("decode run summary");
3486        let updated: RestoreApplyJournal =
3487            serde_json::from_slice(&fs::read(&journal_path).expect("read updated journal"))
3488                .expect("decode updated journal");
3489
3490        fs::remove_dir_all(root).expect("remove temp root");
3491        assert!(matches!(
3492            err,
3493            RestoreCommandError::RestoreRunCommandFailed {
3494                sequence: 0,
3495                status,
3496            } if status == "1"
3497        ));
3498        assert_eq!(updated.failed_operations, 1);
3499        assert_eq!(updated.pending_operations, 0);
3500        assert_eq!(
3501            updated.operations[0].state,
3502            RestoreApplyOperationState::Failed
3503        );
3504        assert_eq!(run_summary["execute"], true);
3505        assert_eq!(run_summary["attention_required"], true);
3506        assert_eq!(run_summary["outcome"], "failed");
3507        assert_eq!(run_summary["stopped_reason"], "command-failed");
3508        assert_eq!(run_summary["next_action"], "inspect-failed-operation");
3509        assert_eq!(run_summary["executed_operation_count"], 1);
3510        assert_eq!(run_summary["executed_operations"][0]["state"], "failed");
3511        assert_eq!(run_summary["executed_operations"][0]["status"], "1");
3512        assert_eq!(
3513            updated.operations[0].blocking_reasons,
3514            vec!["runner-command-exit-1".to_string()]
3515        );
3516    }
3517
3518    // Ensure restore run can fail closed after writing an attention summary.
3519    #[test]
3520    fn run_restore_run_require_no_attention_writes_summary_then_fails() {
3521        let root = temp_dir("canic-cli-restore-run-require-attention");
3522        fs::create_dir_all(&root).expect("create temp root");
3523        let journal_path = root.join("restore-apply-journal.json");
3524        let out_path = root.join("restore-run.json");
3525        let mut journal = ready_apply_journal();
3526        journal
3527            .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3528            .expect("mark pending operation");
3529
3530        fs::write(
3531            &journal_path,
3532            serde_json::to_vec(&journal).expect("serialize journal"),
3533        )
3534        .expect("write journal");
3535
3536        let err = run([
3537            OsString::from("run"),
3538            OsString::from("--journal"),
3539            OsString::from(journal_path.as_os_str()),
3540            OsString::from("--dry-run"),
3541            OsString::from("--out"),
3542            OsString::from(out_path.as_os_str()),
3543            OsString::from("--require-no-attention"),
3544        ])
3545        .expect_err("attention run should fail requirement");
3546
3547        let run_summary: serde_json::Value =
3548            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3549                .expect("decode run summary");
3550
3551        fs::remove_dir_all(root).expect("remove temp root");
3552        assert_eq!(run_summary["attention_required"], true);
3553        assert_eq!(run_summary["outcome"], "pending");
3554        assert_eq!(run_summary["stopped_reason"], "pending");
3555        assert_eq!(run_summary["next_action"], "unclaim-pending");
3556        assert!(matches!(
3557            err,
3558            RestoreCommandError::RestoreApplyReportNeedsAttention {
3559                outcome: canic_backup::restore::RestoreApplyReportOutcome::Pending,
3560                ..
3561            }
3562        ));
3563    }
3564
3565    // Ensure restore run can fail closed on an unexpected run mode.
3566    #[test]
3567    fn run_restore_run_require_run_mode_writes_summary_then_fails() {
3568        let root = temp_dir("canic-cli-restore-run-require-run-mode");
3569        fs::create_dir_all(&root).expect("create temp root");
3570        let journal_path = root.join("restore-apply-journal.json");
3571        let out_path = root.join("restore-run.json");
3572        let journal = ready_apply_journal();
3573
3574        fs::write(
3575            &journal_path,
3576            serde_json::to_vec(&journal).expect("serialize journal"),
3577        )
3578        .expect("write journal");
3579
3580        let err = run([
3581            OsString::from("run"),
3582            OsString::from("--journal"),
3583            OsString::from(journal_path.as_os_str()),
3584            OsString::from("--dry-run"),
3585            OsString::from("--out"),
3586            OsString::from(out_path.as_os_str()),
3587            OsString::from("--require-run-mode"),
3588            OsString::from("execute"),
3589        ])
3590        .expect_err("run mode mismatch should fail requirement");
3591
3592        let run_summary: serde_json::Value =
3593            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3594                .expect("decode run summary");
3595
3596        fs::remove_dir_all(root).expect("remove temp root");
3597        assert_eq!(run_summary["run_mode"], "dry-run");
3598        assert!(matches!(
3599            err,
3600            RestoreCommandError::RestoreRunModeMismatch {
3601                expected,
3602                actual,
3603                ..
3604            } if expected == "execute" && actual == "dry-run"
3605        ));
3606    }
3607
3608    // Ensure restore run can fail closed on an unexpected executed operation count.
3609    #[test]
3610    fn run_restore_run_require_executed_count_writes_summary_then_fails() {
3611        let root = temp_dir("canic-cli-restore-run-require-executed-count");
3612        fs::create_dir_all(&root).expect("create temp root");
3613        let journal_path = root.join("restore-apply-journal.json");
3614        let out_path = root.join("restore-run.json");
3615        let journal = ready_apply_journal();
3616
3617        fs::write(
3618            &journal_path,
3619            serde_json::to_vec(&journal).expect("serialize journal"),
3620        )
3621        .expect("write journal");
3622
3623        let err = run([
3624            OsString::from("run"),
3625            OsString::from("--journal"),
3626            OsString::from(journal_path.as_os_str()),
3627            OsString::from("--execute"),
3628            OsString::from("--dfx"),
3629            OsString::from("/bin/true"),
3630            OsString::from("--max-steps"),
3631            OsString::from("1"),
3632            OsString::from("--out"),
3633            OsString::from(out_path.as_os_str()),
3634            OsString::from("--require-executed-count"),
3635            OsString::from("2"),
3636        ])
3637        .expect_err("executed count mismatch should fail requirement");
3638
3639        let run_summary: serde_json::Value =
3640            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3641                .expect("decode run summary");
3642
3643        fs::remove_dir_all(root).expect("remove temp root");
3644        assert_eq!(run_summary["executed_operation_count"], 1);
3645        assert!(matches!(
3646            err,
3647            RestoreCommandError::RestoreRunExecutedCountMismatch {
3648                expected: 2,
3649                actual: 1,
3650                ..
3651            }
3652        ));
3653    }
3654
3655    // Ensure restore run can fail closed on an unexpected stopped reason.
3656    #[test]
3657    fn run_restore_run_require_stopped_reason_writes_summary_then_fails() {
3658        let root = temp_dir("canic-cli-restore-run-require-stopped-reason");
3659        fs::create_dir_all(&root).expect("create temp root");
3660        let journal_path = root.join("restore-apply-journal.json");
3661        let out_path = root.join("restore-run.json");
3662        let journal = ready_apply_journal();
3663
3664        fs::write(
3665            &journal_path,
3666            serde_json::to_vec(&journal).expect("serialize journal"),
3667        )
3668        .expect("write journal");
3669
3670        let err = run([
3671            OsString::from("run"),
3672            OsString::from("--journal"),
3673            OsString::from(journal_path.as_os_str()),
3674            OsString::from("--dry-run"),
3675            OsString::from("--out"),
3676            OsString::from(out_path.as_os_str()),
3677            OsString::from("--require-stopped-reason"),
3678            OsString::from("complete"),
3679        ])
3680        .expect_err("stopped reason mismatch should fail requirement");
3681
3682        let run_summary: serde_json::Value =
3683            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3684                .expect("decode run summary");
3685
3686        fs::remove_dir_all(root).expect("remove temp root");
3687        assert_eq!(run_summary["stopped_reason"], "preview");
3688        assert!(matches!(
3689            err,
3690            RestoreCommandError::RestoreRunStoppedReasonMismatch {
3691                expected,
3692                actual,
3693                ..
3694            } if expected == "complete" && actual == "preview"
3695        ));
3696    }
3697
3698    // Ensure restore run can fail closed on an unexpected next action.
3699    #[test]
3700    fn run_restore_run_require_next_action_writes_summary_then_fails() {
3701        let root = temp_dir("canic-cli-restore-run-require-next-action");
3702        fs::create_dir_all(&root).expect("create temp root");
3703        let journal_path = root.join("restore-apply-journal.json");
3704        let out_path = root.join("restore-run.json");
3705        let journal = ready_apply_journal();
3706
3707        fs::write(
3708            &journal_path,
3709            serde_json::to_vec(&journal).expect("serialize journal"),
3710        )
3711        .expect("write journal");
3712
3713        let err = run([
3714            OsString::from("run"),
3715            OsString::from("--journal"),
3716            OsString::from(journal_path.as_os_str()),
3717            OsString::from("--dry-run"),
3718            OsString::from("--out"),
3719            OsString::from(out_path.as_os_str()),
3720            OsString::from("--require-next-action"),
3721            OsString::from("done"),
3722        ])
3723        .expect_err("next action mismatch should fail requirement");
3724
3725        let run_summary: serde_json::Value =
3726            serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3727                .expect("decode run summary");
3728
3729        fs::remove_dir_all(root).expect("remove temp root");
3730        assert_eq!(run_summary["next_action"], "rerun");
3731        assert!(matches!(
3732            err,
3733            RestoreCommandError::RestoreRunNextActionMismatch {
3734                expected,
3735                actual,
3736                ..
3737            } if expected == "done" && actual == "rerun"
3738        ));
3739    }
3740
3741    // Ensure apply-report can fail closed after writing an attention report.
3742    #[test]
3743    fn run_restore_apply_report_require_no_attention_writes_report_then_fails() {
3744        let root = temp_dir("canic-cli-restore-apply-report-attention");
3745        fs::create_dir_all(&root).expect("create temp root");
3746        let journal_path = root.join("restore-apply-journal.json");
3747        let out_path = root.join("restore-apply-report.json");
3748        let mut journal = ready_apply_journal();
3749        journal
3750            .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3751            .expect("mark pending operation");
3752
3753        fs::write(
3754            &journal_path,
3755            serde_json::to_vec(&journal).expect("serialize journal"),
3756        )
3757        .expect("write journal");
3758
3759        let err = run([
3760            OsString::from("apply-report"),
3761            OsString::from("--journal"),
3762            OsString::from(journal_path.as_os_str()),
3763            OsString::from("--out"),
3764            OsString::from(out_path.as_os_str()),
3765            OsString::from("--require-no-attention"),
3766        ])
3767        .expect_err("attention report should fail requirement");
3768
3769        let report: RestoreApplyJournalReport =
3770            serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
3771                .expect("decode apply report");
3772
3773        fs::remove_dir_all(root).expect("remove temp root");
3774        assert!(report.attention_required);
3775        assert_eq!(report.pending_operations, 1);
3776        assert!(matches!(
3777            err,
3778            RestoreCommandError::RestoreApplyReportNeedsAttention {
3779                outcome: canic_backup::restore::RestoreApplyReportOutcome::Pending,
3780                ..
3781            }
3782        ));
3783    }
3784
3785    // Ensure apply-status can fail closed after writing status for incomplete work.
3786    #[test]
3787    fn run_restore_apply_status_require_complete_writes_status_then_fails() {
3788        let root = temp_dir("canic-cli-restore-apply-status-incomplete");
3789        fs::create_dir_all(&root).expect("create temp root");
3790        let journal_path = root.join("restore-apply-journal.json");
3791        let out_path = root.join("restore-apply-status.json");
3792        let journal = ready_apply_journal();
3793
3794        fs::write(
3795            &journal_path,
3796            serde_json::to_vec(&journal).expect("serialize journal"),
3797        )
3798        .expect("write journal");
3799
3800        let err = run([
3801            OsString::from("apply-status"),
3802            OsString::from("--journal"),
3803            OsString::from(journal_path.as_os_str()),
3804            OsString::from("--out"),
3805            OsString::from(out_path.as_os_str()),
3806            OsString::from("--require-complete"),
3807        ])
3808        .expect_err("incomplete journal should fail requirement");
3809
3810        assert!(out_path.exists());
3811        let status: RestoreApplyJournalStatus =
3812            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3813                .expect("decode apply status");
3814
3815        fs::remove_dir_all(root).expect("remove temp root");
3816        assert!(!status.complete);
3817        assert_eq!(status.completed_operations, 0);
3818        assert_eq!(status.operation_count, 8);
3819        assert!(matches!(
3820            err,
3821            RestoreCommandError::RestoreApplyIncomplete {
3822                completed_operations: 0,
3823                operation_count: 8,
3824                ..
3825            }
3826        ));
3827    }
3828
3829    // Ensure apply-status can fail closed after writing status for failed work.
3830    #[test]
3831    fn run_restore_apply_status_require_no_failed_writes_status_then_fails() {
3832        let root = temp_dir("canic-cli-restore-apply-status-failed");
3833        fs::create_dir_all(&root).expect("create temp root");
3834        let journal_path = root.join("restore-apply-journal.json");
3835        let out_path = root.join("restore-apply-status.json");
3836        let mut journal = ready_apply_journal();
3837        journal
3838            .mark_operation_failed(0, "dfx-load-failed".to_string())
3839            .expect("mark failed operation");
3840
3841        fs::write(
3842            &journal_path,
3843            serde_json::to_vec(&journal).expect("serialize journal"),
3844        )
3845        .expect("write journal");
3846
3847        let err = run([
3848            OsString::from("apply-status"),
3849            OsString::from("--journal"),
3850            OsString::from(journal_path.as_os_str()),
3851            OsString::from("--out"),
3852            OsString::from(out_path.as_os_str()),
3853            OsString::from("--require-no-failed"),
3854        ])
3855        .expect_err("failed operation should fail requirement");
3856
3857        assert!(out_path.exists());
3858        let status: RestoreApplyJournalStatus =
3859            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3860                .expect("decode apply status");
3861
3862        fs::remove_dir_all(root).expect("remove temp root");
3863        assert_eq!(status.failed_operations, 1);
3864        assert!(matches!(
3865            err,
3866            RestoreCommandError::RestoreApplyFailed {
3867                failed_operations: 1,
3868                ..
3869            }
3870        ));
3871    }
3872
3873    // Ensure apply-status accepts a complete journal when required.
3874    #[test]
3875    fn run_restore_apply_status_require_complete_accepts_complete_journal() {
3876        let root = temp_dir("canic-cli-restore-apply-status-complete");
3877        fs::create_dir_all(&root).expect("create temp root");
3878        let journal_path = root.join("restore-apply-journal.json");
3879        let out_path = root.join("restore-apply-status.json");
3880        let mut journal = ready_apply_journal();
3881        for sequence in 0..journal.operation_count {
3882            journal
3883                .mark_operation_completed(sequence)
3884                .expect("complete operation");
3885        }
3886
3887        fs::write(
3888            &journal_path,
3889            serde_json::to_vec(&journal).expect("serialize journal"),
3890        )
3891        .expect("write journal");
3892
3893        run([
3894            OsString::from("apply-status"),
3895            OsString::from("--journal"),
3896            OsString::from(journal_path.as_os_str()),
3897            OsString::from("--out"),
3898            OsString::from(out_path.as_os_str()),
3899            OsString::from("--require-complete"),
3900        ])
3901        .expect("complete journal should pass requirement");
3902
3903        let status: RestoreApplyJournalStatus =
3904            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3905                .expect("decode apply status");
3906
3907        fs::remove_dir_all(root).expect("remove temp root");
3908        assert!(status.complete);
3909        assert_eq!(status.completed_operations, 8);
3910        assert_eq!(status.operation_count, 8);
3911    }
3912
3913    // Ensure apply-next writes the full next ready operation row for runners.
3914    #[test]
3915    fn run_restore_apply_next_writes_next_ready_operation() {
3916        let root = temp_dir("canic-cli-restore-apply-next");
3917        fs::create_dir_all(&root).expect("create temp root");
3918        let journal_path = root.join("restore-apply-journal.json");
3919        let out_path = root.join("restore-apply-next.json");
3920        let mut journal = ready_apply_journal();
3921        journal
3922            .mark_operation_completed(0)
3923            .expect("mark first operation complete");
3924
3925        fs::write(
3926            &journal_path,
3927            serde_json::to_vec(&journal).expect("serialize journal"),
3928        )
3929        .expect("write journal");
3930
3931        run([
3932            OsString::from("apply-next"),
3933            OsString::from("--journal"),
3934            OsString::from(journal_path.as_os_str()),
3935            OsString::from("--out"),
3936            OsString::from(out_path.as_os_str()),
3937        ])
3938        .expect("write apply next");
3939
3940        let next: RestoreApplyNextOperation =
3941            serde_json::from_slice(&fs::read(&out_path).expect("read next operation"))
3942                .expect("decode next operation");
3943        let operation = next.operation.expect("operation should be available");
3944
3945        fs::remove_dir_all(root).expect("remove temp root");
3946        assert!(next.ready);
3947        assert!(next.operation_available);
3948        assert_eq!(operation.sequence, 1);
3949        assert_eq!(
3950            operation.operation,
3951            canic_backup::restore::RestoreApplyOperationKind::LoadSnapshot
3952        );
3953    }
3954
3955    // Ensure apply-command writes a no-execute command preview for the next operation.
3956    #[test]
3957    fn run_restore_apply_command_writes_next_command_preview() {
3958        let root = temp_dir("canic-cli-restore-apply-command");
3959        fs::create_dir_all(&root).expect("create temp root");
3960        let journal_path = root.join("restore-apply-journal.json");
3961        let out_path = root.join("restore-apply-command.json");
3962        let journal = ready_apply_journal();
3963
3964        fs::write(
3965            &journal_path,
3966            serde_json::to_vec(&journal).expect("serialize journal"),
3967        )
3968        .expect("write journal");
3969
3970        run([
3971            OsString::from("apply-command"),
3972            OsString::from("--journal"),
3973            OsString::from(journal_path.as_os_str()),
3974            OsString::from("--dfx"),
3975            OsString::from("/tmp/dfx"),
3976            OsString::from("--network"),
3977            OsString::from("local"),
3978            OsString::from("--out"),
3979            OsString::from(out_path.as_os_str()),
3980        ])
3981        .expect("write command preview");
3982
3983        let preview: RestoreApplyCommandPreview =
3984            serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
3985                .expect("decode command preview");
3986        let command = preview.command.expect("command should be available");
3987
3988        fs::remove_dir_all(root).expect("remove temp root");
3989        assert!(preview.ready);
3990        assert!(preview.command_available);
3991        assert_eq!(command.program, "/tmp/dfx");
3992        assert_eq!(
3993            command.args,
3994            vec![
3995                "canister".to_string(),
3996                "--network".to_string(),
3997                "local".to_string(),
3998                "snapshot".to_string(),
3999                "upload".to_string(),
4000                "--dir".to_string(),
4001                "artifacts/root".to_string(),
4002                ROOT.to_string(),
4003            ]
4004        );
4005        assert!(command.mutates);
4006    }
4007
4008    // Ensure apply-command can fail closed after writing a command preview.
4009    #[test]
4010    fn run_restore_apply_command_require_command_writes_preview_then_fails() {
4011        let root = temp_dir("canic-cli-restore-apply-command-require");
4012        fs::create_dir_all(&root).expect("create temp root");
4013        let journal_path = root.join("restore-apply-journal.json");
4014        let out_path = root.join("restore-apply-command.json");
4015        let mut journal = ready_apply_journal();
4016
4017        for sequence in 0..journal.operation_count {
4018            journal
4019                .mark_operation_completed(sequence)
4020                .expect("mark operation completed");
4021        }
4022
4023        fs::write(
4024            &journal_path,
4025            serde_json::to_vec(&journal).expect("serialize journal"),
4026        )
4027        .expect("write journal");
4028
4029        let err = run([
4030            OsString::from("apply-command"),
4031            OsString::from("--journal"),
4032            OsString::from(journal_path.as_os_str()),
4033            OsString::from("--out"),
4034            OsString::from(out_path.as_os_str()),
4035            OsString::from("--require-command"),
4036        ])
4037        .expect_err("missing command should fail");
4038
4039        let preview: RestoreApplyCommandPreview =
4040            serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
4041                .expect("decode command preview");
4042
4043        fs::remove_dir_all(root).expect("remove temp root");
4044        assert!(preview.complete);
4045        assert!(!preview.operation_available);
4046        assert!(!preview.command_available);
4047        assert!(matches!(
4048            err,
4049            RestoreCommandError::RestoreApplyCommandUnavailable {
4050                operation_available: false,
4051                complete: true,
4052                ..
4053            }
4054        ));
4055    }
4056
4057    // Ensure apply-claim marks the next operation pending before runner execution.
4058    #[test]
4059    fn run_restore_apply_claim_marks_next_operation_pending() {
4060        let root = temp_dir("canic-cli-restore-apply-claim");
4061        fs::create_dir_all(&root).expect("create temp root");
4062        let journal_path = root.join("restore-apply-journal.json");
4063        let claimed_path = root.join("restore-apply-journal.claimed.json");
4064        let journal = ready_apply_journal();
4065
4066        fs::write(
4067            &journal_path,
4068            serde_json::to_vec(&journal).expect("serialize journal"),
4069        )
4070        .expect("write journal");
4071
4072        run([
4073            OsString::from("apply-claim"),
4074            OsString::from("--journal"),
4075            OsString::from(journal_path.as_os_str()),
4076            OsString::from("--sequence"),
4077            OsString::from("0"),
4078            OsString::from("--updated-at"),
4079            OsString::from("2026-05-04T12:00:00Z"),
4080            OsString::from("--out"),
4081            OsString::from(claimed_path.as_os_str()),
4082        ])
4083        .expect("claim operation");
4084
4085        let claimed: RestoreApplyJournal =
4086            serde_json::from_slice(&fs::read(&claimed_path).expect("read claimed journal"))
4087                .expect("decode claimed journal");
4088        let status = claimed.status();
4089        let next = claimed.next_operation();
4090
4091        fs::remove_dir_all(root).expect("remove temp root");
4092        assert_eq!(claimed.pending_operations, 1);
4093        assert_eq!(claimed.ready_operations, 7);
4094        assert_eq!(
4095            claimed.operations[0].state,
4096            RestoreApplyOperationState::Pending
4097        );
4098        assert_eq!(
4099            claimed.operations[0].state_updated_at.as_deref(),
4100            Some("2026-05-04T12:00:00Z")
4101        );
4102        assert_eq!(status.next_transition_sequence, Some(0));
4103        assert_eq!(
4104            status.next_transition_state,
4105            Some(RestoreApplyOperationState::Pending)
4106        );
4107        assert_eq!(
4108            status.next_transition_updated_at.as_deref(),
4109            Some("2026-05-04T12:00:00Z")
4110        );
4111        assert_eq!(
4112            next.operation.expect("next operation").state,
4113            RestoreApplyOperationState::Pending
4114        );
4115    }
4116
4117    // Ensure apply-claim can reject a stale command preview sequence.
4118    #[test]
4119    fn run_restore_apply_claim_rejects_sequence_mismatch() {
4120        let root = temp_dir("canic-cli-restore-apply-claim-sequence");
4121        fs::create_dir_all(&root).expect("create temp root");
4122        let journal_path = root.join("restore-apply-journal.json");
4123        let claimed_path = root.join("restore-apply-journal.claimed.json");
4124        let journal = ready_apply_journal();
4125
4126        fs::write(
4127            &journal_path,
4128            serde_json::to_vec(&journal).expect("serialize journal"),
4129        )
4130        .expect("write journal");
4131
4132        let err = run([
4133            OsString::from("apply-claim"),
4134            OsString::from("--journal"),
4135            OsString::from(journal_path.as_os_str()),
4136            OsString::from("--sequence"),
4137            OsString::from("1"),
4138            OsString::from("--out"),
4139            OsString::from(claimed_path.as_os_str()),
4140        ])
4141        .expect_err("stale sequence should fail claim");
4142
4143        assert!(!claimed_path.exists());
4144        fs::remove_dir_all(root).expect("remove temp root");
4145        assert!(matches!(
4146            err,
4147            RestoreCommandError::RestoreApplyClaimSequenceMismatch {
4148                expected: 1,
4149                actual: Some(0),
4150            }
4151        ));
4152    }
4153
4154    // Ensure apply-unclaim releases the current pending operation back to ready.
4155    #[test]
4156    fn run_restore_apply_unclaim_marks_pending_operation_ready() {
4157        let root = temp_dir("canic-cli-restore-apply-unclaim");
4158        fs::create_dir_all(&root).expect("create temp root");
4159        let journal_path = root.join("restore-apply-journal.json");
4160        let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
4161        let mut journal = ready_apply_journal();
4162        journal
4163            .mark_next_operation_pending()
4164            .expect("claim operation");
4165
4166        fs::write(
4167            &journal_path,
4168            serde_json::to_vec(&journal).expect("serialize journal"),
4169        )
4170        .expect("write journal");
4171
4172        run([
4173            OsString::from("apply-unclaim"),
4174            OsString::from("--journal"),
4175            OsString::from(journal_path.as_os_str()),
4176            OsString::from("--sequence"),
4177            OsString::from("0"),
4178            OsString::from("--updated-at"),
4179            OsString::from("2026-05-04T12:01:00Z"),
4180            OsString::from("--out"),
4181            OsString::from(unclaimed_path.as_os_str()),
4182        ])
4183        .expect("unclaim operation");
4184
4185        let unclaimed: RestoreApplyJournal =
4186            serde_json::from_slice(&fs::read(&unclaimed_path).expect("read unclaimed journal"))
4187                .expect("decode unclaimed journal");
4188        let status = unclaimed.status();
4189
4190        fs::remove_dir_all(root).expect("remove temp root");
4191        assert_eq!(unclaimed.pending_operations, 0);
4192        assert_eq!(unclaimed.ready_operations, 8);
4193        assert_eq!(
4194            unclaimed.operations[0].state,
4195            RestoreApplyOperationState::Ready
4196        );
4197        assert_eq!(
4198            unclaimed.operations[0].state_updated_at.as_deref(),
4199            Some("2026-05-04T12:01:00Z")
4200        );
4201        assert_eq!(status.next_ready_sequence, Some(0));
4202        assert_eq!(
4203            status.next_transition_state,
4204            Some(RestoreApplyOperationState::Ready)
4205        );
4206        assert_eq!(
4207            status.next_transition_updated_at.as_deref(),
4208            Some("2026-05-04T12:01:00Z")
4209        );
4210    }
4211
4212    // Ensure apply-unclaim can reject a stale pending operation sequence.
4213    #[test]
4214    fn run_restore_apply_unclaim_rejects_sequence_mismatch() {
4215        let root = temp_dir("canic-cli-restore-apply-unclaim-sequence");
4216        fs::create_dir_all(&root).expect("create temp root");
4217        let journal_path = root.join("restore-apply-journal.json");
4218        let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
4219        let mut journal = ready_apply_journal();
4220        journal
4221            .mark_next_operation_pending()
4222            .expect("claim operation");
4223
4224        fs::write(
4225            &journal_path,
4226            serde_json::to_vec(&journal).expect("serialize journal"),
4227        )
4228        .expect("write journal");
4229
4230        let err = run([
4231            OsString::from("apply-unclaim"),
4232            OsString::from("--journal"),
4233            OsString::from(journal_path.as_os_str()),
4234            OsString::from("--sequence"),
4235            OsString::from("1"),
4236            OsString::from("--out"),
4237            OsString::from(unclaimed_path.as_os_str()),
4238        ])
4239        .expect_err("stale sequence should fail unclaim");
4240
4241        assert!(!unclaimed_path.exists());
4242        fs::remove_dir_all(root).expect("remove temp root");
4243        assert!(matches!(
4244            err,
4245            RestoreCommandError::RestoreApplyUnclaimSequenceMismatch {
4246                expected: 1,
4247                actual: Some(0),
4248            }
4249        ));
4250    }
4251
4252    // Ensure apply-mark can advance one journal operation and keep counts consistent.
4253    #[test]
4254    fn run_restore_apply_mark_completes_operation() {
4255        let root = temp_dir("canic-cli-restore-apply-mark-complete");
4256        fs::create_dir_all(&root).expect("create temp root");
4257        let journal_path = root.join("restore-apply-journal.json");
4258        let updated_path = root.join("restore-apply-journal.updated.json");
4259        let journal = ready_apply_journal();
4260
4261        fs::write(
4262            &journal_path,
4263            serde_json::to_vec(&journal).expect("serialize journal"),
4264        )
4265        .expect("write journal");
4266
4267        run([
4268            OsString::from("apply-mark"),
4269            OsString::from("--journal"),
4270            OsString::from(journal_path.as_os_str()),
4271            OsString::from("--sequence"),
4272            OsString::from("0"),
4273            OsString::from("--state"),
4274            OsString::from("completed"),
4275            OsString::from("--updated-at"),
4276            OsString::from("2026-05-04T12:02:00Z"),
4277            OsString::from("--out"),
4278            OsString::from(updated_path.as_os_str()),
4279        ])
4280        .expect("mark operation completed");
4281
4282        let updated: RestoreApplyJournal =
4283            serde_json::from_slice(&fs::read(&updated_path).expect("read updated journal"))
4284                .expect("decode updated journal");
4285        let status = updated.status();
4286
4287        fs::remove_dir_all(root).expect("remove temp root");
4288        assert_eq!(updated.completed_operations, 1);
4289        assert_eq!(updated.ready_operations, 7);
4290        assert_eq!(
4291            updated.operations[0].state_updated_at.as_deref(),
4292            Some("2026-05-04T12:02:00Z")
4293        );
4294        assert_eq!(status.next_ready_sequence, Some(1));
4295    }
4296
4297    // Ensure apply-mark can require an operation claim before completion.
4298    #[test]
4299    fn run_restore_apply_mark_require_pending_rejects_ready_operation() {
4300        let root = temp_dir("canic-cli-restore-apply-mark-require-pending");
4301        fs::create_dir_all(&root).expect("create temp root");
4302        let journal_path = root.join("restore-apply-journal.json");
4303        let updated_path = root.join("restore-apply-journal.updated.json");
4304        let journal = ready_apply_journal();
4305
4306        fs::write(
4307            &journal_path,
4308            serde_json::to_vec(&journal).expect("serialize journal"),
4309        )
4310        .expect("write journal");
4311
4312        let err = run([
4313            OsString::from("apply-mark"),
4314            OsString::from("--journal"),
4315            OsString::from(journal_path.as_os_str()),
4316            OsString::from("--sequence"),
4317            OsString::from("0"),
4318            OsString::from("--state"),
4319            OsString::from("completed"),
4320            OsString::from("--out"),
4321            OsString::from(updated_path.as_os_str()),
4322            OsString::from("--require-pending"),
4323        ])
4324        .expect_err("ready operation should fail pending requirement");
4325
4326        assert!(!updated_path.exists());
4327        fs::remove_dir_all(root).expect("remove temp root");
4328        assert!(matches!(
4329            err,
4330            RestoreCommandError::RestoreApplyMarkRequiresPending {
4331                sequence: 0,
4332                state: RestoreApplyOperationState::Ready,
4333            }
4334        ));
4335    }
4336
4337    // Ensure apply-mark refuses to skip earlier ready operations.
4338    #[test]
4339    fn run_restore_apply_mark_rejects_out_of_order_operation() {
4340        let root = temp_dir("canic-cli-restore-apply-mark-out-of-order");
4341        fs::create_dir_all(&root).expect("create temp root");
4342        let journal_path = root.join("restore-apply-journal.json");
4343        let updated_path = root.join("restore-apply-journal.updated.json");
4344        let journal = ready_apply_journal();
4345
4346        fs::write(
4347            &journal_path,
4348            serde_json::to_vec(&journal).expect("serialize journal"),
4349        )
4350        .expect("write journal");
4351
4352        let err = run([
4353            OsString::from("apply-mark"),
4354            OsString::from("--journal"),
4355            OsString::from(journal_path.as_os_str()),
4356            OsString::from("--sequence"),
4357            OsString::from("1"),
4358            OsString::from("--state"),
4359            OsString::from("completed"),
4360            OsString::from("--out"),
4361            OsString::from(updated_path.as_os_str()),
4362        ])
4363        .expect_err("out-of-order operation should fail");
4364
4365        assert!(!updated_path.exists());
4366        fs::remove_dir_all(root).expect("remove temp root");
4367        assert!(matches!(
4368            err,
4369            RestoreCommandError::RestoreApplyJournal(
4370                RestoreApplyJournalError::OutOfOrderOperationTransition {
4371                    requested: 1,
4372                    next: 0
4373                }
4374            )
4375        ));
4376    }
4377
4378    // Ensure apply-mark requires failure reasons for failed operation state.
4379    #[test]
4380    fn run_restore_apply_mark_failed_requires_reason() {
4381        let root = temp_dir("canic-cli-restore-apply-mark-failed-reason");
4382        fs::create_dir_all(&root).expect("create temp root");
4383        let journal_path = root.join("restore-apply-journal.json");
4384        let journal = ready_apply_journal();
4385
4386        fs::write(
4387            &journal_path,
4388            serde_json::to_vec(&journal).expect("serialize journal"),
4389        )
4390        .expect("write journal");
4391
4392        let err = run([
4393            OsString::from("apply-mark"),
4394            OsString::from("--journal"),
4395            OsString::from(journal_path.as_os_str()),
4396            OsString::from("--sequence"),
4397            OsString::from("0"),
4398            OsString::from("--state"),
4399            OsString::from("failed"),
4400        ])
4401        .expect_err("failed state should require reason");
4402
4403        fs::remove_dir_all(root).expect("remove temp root");
4404        assert!(matches!(
4405            err,
4406            RestoreCommandError::RestoreApplyJournal(
4407                RestoreApplyJournalError::FailureReasonRequired(0)
4408            )
4409        ));
4410    }
4411
4412    // Ensure restore apply dry-run rejects status files from another plan.
4413    #[test]
4414    fn run_restore_apply_dry_run_rejects_mismatched_status() {
4415        let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
4416        fs::create_dir_all(&root).expect("create temp root");
4417        let plan_path = root.join("restore-plan.json");
4418        let status_path = root.join("restore-status.json");
4419        let out_path = root.join("restore-apply-dry-run.json");
4420        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
4421        let mut status = RestoreStatus::from_plan(&plan);
4422        status.backup_id = "other-backup".to_string();
4423
4424        fs::write(
4425            &plan_path,
4426            serde_json::to_vec(&plan).expect("serialize plan"),
4427        )
4428        .expect("write plan");
4429        fs::write(
4430            &status_path,
4431            serde_json::to_vec(&status).expect("serialize status"),
4432        )
4433        .expect("write status");
4434
4435        let err = run([
4436            OsString::from("apply"),
4437            OsString::from("--plan"),
4438            OsString::from(plan_path.as_os_str()),
4439            OsString::from("--status"),
4440            OsString::from(status_path.as_os_str()),
4441            OsString::from("--dry-run"),
4442            OsString::from("--out"),
4443            OsString::from(out_path.as_os_str()),
4444        ])
4445        .expect_err("mismatched status should fail");
4446
4447        assert!(!out_path.exists());
4448        fs::remove_dir_all(root).expect("remove temp root");
4449        assert!(matches!(
4450            err,
4451            RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
4452                field: "backup_id",
4453                ..
4454            })
4455        ));
4456    }
4457
4458    // Build one manually ready apply journal for runner-focused CLI tests.
4459    fn ready_apply_journal() -> RestoreApplyJournal {
4460        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
4461        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
4462        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4463
4464        journal.ready = true;
4465        journal.blocked_reasons = Vec::new();
4466        for operation in &mut journal.operations {
4467            operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
4468            operation.blocking_reasons = Vec::new();
4469        }
4470        journal.blocked_operations = 0;
4471        journal.ready_operations = journal.operation_count;
4472        journal.validate().expect("journal should validate");
4473        journal
4474    }
4475
4476    // Build one valid manifest for restore planning tests.
4477    fn valid_manifest() -> FleetBackupManifest {
4478        FleetBackupManifest {
4479            manifest_version: 1,
4480            backup_id: "backup-test".to_string(),
4481            created_at: "2026-05-03T00:00:00Z".to_string(),
4482            tool: ToolMetadata {
4483                name: "canic".to_string(),
4484                version: "0.30.1".to_string(),
4485            },
4486            source: SourceMetadata {
4487                environment: "local".to_string(),
4488                root_canister: ROOT.to_string(),
4489            },
4490            consistency: ConsistencySection {
4491                mode: ConsistencyMode::CrashConsistent,
4492                backup_units: vec![BackupUnit {
4493                    unit_id: "fleet".to_string(),
4494                    kind: BackupUnitKind::SubtreeRooted,
4495                    roles: vec!["root".to_string(), "app".to_string()],
4496                    consistency_reason: None,
4497                    dependency_closure: Vec::new(),
4498                    topology_validation: "subtree-closed".to_string(),
4499                    quiescence_strategy: None,
4500                }],
4501            },
4502            fleet: FleetSection {
4503                topology_hash_algorithm: "sha256".to_string(),
4504                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
4505                discovery_topology_hash: HASH.to_string(),
4506                pre_snapshot_topology_hash: HASH.to_string(),
4507                topology_hash: HASH.to_string(),
4508                members: vec![
4509                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
4510                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
4511                ],
4512            },
4513            verification: VerificationPlan::default(),
4514        }
4515    }
4516
4517    // Build one manifest whose restore readiness metadata is complete.
4518    fn restore_ready_manifest() -> FleetBackupManifest {
4519        let mut manifest = valid_manifest();
4520        for member in &mut manifest.fleet.members {
4521            member.source_snapshot.module_hash = Some(HASH.to_string());
4522            member.source_snapshot.wasm_hash = Some(HASH.to_string());
4523            member.source_snapshot.checksum = Some(HASH.to_string());
4524        }
4525        manifest
4526    }
4527
4528    // Build one valid manifest member.
4529    fn fleet_member(
4530        role: &str,
4531        canister_id: &str,
4532        parent_canister_id: Option<&str>,
4533        identity_mode: IdentityMode,
4534    ) -> FleetMember {
4535        FleetMember {
4536            role: role.to_string(),
4537            canister_id: canister_id.to_string(),
4538            parent_canister_id: parent_canister_id.map(str::to_string),
4539            subnet_canister_id: Some(ROOT.to_string()),
4540            controller_hint: None,
4541            identity_mode,
4542            restore_group: 1,
4543            verification_class: "basic".to_string(),
4544            verification_checks: vec![VerificationCheck {
4545                kind: "status".to_string(),
4546                method: None,
4547                roles: vec![role.to_string()],
4548            }],
4549            source_snapshot: SourceSnapshot {
4550                snapshot_id: format!("{role}-snapshot"),
4551                module_hash: None,
4552                wasm_hash: None,
4553                code_version: Some("v0.30.1".to_string()),
4554                artifact_path: format!("artifacts/{role}"),
4555                checksum_algorithm: "sha256".to_string(),
4556                checksum: None,
4557            },
4558        }
4559    }
4560
4561    // Write a canonical backup layout whose journal checksums match the artifacts.
4562    fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
4563        layout.write_manifest(manifest).expect("write manifest");
4564
4565        let artifacts = manifest
4566            .fleet
4567            .members
4568            .iter()
4569            .map(|member| {
4570                let bytes = format!("{} artifact", member.role);
4571                let artifact_path = root.join(&member.source_snapshot.artifact_path);
4572                if let Some(parent) = artifact_path.parent() {
4573                    fs::create_dir_all(parent).expect("create artifact parent");
4574                }
4575                fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
4576                let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
4577
4578                ArtifactJournalEntry {
4579                    canister_id: member.canister_id.clone(),
4580                    snapshot_id: member.source_snapshot.snapshot_id.clone(),
4581                    state: ArtifactState::Durable,
4582                    temp_path: None,
4583                    artifact_path: member.source_snapshot.artifact_path.clone(),
4584                    checksum_algorithm: checksum.algorithm,
4585                    checksum: Some(checksum.hash),
4586                    updated_at: "2026-05-03T00:00:00Z".to_string(),
4587                }
4588            })
4589            .collect();
4590
4591        layout
4592            .write_journal(&DownloadJournal {
4593                journal_version: 1,
4594                backup_id: manifest.backup_id.clone(),
4595                discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
4596                pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
4597                operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
4598                artifacts,
4599            })
4600            .expect("write journal");
4601    }
4602
4603    // Write artifact bytes and update the manifest checksums for apply validation.
4604    fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
4605        for member in &mut manifest.fleet.members {
4606            let bytes = format!("{} apply artifact", member.role);
4607            let artifact_path = root.join(&member.source_snapshot.artifact_path);
4608            if let Some(parent) = artifact_path.parent() {
4609                fs::create_dir_all(parent).expect("create artifact parent");
4610            }
4611            fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
4612            let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
4613            member.source_snapshot.checksum = Some(checksum.hash);
4614        }
4615    }
4616
4617    // Build a unique temporary directory.
4618    fn temp_dir(prefix: &str) -> PathBuf {
4619        let nanos = SystemTime::now()
4620            .duration_since(UNIX_EPOCH)
4621            .expect("system time after epoch")
4622            .as_nanos();
4623        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
4624    }
4625}