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        RestoreApplyJournalReport, RestoreApplyJournalStatus, RestoreApplyNextOperation,
8        RestoreApplyOperationState, RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner,
9        RestoreStatus,
10    },
11};
12use std::{
13    ffi::OsString,
14    fs,
15    io::{self, Write},
16    path::PathBuf,
17};
18use thiserror::Error as ThisError;
19
20///
21/// RestoreCommandError
22///
23
24#[derive(Debug, ThisError)]
25pub enum RestoreCommandError {
26    #[error("{0}")]
27    Usage(&'static str),
28
29    #[error("missing required option {0}")]
30    MissingOption(&'static str),
31
32    #[error("use either --manifest or --backup-dir, not both")]
33    ConflictingManifestSources,
34
35    #[error("--require-verified requires --backup-dir")]
36    RequireVerifiedNeedsBackupDir,
37
38    #[error("restore apply currently requires --dry-run")]
39    ApplyRequiresDryRun,
40
41    #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
42    RestoreNotReady {
43        backup_id: String,
44        reasons: Vec<String>,
45    },
46
47    #[error(
48        "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
49    )]
50    RestoreApplyPending {
51        backup_id: String,
52        pending_operations: usize,
53        next_transition_sequence: Option<usize>,
54    },
55
56    #[error(
57        "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
58    )]
59    RestoreApplyIncomplete {
60        backup_id: String,
61        completed_operations: usize,
62        operation_count: usize,
63    },
64
65    #[error(
66        "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
67    )]
68    RestoreApplyFailed {
69        backup_id: String,
70        failed_operations: usize,
71    },
72
73    #[error("restore apply journal for backup {backup_id} is not ready: reasons={reasons:?}")]
74    RestoreApplyNotReady {
75        backup_id: String,
76        reasons: Vec<String>,
77    },
78
79    #[error("restore apply report for backup {backup_id} requires attention: outcome={outcome:?}")]
80    RestoreApplyReportNeedsAttention {
81        backup_id: String,
82        outcome: canic_backup::restore::RestoreApplyReportOutcome,
83    },
84
85    #[error(
86        "restore apply journal for backup {backup_id} has no executable command: operation_available={operation_available}, complete={complete}, blocked_reasons={blocked_reasons:?}"
87    )]
88    RestoreApplyCommandUnavailable {
89        backup_id: String,
90        operation_available: bool,
91        complete: bool,
92        blocked_reasons: Vec<String>,
93    },
94
95    #[error(
96        "restore apply journal operation {sequence} must be pending before apply-mark: state={state:?}"
97    )]
98    RestoreApplyMarkRequiresPending {
99        sequence: usize,
100        state: RestoreApplyOperationState,
101    },
102
103    #[error(
104        "restore apply journal next operation changed before claim: expected={expected}, actual={actual:?}"
105    )]
106    RestoreApplyClaimSequenceMismatch {
107        expected: usize,
108        actual: Option<usize>,
109    },
110
111    #[error(
112        "restore apply journal pending operation changed before unclaim: expected={expected}, actual={actual:?}"
113    )]
114    RestoreApplyUnclaimSequenceMismatch {
115        expected: usize,
116        actual: Option<usize>,
117    },
118
119    #[error("unknown option {0}")]
120    UnknownOption(String),
121
122    #[error("option {0} requires a value")]
123    MissingValue(&'static str),
124
125    #[error("option --sequence requires a non-negative integer value")]
126    InvalidSequence,
127
128    #[error("unsupported apply-mark state {0}; use completed or failed")]
129    InvalidApplyMarkState(String),
130
131    #[error(transparent)]
132    Io(#[from] std::io::Error),
133
134    #[error(transparent)]
135    Json(#[from] serde_json::Error),
136
137    #[error(transparent)]
138    Persistence(#[from] PersistenceError),
139
140    #[error(transparent)]
141    RestorePlan(#[from] RestorePlanError),
142
143    #[error(transparent)]
144    RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
145
146    #[error(transparent)]
147    RestoreApplyJournal(#[from] RestoreApplyJournalError),
148}
149
150///
151/// RestorePlanOptions
152///
153
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct RestorePlanOptions {
156    pub manifest: Option<PathBuf>,
157    pub backup_dir: Option<PathBuf>,
158    pub mapping: Option<PathBuf>,
159    pub out: Option<PathBuf>,
160    pub require_verified: bool,
161    pub require_restore_ready: bool,
162}
163
164impl RestorePlanOptions {
165    /// Parse restore planning options from CLI arguments.
166    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
167    where
168        I: IntoIterator<Item = OsString>,
169    {
170        let mut manifest = None;
171        let mut backup_dir = None;
172        let mut mapping = None;
173        let mut out = None;
174        let mut require_verified = false;
175        let mut require_restore_ready = false;
176
177        let mut args = args.into_iter();
178        while let Some(arg) = args.next() {
179            let arg = arg
180                .into_string()
181                .map_err(|_| RestoreCommandError::Usage(usage()))?;
182            match arg.as_str() {
183                "--manifest" => {
184                    manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
185                }
186                "--backup-dir" => {
187                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
188                }
189                "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
190                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
191                "--require-verified" => require_verified = true,
192                "--require-restore-ready" => require_restore_ready = true,
193                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
194                _ => return Err(RestoreCommandError::UnknownOption(arg)),
195            }
196        }
197
198        if manifest.is_some() && backup_dir.is_some() {
199            return Err(RestoreCommandError::ConflictingManifestSources);
200        }
201
202        if manifest.is_none() && backup_dir.is_none() {
203            return Err(RestoreCommandError::MissingOption(
204                "--manifest or --backup-dir",
205            ));
206        }
207
208        if require_verified && backup_dir.is_none() {
209            return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
210        }
211
212        Ok(Self {
213            manifest,
214            backup_dir,
215            mapping,
216            out,
217            require_verified,
218            require_restore_ready,
219        })
220    }
221}
222
223///
224/// RestoreStatusOptions
225///
226
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct RestoreStatusOptions {
229    pub plan: PathBuf,
230    pub out: Option<PathBuf>,
231}
232
233impl RestoreStatusOptions {
234    /// Parse restore status options from CLI arguments.
235    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
236    where
237        I: IntoIterator<Item = OsString>,
238    {
239        let mut plan = None;
240        let mut out = None;
241
242        let mut args = args.into_iter();
243        while let Some(arg) = args.next() {
244            let arg = arg
245                .into_string()
246                .map_err(|_| RestoreCommandError::Usage(usage()))?;
247            match arg.as_str() {
248                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
249                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
250                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
251                _ => return Err(RestoreCommandError::UnknownOption(arg)),
252            }
253        }
254
255        Ok(Self {
256            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
257            out,
258        })
259    }
260}
261
262///
263/// RestoreApplyOptions
264///
265
266#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct RestoreApplyOptions {
268    pub plan: PathBuf,
269    pub status: Option<PathBuf>,
270    pub backup_dir: Option<PathBuf>,
271    pub out: Option<PathBuf>,
272    pub journal_out: Option<PathBuf>,
273    pub dry_run: bool,
274}
275
276impl RestoreApplyOptions {
277    /// Parse restore apply options from CLI arguments.
278    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
279    where
280        I: IntoIterator<Item = OsString>,
281    {
282        let mut plan = None;
283        let mut status = None;
284        let mut backup_dir = None;
285        let mut out = None;
286        let mut journal_out = None;
287        let mut dry_run = false;
288
289        let mut args = args.into_iter();
290        while let Some(arg) = args.next() {
291            let arg = arg
292                .into_string()
293                .map_err(|_| RestoreCommandError::Usage(usage()))?;
294            match arg.as_str() {
295                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
296                "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
297                "--backup-dir" => {
298                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
299                }
300                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
301                "--journal-out" => {
302                    journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
303                }
304                "--dry-run" => dry_run = true,
305                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
306                _ => return Err(RestoreCommandError::UnknownOption(arg)),
307            }
308        }
309
310        if !dry_run {
311            return Err(RestoreCommandError::ApplyRequiresDryRun);
312        }
313
314        Ok(Self {
315            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
316            status,
317            backup_dir,
318            out,
319            journal_out,
320            dry_run,
321        })
322    }
323}
324
325///
326/// RestoreApplyStatusOptions
327///
328
329#[derive(Clone, Debug, Eq, PartialEq)]
330#[expect(
331    clippy::struct_excessive_bools,
332    reason = "CLI status options mirror independent fail-closed guard flags"
333)]
334pub struct RestoreApplyStatusOptions {
335    pub journal: PathBuf,
336    pub require_ready: bool,
337    pub require_no_pending: bool,
338    pub require_no_failed: bool,
339    pub require_complete: bool,
340    pub out: Option<PathBuf>,
341}
342
343impl RestoreApplyStatusOptions {
344    /// Parse restore apply-status options from CLI arguments.
345    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
346    where
347        I: IntoIterator<Item = OsString>,
348    {
349        let mut journal = None;
350        let mut require_ready = false;
351        let mut require_no_pending = false;
352        let mut require_no_failed = false;
353        let mut require_complete = false;
354        let mut out = None;
355
356        let mut args = args.into_iter();
357        while let Some(arg) = args.next() {
358            let arg = arg
359                .into_string()
360                .map_err(|_| RestoreCommandError::Usage(usage()))?;
361            match arg.as_str() {
362                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
363                "--require-ready" => require_ready = true,
364                "--require-no-pending" => require_no_pending = true,
365                "--require-no-failed" => require_no_failed = true,
366                "--require-complete" => require_complete = true,
367                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
368                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
369                _ => return Err(RestoreCommandError::UnknownOption(arg)),
370            }
371        }
372
373        Ok(Self {
374            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
375            require_ready,
376            require_no_pending,
377            require_no_failed,
378            require_complete,
379            out,
380        })
381    }
382}
383
384///
385/// RestoreApplyReportOptions
386///
387
388#[derive(Clone, Debug, Eq, PartialEq)]
389pub struct RestoreApplyReportOptions {
390    pub journal: PathBuf,
391    pub require_no_attention: bool,
392    pub out: Option<PathBuf>,
393}
394
395impl RestoreApplyReportOptions {
396    /// Parse restore apply-report options from CLI arguments.
397    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
398    where
399        I: IntoIterator<Item = OsString>,
400    {
401        let mut journal = None;
402        let mut require_no_attention = false;
403        let mut out = None;
404
405        let mut args = args.into_iter();
406        while let Some(arg) = args.next() {
407            let arg = arg
408                .into_string()
409                .map_err(|_| RestoreCommandError::Usage(usage()))?;
410            match arg.as_str() {
411                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
412                "--require-no-attention" => require_no_attention = true,
413                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
414                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
415                _ => return Err(RestoreCommandError::UnknownOption(arg)),
416            }
417        }
418
419        Ok(Self {
420            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
421            require_no_attention,
422            out,
423        })
424    }
425}
426
427///
428/// RestoreApplyNextOptions
429///
430
431#[derive(Clone, Debug, Eq, PartialEq)]
432pub struct RestoreApplyNextOptions {
433    pub journal: PathBuf,
434    pub out: Option<PathBuf>,
435}
436
437impl RestoreApplyNextOptions {
438    /// Parse restore apply-next options from CLI arguments.
439    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
440    where
441        I: IntoIterator<Item = OsString>,
442    {
443        let mut journal = None;
444        let mut out = None;
445
446        let mut args = args.into_iter();
447        while let Some(arg) = args.next() {
448            let arg = arg
449                .into_string()
450                .map_err(|_| RestoreCommandError::Usage(usage()))?;
451            match arg.as_str() {
452                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
453                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
454                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
455                _ => return Err(RestoreCommandError::UnknownOption(arg)),
456            }
457        }
458
459        Ok(Self {
460            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
461            out,
462        })
463    }
464}
465
466///
467/// RestoreApplyCommandOptions
468///
469
470#[derive(Clone, Debug, Eq, PartialEq)]
471pub struct RestoreApplyCommandOptions {
472    pub journal: PathBuf,
473    pub dfx: String,
474    pub network: Option<String>,
475    pub out: Option<PathBuf>,
476    pub require_command: bool,
477}
478
479impl RestoreApplyCommandOptions {
480    /// Parse restore apply-command options from CLI arguments.
481    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
482    where
483        I: IntoIterator<Item = OsString>,
484    {
485        let mut journal = None;
486        let mut dfx = "dfx".to_string();
487        let mut network = None;
488        let mut out = None;
489        let mut require_command = false;
490
491        let mut args = args.into_iter();
492        while let Some(arg) = args.next() {
493            let arg = arg
494                .into_string()
495                .map_err(|_| RestoreCommandError::Usage(usage()))?;
496            match arg.as_str() {
497                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
498                "--dfx" => dfx = next_value(&mut args, "--dfx")?,
499                "--network" => network = Some(next_value(&mut args, "--network")?),
500                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
501                "--require-command" => require_command = true,
502                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
503                _ => return Err(RestoreCommandError::UnknownOption(arg)),
504            }
505        }
506
507        Ok(Self {
508            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
509            dfx,
510            network,
511            out,
512            require_command,
513        })
514    }
515}
516
517///
518/// RestoreApplyClaimOptions
519///
520
521#[derive(Clone, Debug, Eq, PartialEq)]
522pub struct RestoreApplyClaimOptions {
523    pub journal: PathBuf,
524    pub sequence: Option<usize>,
525    pub updated_at: Option<String>,
526    pub out: Option<PathBuf>,
527}
528
529impl RestoreApplyClaimOptions {
530    /// Parse restore apply-claim options from CLI arguments.
531    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
532    where
533        I: IntoIterator<Item = OsString>,
534    {
535        let mut journal = None;
536        let mut sequence = None;
537        let mut updated_at = None;
538        let mut out = None;
539
540        let mut args = args.into_iter();
541        while let Some(arg) = args.next() {
542            let arg = arg
543                .into_string()
544                .map_err(|_| RestoreCommandError::Usage(usage()))?;
545            match arg.as_str() {
546                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
547                "--sequence" => {
548                    sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
549                }
550                "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
551                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
552                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
553                _ => return Err(RestoreCommandError::UnknownOption(arg)),
554            }
555        }
556
557        Ok(Self {
558            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
559            sequence,
560            updated_at,
561            out,
562        })
563    }
564}
565
566///
567/// RestoreApplyMarkOptions
568///
569
570#[derive(Clone, Debug, Eq, PartialEq)]
571pub struct RestoreApplyUnclaimOptions {
572    pub journal: PathBuf,
573    pub sequence: Option<usize>,
574    pub updated_at: Option<String>,
575    pub out: Option<PathBuf>,
576}
577
578impl RestoreApplyUnclaimOptions {
579    /// Parse restore apply-unclaim options from CLI arguments.
580    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
581    where
582        I: IntoIterator<Item = OsString>,
583    {
584        let mut journal = None;
585        let mut sequence = None;
586        let mut updated_at = None;
587        let mut out = None;
588
589        let mut args = args.into_iter();
590        while let Some(arg) = args.next() {
591            let arg = arg
592                .into_string()
593                .map_err(|_| RestoreCommandError::Usage(usage()))?;
594            match arg.as_str() {
595                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
596                "--sequence" => {
597                    sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
598                }
599                "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
600                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
601                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
602                _ => return Err(RestoreCommandError::UnknownOption(arg)),
603            }
604        }
605
606        Ok(Self {
607            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
608            sequence,
609            updated_at,
610            out,
611        })
612    }
613}
614
615///
616/// RestoreApplyMarkOptions
617///
618
619#[derive(Clone, Debug, Eq, PartialEq)]
620pub struct RestoreApplyMarkOptions {
621    pub journal: PathBuf,
622    pub sequence: usize,
623    pub state: RestoreApplyMarkState,
624    pub reason: Option<String>,
625    pub updated_at: Option<String>,
626    pub out: Option<PathBuf>,
627    pub require_pending: bool,
628}
629
630impl RestoreApplyMarkOptions {
631    /// Parse restore apply-mark options from CLI arguments.
632    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
633    where
634        I: IntoIterator<Item = OsString>,
635    {
636        let mut journal = None;
637        let mut sequence = None;
638        let mut state = None;
639        let mut reason = None;
640        let mut updated_at = None;
641        let mut out = None;
642        let mut require_pending = false;
643
644        let mut args = args.into_iter();
645        while let Some(arg) = args.next() {
646            let arg = arg
647                .into_string()
648                .map_err(|_| RestoreCommandError::Usage(usage()))?;
649            match arg.as_str() {
650                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
651                "--sequence" => {
652                    sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
653                }
654                "--state" => {
655                    state = Some(RestoreApplyMarkState::parse(next_value(
656                        &mut args, "--state",
657                    )?)?);
658                }
659                "--reason" => reason = Some(next_value(&mut args, "--reason")?),
660                "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
661                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
662                "--require-pending" => require_pending = true,
663                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
664                _ => return Err(RestoreCommandError::UnknownOption(arg)),
665            }
666        }
667
668        Ok(Self {
669            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
670            sequence: sequence.ok_or(RestoreCommandError::MissingOption("--sequence"))?,
671            state: state.ok_or(RestoreCommandError::MissingOption("--state"))?,
672            reason,
673            updated_at,
674            out,
675            require_pending,
676        })
677    }
678}
679
680///
681/// RestoreApplyMarkState
682///
683
684#[derive(Clone, Debug, Eq, PartialEq)]
685pub enum RestoreApplyMarkState {
686    Completed,
687    Failed,
688}
689
690impl RestoreApplyMarkState {
691    // Parse the restricted operation states accepted by apply-mark.
692    fn parse(value: String) -> Result<Self, RestoreCommandError> {
693        match value.as_str() {
694            "completed" => Ok(Self::Completed),
695            "failed" => Ok(Self::Failed),
696            _ => Err(RestoreCommandError::InvalidApplyMarkState(value)),
697        }
698    }
699}
700
701/// Run a restore subcommand.
702pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
703where
704    I: IntoIterator<Item = OsString>,
705{
706    let mut args = args.into_iter();
707    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
708        return Err(RestoreCommandError::Usage(usage()));
709    };
710
711    match command.as_str() {
712        "plan" => {
713            let options = RestorePlanOptions::parse(args)?;
714            let plan = plan_restore(&options)?;
715            write_plan(&options, &plan)?;
716            enforce_restore_plan_requirements(&options, &plan)?;
717            Ok(())
718        }
719        "status" => {
720            let options = RestoreStatusOptions::parse(args)?;
721            let status = restore_status(&options)?;
722            write_status(&options, &status)?;
723            Ok(())
724        }
725        "apply" => {
726            let options = RestoreApplyOptions::parse(args)?;
727            let dry_run = restore_apply_dry_run(&options)?;
728            write_apply_dry_run(&options, &dry_run)?;
729            write_apply_journal_if_requested(&options, &dry_run)?;
730            Ok(())
731        }
732        "apply-status" => {
733            let options = RestoreApplyStatusOptions::parse(args)?;
734            let status = restore_apply_status(&options)?;
735            write_apply_status(&options, &status)?;
736            enforce_apply_status_requirements(&options, &status)?;
737            Ok(())
738        }
739        "apply-report" => {
740            let options = RestoreApplyReportOptions::parse(args)?;
741            let report = restore_apply_report(&options)?;
742            write_apply_report(&options, &report)?;
743            enforce_apply_report_requirements(&options, &report)?;
744            Ok(())
745        }
746        "apply-next" => {
747            let options = RestoreApplyNextOptions::parse(args)?;
748            let next = restore_apply_next(&options)?;
749            write_apply_next(&options, &next)?;
750            Ok(())
751        }
752        "apply-command" => {
753            let options = RestoreApplyCommandOptions::parse(args)?;
754            let preview = restore_apply_command(&options)?;
755            write_apply_command(&options, &preview)?;
756            enforce_apply_command_requirements(&options, &preview)?;
757            Ok(())
758        }
759        "apply-claim" => {
760            let options = RestoreApplyClaimOptions::parse(args)?;
761            let journal = restore_apply_claim(&options)?;
762            write_apply_claim(&options, &journal)?;
763            Ok(())
764        }
765        "apply-unclaim" => {
766            let options = RestoreApplyUnclaimOptions::parse(args)?;
767            let journal = restore_apply_unclaim(&options)?;
768            write_apply_unclaim(&options, &journal)?;
769            Ok(())
770        }
771        "apply-mark" => {
772            let options = RestoreApplyMarkOptions::parse(args)?;
773            let journal = restore_apply_mark(&options)?;
774            write_apply_mark(&options, &journal)?;
775            Ok(())
776        }
777        "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
778        _ => Err(RestoreCommandError::UnknownOption(command)),
779    }
780}
781
782/// Build a no-mutation restore plan from a manifest and optional mapping.
783pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
784    verify_backup_layout_if_required(options)?;
785
786    let manifest = read_manifest_source(options)?;
787    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
788
789    RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
790}
791
792/// Build the initial no-mutation restore status from a restore plan.
793pub fn restore_status(
794    options: &RestoreStatusOptions,
795) -> Result<RestoreStatus, RestoreCommandError> {
796    let plan = read_plan(&options.plan)?;
797    Ok(RestoreStatus::from_plan(&plan))
798}
799
800/// Build a no-mutation restore apply dry-run from a restore plan.
801pub fn restore_apply_dry_run(
802    options: &RestoreApplyOptions,
803) -> Result<RestoreApplyDryRun, RestoreCommandError> {
804    let plan = read_plan(&options.plan)?;
805    let status = options.status.as_ref().map(read_status).transpose()?;
806    if let Some(backup_dir) = &options.backup_dir {
807        return RestoreApplyDryRun::try_from_plan_with_artifacts(
808            &plan,
809            status.as_ref(),
810            backup_dir,
811        )
812        .map_err(RestoreCommandError::from);
813    }
814
815    RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
816}
817
818/// Build a compact restore apply status from a journal file.
819pub fn restore_apply_status(
820    options: &RestoreApplyStatusOptions,
821) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
822    let journal = read_apply_journal(&options.journal)?;
823    Ok(journal.status())
824}
825
826/// Build an operator-oriented restore apply report from a journal file.
827pub fn restore_apply_report(
828    options: &RestoreApplyReportOptions,
829) -> Result<RestoreApplyJournalReport, RestoreCommandError> {
830    let journal = read_apply_journal(&options.journal)?;
831    Ok(journal.report())
832}
833
834// Enforce caller-requested apply report requirements after report output is emitted.
835fn enforce_apply_report_requirements(
836    options: &RestoreApplyReportOptions,
837    report: &RestoreApplyJournalReport,
838) -> Result<(), RestoreCommandError> {
839    if !options.require_no_attention || !report.attention_required {
840        return Ok(());
841    }
842
843    Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
844        backup_id: report.backup_id.clone(),
845        outcome: report.outcome.clone(),
846    })
847}
848
849// Enforce caller-requested apply journal requirements after status is emitted.
850fn enforce_apply_status_requirements(
851    options: &RestoreApplyStatusOptions,
852    status: &RestoreApplyJournalStatus,
853) -> Result<(), RestoreCommandError> {
854    if options.require_ready && !status.ready {
855        return Err(RestoreCommandError::RestoreApplyNotReady {
856            backup_id: status.backup_id.clone(),
857            reasons: status.blocked_reasons.clone(),
858        });
859    }
860
861    if options.require_no_pending && status.pending_operations > 0 {
862        return Err(RestoreCommandError::RestoreApplyPending {
863            backup_id: status.backup_id.clone(),
864            pending_operations: status.pending_operations,
865            next_transition_sequence: status.next_transition_sequence,
866        });
867    }
868
869    if options.require_no_failed && status.failed_operations > 0 {
870        return Err(RestoreCommandError::RestoreApplyFailed {
871            backup_id: status.backup_id.clone(),
872            failed_operations: status.failed_operations,
873        });
874    }
875
876    if options.require_complete && !status.complete {
877        return Err(RestoreCommandError::RestoreApplyIncomplete {
878            backup_id: status.backup_id.clone(),
879            completed_operations: status.completed_operations,
880            operation_count: status.operation_count,
881        });
882    }
883
884    Ok(())
885}
886
887/// Build the next restore apply operation response from a journal file.
888pub fn restore_apply_next(
889    options: &RestoreApplyNextOptions,
890) -> Result<RestoreApplyNextOperation, RestoreCommandError> {
891    let journal = read_apply_journal(&options.journal)?;
892    Ok(journal.next_operation())
893}
894
895/// Build the next restore apply command preview from a journal file.
896pub fn restore_apply_command(
897    options: &RestoreApplyCommandOptions,
898) -> Result<RestoreApplyCommandPreview, RestoreCommandError> {
899    let journal = read_apply_journal(&options.journal)?;
900    Ok(
901        journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
902            program: options.dfx.clone(),
903            network: options.network.clone(),
904        }),
905    )
906}
907
908// Enforce caller-requested command preview requirements after preview output is emitted.
909fn enforce_apply_command_requirements(
910    options: &RestoreApplyCommandOptions,
911    preview: &RestoreApplyCommandPreview,
912) -> Result<(), RestoreCommandError> {
913    if !options.require_command || preview.command_available {
914        return Ok(());
915    }
916
917    Err(RestoreCommandError::RestoreApplyCommandUnavailable {
918        backup_id: preview.backup_id.clone(),
919        operation_available: preview.operation_available,
920        complete: preview.complete,
921        blocked_reasons: preview.blocked_reasons.clone(),
922    })
923}
924
925/// Mark the next restore apply journal operation pending.
926pub fn restore_apply_claim(
927    options: &RestoreApplyClaimOptions,
928) -> Result<RestoreApplyJournal, RestoreCommandError> {
929    let mut journal = read_apply_journal(&options.journal)?;
930    let updated_at = Some(state_updated_at(options.updated_at.as_ref()));
931
932    if let Some(sequence) = options.sequence {
933        enforce_apply_claim_sequence(sequence, &journal)?;
934        journal.mark_operation_pending_at(sequence, updated_at)?;
935        return Ok(journal);
936    }
937
938    journal.mark_next_operation_pending_at(updated_at)?;
939    Ok(journal)
940}
941
942// Ensure a runner claim still matches the operation it previewed.
943fn enforce_apply_claim_sequence(
944    expected: usize,
945    journal: &RestoreApplyJournal,
946) -> Result<(), RestoreCommandError> {
947    let actual = journal
948        .next_transition_operation()
949        .map(|operation| operation.sequence);
950
951    if actual == Some(expected) {
952        return Ok(());
953    }
954
955    Err(RestoreCommandError::RestoreApplyClaimSequenceMismatch { expected, actual })
956}
957
958/// Mark the current pending restore apply journal operation ready again.
959pub fn restore_apply_unclaim(
960    options: &RestoreApplyUnclaimOptions,
961) -> Result<RestoreApplyJournal, RestoreCommandError> {
962    let mut journal = read_apply_journal(&options.journal)?;
963    if let Some(sequence) = options.sequence {
964        enforce_apply_unclaim_sequence(sequence, &journal)?;
965    }
966
967    journal.mark_next_operation_ready_at(Some(state_updated_at(options.updated_at.as_ref())))?;
968    Ok(journal)
969}
970
971// Ensure a runner unclaim still matches the pending operation it recovered.
972fn enforce_apply_unclaim_sequence(
973    expected: usize,
974    journal: &RestoreApplyJournal,
975) -> Result<(), RestoreCommandError> {
976    let actual = journal
977        .next_transition_operation()
978        .map(|operation| operation.sequence);
979
980    if actual == Some(expected) {
981        return Ok(());
982    }
983
984    Err(RestoreCommandError::RestoreApplyUnclaimSequenceMismatch { expected, actual })
985}
986
987/// Mark one restore apply journal operation completed or failed.
988pub fn restore_apply_mark(
989    options: &RestoreApplyMarkOptions,
990) -> Result<RestoreApplyJournal, RestoreCommandError> {
991    let mut journal = read_apply_journal(&options.journal)?;
992    enforce_apply_mark_pending_requirement(options, &journal)?;
993
994    match options.state {
995        RestoreApplyMarkState::Completed => {
996            journal.mark_operation_completed_at(
997                options.sequence,
998                Some(state_updated_at(options.updated_at.as_ref())),
999            )?;
1000        }
1001        RestoreApplyMarkState::Failed => {
1002            let reason =
1003                options
1004                    .reason
1005                    .clone()
1006                    .ok_or(RestoreApplyJournalError::FailureReasonRequired(
1007                        options.sequence,
1008                    ))?;
1009            journal.mark_operation_failed_at(
1010                options.sequence,
1011                reason,
1012                Some(state_updated_at(options.updated_at.as_ref())),
1013            )?;
1014        }
1015    }
1016
1017    Ok(journal)
1018}
1019
1020// Enforce that apply-mark only records an already claimed operation when requested.
1021fn enforce_apply_mark_pending_requirement(
1022    options: &RestoreApplyMarkOptions,
1023    journal: &RestoreApplyJournal,
1024) -> Result<(), RestoreCommandError> {
1025    if !options.require_pending {
1026        return Ok(());
1027    }
1028
1029    let state = journal
1030        .operations
1031        .iter()
1032        .find(|operation| operation.sequence == options.sequence)
1033        .map(|operation| operation.state.clone())
1034        .ok_or(RestoreApplyJournalError::OperationNotFound(
1035            options.sequence,
1036        ))?;
1037
1038    if state == RestoreApplyOperationState::Pending {
1039        return Ok(());
1040    }
1041
1042    Err(RestoreCommandError::RestoreApplyMarkRequiresPending {
1043        sequence: options.sequence,
1044        state,
1045    })
1046}
1047
1048// Enforce caller-requested restore plan requirements after the plan is emitted.
1049fn enforce_restore_plan_requirements(
1050    options: &RestorePlanOptions,
1051    plan: &RestorePlan,
1052) -> Result<(), RestoreCommandError> {
1053    if !options.require_restore_ready || plan.readiness_summary.ready {
1054        return Ok(());
1055    }
1056
1057    Err(RestoreCommandError::RestoreNotReady {
1058        backup_id: plan.backup_id.clone(),
1059        reasons: plan.readiness_summary.reasons.clone(),
1060    })
1061}
1062
1063// Verify backup layout integrity before restore planning when requested.
1064fn verify_backup_layout_if_required(
1065    options: &RestorePlanOptions,
1066) -> Result<(), RestoreCommandError> {
1067    if !options.require_verified {
1068        return Ok(());
1069    }
1070
1071    let Some(dir) = &options.backup_dir else {
1072        return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
1073    };
1074
1075    BackupLayout::new(dir.clone()).verify_integrity()?;
1076    Ok(())
1077}
1078
1079// Read the manifest from a direct path or canonical backup layout.
1080fn read_manifest_source(
1081    options: &RestorePlanOptions,
1082) -> Result<FleetBackupManifest, RestoreCommandError> {
1083    if let Some(path) = &options.manifest {
1084        return read_manifest(path);
1085    }
1086
1087    let Some(dir) = &options.backup_dir else {
1088        return Err(RestoreCommandError::MissingOption(
1089            "--manifest or --backup-dir",
1090        ));
1091    };
1092
1093    BackupLayout::new(dir.clone())
1094        .read_manifest()
1095        .map_err(RestoreCommandError::from)
1096}
1097
1098// Read and decode a fleet backup manifest from disk.
1099fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
1100    let data = fs::read_to_string(path)?;
1101    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1102}
1103
1104// Read and decode an optional source-to-target restore mapping from disk.
1105fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
1106    let data = fs::read_to_string(path)?;
1107    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1108}
1109
1110// Read and decode a restore plan from disk.
1111fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
1112    let data = fs::read_to_string(path)?;
1113    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1114}
1115
1116// Read and decode a restore status from disk.
1117fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
1118    let data = fs::read_to_string(path)?;
1119    serde_json::from_str(&data).map_err(RestoreCommandError::from)
1120}
1121
1122// Read and decode a restore apply journal from disk.
1123fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
1124    let data = fs::read_to_string(path)?;
1125    let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
1126    journal.validate()?;
1127    Ok(journal)
1128}
1129
1130// Parse a restore apply journal operation sequence value.
1131fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
1132    value
1133        .parse::<usize>()
1134        .map_err(|_| RestoreCommandError::InvalidSequence)
1135}
1136
1137// Return the caller-supplied journal update marker or the current placeholder.
1138fn state_updated_at(updated_at: Option<&String>) -> String {
1139    updated_at.cloned().unwrap_or_else(timestamp_placeholder)
1140}
1141
1142// Return a placeholder timestamp until the CLI owns a clock abstraction.
1143fn timestamp_placeholder() -> String {
1144    "unknown".to_string()
1145}
1146
1147// Write the computed plan to stdout or a requested output file.
1148fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
1149    if let Some(path) = &options.out {
1150        let data = serde_json::to_vec_pretty(plan)?;
1151        fs::write(path, data)?;
1152        return Ok(());
1153    }
1154
1155    let stdout = io::stdout();
1156    let mut handle = stdout.lock();
1157    serde_json::to_writer_pretty(&mut handle, plan)?;
1158    writeln!(handle)?;
1159    Ok(())
1160}
1161
1162// Write the computed status to stdout or a requested output file.
1163fn write_status(
1164    options: &RestoreStatusOptions,
1165    status: &RestoreStatus,
1166) -> Result<(), RestoreCommandError> {
1167    if let Some(path) = &options.out {
1168        let data = serde_json::to_vec_pretty(status)?;
1169        fs::write(path, data)?;
1170        return Ok(());
1171    }
1172
1173    let stdout = io::stdout();
1174    let mut handle = stdout.lock();
1175    serde_json::to_writer_pretty(&mut handle, status)?;
1176    writeln!(handle)?;
1177    Ok(())
1178}
1179
1180// Write the computed apply dry-run to stdout or a requested output file.
1181fn write_apply_dry_run(
1182    options: &RestoreApplyOptions,
1183    dry_run: &RestoreApplyDryRun,
1184) -> Result<(), RestoreCommandError> {
1185    if let Some(path) = &options.out {
1186        let data = serde_json::to_vec_pretty(dry_run)?;
1187        fs::write(path, data)?;
1188        return Ok(());
1189    }
1190
1191    let stdout = io::stdout();
1192    let mut handle = stdout.lock();
1193    serde_json::to_writer_pretty(&mut handle, dry_run)?;
1194    writeln!(handle)?;
1195    Ok(())
1196}
1197
1198// Write the initial apply journal when the caller requests one.
1199fn write_apply_journal_if_requested(
1200    options: &RestoreApplyOptions,
1201    dry_run: &RestoreApplyDryRun,
1202) -> Result<(), RestoreCommandError> {
1203    let Some(path) = &options.journal_out else {
1204        return Ok(());
1205    };
1206
1207    let journal = RestoreApplyJournal::from_dry_run(dry_run);
1208    let data = serde_json::to_vec_pretty(&journal)?;
1209    fs::write(path, data)?;
1210    Ok(())
1211}
1212
1213// Write the computed apply journal status to stdout or a requested output file.
1214fn write_apply_status(
1215    options: &RestoreApplyStatusOptions,
1216    status: &RestoreApplyJournalStatus,
1217) -> Result<(), RestoreCommandError> {
1218    if let Some(path) = &options.out {
1219        let data = serde_json::to_vec_pretty(status)?;
1220        fs::write(path, data)?;
1221        return Ok(());
1222    }
1223
1224    let stdout = io::stdout();
1225    let mut handle = stdout.lock();
1226    serde_json::to_writer_pretty(&mut handle, status)?;
1227    writeln!(handle)?;
1228    Ok(())
1229}
1230
1231// Write the computed apply journal report to stdout or a requested output file.
1232fn write_apply_report(
1233    options: &RestoreApplyReportOptions,
1234    report: &RestoreApplyJournalReport,
1235) -> Result<(), RestoreCommandError> {
1236    if let Some(path) = &options.out {
1237        let data = serde_json::to_vec_pretty(report)?;
1238        fs::write(path, data)?;
1239        return Ok(());
1240    }
1241
1242    let stdout = io::stdout();
1243    let mut handle = stdout.lock();
1244    serde_json::to_writer_pretty(&mut handle, report)?;
1245    writeln!(handle)?;
1246    Ok(())
1247}
1248
1249// Write the computed apply next-operation response to stdout or a requested output file.
1250fn write_apply_next(
1251    options: &RestoreApplyNextOptions,
1252    next: &RestoreApplyNextOperation,
1253) -> Result<(), RestoreCommandError> {
1254    if let Some(path) = &options.out {
1255        let data = serde_json::to_vec_pretty(next)?;
1256        fs::write(path, data)?;
1257        return Ok(());
1258    }
1259
1260    let stdout = io::stdout();
1261    let mut handle = stdout.lock();
1262    serde_json::to_writer_pretty(&mut handle, next)?;
1263    writeln!(handle)?;
1264    Ok(())
1265}
1266
1267// Write the computed apply command preview to stdout or a requested output file.
1268fn write_apply_command(
1269    options: &RestoreApplyCommandOptions,
1270    preview: &RestoreApplyCommandPreview,
1271) -> Result<(), RestoreCommandError> {
1272    if let Some(path) = &options.out {
1273        let data = serde_json::to_vec_pretty(preview)?;
1274        fs::write(path, data)?;
1275        return Ok(());
1276    }
1277
1278    let stdout = io::stdout();
1279    let mut handle = stdout.lock();
1280    serde_json::to_writer_pretty(&mut handle, preview)?;
1281    writeln!(handle)?;
1282    Ok(())
1283}
1284
1285// Write the claimed apply journal to stdout or a requested output file.
1286fn write_apply_claim(
1287    options: &RestoreApplyClaimOptions,
1288    journal: &RestoreApplyJournal,
1289) -> Result<(), RestoreCommandError> {
1290    if let Some(path) = &options.out {
1291        let data = serde_json::to_vec_pretty(journal)?;
1292        fs::write(path, data)?;
1293        return Ok(());
1294    }
1295
1296    let stdout = io::stdout();
1297    let mut handle = stdout.lock();
1298    serde_json::to_writer_pretty(&mut handle, journal)?;
1299    writeln!(handle)?;
1300    Ok(())
1301}
1302
1303// Write the unclaimed apply journal to stdout or a requested output file.
1304fn write_apply_unclaim(
1305    options: &RestoreApplyUnclaimOptions,
1306    journal: &RestoreApplyJournal,
1307) -> Result<(), RestoreCommandError> {
1308    if let Some(path) = &options.out {
1309        let data = serde_json::to_vec_pretty(journal)?;
1310        fs::write(path, data)?;
1311        return Ok(());
1312    }
1313
1314    let stdout = io::stdout();
1315    let mut handle = stdout.lock();
1316    serde_json::to_writer_pretty(&mut handle, journal)?;
1317    writeln!(handle)?;
1318    Ok(())
1319}
1320
1321// Write the updated apply journal to stdout or a requested output file.
1322fn write_apply_mark(
1323    options: &RestoreApplyMarkOptions,
1324    journal: &RestoreApplyJournal,
1325) -> Result<(), RestoreCommandError> {
1326    if let Some(path) = &options.out {
1327        let data = serde_json::to_vec_pretty(journal)?;
1328        fs::write(path, data)?;
1329        return Ok(());
1330    }
1331
1332    let stdout = io::stdout();
1333    let mut handle = stdout.lock();
1334    serde_json::to_writer_pretty(&mut handle, journal)?;
1335    writeln!(handle)?;
1336    Ok(())
1337}
1338
1339// Read the next required option value.
1340fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
1341where
1342    I: Iterator<Item = OsString>,
1343{
1344    args.next()
1345        .and_then(|value| value.into_string().ok())
1346        .ok_or(RestoreCommandError::MissingValue(option))
1347}
1348
1349// Return restore command usage text.
1350const fn usage() -> &'static str {
1351    "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 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]"
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357    use canic_backup::restore::RestoreApplyOperationState;
1358    use canic_backup::{
1359        artifacts::ArtifactChecksum,
1360        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
1361        manifest::{
1362            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
1363            FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
1364            VerificationCheck, VerificationPlan,
1365        },
1366    };
1367    use serde_json::json;
1368    use std::{
1369        path::Path,
1370        time::{SystemTime, UNIX_EPOCH},
1371    };
1372
1373    const ROOT: &str = "aaaaa-aa";
1374    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1375    const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1376    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1377
1378    // Ensure restore plan options parse the intended no-mutation command.
1379    #[test]
1380    fn parses_restore_plan_options() {
1381        let options = RestorePlanOptions::parse([
1382            OsString::from("--manifest"),
1383            OsString::from("manifest.json"),
1384            OsString::from("--mapping"),
1385            OsString::from("mapping.json"),
1386            OsString::from("--out"),
1387            OsString::from("plan.json"),
1388            OsString::from("--require-restore-ready"),
1389        ])
1390        .expect("parse options");
1391
1392        assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
1393        assert_eq!(options.backup_dir, None);
1394        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
1395        assert_eq!(options.out, Some(PathBuf::from("plan.json")));
1396        assert!(!options.require_verified);
1397        assert!(options.require_restore_ready);
1398    }
1399
1400    // Ensure verified restore plan options parse with the canonical backup source.
1401    #[test]
1402    fn parses_verified_restore_plan_options() {
1403        let options = RestorePlanOptions::parse([
1404            OsString::from("--backup-dir"),
1405            OsString::from("backups/run"),
1406            OsString::from("--require-verified"),
1407        ])
1408        .expect("parse verified options");
1409
1410        assert_eq!(options.manifest, None);
1411        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
1412        assert_eq!(options.mapping, None);
1413        assert_eq!(options.out, None);
1414        assert!(options.require_verified);
1415        assert!(!options.require_restore_ready);
1416    }
1417
1418    // Ensure restore status options parse the intended no-mutation command.
1419    #[test]
1420    fn parses_restore_status_options() {
1421        let options = RestoreStatusOptions::parse([
1422            OsString::from("--plan"),
1423            OsString::from("restore-plan.json"),
1424            OsString::from("--out"),
1425            OsString::from("restore-status.json"),
1426        ])
1427        .expect("parse status options");
1428
1429        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
1430        assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
1431    }
1432
1433    // Ensure restore apply options require the explicit dry-run mode.
1434    #[test]
1435    fn parses_restore_apply_dry_run_options() {
1436        let options = RestoreApplyOptions::parse([
1437            OsString::from("--plan"),
1438            OsString::from("restore-plan.json"),
1439            OsString::from("--status"),
1440            OsString::from("restore-status.json"),
1441            OsString::from("--backup-dir"),
1442            OsString::from("backups/run"),
1443            OsString::from("--dry-run"),
1444            OsString::from("--out"),
1445            OsString::from("restore-apply-dry-run.json"),
1446            OsString::from("--journal-out"),
1447            OsString::from("restore-apply-journal.json"),
1448        ])
1449        .expect("parse apply options");
1450
1451        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
1452        assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
1453        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
1454        assert_eq!(
1455            options.out,
1456            Some(PathBuf::from("restore-apply-dry-run.json"))
1457        );
1458        assert_eq!(
1459            options.journal_out,
1460            Some(PathBuf::from("restore-apply-journal.json"))
1461        );
1462        assert!(options.dry_run);
1463    }
1464
1465    // Ensure restore apply-status options parse the intended journal command.
1466    #[test]
1467    fn parses_restore_apply_status_options() {
1468        let options = RestoreApplyStatusOptions::parse([
1469            OsString::from("--journal"),
1470            OsString::from("restore-apply-journal.json"),
1471            OsString::from("--out"),
1472            OsString::from("restore-apply-status.json"),
1473            OsString::from("--require-ready"),
1474            OsString::from("--require-no-pending"),
1475            OsString::from("--require-no-failed"),
1476            OsString::from("--require-complete"),
1477        ])
1478        .expect("parse apply-status options");
1479
1480        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1481        assert!(options.require_ready);
1482        assert!(options.require_no_pending);
1483        assert!(options.require_no_failed);
1484        assert!(options.require_complete);
1485        assert_eq!(
1486            options.out,
1487            Some(PathBuf::from("restore-apply-status.json"))
1488        );
1489    }
1490
1491    // Ensure restore apply-report options parse the intended journal command.
1492    #[test]
1493    fn parses_restore_apply_report_options() {
1494        let options = RestoreApplyReportOptions::parse([
1495            OsString::from("--journal"),
1496            OsString::from("restore-apply-journal.json"),
1497            OsString::from("--out"),
1498            OsString::from("restore-apply-report.json"),
1499            OsString::from("--require-no-attention"),
1500        ])
1501        .expect("parse apply-report options");
1502
1503        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1504        assert!(options.require_no_attention);
1505        assert_eq!(
1506            options.out,
1507            Some(PathBuf::from("restore-apply-report.json"))
1508        );
1509    }
1510
1511    // Ensure restore apply-next options parse the intended journal command.
1512    #[test]
1513    fn parses_restore_apply_next_options() {
1514        let options = RestoreApplyNextOptions::parse([
1515            OsString::from("--journal"),
1516            OsString::from("restore-apply-journal.json"),
1517            OsString::from("--out"),
1518            OsString::from("restore-apply-next.json"),
1519        ])
1520        .expect("parse apply-next options");
1521
1522        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1523        assert_eq!(options.out, Some(PathBuf::from("restore-apply-next.json")));
1524    }
1525
1526    // Ensure restore apply-command options parse the intended preview command.
1527    #[test]
1528    fn parses_restore_apply_command_options() {
1529        let options = RestoreApplyCommandOptions::parse([
1530            OsString::from("--journal"),
1531            OsString::from("restore-apply-journal.json"),
1532            OsString::from("--dfx"),
1533            OsString::from("/tmp/dfx"),
1534            OsString::from("--network"),
1535            OsString::from("local"),
1536            OsString::from("--out"),
1537            OsString::from("restore-apply-command.json"),
1538            OsString::from("--require-command"),
1539        ])
1540        .expect("parse apply-command options");
1541
1542        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1543        assert_eq!(options.dfx, "/tmp/dfx");
1544        assert_eq!(options.network.as_deref(), Some("local"));
1545        assert!(options.require_command);
1546        assert_eq!(
1547            options.out,
1548            Some(PathBuf::from("restore-apply-command.json"))
1549        );
1550    }
1551
1552    // Ensure restore apply-claim options parse the intended journal command.
1553    #[test]
1554    fn parses_restore_apply_claim_options() {
1555        let options = RestoreApplyClaimOptions::parse([
1556            OsString::from("--journal"),
1557            OsString::from("restore-apply-journal.json"),
1558            OsString::from("--sequence"),
1559            OsString::from("0"),
1560            OsString::from("--updated-at"),
1561            OsString::from("2026-05-04T12:00:00Z"),
1562            OsString::from("--out"),
1563            OsString::from("restore-apply-journal.claimed.json"),
1564        ])
1565        .expect("parse apply-claim options");
1566
1567        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1568        assert_eq!(options.sequence, Some(0));
1569        assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:00:00Z"));
1570        assert_eq!(
1571            options.out,
1572            Some(PathBuf::from("restore-apply-journal.claimed.json"))
1573        );
1574    }
1575
1576    // Ensure restore apply-unclaim options parse the intended journal command.
1577    #[test]
1578    fn parses_restore_apply_unclaim_options() {
1579        let options = RestoreApplyUnclaimOptions::parse([
1580            OsString::from("--journal"),
1581            OsString::from("restore-apply-journal.json"),
1582            OsString::from("--sequence"),
1583            OsString::from("0"),
1584            OsString::from("--updated-at"),
1585            OsString::from("2026-05-04T12:01:00Z"),
1586            OsString::from("--out"),
1587            OsString::from("restore-apply-journal.unclaimed.json"),
1588        ])
1589        .expect("parse apply-unclaim options");
1590
1591        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1592        assert_eq!(options.sequence, Some(0));
1593        assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:01:00Z"));
1594        assert_eq!(
1595            options.out,
1596            Some(PathBuf::from("restore-apply-journal.unclaimed.json"))
1597        );
1598    }
1599
1600    // Ensure restore apply-mark options parse the intended journal update command.
1601    #[test]
1602    fn parses_restore_apply_mark_options() {
1603        let options = RestoreApplyMarkOptions::parse([
1604            OsString::from("--journal"),
1605            OsString::from("restore-apply-journal.json"),
1606            OsString::from("--sequence"),
1607            OsString::from("4"),
1608            OsString::from("--state"),
1609            OsString::from("failed"),
1610            OsString::from("--reason"),
1611            OsString::from("dfx-load-failed"),
1612            OsString::from("--updated-at"),
1613            OsString::from("2026-05-04T12:02:00Z"),
1614            OsString::from("--out"),
1615            OsString::from("restore-apply-journal.updated.json"),
1616            OsString::from("--require-pending"),
1617        ])
1618        .expect("parse apply-mark options");
1619
1620        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1621        assert_eq!(options.sequence, 4);
1622        assert_eq!(options.state, RestoreApplyMarkState::Failed);
1623        assert_eq!(options.reason.as_deref(), Some("dfx-load-failed"));
1624        assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:02:00Z"));
1625        assert!(options.require_pending);
1626        assert_eq!(
1627            options.out,
1628            Some(PathBuf::from("restore-apply-journal.updated.json"))
1629        );
1630    }
1631
1632    // Ensure restore apply refuses non-dry-run execution while apply is scaffolded.
1633    #[test]
1634    fn restore_apply_requires_dry_run() {
1635        let err = RestoreApplyOptions::parse([
1636            OsString::from("--plan"),
1637            OsString::from("restore-plan.json"),
1638        ])
1639        .expect_err("apply without dry-run should fail");
1640
1641        assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
1642    }
1643
1644    // Ensure backup-dir restore planning reads the canonical layout manifest.
1645    #[test]
1646    fn plan_restore_reads_manifest_from_backup_dir() {
1647        let root = temp_dir("canic-cli-restore-plan-layout");
1648        let layout = BackupLayout::new(root.clone());
1649        layout
1650            .write_manifest(&valid_manifest())
1651            .expect("write manifest");
1652
1653        let options = RestorePlanOptions {
1654            manifest: None,
1655            backup_dir: Some(root.clone()),
1656            mapping: None,
1657            out: None,
1658            require_verified: false,
1659            require_restore_ready: false,
1660        };
1661
1662        let plan = plan_restore(&options).expect("plan restore");
1663
1664        fs::remove_dir_all(root).expect("remove temp root");
1665        assert_eq!(plan.backup_id, "backup-test");
1666        assert_eq!(plan.member_count, 2);
1667    }
1668
1669    // Ensure restore planning has exactly one manifest source.
1670    #[test]
1671    fn parse_rejects_conflicting_manifest_sources() {
1672        let err = RestorePlanOptions::parse([
1673            OsString::from("--manifest"),
1674            OsString::from("manifest.json"),
1675            OsString::from("--backup-dir"),
1676            OsString::from("backups/run"),
1677        ])
1678        .expect_err("conflicting sources should fail");
1679
1680        assert!(matches!(
1681            err,
1682            RestoreCommandError::ConflictingManifestSources
1683        ));
1684    }
1685
1686    // Ensure verified planning requires the canonical backup layout source.
1687    #[test]
1688    fn parse_rejects_require_verified_with_manifest_source() {
1689        let err = RestorePlanOptions::parse([
1690            OsString::from("--manifest"),
1691            OsString::from("manifest.json"),
1692            OsString::from("--require-verified"),
1693        ])
1694        .expect_err("verification should require a backup layout");
1695
1696        assert!(matches!(
1697            err,
1698            RestoreCommandError::RequireVerifiedNeedsBackupDir
1699        ));
1700    }
1701
1702    // Ensure restore planning can require manifest, journal, and artifact integrity.
1703    #[test]
1704    fn plan_restore_requires_verified_backup_layout() {
1705        let root = temp_dir("canic-cli-restore-plan-verified");
1706        let layout = BackupLayout::new(root.clone());
1707        let manifest = valid_manifest();
1708        write_verified_layout(&root, &layout, &manifest);
1709
1710        let options = RestorePlanOptions {
1711            manifest: None,
1712            backup_dir: Some(root.clone()),
1713            mapping: None,
1714            out: None,
1715            require_verified: true,
1716            require_restore_ready: false,
1717        };
1718
1719        let plan = plan_restore(&options).expect("plan verified restore");
1720
1721        fs::remove_dir_all(root).expect("remove temp root");
1722        assert_eq!(plan.backup_id, "backup-test");
1723        assert_eq!(plan.member_count, 2);
1724    }
1725
1726    // Ensure required verification fails before planning when the layout is incomplete.
1727    #[test]
1728    fn plan_restore_rejects_unverified_backup_layout() {
1729        let root = temp_dir("canic-cli-restore-plan-unverified");
1730        let layout = BackupLayout::new(root.clone());
1731        layout
1732            .write_manifest(&valid_manifest())
1733            .expect("write manifest");
1734
1735        let options = RestorePlanOptions {
1736            manifest: None,
1737            backup_dir: Some(root.clone()),
1738            mapping: None,
1739            out: None,
1740            require_verified: true,
1741            require_restore_ready: false,
1742        };
1743
1744        let err = plan_restore(&options).expect_err("missing journal should fail");
1745
1746        fs::remove_dir_all(root).expect("remove temp root");
1747        assert!(matches!(err, RestoreCommandError::Persistence(_)));
1748    }
1749
1750    // Ensure the CLI planning path validates manifests and applies mappings.
1751    #[test]
1752    fn plan_restore_reads_manifest_and_mapping() {
1753        let root = temp_dir("canic-cli-restore-plan");
1754        fs::create_dir_all(&root).expect("create temp root");
1755        let manifest_path = root.join("manifest.json");
1756        let mapping_path = root.join("mapping.json");
1757
1758        fs::write(
1759            &manifest_path,
1760            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1761        )
1762        .expect("write manifest");
1763        fs::write(
1764            &mapping_path,
1765            json!({
1766                "members": [
1767                    {"source_canister": ROOT, "target_canister": ROOT},
1768                    {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
1769                ]
1770            })
1771            .to_string(),
1772        )
1773        .expect("write mapping");
1774
1775        let options = RestorePlanOptions {
1776            manifest: Some(manifest_path),
1777            backup_dir: None,
1778            mapping: Some(mapping_path),
1779            out: None,
1780            require_verified: false,
1781            require_restore_ready: false,
1782        };
1783
1784        let plan = plan_restore(&options).expect("plan restore");
1785
1786        fs::remove_dir_all(root).expect("remove temp root");
1787        let members = plan.ordered_members();
1788        assert_eq!(members.len(), 2);
1789        assert_eq!(members[0].source_canister, ROOT);
1790        assert_eq!(members[1].target_canister, MAPPED_CHILD);
1791    }
1792
1793    // Ensure restore-readiness gating happens after writing the plan artifact.
1794    #[test]
1795    fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
1796        let root = temp_dir("canic-cli-restore-plan-require-ready");
1797        fs::create_dir_all(&root).expect("create temp root");
1798        let manifest_path = root.join("manifest.json");
1799        let out_path = root.join("plan.json");
1800
1801        fs::write(
1802            &manifest_path,
1803            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1804        )
1805        .expect("write manifest");
1806
1807        let err = run([
1808            OsString::from("plan"),
1809            OsString::from("--manifest"),
1810            OsString::from(manifest_path.as_os_str()),
1811            OsString::from("--out"),
1812            OsString::from(out_path.as_os_str()),
1813            OsString::from("--require-restore-ready"),
1814        ])
1815        .expect_err("restore readiness should be enforced");
1816
1817        assert!(out_path.exists());
1818        let plan: RestorePlan =
1819            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1820
1821        fs::remove_dir_all(root).expect("remove temp root");
1822        assert!(!plan.readiness_summary.ready);
1823        assert!(matches!(
1824            err,
1825            RestoreCommandError::RestoreNotReady {
1826                reasons,
1827                ..
1828            } if reasons == [
1829                "missing-module-hash",
1830                "missing-wasm-hash",
1831                "missing-snapshot-checksum"
1832            ]
1833        ));
1834    }
1835
1836    // Ensure restore-readiness gating accepts plans with complete provenance.
1837    #[test]
1838    fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
1839        let root = temp_dir("canic-cli-restore-plan-ready");
1840        fs::create_dir_all(&root).expect("create temp root");
1841        let manifest_path = root.join("manifest.json");
1842        let out_path = root.join("plan.json");
1843
1844        fs::write(
1845            &manifest_path,
1846            serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
1847        )
1848        .expect("write manifest");
1849
1850        run([
1851            OsString::from("plan"),
1852            OsString::from("--manifest"),
1853            OsString::from(manifest_path.as_os_str()),
1854            OsString::from("--out"),
1855            OsString::from(out_path.as_os_str()),
1856            OsString::from("--require-restore-ready"),
1857        ])
1858        .expect("restore-ready plan should pass");
1859
1860        let plan: RestorePlan =
1861            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1862
1863        fs::remove_dir_all(root).expect("remove temp root");
1864        assert!(plan.readiness_summary.ready);
1865        assert!(plan.readiness_summary.reasons.is_empty());
1866    }
1867
1868    // Ensure restore status writes the initial planned execution journal.
1869    #[test]
1870    fn run_restore_status_writes_planned_status() {
1871        let root = temp_dir("canic-cli-restore-status");
1872        fs::create_dir_all(&root).expect("create temp root");
1873        let plan_path = root.join("restore-plan.json");
1874        let out_path = root.join("restore-status.json");
1875        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1876
1877        fs::write(
1878            &plan_path,
1879            serde_json::to_vec(&plan).expect("serialize plan"),
1880        )
1881        .expect("write plan");
1882
1883        run([
1884            OsString::from("status"),
1885            OsString::from("--plan"),
1886            OsString::from(plan_path.as_os_str()),
1887            OsString::from("--out"),
1888            OsString::from(out_path.as_os_str()),
1889        ])
1890        .expect("write restore status");
1891
1892        let status: RestoreStatus =
1893            serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
1894                .expect("decode restore status");
1895        let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
1896
1897        fs::remove_dir_all(root).expect("remove temp root");
1898        assert_eq!(status.status_version, 1);
1899        assert_eq!(status.backup_id.as_str(), "backup-test");
1900        assert!(status.ready);
1901        assert!(status.readiness_reasons.is_empty());
1902        assert_eq!(status.member_count, 2);
1903        assert_eq!(status.phase_count, 1);
1904        assert_eq!(status.planned_snapshot_loads, 2);
1905        assert_eq!(status.planned_code_reinstalls, 2);
1906        assert_eq!(status.planned_verification_checks, 2);
1907        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1908        assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
1909    }
1910
1911    // Ensure restore apply dry-run writes ordered operations from plan and status.
1912    #[test]
1913    fn run_restore_apply_dry_run_writes_operations() {
1914        let root = temp_dir("canic-cli-restore-apply-dry-run");
1915        fs::create_dir_all(&root).expect("create temp root");
1916        let plan_path = root.join("restore-plan.json");
1917        let status_path = root.join("restore-status.json");
1918        let out_path = root.join("restore-apply-dry-run.json");
1919        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1920        let status = RestoreStatus::from_plan(&plan);
1921
1922        fs::write(
1923            &plan_path,
1924            serde_json::to_vec(&plan).expect("serialize plan"),
1925        )
1926        .expect("write plan");
1927        fs::write(
1928            &status_path,
1929            serde_json::to_vec(&status).expect("serialize status"),
1930        )
1931        .expect("write status");
1932
1933        run([
1934            OsString::from("apply"),
1935            OsString::from("--plan"),
1936            OsString::from(plan_path.as_os_str()),
1937            OsString::from("--status"),
1938            OsString::from(status_path.as_os_str()),
1939            OsString::from("--dry-run"),
1940            OsString::from("--out"),
1941            OsString::from(out_path.as_os_str()),
1942        ])
1943        .expect("write apply dry-run");
1944
1945        let dry_run: RestoreApplyDryRun =
1946            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1947                .expect("decode dry-run");
1948        let dry_run_json: serde_json::Value =
1949            serde_json::to_value(&dry_run).expect("encode dry-run");
1950
1951        fs::remove_dir_all(root).expect("remove temp root");
1952        assert_eq!(dry_run.dry_run_version, 1);
1953        assert_eq!(dry_run.backup_id.as_str(), "backup-test");
1954        assert!(dry_run.ready);
1955        assert!(dry_run.status_supplied);
1956        assert_eq!(dry_run.member_count, 2);
1957        assert_eq!(dry_run.phase_count, 1);
1958        assert_eq!(dry_run.rendered_operations, 8);
1959        assert_eq!(
1960            dry_run_json["phases"][0]["operations"][0]["operation"],
1961            "upload-snapshot"
1962        );
1963        assert_eq!(
1964            dry_run_json["phases"][0]["operations"][3]["operation"],
1965            "verify-member"
1966        );
1967        assert_eq!(
1968            dry_run_json["phases"][0]["operations"][3]["verification_kind"],
1969            "status"
1970        );
1971        assert_eq!(
1972            dry_run_json["phases"][0]["operations"][3]["verification_method"],
1973            serde_json::Value::Null
1974        );
1975    }
1976
1977    // Ensure restore apply dry-run can validate artifacts under a backup directory.
1978    #[test]
1979    fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
1980        let root = temp_dir("canic-cli-restore-apply-artifacts");
1981        fs::create_dir_all(&root).expect("create temp root");
1982        let plan_path = root.join("restore-plan.json");
1983        let out_path = root.join("restore-apply-dry-run.json");
1984        let journal_path = root.join("restore-apply-journal.json");
1985        let status_path = root.join("restore-apply-status.json");
1986        let mut manifest = restore_ready_manifest();
1987        write_manifest_artifacts(&root, &mut manifest);
1988        let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
1989
1990        fs::write(
1991            &plan_path,
1992            serde_json::to_vec(&plan).expect("serialize plan"),
1993        )
1994        .expect("write plan");
1995
1996        run([
1997            OsString::from("apply"),
1998            OsString::from("--plan"),
1999            OsString::from(plan_path.as_os_str()),
2000            OsString::from("--backup-dir"),
2001            OsString::from(root.as_os_str()),
2002            OsString::from("--dry-run"),
2003            OsString::from("--out"),
2004            OsString::from(out_path.as_os_str()),
2005            OsString::from("--journal-out"),
2006            OsString::from(journal_path.as_os_str()),
2007        ])
2008        .expect("write apply dry-run");
2009        run([
2010            OsString::from("apply-status"),
2011            OsString::from("--journal"),
2012            OsString::from(journal_path.as_os_str()),
2013            OsString::from("--out"),
2014            OsString::from(status_path.as_os_str()),
2015        ])
2016        .expect("write apply status");
2017
2018        let dry_run: RestoreApplyDryRun =
2019            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
2020                .expect("decode dry-run");
2021        let validation = dry_run
2022            .artifact_validation
2023            .expect("artifact validation should be present");
2024        let journal_json: serde_json::Value =
2025            serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
2026                .expect("decode journal");
2027        let status_json: serde_json::Value =
2028            serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
2029                .expect("decode apply status");
2030
2031        fs::remove_dir_all(root).expect("remove temp root");
2032        assert_eq!(validation.checked_members, 2);
2033        assert!(validation.artifacts_present);
2034        assert!(validation.checksums_verified);
2035        assert_eq!(validation.members_with_expected_checksums, 2);
2036        assert_eq!(journal_json["ready"], true);
2037        assert_eq!(journal_json["operation_count"], 8);
2038        assert_eq!(journal_json["ready_operations"], 8);
2039        assert_eq!(journal_json["blocked_operations"], 0);
2040        assert_eq!(journal_json["operations"][0]["state"], "ready");
2041        assert_eq!(status_json["ready"], true);
2042        assert_eq!(status_json["operation_count"], 8);
2043        assert_eq!(status_json["next_ready_sequence"], 0);
2044        assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
2045    }
2046
2047    // Ensure apply-status rejects structurally inconsistent journals.
2048    #[test]
2049    fn run_restore_apply_status_rejects_invalid_journal() {
2050        let root = temp_dir("canic-cli-restore-apply-status-invalid");
2051        fs::create_dir_all(&root).expect("create temp root");
2052        let journal_path = root.join("restore-apply-journal.json");
2053        let out_path = root.join("restore-apply-status.json");
2054        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2055        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
2056        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2057        journal.operation_count += 1;
2058
2059        fs::write(
2060            &journal_path,
2061            serde_json::to_vec(&journal).expect("serialize journal"),
2062        )
2063        .expect("write journal");
2064
2065        let err = run([
2066            OsString::from("apply-status"),
2067            OsString::from("--journal"),
2068            OsString::from(journal_path.as_os_str()),
2069            OsString::from("--out"),
2070            OsString::from(out_path.as_os_str()),
2071        ])
2072        .expect_err("invalid journal should fail");
2073
2074        assert!(!out_path.exists());
2075        fs::remove_dir_all(root).expect("remove temp root");
2076        assert!(matches!(
2077            err,
2078            RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
2079                field: "operation_count",
2080                ..
2081            })
2082        ));
2083    }
2084
2085    // Ensure apply-status can fail closed after writing status for pending work.
2086    #[test]
2087    fn run_restore_apply_status_require_no_pending_writes_status_then_fails() {
2088        let root = temp_dir("canic-cli-restore-apply-status-pending");
2089        fs::create_dir_all(&root).expect("create temp root");
2090        let journal_path = root.join("restore-apply-journal.json");
2091        let out_path = root.join("restore-apply-status.json");
2092        let mut journal = ready_apply_journal();
2093        journal
2094            .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
2095            .expect("claim operation");
2096
2097        fs::write(
2098            &journal_path,
2099            serde_json::to_vec(&journal).expect("serialize journal"),
2100        )
2101        .expect("write journal");
2102
2103        let err = run([
2104            OsString::from("apply-status"),
2105            OsString::from("--journal"),
2106            OsString::from(journal_path.as_os_str()),
2107            OsString::from("--out"),
2108            OsString::from(out_path.as_os_str()),
2109            OsString::from("--require-no-pending"),
2110        ])
2111        .expect_err("pending operation should fail requirement");
2112
2113        assert!(out_path.exists());
2114        let status: RestoreApplyJournalStatus =
2115            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2116                .expect("decode apply status");
2117
2118        fs::remove_dir_all(root).expect("remove temp root");
2119        assert_eq!(status.pending_operations, 1);
2120        assert_eq!(status.next_transition_sequence, Some(0));
2121        assert_eq!(
2122            status.next_transition_updated_at.as_deref(),
2123            Some("2026-05-04T12:00:00Z")
2124        );
2125        assert!(matches!(
2126            err,
2127            RestoreCommandError::RestoreApplyPending {
2128                pending_operations: 1,
2129                next_transition_sequence: Some(0),
2130                ..
2131            }
2132        ));
2133    }
2134
2135    // Ensure apply-status can fail closed after writing status for unready work.
2136    #[test]
2137    fn run_restore_apply_status_require_ready_writes_status_then_fails() {
2138        let root = temp_dir("canic-cli-restore-apply-status-ready");
2139        fs::create_dir_all(&root).expect("create temp root");
2140        let journal_path = root.join("restore-apply-journal.json");
2141        let out_path = root.join("restore-apply-status.json");
2142        let plan = RestorePlanner::plan(&valid_manifest(), None).expect("build plan");
2143        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
2144        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2145
2146        fs::write(
2147            &journal_path,
2148            serde_json::to_vec(&journal).expect("serialize journal"),
2149        )
2150        .expect("write journal");
2151
2152        let err = run([
2153            OsString::from("apply-status"),
2154            OsString::from("--journal"),
2155            OsString::from(journal_path.as_os_str()),
2156            OsString::from("--out"),
2157            OsString::from(out_path.as_os_str()),
2158            OsString::from("--require-ready"),
2159        ])
2160        .expect_err("unready journal should fail requirement");
2161
2162        let status: RestoreApplyJournalStatus =
2163            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2164                .expect("decode apply status");
2165
2166        fs::remove_dir_all(root).expect("remove temp root");
2167        assert!(!status.ready);
2168        assert_eq!(status.blocked_operations, status.operation_count);
2169        assert!(
2170            status
2171                .blocked_reasons
2172                .contains(&"missing-snapshot-checksum".to_string())
2173        );
2174        assert!(matches!(
2175            err,
2176            RestoreCommandError::RestoreApplyNotReady { reasons, .. }
2177                if reasons.contains(&"missing-snapshot-checksum".to_string())
2178        ));
2179    }
2180
2181    // Ensure apply-report writes the operator-focused journal summary.
2182    #[test]
2183    fn run_restore_apply_report_writes_attention_summary() {
2184        let root = temp_dir("canic-cli-restore-apply-report");
2185        fs::create_dir_all(&root).expect("create temp root");
2186        let journal_path = root.join("restore-apply-journal.json");
2187        let out_path = root.join("restore-apply-report.json");
2188        let mut journal = ready_apply_journal();
2189        journal
2190            .mark_operation_failed_at(
2191                0,
2192                "dfx-upload-failed".to_string(),
2193                Some("2026-05-05T12:00:00Z".to_string()),
2194            )
2195            .expect("mark failed operation");
2196        journal
2197            .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
2198            .expect("mark pending operation");
2199
2200        fs::write(
2201            &journal_path,
2202            serde_json::to_vec(&journal).expect("serialize journal"),
2203        )
2204        .expect("write journal");
2205
2206        run([
2207            OsString::from("apply-report"),
2208            OsString::from("--journal"),
2209            OsString::from(journal_path.as_os_str()),
2210            OsString::from("--out"),
2211            OsString::from(out_path.as_os_str()),
2212        ])
2213        .expect("write apply report");
2214
2215        let report: RestoreApplyJournalReport =
2216            serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
2217                .expect("decode apply report");
2218        let report_json: serde_json::Value =
2219            serde_json::to_value(&report).expect("encode apply report");
2220
2221        fs::remove_dir_all(root).expect("remove temp root");
2222        assert_eq!(report.backup_id, "backup-test");
2223        assert!(report.attention_required);
2224        assert_eq!(report.failed_operations, 1);
2225        assert_eq!(report.pending_operations, 1);
2226        assert_eq!(report.failed.len(), 1);
2227        assert_eq!(report.pending.len(), 1);
2228        assert_eq!(report.failed[0].sequence, 0);
2229        assert_eq!(report.pending[0].sequence, 1);
2230        assert_eq!(
2231            report.next_transition.as_ref().map(|op| op.sequence),
2232            Some(1)
2233        );
2234        assert_eq!(report_json["outcome"], "failed");
2235        assert_eq!(report_json["failed"][0]["reasons"][0], "dfx-upload-failed");
2236    }
2237
2238    // Ensure apply-report can fail closed after writing an attention report.
2239    #[test]
2240    fn run_restore_apply_report_require_no_attention_writes_report_then_fails() {
2241        let root = temp_dir("canic-cli-restore-apply-report-attention");
2242        fs::create_dir_all(&root).expect("create temp root");
2243        let journal_path = root.join("restore-apply-journal.json");
2244        let out_path = root.join("restore-apply-report.json");
2245        let mut journal = ready_apply_journal();
2246        journal
2247            .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
2248            .expect("mark pending operation");
2249
2250        fs::write(
2251            &journal_path,
2252            serde_json::to_vec(&journal).expect("serialize journal"),
2253        )
2254        .expect("write journal");
2255
2256        let err = run([
2257            OsString::from("apply-report"),
2258            OsString::from("--journal"),
2259            OsString::from(journal_path.as_os_str()),
2260            OsString::from("--out"),
2261            OsString::from(out_path.as_os_str()),
2262            OsString::from("--require-no-attention"),
2263        ])
2264        .expect_err("attention report should fail requirement");
2265
2266        let report: RestoreApplyJournalReport =
2267            serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
2268                .expect("decode apply report");
2269
2270        fs::remove_dir_all(root).expect("remove temp root");
2271        assert!(report.attention_required);
2272        assert_eq!(report.pending_operations, 1);
2273        assert!(matches!(
2274            err,
2275            RestoreCommandError::RestoreApplyReportNeedsAttention {
2276                outcome: canic_backup::restore::RestoreApplyReportOutcome::Pending,
2277                ..
2278            }
2279        ));
2280    }
2281
2282    // Ensure apply-status can fail closed after writing status for incomplete work.
2283    #[test]
2284    fn run_restore_apply_status_require_complete_writes_status_then_fails() {
2285        let root = temp_dir("canic-cli-restore-apply-status-incomplete");
2286        fs::create_dir_all(&root).expect("create temp root");
2287        let journal_path = root.join("restore-apply-journal.json");
2288        let out_path = root.join("restore-apply-status.json");
2289        let journal = ready_apply_journal();
2290
2291        fs::write(
2292            &journal_path,
2293            serde_json::to_vec(&journal).expect("serialize journal"),
2294        )
2295        .expect("write journal");
2296
2297        let err = run([
2298            OsString::from("apply-status"),
2299            OsString::from("--journal"),
2300            OsString::from(journal_path.as_os_str()),
2301            OsString::from("--out"),
2302            OsString::from(out_path.as_os_str()),
2303            OsString::from("--require-complete"),
2304        ])
2305        .expect_err("incomplete journal should fail requirement");
2306
2307        assert!(out_path.exists());
2308        let status: RestoreApplyJournalStatus =
2309            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2310                .expect("decode apply status");
2311
2312        fs::remove_dir_all(root).expect("remove temp root");
2313        assert!(!status.complete);
2314        assert_eq!(status.completed_operations, 0);
2315        assert_eq!(status.operation_count, 8);
2316        assert!(matches!(
2317            err,
2318            RestoreCommandError::RestoreApplyIncomplete {
2319                completed_operations: 0,
2320                operation_count: 8,
2321                ..
2322            }
2323        ));
2324    }
2325
2326    // Ensure apply-status can fail closed after writing status for failed work.
2327    #[test]
2328    fn run_restore_apply_status_require_no_failed_writes_status_then_fails() {
2329        let root = temp_dir("canic-cli-restore-apply-status-failed");
2330        fs::create_dir_all(&root).expect("create temp root");
2331        let journal_path = root.join("restore-apply-journal.json");
2332        let out_path = root.join("restore-apply-status.json");
2333        let mut journal = ready_apply_journal();
2334        journal
2335            .mark_operation_failed(0, "dfx-load-failed".to_string())
2336            .expect("mark failed operation");
2337
2338        fs::write(
2339            &journal_path,
2340            serde_json::to_vec(&journal).expect("serialize journal"),
2341        )
2342        .expect("write journal");
2343
2344        let err = run([
2345            OsString::from("apply-status"),
2346            OsString::from("--journal"),
2347            OsString::from(journal_path.as_os_str()),
2348            OsString::from("--out"),
2349            OsString::from(out_path.as_os_str()),
2350            OsString::from("--require-no-failed"),
2351        ])
2352        .expect_err("failed operation should fail requirement");
2353
2354        assert!(out_path.exists());
2355        let status: RestoreApplyJournalStatus =
2356            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2357                .expect("decode apply status");
2358
2359        fs::remove_dir_all(root).expect("remove temp root");
2360        assert_eq!(status.failed_operations, 1);
2361        assert!(matches!(
2362            err,
2363            RestoreCommandError::RestoreApplyFailed {
2364                failed_operations: 1,
2365                ..
2366            }
2367        ));
2368    }
2369
2370    // Ensure apply-status accepts a complete journal when required.
2371    #[test]
2372    fn run_restore_apply_status_require_complete_accepts_complete_journal() {
2373        let root = temp_dir("canic-cli-restore-apply-status-complete");
2374        fs::create_dir_all(&root).expect("create temp root");
2375        let journal_path = root.join("restore-apply-journal.json");
2376        let out_path = root.join("restore-apply-status.json");
2377        let mut journal = ready_apply_journal();
2378        for sequence in 0..journal.operation_count {
2379            journal
2380                .mark_operation_completed(sequence)
2381                .expect("complete operation");
2382        }
2383
2384        fs::write(
2385            &journal_path,
2386            serde_json::to_vec(&journal).expect("serialize journal"),
2387        )
2388        .expect("write journal");
2389
2390        run([
2391            OsString::from("apply-status"),
2392            OsString::from("--journal"),
2393            OsString::from(journal_path.as_os_str()),
2394            OsString::from("--out"),
2395            OsString::from(out_path.as_os_str()),
2396            OsString::from("--require-complete"),
2397        ])
2398        .expect("complete journal should pass requirement");
2399
2400        let status: RestoreApplyJournalStatus =
2401            serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2402                .expect("decode apply status");
2403
2404        fs::remove_dir_all(root).expect("remove temp root");
2405        assert!(status.complete);
2406        assert_eq!(status.completed_operations, 8);
2407        assert_eq!(status.operation_count, 8);
2408    }
2409
2410    // Ensure apply-next writes the full next ready operation row for runners.
2411    #[test]
2412    fn run_restore_apply_next_writes_next_ready_operation() {
2413        let root = temp_dir("canic-cli-restore-apply-next");
2414        fs::create_dir_all(&root).expect("create temp root");
2415        let journal_path = root.join("restore-apply-journal.json");
2416        let out_path = root.join("restore-apply-next.json");
2417        let mut journal = ready_apply_journal();
2418        journal
2419            .mark_operation_completed(0)
2420            .expect("mark first operation complete");
2421
2422        fs::write(
2423            &journal_path,
2424            serde_json::to_vec(&journal).expect("serialize journal"),
2425        )
2426        .expect("write journal");
2427
2428        run([
2429            OsString::from("apply-next"),
2430            OsString::from("--journal"),
2431            OsString::from(journal_path.as_os_str()),
2432            OsString::from("--out"),
2433            OsString::from(out_path.as_os_str()),
2434        ])
2435        .expect("write apply next");
2436
2437        let next: RestoreApplyNextOperation =
2438            serde_json::from_slice(&fs::read(&out_path).expect("read next operation"))
2439                .expect("decode next operation");
2440        let operation = next.operation.expect("operation should be available");
2441
2442        fs::remove_dir_all(root).expect("remove temp root");
2443        assert!(next.ready);
2444        assert!(next.operation_available);
2445        assert_eq!(operation.sequence, 1);
2446        assert_eq!(
2447            operation.operation,
2448            canic_backup::restore::RestoreApplyOperationKind::LoadSnapshot
2449        );
2450    }
2451
2452    // Ensure apply-command writes a no-execute command preview for the next operation.
2453    #[test]
2454    fn run_restore_apply_command_writes_next_command_preview() {
2455        let root = temp_dir("canic-cli-restore-apply-command");
2456        fs::create_dir_all(&root).expect("create temp root");
2457        let journal_path = root.join("restore-apply-journal.json");
2458        let out_path = root.join("restore-apply-command.json");
2459        let journal = ready_apply_journal();
2460
2461        fs::write(
2462            &journal_path,
2463            serde_json::to_vec(&journal).expect("serialize journal"),
2464        )
2465        .expect("write journal");
2466
2467        run([
2468            OsString::from("apply-command"),
2469            OsString::from("--journal"),
2470            OsString::from(journal_path.as_os_str()),
2471            OsString::from("--dfx"),
2472            OsString::from("/tmp/dfx"),
2473            OsString::from("--network"),
2474            OsString::from("local"),
2475            OsString::from("--out"),
2476            OsString::from(out_path.as_os_str()),
2477        ])
2478        .expect("write command preview");
2479
2480        let preview: RestoreApplyCommandPreview =
2481            serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
2482                .expect("decode command preview");
2483        let command = preview.command.expect("command should be available");
2484
2485        fs::remove_dir_all(root).expect("remove temp root");
2486        assert!(preview.ready);
2487        assert!(preview.command_available);
2488        assert_eq!(command.program, "/tmp/dfx");
2489        assert_eq!(
2490            command.args,
2491            vec![
2492                "canister".to_string(),
2493                "--network".to_string(),
2494                "local".to_string(),
2495                "snapshot".to_string(),
2496                "upload".to_string(),
2497                "--dir".to_string(),
2498                "artifacts/root".to_string(),
2499                ROOT.to_string(),
2500            ]
2501        );
2502        assert!(command.mutates);
2503    }
2504
2505    // Ensure apply-command can fail closed after writing a command preview.
2506    #[test]
2507    fn run_restore_apply_command_require_command_writes_preview_then_fails() {
2508        let root = temp_dir("canic-cli-restore-apply-command-require");
2509        fs::create_dir_all(&root).expect("create temp root");
2510        let journal_path = root.join("restore-apply-journal.json");
2511        let out_path = root.join("restore-apply-command.json");
2512        let mut journal = ready_apply_journal();
2513
2514        for sequence in 0..journal.operation_count {
2515            journal
2516                .mark_operation_completed(sequence)
2517                .expect("mark operation completed");
2518        }
2519
2520        fs::write(
2521            &journal_path,
2522            serde_json::to_vec(&journal).expect("serialize journal"),
2523        )
2524        .expect("write journal");
2525
2526        let err = run([
2527            OsString::from("apply-command"),
2528            OsString::from("--journal"),
2529            OsString::from(journal_path.as_os_str()),
2530            OsString::from("--out"),
2531            OsString::from(out_path.as_os_str()),
2532            OsString::from("--require-command"),
2533        ])
2534        .expect_err("missing command should fail");
2535
2536        let preview: RestoreApplyCommandPreview =
2537            serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
2538                .expect("decode command preview");
2539
2540        fs::remove_dir_all(root).expect("remove temp root");
2541        assert!(preview.complete);
2542        assert!(!preview.operation_available);
2543        assert!(!preview.command_available);
2544        assert!(matches!(
2545            err,
2546            RestoreCommandError::RestoreApplyCommandUnavailable {
2547                operation_available: false,
2548                complete: true,
2549                ..
2550            }
2551        ));
2552    }
2553
2554    // Ensure apply-claim marks the next operation pending before runner execution.
2555    #[test]
2556    fn run_restore_apply_claim_marks_next_operation_pending() {
2557        let root = temp_dir("canic-cli-restore-apply-claim");
2558        fs::create_dir_all(&root).expect("create temp root");
2559        let journal_path = root.join("restore-apply-journal.json");
2560        let claimed_path = root.join("restore-apply-journal.claimed.json");
2561        let journal = ready_apply_journal();
2562
2563        fs::write(
2564            &journal_path,
2565            serde_json::to_vec(&journal).expect("serialize journal"),
2566        )
2567        .expect("write journal");
2568
2569        run([
2570            OsString::from("apply-claim"),
2571            OsString::from("--journal"),
2572            OsString::from(journal_path.as_os_str()),
2573            OsString::from("--sequence"),
2574            OsString::from("0"),
2575            OsString::from("--updated-at"),
2576            OsString::from("2026-05-04T12:00:00Z"),
2577            OsString::from("--out"),
2578            OsString::from(claimed_path.as_os_str()),
2579        ])
2580        .expect("claim operation");
2581
2582        let claimed: RestoreApplyJournal =
2583            serde_json::from_slice(&fs::read(&claimed_path).expect("read claimed journal"))
2584                .expect("decode claimed journal");
2585        let status = claimed.status();
2586        let next = claimed.next_operation();
2587
2588        fs::remove_dir_all(root).expect("remove temp root");
2589        assert_eq!(claimed.pending_operations, 1);
2590        assert_eq!(claimed.ready_operations, 7);
2591        assert_eq!(
2592            claimed.operations[0].state,
2593            RestoreApplyOperationState::Pending
2594        );
2595        assert_eq!(
2596            claimed.operations[0].state_updated_at.as_deref(),
2597            Some("2026-05-04T12:00:00Z")
2598        );
2599        assert_eq!(status.next_transition_sequence, Some(0));
2600        assert_eq!(
2601            status.next_transition_state,
2602            Some(RestoreApplyOperationState::Pending)
2603        );
2604        assert_eq!(
2605            status.next_transition_updated_at.as_deref(),
2606            Some("2026-05-04T12:00:00Z")
2607        );
2608        assert_eq!(
2609            next.operation.expect("next operation").state,
2610            RestoreApplyOperationState::Pending
2611        );
2612    }
2613
2614    // Ensure apply-claim can reject a stale command preview sequence.
2615    #[test]
2616    fn run_restore_apply_claim_rejects_sequence_mismatch() {
2617        let root = temp_dir("canic-cli-restore-apply-claim-sequence");
2618        fs::create_dir_all(&root).expect("create temp root");
2619        let journal_path = root.join("restore-apply-journal.json");
2620        let claimed_path = root.join("restore-apply-journal.claimed.json");
2621        let journal = ready_apply_journal();
2622
2623        fs::write(
2624            &journal_path,
2625            serde_json::to_vec(&journal).expect("serialize journal"),
2626        )
2627        .expect("write journal");
2628
2629        let err = run([
2630            OsString::from("apply-claim"),
2631            OsString::from("--journal"),
2632            OsString::from(journal_path.as_os_str()),
2633            OsString::from("--sequence"),
2634            OsString::from("1"),
2635            OsString::from("--out"),
2636            OsString::from(claimed_path.as_os_str()),
2637        ])
2638        .expect_err("stale sequence should fail claim");
2639
2640        assert!(!claimed_path.exists());
2641        fs::remove_dir_all(root).expect("remove temp root");
2642        assert!(matches!(
2643            err,
2644            RestoreCommandError::RestoreApplyClaimSequenceMismatch {
2645                expected: 1,
2646                actual: Some(0),
2647            }
2648        ));
2649    }
2650
2651    // Ensure apply-unclaim releases the current pending operation back to ready.
2652    #[test]
2653    fn run_restore_apply_unclaim_marks_pending_operation_ready() {
2654        let root = temp_dir("canic-cli-restore-apply-unclaim");
2655        fs::create_dir_all(&root).expect("create temp root");
2656        let journal_path = root.join("restore-apply-journal.json");
2657        let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
2658        let mut journal = ready_apply_journal();
2659        journal
2660            .mark_next_operation_pending()
2661            .expect("claim operation");
2662
2663        fs::write(
2664            &journal_path,
2665            serde_json::to_vec(&journal).expect("serialize journal"),
2666        )
2667        .expect("write journal");
2668
2669        run([
2670            OsString::from("apply-unclaim"),
2671            OsString::from("--journal"),
2672            OsString::from(journal_path.as_os_str()),
2673            OsString::from("--sequence"),
2674            OsString::from("0"),
2675            OsString::from("--updated-at"),
2676            OsString::from("2026-05-04T12:01:00Z"),
2677            OsString::from("--out"),
2678            OsString::from(unclaimed_path.as_os_str()),
2679        ])
2680        .expect("unclaim operation");
2681
2682        let unclaimed: RestoreApplyJournal =
2683            serde_json::from_slice(&fs::read(&unclaimed_path).expect("read unclaimed journal"))
2684                .expect("decode unclaimed journal");
2685        let status = unclaimed.status();
2686
2687        fs::remove_dir_all(root).expect("remove temp root");
2688        assert_eq!(unclaimed.pending_operations, 0);
2689        assert_eq!(unclaimed.ready_operations, 8);
2690        assert_eq!(
2691            unclaimed.operations[0].state,
2692            RestoreApplyOperationState::Ready
2693        );
2694        assert_eq!(
2695            unclaimed.operations[0].state_updated_at.as_deref(),
2696            Some("2026-05-04T12:01:00Z")
2697        );
2698        assert_eq!(status.next_ready_sequence, Some(0));
2699        assert_eq!(
2700            status.next_transition_state,
2701            Some(RestoreApplyOperationState::Ready)
2702        );
2703        assert_eq!(
2704            status.next_transition_updated_at.as_deref(),
2705            Some("2026-05-04T12:01:00Z")
2706        );
2707    }
2708
2709    // Ensure apply-unclaim can reject a stale pending operation sequence.
2710    #[test]
2711    fn run_restore_apply_unclaim_rejects_sequence_mismatch() {
2712        let root = temp_dir("canic-cli-restore-apply-unclaim-sequence");
2713        fs::create_dir_all(&root).expect("create temp root");
2714        let journal_path = root.join("restore-apply-journal.json");
2715        let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
2716        let mut journal = ready_apply_journal();
2717        journal
2718            .mark_next_operation_pending()
2719            .expect("claim operation");
2720
2721        fs::write(
2722            &journal_path,
2723            serde_json::to_vec(&journal).expect("serialize journal"),
2724        )
2725        .expect("write journal");
2726
2727        let err = run([
2728            OsString::from("apply-unclaim"),
2729            OsString::from("--journal"),
2730            OsString::from(journal_path.as_os_str()),
2731            OsString::from("--sequence"),
2732            OsString::from("1"),
2733            OsString::from("--out"),
2734            OsString::from(unclaimed_path.as_os_str()),
2735        ])
2736        .expect_err("stale sequence should fail unclaim");
2737
2738        assert!(!unclaimed_path.exists());
2739        fs::remove_dir_all(root).expect("remove temp root");
2740        assert!(matches!(
2741            err,
2742            RestoreCommandError::RestoreApplyUnclaimSequenceMismatch {
2743                expected: 1,
2744                actual: Some(0),
2745            }
2746        ));
2747    }
2748
2749    // Ensure apply-mark can advance one journal operation and keep counts consistent.
2750    #[test]
2751    fn run_restore_apply_mark_completes_operation() {
2752        let root = temp_dir("canic-cli-restore-apply-mark-complete");
2753        fs::create_dir_all(&root).expect("create temp root");
2754        let journal_path = root.join("restore-apply-journal.json");
2755        let updated_path = root.join("restore-apply-journal.updated.json");
2756        let journal = ready_apply_journal();
2757
2758        fs::write(
2759            &journal_path,
2760            serde_json::to_vec(&journal).expect("serialize journal"),
2761        )
2762        .expect("write journal");
2763
2764        run([
2765            OsString::from("apply-mark"),
2766            OsString::from("--journal"),
2767            OsString::from(journal_path.as_os_str()),
2768            OsString::from("--sequence"),
2769            OsString::from("0"),
2770            OsString::from("--state"),
2771            OsString::from("completed"),
2772            OsString::from("--updated-at"),
2773            OsString::from("2026-05-04T12:02:00Z"),
2774            OsString::from("--out"),
2775            OsString::from(updated_path.as_os_str()),
2776        ])
2777        .expect("mark operation completed");
2778
2779        let updated: RestoreApplyJournal =
2780            serde_json::from_slice(&fs::read(&updated_path).expect("read updated journal"))
2781                .expect("decode updated journal");
2782        let status = updated.status();
2783
2784        fs::remove_dir_all(root).expect("remove temp root");
2785        assert_eq!(updated.completed_operations, 1);
2786        assert_eq!(updated.ready_operations, 7);
2787        assert_eq!(
2788            updated.operations[0].state_updated_at.as_deref(),
2789            Some("2026-05-04T12:02:00Z")
2790        );
2791        assert_eq!(status.next_ready_sequence, Some(1));
2792    }
2793
2794    // Ensure apply-mark can require an operation claim before completion.
2795    #[test]
2796    fn run_restore_apply_mark_require_pending_rejects_ready_operation() {
2797        let root = temp_dir("canic-cli-restore-apply-mark-require-pending");
2798        fs::create_dir_all(&root).expect("create temp root");
2799        let journal_path = root.join("restore-apply-journal.json");
2800        let updated_path = root.join("restore-apply-journal.updated.json");
2801        let journal = ready_apply_journal();
2802
2803        fs::write(
2804            &journal_path,
2805            serde_json::to_vec(&journal).expect("serialize journal"),
2806        )
2807        .expect("write journal");
2808
2809        let err = run([
2810            OsString::from("apply-mark"),
2811            OsString::from("--journal"),
2812            OsString::from(journal_path.as_os_str()),
2813            OsString::from("--sequence"),
2814            OsString::from("0"),
2815            OsString::from("--state"),
2816            OsString::from("completed"),
2817            OsString::from("--out"),
2818            OsString::from(updated_path.as_os_str()),
2819            OsString::from("--require-pending"),
2820        ])
2821        .expect_err("ready operation should fail pending requirement");
2822
2823        assert!(!updated_path.exists());
2824        fs::remove_dir_all(root).expect("remove temp root");
2825        assert!(matches!(
2826            err,
2827            RestoreCommandError::RestoreApplyMarkRequiresPending {
2828                sequence: 0,
2829                state: RestoreApplyOperationState::Ready,
2830            }
2831        ));
2832    }
2833
2834    // Ensure apply-mark refuses to skip earlier ready operations.
2835    #[test]
2836    fn run_restore_apply_mark_rejects_out_of_order_operation() {
2837        let root = temp_dir("canic-cli-restore-apply-mark-out-of-order");
2838        fs::create_dir_all(&root).expect("create temp root");
2839        let journal_path = root.join("restore-apply-journal.json");
2840        let updated_path = root.join("restore-apply-journal.updated.json");
2841        let journal = ready_apply_journal();
2842
2843        fs::write(
2844            &journal_path,
2845            serde_json::to_vec(&journal).expect("serialize journal"),
2846        )
2847        .expect("write journal");
2848
2849        let err = run([
2850            OsString::from("apply-mark"),
2851            OsString::from("--journal"),
2852            OsString::from(journal_path.as_os_str()),
2853            OsString::from("--sequence"),
2854            OsString::from("1"),
2855            OsString::from("--state"),
2856            OsString::from("completed"),
2857            OsString::from("--out"),
2858            OsString::from(updated_path.as_os_str()),
2859        ])
2860        .expect_err("out-of-order operation should fail");
2861
2862        assert!(!updated_path.exists());
2863        fs::remove_dir_all(root).expect("remove temp root");
2864        assert!(matches!(
2865            err,
2866            RestoreCommandError::RestoreApplyJournal(
2867                RestoreApplyJournalError::OutOfOrderOperationTransition {
2868                    requested: 1,
2869                    next: 0
2870                }
2871            )
2872        ));
2873    }
2874
2875    // Ensure apply-mark requires failure reasons for failed operation state.
2876    #[test]
2877    fn run_restore_apply_mark_failed_requires_reason() {
2878        let root = temp_dir("canic-cli-restore-apply-mark-failed-reason");
2879        fs::create_dir_all(&root).expect("create temp root");
2880        let journal_path = root.join("restore-apply-journal.json");
2881        let journal = ready_apply_journal();
2882
2883        fs::write(
2884            &journal_path,
2885            serde_json::to_vec(&journal).expect("serialize journal"),
2886        )
2887        .expect("write journal");
2888
2889        let err = run([
2890            OsString::from("apply-mark"),
2891            OsString::from("--journal"),
2892            OsString::from(journal_path.as_os_str()),
2893            OsString::from("--sequence"),
2894            OsString::from("0"),
2895            OsString::from("--state"),
2896            OsString::from("failed"),
2897        ])
2898        .expect_err("failed state should require reason");
2899
2900        fs::remove_dir_all(root).expect("remove temp root");
2901        assert!(matches!(
2902            err,
2903            RestoreCommandError::RestoreApplyJournal(
2904                RestoreApplyJournalError::FailureReasonRequired(0)
2905            )
2906        ));
2907    }
2908
2909    // Ensure restore apply dry-run rejects status files from another plan.
2910    #[test]
2911    fn run_restore_apply_dry_run_rejects_mismatched_status() {
2912        let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
2913        fs::create_dir_all(&root).expect("create temp root");
2914        let plan_path = root.join("restore-plan.json");
2915        let status_path = root.join("restore-status.json");
2916        let out_path = root.join("restore-apply-dry-run.json");
2917        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2918        let mut status = RestoreStatus::from_plan(&plan);
2919        status.backup_id = "other-backup".to_string();
2920
2921        fs::write(
2922            &plan_path,
2923            serde_json::to_vec(&plan).expect("serialize plan"),
2924        )
2925        .expect("write plan");
2926        fs::write(
2927            &status_path,
2928            serde_json::to_vec(&status).expect("serialize status"),
2929        )
2930        .expect("write status");
2931
2932        let err = run([
2933            OsString::from("apply"),
2934            OsString::from("--plan"),
2935            OsString::from(plan_path.as_os_str()),
2936            OsString::from("--status"),
2937            OsString::from(status_path.as_os_str()),
2938            OsString::from("--dry-run"),
2939            OsString::from("--out"),
2940            OsString::from(out_path.as_os_str()),
2941        ])
2942        .expect_err("mismatched status should fail");
2943
2944        assert!(!out_path.exists());
2945        fs::remove_dir_all(root).expect("remove temp root");
2946        assert!(matches!(
2947            err,
2948            RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
2949                field: "backup_id",
2950                ..
2951            })
2952        ));
2953    }
2954
2955    // Build one manually ready apply journal for runner-focused CLI tests.
2956    fn ready_apply_journal() -> RestoreApplyJournal {
2957        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2958        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
2959        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2960
2961        journal.ready = true;
2962        journal.blocked_reasons = Vec::new();
2963        for operation in &mut journal.operations {
2964            operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
2965            operation.blocking_reasons = Vec::new();
2966        }
2967        journal.blocked_operations = 0;
2968        journal.ready_operations = journal.operation_count;
2969        journal.validate().expect("journal should validate");
2970        journal
2971    }
2972
2973    // Build one valid manifest for restore planning tests.
2974    fn valid_manifest() -> FleetBackupManifest {
2975        FleetBackupManifest {
2976            manifest_version: 1,
2977            backup_id: "backup-test".to_string(),
2978            created_at: "2026-05-03T00:00:00Z".to_string(),
2979            tool: ToolMetadata {
2980                name: "canic".to_string(),
2981                version: "0.30.1".to_string(),
2982            },
2983            source: SourceMetadata {
2984                environment: "local".to_string(),
2985                root_canister: ROOT.to_string(),
2986            },
2987            consistency: ConsistencySection {
2988                mode: ConsistencyMode::CrashConsistent,
2989                backup_units: vec![BackupUnit {
2990                    unit_id: "fleet".to_string(),
2991                    kind: BackupUnitKind::SubtreeRooted,
2992                    roles: vec!["root".to_string(), "app".to_string()],
2993                    consistency_reason: None,
2994                    dependency_closure: Vec::new(),
2995                    topology_validation: "subtree-closed".to_string(),
2996                    quiescence_strategy: None,
2997                }],
2998            },
2999            fleet: FleetSection {
3000                topology_hash_algorithm: "sha256".to_string(),
3001                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
3002                discovery_topology_hash: HASH.to_string(),
3003                pre_snapshot_topology_hash: HASH.to_string(),
3004                topology_hash: HASH.to_string(),
3005                members: vec![
3006                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
3007                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
3008                ],
3009            },
3010            verification: VerificationPlan::default(),
3011        }
3012    }
3013
3014    // Build one manifest whose restore readiness metadata is complete.
3015    fn restore_ready_manifest() -> FleetBackupManifest {
3016        let mut manifest = valid_manifest();
3017        for member in &mut manifest.fleet.members {
3018            member.source_snapshot.module_hash = Some(HASH.to_string());
3019            member.source_snapshot.wasm_hash = Some(HASH.to_string());
3020            member.source_snapshot.checksum = Some(HASH.to_string());
3021        }
3022        manifest
3023    }
3024
3025    // Build one valid manifest member.
3026    fn fleet_member(
3027        role: &str,
3028        canister_id: &str,
3029        parent_canister_id: Option<&str>,
3030        identity_mode: IdentityMode,
3031    ) -> FleetMember {
3032        FleetMember {
3033            role: role.to_string(),
3034            canister_id: canister_id.to_string(),
3035            parent_canister_id: parent_canister_id.map(str::to_string),
3036            subnet_canister_id: Some(ROOT.to_string()),
3037            controller_hint: None,
3038            identity_mode,
3039            restore_group: 1,
3040            verification_class: "basic".to_string(),
3041            verification_checks: vec![VerificationCheck {
3042                kind: "status".to_string(),
3043                method: None,
3044                roles: vec![role.to_string()],
3045            }],
3046            source_snapshot: SourceSnapshot {
3047                snapshot_id: format!("{role}-snapshot"),
3048                module_hash: None,
3049                wasm_hash: None,
3050                code_version: Some("v0.30.1".to_string()),
3051                artifact_path: format!("artifacts/{role}"),
3052                checksum_algorithm: "sha256".to_string(),
3053                checksum: None,
3054            },
3055        }
3056    }
3057
3058    // Write a canonical backup layout whose journal checksums match the artifacts.
3059    fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
3060        layout.write_manifest(manifest).expect("write manifest");
3061
3062        let artifacts = manifest
3063            .fleet
3064            .members
3065            .iter()
3066            .map(|member| {
3067                let bytes = format!("{} artifact", member.role);
3068                let artifact_path = root.join(&member.source_snapshot.artifact_path);
3069                if let Some(parent) = artifact_path.parent() {
3070                    fs::create_dir_all(parent).expect("create artifact parent");
3071                }
3072                fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
3073                let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
3074
3075                ArtifactJournalEntry {
3076                    canister_id: member.canister_id.clone(),
3077                    snapshot_id: member.source_snapshot.snapshot_id.clone(),
3078                    state: ArtifactState::Durable,
3079                    temp_path: None,
3080                    artifact_path: member.source_snapshot.artifact_path.clone(),
3081                    checksum_algorithm: checksum.algorithm,
3082                    checksum: Some(checksum.hash),
3083                    updated_at: "2026-05-03T00:00:00Z".to_string(),
3084                }
3085            })
3086            .collect();
3087
3088        layout
3089            .write_journal(&DownloadJournal {
3090                journal_version: 1,
3091                backup_id: manifest.backup_id.clone(),
3092                discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
3093                pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
3094                operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
3095                artifacts,
3096            })
3097            .expect("write journal");
3098    }
3099
3100    // Write artifact bytes and update the manifest checksums for apply validation.
3101    fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
3102        for member in &mut manifest.fleet.members {
3103            let bytes = format!("{} apply artifact", member.role);
3104            let artifact_path = root.join(&member.source_snapshot.artifact_path);
3105            if let Some(parent) = artifact_path.parent() {
3106                fs::create_dir_all(parent).expect("create artifact parent");
3107            }
3108            fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
3109            let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
3110            member.source_snapshot.checksum = Some(checksum.hash);
3111        }
3112    }
3113
3114    // Build a unique temporary directory.
3115    fn temp_dir(prefix: &str) -> PathBuf {
3116        let nanos = SystemTime::now()
3117            .duration_since(UNIX_EPOCH)
3118            .expect("system time after epoch")
3119            .as_nanos();
3120        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
3121    }
3122}