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