Skip to main content

canic_cli/restore/
mod.rs

1use canic_backup::{
2    manifest::FleetBackupManifest,
3    persistence::{BackupLayout, PersistenceError},
4    restore::{
5        RestoreApplyDryRun, RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
6        RestoreApplyJournalStatus, RestoreApplyNextOperation, RestoreMapping, RestorePlan,
7        RestorePlanError, RestorePlanner, RestoreStatus,
8    },
9};
10use std::{
11    ffi::OsString,
12    fs,
13    io::{self, Write},
14    path::PathBuf,
15};
16use thiserror::Error as ThisError;
17
18///
19/// RestoreCommandError
20///
21
22#[derive(Debug, ThisError)]
23pub enum RestoreCommandError {
24    #[error("{0}")]
25    Usage(&'static str),
26
27    #[error("missing required option {0}")]
28    MissingOption(&'static str),
29
30    #[error("use either --manifest or --backup-dir, not both")]
31    ConflictingManifestSources,
32
33    #[error("--require-verified requires --backup-dir")]
34    RequireVerifiedNeedsBackupDir,
35
36    #[error("restore apply currently requires --dry-run")]
37    ApplyRequiresDryRun,
38
39    #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
40    RestoreNotReady {
41        backup_id: String,
42        reasons: Vec<String>,
43    },
44
45    #[error("unknown option {0}")]
46    UnknownOption(String),
47
48    #[error("option {0} requires a value")]
49    MissingValue(&'static str),
50
51    #[error("option --sequence requires a non-negative integer value")]
52    InvalidSequence,
53
54    #[error("unsupported apply-mark state {0}; use completed or failed")]
55    InvalidApplyMarkState(String),
56
57    #[error(transparent)]
58    Io(#[from] std::io::Error),
59
60    #[error(transparent)]
61    Json(#[from] serde_json::Error),
62
63    #[error(transparent)]
64    Persistence(#[from] PersistenceError),
65
66    #[error(transparent)]
67    RestorePlan(#[from] RestorePlanError),
68
69    #[error(transparent)]
70    RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
71
72    #[error(transparent)]
73    RestoreApplyJournal(#[from] RestoreApplyJournalError),
74}
75
76///
77/// RestorePlanOptions
78///
79
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct RestorePlanOptions {
82    pub manifest: Option<PathBuf>,
83    pub backup_dir: Option<PathBuf>,
84    pub mapping: Option<PathBuf>,
85    pub out: Option<PathBuf>,
86    pub require_verified: bool,
87    pub require_restore_ready: bool,
88}
89
90impl RestorePlanOptions {
91    /// Parse restore planning options from CLI arguments.
92    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
93    where
94        I: IntoIterator<Item = OsString>,
95    {
96        let mut manifest = None;
97        let mut backup_dir = None;
98        let mut mapping = None;
99        let mut out = None;
100        let mut require_verified = false;
101        let mut require_restore_ready = false;
102
103        let mut args = args.into_iter();
104        while let Some(arg) = args.next() {
105            let arg = arg
106                .into_string()
107                .map_err(|_| RestoreCommandError::Usage(usage()))?;
108            match arg.as_str() {
109                "--manifest" => {
110                    manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
111                }
112                "--backup-dir" => {
113                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
114                }
115                "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
116                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
117                "--require-verified" => require_verified = true,
118                "--require-restore-ready" => require_restore_ready = true,
119                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
120                _ => return Err(RestoreCommandError::UnknownOption(arg)),
121            }
122        }
123
124        if manifest.is_some() && backup_dir.is_some() {
125            return Err(RestoreCommandError::ConflictingManifestSources);
126        }
127
128        if manifest.is_none() && backup_dir.is_none() {
129            return Err(RestoreCommandError::MissingOption(
130                "--manifest or --backup-dir",
131            ));
132        }
133
134        if require_verified && backup_dir.is_none() {
135            return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
136        }
137
138        Ok(Self {
139            manifest,
140            backup_dir,
141            mapping,
142            out,
143            require_verified,
144            require_restore_ready,
145        })
146    }
147}
148
149///
150/// RestoreStatusOptions
151///
152
153#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct RestoreStatusOptions {
155    pub plan: PathBuf,
156    pub out: Option<PathBuf>,
157}
158
159impl RestoreStatusOptions {
160    /// Parse restore status options from CLI arguments.
161    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
162    where
163        I: IntoIterator<Item = OsString>,
164    {
165        let mut plan = None;
166        let mut out = None;
167
168        let mut args = args.into_iter();
169        while let Some(arg) = args.next() {
170            let arg = arg
171                .into_string()
172                .map_err(|_| RestoreCommandError::Usage(usage()))?;
173            match arg.as_str() {
174                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
175                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
176                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
177                _ => return Err(RestoreCommandError::UnknownOption(arg)),
178            }
179        }
180
181        Ok(Self {
182            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
183            out,
184        })
185    }
186}
187
188///
189/// RestoreApplyOptions
190///
191
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct RestoreApplyOptions {
194    pub plan: PathBuf,
195    pub status: Option<PathBuf>,
196    pub backup_dir: Option<PathBuf>,
197    pub out: Option<PathBuf>,
198    pub journal_out: Option<PathBuf>,
199    pub dry_run: bool,
200}
201
202impl RestoreApplyOptions {
203    /// Parse restore apply options from CLI arguments.
204    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
205    where
206        I: IntoIterator<Item = OsString>,
207    {
208        let mut plan = None;
209        let mut status = None;
210        let mut backup_dir = None;
211        let mut out = None;
212        let mut journal_out = None;
213        let mut dry_run = false;
214
215        let mut args = args.into_iter();
216        while let Some(arg) = args.next() {
217            let arg = arg
218                .into_string()
219                .map_err(|_| RestoreCommandError::Usage(usage()))?;
220            match arg.as_str() {
221                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
222                "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
223                "--backup-dir" => {
224                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
225                }
226                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
227                "--journal-out" => {
228                    journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
229                }
230                "--dry-run" => dry_run = true,
231                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
232                _ => return Err(RestoreCommandError::UnknownOption(arg)),
233            }
234        }
235
236        if !dry_run {
237            return Err(RestoreCommandError::ApplyRequiresDryRun);
238        }
239
240        Ok(Self {
241            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
242            status,
243            backup_dir,
244            out,
245            journal_out,
246            dry_run,
247        })
248    }
249}
250
251///
252/// RestoreApplyStatusOptions
253///
254
255#[derive(Clone, Debug, Eq, PartialEq)]
256pub struct RestoreApplyStatusOptions {
257    pub journal: PathBuf,
258    pub out: Option<PathBuf>,
259}
260
261impl RestoreApplyStatusOptions {
262    /// Parse restore apply-status options from CLI arguments.
263    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
264    where
265        I: IntoIterator<Item = OsString>,
266    {
267        let mut journal = None;
268        let mut out = None;
269
270        let mut args = args.into_iter();
271        while let Some(arg) = args.next() {
272            let arg = arg
273                .into_string()
274                .map_err(|_| RestoreCommandError::Usage(usage()))?;
275            match arg.as_str() {
276                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
277                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
278                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
279                _ => return Err(RestoreCommandError::UnknownOption(arg)),
280            }
281        }
282
283        Ok(Self {
284            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
285            out,
286        })
287    }
288}
289
290///
291/// RestoreApplyNextOptions
292///
293
294#[derive(Clone, Debug, Eq, PartialEq)]
295pub struct RestoreApplyNextOptions {
296    pub journal: PathBuf,
297    pub out: Option<PathBuf>,
298}
299
300impl RestoreApplyNextOptions {
301    /// Parse restore apply-next options from CLI arguments.
302    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
303    where
304        I: IntoIterator<Item = OsString>,
305    {
306        let mut journal = None;
307        let mut out = None;
308
309        let mut args = args.into_iter();
310        while let Some(arg) = args.next() {
311            let arg = arg
312                .into_string()
313                .map_err(|_| RestoreCommandError::Usage(usage()))?;
314            match arg.as_str() {
315                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
316                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
317                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
318                _ => return Err(RestoreCommandError::UnknownOption(arg)),
319            }
320        }
321
322        Ok(Self {
323            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
324            out,
325        })
326    }
327}
328
329///
330/// RestoreApplyMarkOptions
331///
332
333#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct RestoreApplyMarkOptions {
335    pub journal: PathBuf,
336    pub sequence: usize,
337    pub state: RestoreApplyMarkState,
338    pub reason: Option<String>,
339    pub out: Option<PathBuf>,
340}
341
342impl RestoreApplyMarkOptions {
343    /// Parse restore apply-mark options from CLI arguments.
344    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
345    where
346        I: IntoIterator<Item = OsString>,
347    {
348        let mut journal = None;
349        let mut sequence = None;
350        let mut state = None;
351        let mut reason = None;
352        let mut out = None;
353
354        let mut args = args.into_iter();
355        while let Some(arg) = args.next() {
356            let arg = arg
357                .into_string()
358                .map_err(|_| RestoreCommandError::Usage(usage()))?;
359            match arg.as_str() {
360                "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
361                "--sequence" => {
362                    sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
363                }
364                "--state" => {
365                    state = Some(RestoreApplyMarkState::parse(next_value(
366                        &mut args, "--state",
367                    )?)?);
368                }
369                "--reason" => reason = Some(next_value(&mut args, "--reason")?),
370                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
371                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
372                _ => return Err(RestoreCommandError::UnknownOption(arg)),
373            }
374        }
375
376        Ok(Self {
377            journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
378            sequence: sequence.ok_or(RestoreCommandError::MissingOption("--sequence"))?,
379            state: state.ok_or(RestoreCommandError::MissingOption("--state"))?,
380            reason,
381            out,
382        })
383    }
384}
385
386///
387/// RestoreApplyMarkState
388///
389
390#[derive(Clone, Debug, Eq, PartialEq)]
391pub enum RestoreApplyMarkState {
392    Completed,
393    Failed,
394}
395
396impl RestoreApplyMarkState {
397    // Parse the restricted operation states accepted by apply-mark.
398    fn parse(value: String) -> Result<Self, RestoreCommandError> {
399        match value.as_str() {
400            "completed" => Ok(Self::Completed),
401            "failed" => Ok(Self::Failed),
402            _ => Err(RestoreCommandError::InvalidApplyMarkState(value)),
403        }
404    }
405}
406
407/// Run a restore subcommand.
408pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
409where
410    I: IntoIterator<Item = OsString>,
411{
412    let mut args = args.into_iter();
413    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
414        return Err(RestoreCommandError::Usage(usage()));
415    };
416
417    match command.as_str() {
418        "plan" => {
419            let options = RestorePlanOptions::parse(args)?;
420            let plan = plan_restore(&options)?;
421            write_plan(&options, &plan)?;
422            enforce_restore_plan_requirements(&options, &plan)?;
423            Ok(())
424        }
425        "status" => {
426            let options = RestoreStatusOptions::parse(args)?;
427            let status = restore_status(&options)?;
428            write_status(&options, &status)?;
429            Ok(())
430        }
431        "apply" => {
432            let options = RestoreApplyOptions::parse(args)?;
433            let dry_run = restore_apply_dry_run(&options)?;
434            write_apply_dry_run(&options, &dry_run)?;
435            write_apply_journal_if_requested(&options, &dry_run)?;
436            Ok(())
437        }
438        "apply-status" => {
439            let options = RestoreApplyStatusOptions::parse(args)?;
440            let status = restore_apply_status(&options)?;
441            write_apply_status(&options, &status)?;
442            Ok(())
443        }
444        "apply-next" => {
445            let options = RestoreApplyNextOptions::parse(args)?;
446            let next = restore_apply_next(&options)?;
447            write_apply_next(&options, &next)?;
448            Ok(())
449        }
450        "apply-mark" => {
451            let options = RestoreApplyMarkOptions::parse(args)?;
452            let journal = restore_apply_mark(&options)?;
453            write_apply_mark(&options, &journal)?;
454            Ok(())
455        }
456        "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
457        _ => Err(RestoreCommandError::UnknownOption(command)),
458    }
459}
460
461/// Build a no-mutation restore plan from a manifest and optional mapping.
462pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
463    verify_backup_layout_if_required(options)?;
464
465    let manifest = read_manifest_source(options)?;
466    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
467
468    RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
469}
470
471/// Build the initial no-mutation restore status from a restore plan.
472pub fn restore_status(
473    options: &RestoreStatusOptions,
474) -> Result<RestoreStatus, RestoreCommandError> {
475    let plan = read_plan(&options.plan)?;
476    Ok(RestoreStatus::from_plan(&plan))
477}
478
479/// Build a no-mutation restore apply dry-run from a restore plan.
480pub fn restore_apply_dry_run(
481    options: &RestoreApplyOptions,
482) -> Result<RestoreApplyDryRun, RestoreCommandError> {
483    let plan = read_plan(&options.plan)?;
484    let status = options.status.as_ref().map(read_status).transpose()?;
485    if let Some(backup_dir) = &options.backup_dir {
486        return RestoreApplyDryRun::try_from_plan_with_artifacts(
487            &plan,
488            status.as_ref(),
489            backup_dir,
490        )
491        .map_err(RestoreCommandError::from);
492    }
493
494    RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
495}
496
497/// Build a compact restore apply status from a journal file.
498pub fn restore_apply_status(
499    options: &RestoreApplyStatusOptions,
500) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
501    let journal = read_apply_journal(&options.journal)?;
502    Ok(journal.status())
503}
504
505/// Build the next restore apply operation response from a journal file.
506pub fn restore_apply_next(
507    options: &RestoreApplyNextOptions,
508) -> Result<RestoreApplyNextOperation, RestoreCommandError> {
509    let journal = read_apply_journal(&options.journal)?;
510    Ok(journal.next_operation())
511}
512
513/// Mark one restore apply journal operation completed or failed.
514pub fn restore_apply_mark(
515    options: &RestoreApplyMarkOptions,
516) -> Result<RestoreApplyJournal, RestoreCommandError> {
517    let mut journal = read_apply_journal(&options.journal)?;
518
519    match options.state {
520        RestoreApplyMarkState::Completed => {
521            journal.mark_operation_completed(options.sequence)?;
522        }
523        RestoreApplyMarkState::Failed => {
524            let reason =
525                options
526                    .reason
527                    .clone()
528                    .ok_or(RestoreApplyJournalError::FailureReasonRequired(
529                        options.sequence,
530                    ))?;
531            journal.mark_operation_failed(options.sequence, reason)?;
532        }
533    }
534
535    Ok(journal)
536}
537
538// Enforce caller-requested restore plan requirements after the plan is emitted.
539fn enforce_restore_plan_requirements(
540    options: &RestorePlanOptions,
541    plan: &RestorePlan,
542) -> Result<(), RestoreCommandError> {
543    if !options.require_restore_ready || plan.readiness_summary.ready {
544        return Ok(());
545    }
546
547    Err(RestoreCommandError::RestoreNotReady {
548        backup_id: plan.backup_id.clone(),
549        reasons: plan.readiness_summary.reasons.clone(),
550    })
551}
552
553// Verify backup layout integrity before restore planning when requested.
554fn verify_backup_layout_if_required(
555    options: &RestorePlanOptions,
556) -> Result<(), RestoreCommandError> {
557    if !options.require_verified {
558        return Ok(());
559    }
560
561    let Some(dir) = &options.backup_dir else {
562        return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
563    };
564
565    BackupLayout::new(dir.clone()).verify_integrity()?;
566    Ok(())
567}
568
569// Read the manifest from a direct path or canonical backup layout.
570fn read_manifest_source(
571    options: &RestorePlanOptions,
572) -> Result<FleetBackupManifest, RestoreCommandError> {
573    if let Some(path) = &options.manifest {
574        return read_manifest(path);
575    }
576
577    let Some(dir) = &options.backup_dir else {
578        return Err(RestoreCommandError::MissingOption(
579            "--manifest or --backup-dir",
580        ));
581    };
582
583    BackupLayout::new(dir.clone())
584        .read_manifest()
585        .map_err(RestoreCommandError::from)
586}
587
588// Read and decode a fleet backup manifest from disk.
589fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
590    let data = fs::read_to_string(path)?;
591    serde_json::from_str(&data).map_err(RestoreCommandError::from)
592}
593
594// Read and decode an optional source-to-target restore mapping from disk.
595fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
596    let data = fs::read_to_string(path)?;
597    serde_json::from_str(&data).map_err(RestoreCommandError::from)
598}
599
600// Read and decode a restore plan from disk.
601fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
602    let data = fs::read_to_string(path)?;
603    serde_json::from_str(&data).map_err(RestoreCommandError::from)
604}
605
606// Read and decode a restore status from disk.
607fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
608    let data = fs::read_to_string(path)?;
609    serde_json::from_str(&data).map_err(RestoreCommandError::from)
610}
611
612// Read and decode a restore apply journal from disk.
613fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
614    let data = fs::read_to_string(path)?;
615    let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
616    journal.validate()?;
617    Ok(journal)
618}
619
620// Parse a restore apply journal operation sequence value.
621fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
622    value
623        .parse::<usize>()
624        .map_err(|_| RestoreCommandError::InvalidSequence)
625}
626
627// Write the computed plan to stdout or a requested output file.
628fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
629    if let Some(path) = &options.out {
630        let data = serde_json::to_vec_pretty(plan)?;
631        fs::write(path, data)?;
632        return Ok(());
633    }
634
635    let stdout = io::stdout();
636    let mut handle = stdout.lock();
637    serde_json::to_writer_pretty(&mut handle, plan)?;
638    writeln!(handle)?;
639    Ok(())
640}
641
642// Write the computed status to stdout or a requested output file.
643fn write_status(
644    options: &RestoreStatusOptions,
645    status: &RestoreStatus,
646) -> Result<(), RestoreCommandError> {
647    if let Some(path) = &options.out {
648        let data = serde_json::to_vec_pretty(status)?;
649        fs::write(path, data)?;
650        return Ok(());
651    }
652
653    let stdout = io::stdout();
654    let mut handle = stdout.lock();
655    serde_json::to_writer_pretty(&mut handle, status)?;
656    writeln!(handle)?;
657    Ok(())
658}
659
660// Write the computed apply dry-run to stdout or a requested output file.
661fn write_apply_dry_run(
662    options: &RestoreApplyOptions,
663    dry_run: &RestoreApplyDryRun,
664) -> Result<(), RestoreCommandError> {
665    if let Some(path) = &options.out {
666        let data = serde_json::to_vec_pretty(dry_run)?;
667        fs::write(path, data)?;
668        return Ok(());
669    }
670
671    let stdout = io::stdout();
672    let mut handle = stdout.lock();
673    serde_json::to_writer_pretty(&mut handle, dry_run)?;
674    writeln!(handle)?;
675    Ok(())
676}
677
678// Write the initial apply journal when the caller requests one.
679fn write_apply_journal_if_requested(
680    options: &RestoreApplyOptions,
681    dry_run: &RestoreApplyDryRun,
682) -> Result<(), RestoreCommandError> {
683    let Some(path) = &options.journal_out else {
684        return Ok(());
685    };
686
687    let journal = RestoreApplyJournal::from_dry_run(dry_run);
688    let data = serde_json::to_vec_pretty(&journal)?;
689    fs::write(path, data)?;
690    Ok(())
691}
692
693// Write the computed apply journal status to stdout or a requested output file.
694fn write_apply_status(
695    options: &RestoreApplyStatusOptions,
696    status: &RestoreApplyJournalStatus,
697) -> Result<(), RestoreCommandError> {
698    if let Some(path) = &options.out {
699        let data = serde_json::to_vec_pretty(status)?;
700        fs::write(path, data)?;
701        return Ok(());
702    }
703
704    let stdout = io::stdout();
705    let mut handle = stdout.lock();
706    serde_json::to_writer_pretty(&mut handle, status)?;
707    writeln!(handle)?;
708    Ok(())
709}
710
711// Write the computed apply next-operation response to stdout or a requested output file.
712fn write_apply_next(
713    options: &RestoreApplyNextOptions,
714    next: &RestoreApplyNextOperation,
715) -> Result<(), RestoreCommandError> {
716    if let Some(path) = &options.out {
717        let data = serde_json::to_vec_pretty(next)?;
718        fs::write(path, data)?;
719        return Ok(());
720    }
721
722    let stdout = io::stdout();
723    let mut handle = stdout.lock();
724    serde_json::to_writer_pretty(&mut handle, next)?;
725    writeln!(handle)?;
726    Ok(())
727}
728
729// Write the updated apply journal to stdout or a requested output file.
730fn write_apply_mark(
731    options: &RestoreApplyMarkOptions,
732    journal: &RestoreApplyJournal,
733) -> Result<(), RestoreCommandError> {
734    if let Some(path) = &options.out {
735        let data = serde_json::to_vec_pretty(journal)?;
736        fs::write(path, data)?;
737        return Ok(());
738    }
739
740    let stdout = io::stdout();
741    let mut handle = stdout.lock();
742    serde_json::to_writer_pretty(&mut handle, journal)?;
743    writeln!(handle)?;
744    Ok(())
745}
746
747// Read the next required option value.
748fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
749where
750    I: Iterator<Item = OsString>,
751{
752    args.next()
753        .and_then(|value| value.into_string().ok())
754        .ok_or(RestoreCommandError::MissingValue(option))
755}
756
757// Return restore command usage text.
758const fn usage() -> &'static str {
759    "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>]\n       canic restore apply-next --journal <file> [--out <file>]\n       canic restore apply-mark --journal <file> --sequence <n> --state completed|failed [--reason <text>] [--out <file>]"
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use canic_backup::{
766        artifacts::ArtifactChecksum,
767        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
768        manifest::{
769            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
770            FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
771            VerificationCheck, VerificationPlan,
772        },
773    };
774    use serde_json::json;
775    use std::{
776        path::Path,
777        time::{SystemTime, UNIX_EPOCH},
778    };
779
780    const ROOT: &str = "aaaaa-aa";
781    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
782    const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
783    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
784
785    // Ensure restore plan options parse the intended no-mutation command.
786    #[test]
787    fn parses_restore_plan_options() {
788        let options = RestorePlanOptions::parse([
789            OsString::from("--manifest"),
790            OsString::from("manifest.json"),
791            OsString::from("--mapping"),
792            OsString::from("mapping.json"),
793            OsString::from("--out"),
794            OsString::from("plan.json"),
795            OsString::from("--require-restore-ready"),
796        ])
797        .expect("parse options");
798
799        assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
800        assert_eq!(options.backup_dir, None);
801        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
802        assert_eq!(options.out, Some(PathBuf::from("plan.json")));
803        assert!(!options.require_verified);
804        assert!(options.require_restore_ready);
805    }
806
807    // Ensure verified restore plan options parse with the canonical backup source.
808    #[test]
809    fn parses_verified_restore_plan_options() {
810        let options = RestorePlanOptions::parse([
811            OsString::from("--backup-dir"),
812            OsString::from("backups/run"),
813            OsString::from("--require-verified"),
814        ])
815        .expect("parse verified options");
816
817        assert_eq!(options.manifest, None);
818        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
819        assert_eq!(options.mapping, None);
820        assert_eq!(options.out, None);
821        assert!(options.require_verified);
822        assert!(!options.require_restore_ready);
823    }
824
825    // Ensure restore status options parse the intended no-mutation command.
826    #[test]
827    fn parses_restore_status_options() {
828        let options = RestoreStatusOptions::parse([
829            OsString::from("--plan"),
830            OsString::from("restore-plan.json"),
831            OsString::from("--out"),
832            OsString::from("restore-status.json"),
833        ])
834        .expect("parse status options");
835
836        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
837        assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
838    }
839
840    // Ensure restore apply options require the explicit dry-run mode.
841    #[test]
842    fn parses_restore_apply_dry_run_options() {
843        let options = RestoreApplyOptions::parse([
844            OsString::from("--plan"),
845            OsString::from("restore-plan.json"),
846            OsString::from("--status"),
847            OsString::from("restore-status.json"),
848            OsString::from("--backup-dir"),
849            OsString::from("backups/run"),
850            OsString::from("--dry-run"),
851            OsString::from("--out"),
852            OsString::from("restore-apply-dry-run.json"),
853            OsString::from("--journal-out"),
854            OsString::from("restore-apply-journal.json"),
855        ])
856        .expect("parse apply options");
857
858        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
859        assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
860        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
861        assert_eq!(
862            options.out,
863            Some(PathBuf::from("restore-apply-dry-run.json"))
864        );
865        assert_eq!(
866            options.journal_out,
867            Some(PathBuf::from("restore-apply-journal.json"))
868        );
869        assert!(options.dry_run);
870    }
871
872    // Ensure restore apply-status options parse the intended journal command.
873    #[test]
874    fn parses_restore_apply_status_options() {
875        let options = RestoreApplyStatusOptions::parse([
876            OsString::from("--journal"),
877            OsString::from("restore-apply-journal.json"),
878            OsString::from("--out"),
879            OsString::from("restore-apply-status.json"),
880        ])
881        .expect("parse apply-status options");
882
883        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
884        assert_eq!(
885            options.out,
886            Some(PathBuf::from("restore-apply-status.json"))
887        );
888    }
889
890    // Ensure restore apply-next options parse the intended journal command.
891    #[test]
892    fn parses_restore_apply_next_options() {
893        let options = RestoreApplyNextOptions::parse([
894            OsString::from("--journal"),
895            OsString::from("restore-apply-journal.json"),
896            OsString::from("--out"),
897            OsString::from("restore-apply-next.json"),
898        ])
899        .expect("parse apply-next options");
900
901        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
902        assert_eq!(options.out, Some(PathBuf::from("restore-apply-next.json")));
903    }
904
905    // Ensure restore apply-mark options parse the intended journal update command.
906    #[test]
907    fn parses_restore_apply_mark_options() {
908        let options = RestoreApplyMarkOptions::parse([
909            OsString::from("--journal"),
910            OsString::from("restore-apply-journal.json"),
911            OsString::from("--sequence"),
912            OsString::from("4"),
913            OsString::from("--state"),
914            OsString::from("failed"),
915            OsString::from("--reason"),
916            OsString::from("dfx-load-failed"),
917            OsString::from("--out"),
918            OsString::from("restore-apply-journal.updated.json"),
919        ])
920        .expect("parse apply-mark options");
921
922        assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
923        assert_eq!(options.sequence, 4);
924        assert_eq!(options.state, RestoreApplyMarkState::Failed);
925        assert_eq!(options.reason.as_deref(), Some("dfx-load-failed"));
926        assert_eq!(
927            options.out,
928            Some(PathBuf::from("restore-apply-journal.updated.json"))
929        );
930    }
931
932    // Ensure restore apply refuses non-dry-run execution while apply is scaffolded.
933    #[test]
934    fn restore_apply_requires_dry_run() {
935        let err = RestoreApplyOptions::parse([
936            OsString::from("--plan"),
937            OsString::from("restore-plan.json"),
938        ])
939        .expect_err("apply without dry-run should fail");
940
941        assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
942    }
943
944    // Ensure backup-dir restore planning reads the canonical layout manifest.
945    #[test]
946    fn plan_restore_reads_manifest_from_backup_dir() {
947        let root = temp_dir("canic-cli-restore-plan-layout");
948        let layout = BackupLayout::new(root.clone());
949        layout
950            .write_manifest(&valid_manifest())
951            .expect("write manifest");
952
953        let options = RestorePlanOptions {
954            manifest: None,
955            backup_dir: Some(root.clone()),
956            mapping: None,
957            out: None,
958            require_verified: false,
959            require_restore_ready: false,
960        };
961
962        let plan = plan_restore(&options).expect("plan restore");
963
964        fs::remove_dir_all(root).expect("remove temp root");
965        assert_eq!(plan.backup_id, "backup-test");
966        assert_eq!(plan.member_count, 2);
967    }
968
969    // Ensure restore planning has exactly one manifest source.
970    #[test]
971    fn parse_rejects_conflicting_manifest_sources() {
972        let err = RestorePlanOptions::parse([
973            OsString::from("--manifest"),
974            OsString::from("manifest.json"),
975            OsString::from("--backup-dir"),
976            OsString::from("backups/run"),
977        ])
978        .expect_err("conflicting sources should fail");
979
980        assert!(matches!(
981            err,
982            RestoreCommandError::ConflictingManifestSources
983        ));
984    }
985
986    // Ensure verified planning requires the canonical backup layout source.
987    #[test]
988    fn parse_rejects_require_verified_with_manifest_source() {
989        let err = RestorePlanOptions::parse([
990            OsString::from("--manifest"),
991            OsString::from("manifest.json"),
992            OsString::from("--require-verified"),
993        ])
994        .expect_err("verification should require a backup layout");
995
996        assert!(matches!(
997            err,
998            RestoreCommandError::RequireVerifiedNeedsBackupDir
999        ));
1000    }
1001
1002    // Ensure restore planning can require manifest, journal, and artifact integrity.
1003    #[test]
1004    fn plan_restore_requires_verified_backup_layout() {
1005        let root = temp_dir("canic-cli-restore-plan-verified");
1006        let layout = BackupLayout::new(root.clone());
1007        let manifest = valid_manifest();
1008        write_verified_layout(&root, &layout, &manifest);
1009
1010        let options = RestorePlanOptions {
1011            manifest: None,
1012            backup_dir: Some(root.clone()),
1013            mapping: None,
1014            out: None,
1015            require_verified: true,
1016            require_restore_ready: false,
1017        };
1018
1019        let plan = plan_restore(&options).expect("plan verified restore");
1020
1021        fs::remove_dir_all(root).expect("remove temp root");
1022        assert_eq!(plan.backup_id, "backup-test");
1023        assert_eq!(plan.member_count, 2);
1024    }
1025
1026    // Ensure required verification fails before planning when the layout is incomplete.
1027    #[test]
1028    fn plan_restore_rejects_unverified_backup_layout() {
1029        let root = temp_dir("canic-cli-restore-plan-unverified");
1030        let layout = BackupLayout::new(root.clone());
1031        layout
1032            .write_manifest(&valid_manifest())
1033            .expect("write manifest");
1034
1035        let options = RestorePlanOptions {
1036            manifest: None,
1037            backup_dir: Some(root.clone()),
1038            mapping: None,
1039            out: None,
1040            require_verified: true,
1041            require_restore_ready: false,
1042        };
1043
1044        let err = plan_restore(&options).expect_err("missing journal should fail");
1045
1046        fs::remove_dir_all(root).expect("remove temp root");
1047        assert!(matches!(err, RestoreCommandError::Persistence(_)));
1048    }
1049
1050    // Ensure the CLI planning path validates manifests and applies mappings.
1051    #[test]
1052    fn plan_restore_reads_manifest_and_mapping() {
1053        let root = temp_dir("canic-cli-restore-plan");
1054        fs::create_dir_all(&root).expect("create temp root");
1055        let manifest_path = root.join("manifest.json");
1056        let mapping_path = root.join("mapping.json");
1057
1058        fs::write(
1059            &manifest_path,
1060            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1061        )
1062        .expect("write manifest");
1063        fs::write(
1064            &mapping_path,
1065            json!({
1066                "members": [
1067                    {"source_canister": ROOT, "target_canister": ROOT},
1068                    {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
1069                ]
1070            })
1071            .to_string(),
1072        )
1073        .expect("write mapping");
1074
1075        let options = RestorePlanOptions {
1076            manifest: Some(manifest_path),
1077            backup_dir: None,
1078            mapping: Some(mapping_path),
1079            out: None,
1080            require_verified: false,
1081            require_restore_ready: false,
1082        };
1083
1084        let plan = plan_restore(&options).expect("plan restore");
1085
1086        fs::remove_dir_all(root).expect("remove temp root");
1087        let members = plan.ordered_members();
1088        assert_eq!(members.len(), 2);
1089        assert_eq!(members[0].source_canister, ROOT);
1090        assert_eq!(members[1].target_canister, MAPPED_CHILD);
1091    }
1092
1093    // Ensure restore-readiness gating happens after writing the plan artifact.
1094    #[test]
1095    fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
1096        let root = temp_dir("canic-cli-restore-plan-require-ready");
1097        fs::create_dir_all(&root).expect("create temp root");
1098        let manifest_path = root.join("manifest.json");
1099        let out_path = root.join("plan.json");
1100
1101        fs::write(
1102            &manifest_path,
1103            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1104        )
1105        .expect("write manifest");
1106
1107        let err = run([
1108            OsString::from("plan"),
1109            OsString::from("--manifest"),
1110            OsString::from(manifest_path.as_os_str()),
1111            OsString::from("--out"),
1112            OsString::from(out_path.as_os_str()),
1113            OsString::from("--require-restore-ready"),
1114        ])
1115        .expect_err("restore readiness should be enforced");
1116
1117        assert!(out_path.exists());
1118        let plan: RestorePlan =
1119            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1120
1121        fs::remove_dir_all(root).expect("remove temp root");
1122        assert!(!plan.readiness_summary.ready);
1123        assert!(matches!(
1124            err,
1125            RestoreCommandError::RestoreNotReady {
1126                reasons,
1127                ..
1128            } if reasons == [
1129                "missing-module-hash",
1130                "missing-wasm-hash",
1131                "missing-snapshot-checksum"
1132            ]
1133        ));
1134    }
1135
1136    // Ensure restore-readiness gating accepts plans with complete provenance.
1137    #[test]
1138    fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
1139        let root = temp_dir("canic-cli-restore-plan-ready");
1140        fs::create_dir_all(&root).expect("create temp root");
1141        let manifest_path = root.join("manifest.json");
1142        let out_path = root.join("plan.json");
1143
1144        fs::write(
1145            &manifest_path,
1146            serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
1147        )
1148        .expect("write manifest");
1149
1150        run([
1151            OsString::from("plan"),
1152            OsString::from("--manifest"),
1153            OsString::from(manifest_path.as_os_str()),
1154            OsString::from("--out"),
1155            OsString::from(out_path.as_os_str()),
1156            OsString::from("--require-restore-ready"),
1157        ])
1158        .expect("restore-ready plan should pass");
1159
1160        let plan: RestorePlan =
1161            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1162
1163        fs::remove_dir_all(root).expect("remove temp root");
1164        assert!(plan.readiness_summary.ready);
1165        assert!(plan.readiness_summary.reasons.is_empty());
1166    }
1167
1168    // Ensure restore status writes the initial planned execution journal.
1169    #[test]
1170    fn run_restore_status_writes_planned_status() {
1171        let root = temp_dir("canic-cli-restore-status");
1172        fs::create_dir_all(&root).expect("create temp root");
1173        let plan_path = root.join("restore-plan.json");
1174        let out_path = root.join("restore-status.json");
1175        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1176
1177        fs::write(
1178            &plan_path,
1179            serde_json::to_vec(&plan).expect("serialize plan"),
1180        )
1181        .expect("write plan");
1182
1183        run([
1184            OsString::from("status"),
1185            OsString::from("--plan"),
1186            OsString::from(plan_path.as_os_str()),
1187            OsString::from("--out"),
1188            OsString::from(out_path.as_os_str()),
1189        ])
1190        .expect("write restore status");
1191
1192        let status: RestoreStatus =
1193            serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
1194                .expect("decode restore status");
1195        let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
1196
1197        fs::remove_dir_all(root).expect("remove temp root");
1198        assert_eq!(status.status_version, 1);
1199        assert_eq!(status.backup_id.as_str(), "backup-test");
1200        assert!(status.ready);
1201        assert!(status.readiness_reasons.is_empty());
1202        assert_eq!(status.member_count, 2);
1203        assert_eq!(status.phase_count, 1);
1204        assert_eq!(status.planned_snapshot_loads, 2);
1205        assert_eq!(status.planned_code_reinstalls, 2);
1206        assert_eq!(status.planned_verification_checks, 2);
1207        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1208        assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
1209    }
1210
1211    // Ensure restore apply dry-run writes ordered operations from plan and status.
1212    #[test]
1213    fn run_restore_apply_dry_run_writes_operations() {
1214        let root = temp_dir("canic-cli-restore-apply-dry-run");
1215        fs::create_dir_all(&root).expect("create temp root");
1216        let plan_path = root.join("restore-plan.json");
1217        let status_path = root.join("restore-status.json");
1218        let out_path = root.join("restore-apply-dry-run.json");
1219        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1220        let status = RestoreStatus::from_plan(&plan);
1221
1222        fs::write(
1223            &plan_path,
1224            serde_json::to_vec(&plan).expect("serialize plan"),
1225        )
1226        .expect("write plan");
1227        fs::write(
1228            &status_path,
1229            serde_json::to_vec(&status).expect("serialize status"),
1230        )
1231        .expect("write status");
1232
1233        run([
1234            OsString::from("apply"),
1235            OsString::from("--plan"),
1236            OsString::from(plan_path.as_os_str()),
1237            OsString::from("--status"),
1238            OsString::from(status_path.as_os_str()),
1239            OsString::from("--dry-run"),
1240            OsString::from("--out"),
1241            OsString::from(out_path.as_os_str()),
1242        ])
1243        .expect("write apply dry-run");
1244
1245        let dry_run: RestoreApplyDryRun =
1246            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1247                .expect("decode dry-run");
1248        let dry_run_json: serde_json::Value =
1249            serde_json::to_value(&dry_run).expect("encode dry-run");
1250
1251        fs::remove_dir_all(root).expect("remove temp root");
1252        assert_eq!(dry_run.dry_run_version, 1);
1253        assert_eq!(dry_run.backup_id.as_str(), "backup-test");
1254        assert!(dry_run.ready);
1255        assert!(dry_run.status_supplied);
1256        assert_eq!(dry_run.member_count, 2);
1257        assert_eq!(dry_run.phase_count, 1);
1258        assert_eq!(dry_run.rendered_operations, 8);
1259        assert_eq!(
1260            dry_run_json["phases"][0]["operations"][0]["operation"],
1261            "upload-snapshot"
1262        );
1263        assert_eq!(
1264            dry_run_json["phases"][0]["operations"][3]["operation"],
1265            "verify-member"
1266        );
1267        assert_eq!(
1268            dry_run_json["phases"][0]["operations"][3]["verification_kind"],
1269            "status"
1270        );
1271        assert_eq!(
1272            dry_run_json["phases"][0]["operations"][3]["verification_method"],
1273            serde_json::Value::Null
1274        );
1275    }
1276
1277    // Ensure restore apply dry-run can validate artifacts under a backup directory.
1278    #[test]
1279    fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
1280        let root = temp_dir("canic-cli-restore-apply-artifacts");
1281        fs::create_dir_all(&root).expect("create temp root");
1282        let plan_path = root.join("restore-plan.json");
1283        let out_path = root.join("restore-apply-dry-run.json");
1284        let journal_path = root.join("restore-apply-journal.json");
1285        let status_path = root.join("restore-apply-status.json");
1286        let mut manifest = restore_ready_manifest();
1287        write_manifest_artifacts(&root, &mut manifest);
1288        let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
1289
1290        fs::write(
1291            &plan_path,
1292            serde_json::to_vec(&plan).expect("serialize plan"),
1293        )
1294        .expect("write plan");
1295
1296        run([
1297            OsString::from("apply"),
1298            OsString::from("--plan"),
1299            OsString::from(plan_path.as_os_str()),
1300            OsString::from("--backup-dir"),
1301            OsString::from(root.as_os_str()),
1302            OsString::from("--dry-run"),
1303            OsString::from("--out"),
1304            OsString::from(out_path.as_os_str()),
1305            OsString::from("--journal-out"),
1306            OsString::from(journal_path.as_os_str()),
1307        ])
1308        .expect("write apply dry-run");
1309        run([
1310            OsString::from("apply-status"),
1311            OsString::from("--journal"),
1312            OsString::from(journal_path.as_os_str()),
1313            OsString::from("--out"),
1314            OsString::from(status_path.as_os_str()),
1315        ])
1316        .expect("write apply status");
1317
1318        let dry_run: RestoreApplyDryRun =
1319            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1320                .expect("decode dry-run");
1321        let validation = dry_run
1322            .artifact_validation
1323            .expect("artifact validation should be present");
1324        let journal_json: serde_json::Value =
1325            serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
1326                .expect("decode journal");
1327        let status_json: serde_json::Value =
1328            serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
1329                .expect("decode apply status");
1330
1331        fs::remove_dir_all(root).expect("remove temp root");
1332        assert_eq!(validation.checked_members, 2);
1333        assert!(validation.artifacts_present);
1334        assert!(validation.checksums_verified);
1335        assert_eq!(validation.members_with_expected_checksums, 2);
1336        assert_eq!(journal_json["ready"], true);
1337        assert_eq!(journal_json["operation_count"], 8);
1338        assert_eq!(journal_json["ready_operations"], 8);
1339        assert_eq!(journal_json["blocked_operations"], 0);
1340        assert_eq!(journal_json["operations"][0]["state"], "ready");
1341        assert_eq!(status_json["ready"], true);
1342        assert_eq!(status_json["operation_count"], 8);
1343        assert_eq!(status_json["next_ready_sequence"], 0);
1344        assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
1345    }
1346
1347    // Ensure apply-status rejects structurally inconsistent journals.
1348    #[test]
1349    fn run_restore_apply_status_rejects_invalid_journal() {
1350        let root = temp_dir("canic-cli-restore-apply-status-invalid");
1351        fs::create_dir_all(&root).expect("create temp root");
1352        let journal_path = root.join("restore-apply-journal.json");
1353        let out_path = root.join("restore-apply-status.json");
1354        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1355        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
1356        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
1357        journal.operation_count += 1;
1358
1359        fs::write(
1360            &journal_path,
1361            serde_json::to_vec(&journal).expect("serialize journal"),
1362        )
1363        .expect("write journal");
1364
1365        let err = run([
1366            OsString::from("apply-status"),
1367            OsString::from("--journal"),
1368            OsString::from(journal_path.as_os_str()),
1369            OsString::from("--out"),
1370            OsString::from(out_path.as_os_str()),
1371        ])
1372        .expect_err("invalid journal should fail");
1373
1374        assert!(!out_path.exists());
1375        fs::remove_dir_all(root).expect("remove temp root");
1376        assert!(matches!(
1377            err,
1378            RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
1379                field: "operation_count",
1380                ..
1381            })
1382        ));
1383    }
1384
1385    // Ensure apply-next writes the full next ready operation row for runners.
1386    #[test]
1387    fn run_restore_apply_next_writes_next_ready_operation() {
1388        let root = temp_dir("canic-cli-restore-apply-next");
1389        fs::create_dir_all(&root).expect("create temp root");
1390        let journal_path = root.join("restore-apply-journal.json");
1391        let out_path = root.join("restore-apply-next.json");
1392        let mut journal = ready_apply_journal();
1393        journal
1394            .mark_operation_completed(0)
1395            .expect("mark first operation complete");
1396
1397        fs::write(
1398            &journal_path,
1399            serde_json::to_vec(&journal).expect("serialize journal"),
1400        )
1401        .expect("write journal");
1402
1403        run([
1404            OsString::from("apply-next"),
1405            OsString::from("--journal"),
1406            OsString::from(journal_path.as_os_str()),
1407            OsString::from("--out"),
1408            OsString::from(out_path.as_os_str()),
1409        ])
1410        .expect("write apply next");
1411
1412        let next: RestoreApplyNextOperation =
1413            serde_json::from_slice(&fs::read(&out_path).expect("read next operation"))
1414                .expect("decode next operation");
1415        let operation = next.operation.expect("operation should be available");
1416
1417        fs::remove_dir_all(root).expect("remove temp root");
1418        assert!(next.ready);
1419        assert!(next.operation_available);
1420        assert_eq!(operation.sequence, 1);
1421        assert_eq!(
1422            operation.operation,
1423            canic_backup::restore::RestoreApplyOperationKind::LoadSnapshot
1424        );
1425    }
1426
1427    // Ensure apply-mark can advance one journal operation and keep counts consistent.
1428    #[test]
1429    fn run_restore_apply_mark_completes_operation() {
1430        let root = temp_dir("canic-cli-restore-apply-mark-complete");
1431        fs::create_dir_all(&root).expect("create temp root");
1432        let journal_path = root.join("restore-apply-journal.json");
1433        let updated_path = root.join("restore-apply-journal.updated.json");
1434        let journal = ready_apply_journal();
1435
1436        fs::write(
1437            &journal_path,
1438            serde_json::to_vec(&journal).expect("serialize journal"),
1439        )
1440        .expect("write journal");
1441
1442        run([
1443            OsString::from("apply-mark"),
1444            OsString::from("--journal"),
1445            OsString::from(journal_path.as_os_str()),
1446            OsString::from("--sequence"),
1447            OsString::from("0"),
1448            OsString::from("--state"),
1449            OsString::from("completed"),
1450            OsString::from("--out"),
1451            OsString::from(updated_path.as_os_str()),
1452        ])
1453        .expect("mark operation completed");
1454
1455        let updated: RestoreApplyJournal =
1456            serde_json::from_slice(&fs::read(&updated_path).expect("read updated journal"))
1457                .expect("decode updated journal");
1458        let status = updated.status();
1459
1460        fs::remove_dir_all(root).expect("remove temp root");
1461        assert_eq!(updated.completed_operations, 1);
1462        assert_eq!(updated.ready_operations, 7);
1463        assert_eq!(status.next_ready_sequence, Some(1));
1464    }
1465
1466    // Ensure apply-mark requires failure reasons for failed operation state.
1467    #[test]
1468    fn run_restore_apply_mark_failed_requires_reason() {
1469        let root = temp_dir("canic-cli-restore-apply-mark-failed-reason");
1470        fs::create_dir_all(&root).expect("create temp root");
1471        let journal_path = root.join("restore-apply-journal.json");
1472        let journal = ready_apply_journal();
1473
1474        fs::write(
1475            &journal_path,
1476            serde_json::to_vec(&journal).expect("serialize journal"),
1477        )
1478        .expect("write journal");
1479
1480        let err = run([
1481            OsString::from("apply-mark"),
1482            OsString::from("--journal"),
1483            OsString::from(journal_path.as_os_str()),
1484            OsString::from("--sequence"),
1485            OsString::from("0"),
1486            OsString::from("--state"),
1487            OsString::from("failed"),
1488        ])
1489        .expect_err("failed state should require reason");
1490
1491        fs::remove_dir_all(root).expect("remove temp root");
1492        assert!(matches!(
1493            err,
1494            RestoreCommandError::RestoreApplyJournal(
1495                RestoreApplyJournalError::FailureReasonRequired(0)
1496            )
1497        ));
1498    }
1499
1500    // Ensure restore apply dry-run rejects status files from another plan.
1501    #[test]
1502    fn run_restore_apply_dry_run_rejects_mismatched_status() {
1503        let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
1504        fs::create_dir_all(&root).expect("create temp root");
1505        let plan_path = root.join("restore-plan.json");
1506        let status_path = root.join("restore-status.json");
1507        let out_path = root.join("restore-apply-dry-run.json");
1508        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1509        let mut status = RestoreStatus::from_plan(&plan);
1510        status.backup_id = "other-backup".to_string();
1511
1512        fs::write(
1513            &plan_path,
1514            serde_json::to_vec(&plan).expect("serialize plan"),
1515        )
1516        .expect("write plan");
1517        fs::write(
1518            &status_path,
1519            serde_json::to_vec(&status).expect("serialize status"),
1520        )
1521        .expect("write status");
1522
1523        let err = run([
1524            OsString::from("apply"),
1525            OsString::from("--plan"),
1526            OsString::from(plan_path.as_os_str()),
1527            OsString::from("--status"),
1528            OsString::from(status_path.as_os_str()),
1529            OsString::from("--dry-run"),
1530            OsString::from("--out"),
1531            OsString::from(out_path.as_os_str()),
1532        ])
1533        .expect_err("mismatched status should fail");
1534
1535        assert!(!out_path.exists());
1536        fs::remove_dir_all(root).expect("remove temp root");
1537        assert!(matches!(
1538            err,
1539            RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
1540                field: "backup_id",
1541                ..
1542            })
1543        ));
1544    }
1545
1546    // Build one manually ready apply journal for runner-focused CLI tests.
1547    fn ready_apply_journal() -> RestoreApplyJournal {
1548        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1549        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
1550        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
1551
1552        journal.ready = true;
1553        journal.blocked_reasons = Vec::new();
1554        for operation in &mut journal.operations {
1555            operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
1556            operation.blocking_reasons = Vec::new();
1557        }
1558        journal.blocked_operations = 0;
1559        journal.ready_operations = journal.operation_count;
1560        journal.validate().expect("journal should validate");
1561        journal
1562    }
1563
1564    // Build one valid manifest for restore planning tests.
1565    fn valid_manifest() -> FleetBackupManifest {
1566        FleetBackupManifest {
1567            manifest_version: 1,
1568            backup_id: "backup-test".to_string(),
1569            created_at: "2026-05-03T00:00:00Z".to_string(),
1570            tool: ToolMetadata {
1571                name: "canic".to_string(),
1572                version: "0.30.1".to_string(),
1573            },
1574            source: SourceMetadata {
1575                environment: "local".to_string(),
1576                root_canister: ROOT.to_string(),
1577            },
1578            consistency: ConsistencySection {
1579                mode: ConsistencyMode::CrashConsistent,
1580                backup_units: vec![BackupUnit {
1581                    unit_id: "fleet".to_string(),
1582                    kind: BackupUnitKind::SubtreeRooted,
1583                    roles: vec!["root".to_string(), "app".to_string()],
1584                    consistency_reason: None,
1585                    dependency_closure: Vec::new(),
1586                    topology_validation: "subtree-closed".to_string(),
1587                    quiescence_strategy: None,
1588                }],
1589            },
1590            fleet: FleetSection {
1591                topology_hash_algorithm: "sha256".to_string(),
1592                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1593                discovery_topology_hash: HASH.to_string(),
1594                pre_snapshot_topology_hash: HASH.to_string(),
1595                topology_hash: HASH.to_string(),
1596                members: vec![
1597                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
1598                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
1599                ],
1600            },
1601            verification: VerificationPlan::default(),
1602        }
1603    }
1604
1605    // Build one manifest whose restore readiness metadata is complete.
1606    fn restore_ready_manifest() -> FleetBackupManifest {
1607        let mut manifest = valid_manifest();
1608        for member in &mut manifest.fleet.members {
1609            member.source_snapshot.module_hash = Some(HASH.to_string());
1610            member.source_snapshot.wasm_hash = Some(HASH.to_string());
1611            member.source_snapshot.checksum = Some(HASH.to_string());
1612        }
1613        manifest
1614    }
1615
1616    // Build one valid manifest member.
1617    fn fleet_member(
1618        role: &str,
1619        canister_id: &str,
1620        parent_canister_id: Option<&str>,
1621        identity_mode: IdentityMode,
1622    ) -> FleetMember {
1623        FleetMember {
1624            role: role.to_string(),
1625            canister_id: canister_id.to_string(),
1626            parent_canister_id: parent_canister_id.map(str::to_string),
1627            subnet_canister_id: Some(ROOT.to_string()),
1628            controller_hint: None,
1629            identity_mode,
1630            restore_group: 1,
1631            verification_class: "basic".to_string(),
1632            verification_checks: vec![VerificationCheck {
1633                kind: "status".to_string(),
1634                method: None,
1635                roles: vec![role.to_string()],
1636            }],
1637            source_snapshot: SourceSnapshot {
1638                snapshot_id: format!("{role}-snapshot"),
1639                module_hash: None,
1640                wasm_hash: None,
1641                code_version: Some("v0.30.1".to_string()),
1642                artifact_path: format!("artifacts/{role}"),
1643                checksum_algorithm: "sha256".to_string(),
1644                checksum: None,
1645            },
1646        }
1647    }
1648
1649    // Write a canonical backup layout whose journal checksums match the artifacts.
1650    fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
1651        layout.write_manifest(manifest).expect("write manifest");
1652
1653        let artifacts = manifest
1654            .fleet
1655            .members
1656            .iter()
1657            .map(|member| {
1658                let bytes = format!("{} artifact", member.role);
1659                let artifact_path = root.join(&member.source_snapshot.artifact_path);
1660                if let Some(parent) = artifact_path.parent() {
1661                    fs::create_dir_all(parent).expect("create artifact parent");
1662                }
1663                fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1664                let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1665
1666                ArtifactJournalEntry {
1667                    canister_id: member.canister_id.clone(),
1668                    snapshot_id: member.source_snapshot.snapshot_id.clone(),
1669                    state: ArtifactState::Durable,
1670                    temp_path: None,
1671                    artifact_path: member.source_snapshot.artifact_path.clone(),
1672                    checksum_algorithm: checksum.algorithm,
1673                    checksum: Some(checksum.hash),
1674                    updated_at: "2026-05-03T00:00:00Z".to_string(),
1675                }
1676            })
1677            .collect();
1678
1679        layout
1680            .write_journal(&DownloadJournal {
1681                journal_version: 1,
1682                backup_id: manifest.backup_id.clone(),
1683                discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
1684                pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
1685                operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
1686                artifacts,
1687            })
1688            .expect("write journal");
1689    }
1690
1691    // Write artifact bytes and update the manifest checksums for apply validation.
1692    fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
1693        for member in &mut manifest.fleet.members {
1694            let bytes = format!("{} apply artifact", member.role);
1695            let artifact_path = root.join(&member.source_snapshot.artifact_path);
1696            if let Some(parent) = artifact_path.parent() {
1697                fs::create_dir_all(parent).expect("create artifact parent");
1698            }
1699            fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1700            let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1701            member.source_snapshot.checksum = Some(checksum.hash);
1702        }
1703    }
1704
1705    // Build a unique temporary directory.
1706    fn temp_dir(prefix: &str) -> PathBuf {
1707        let nanos = SystemTime::now()
1708            .duration_since(UNIX_EPOCH)
1709            .expect("system time after epoch")
1710            .as_nanos();
1711        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1712    }
1713}