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