Skip to main content

agent_runtime/commands/
restore_backups.rs

1use crate::render::manifest::SourceRoot;
2use crate::restore_backups::{
3    self, BackupRunSelector, Mode, RestoreError, RestoreOptions, RestoredChange,
4};
5use clap::Args;
6use std::path::PathBuf;
7
8#[derive(Args, Debug)]
9pub struct RestoreBackupsArgs {
10    /// Source root containing `manifests/`, `core/`, `targets/`, `build/`.
11    /// Defaults to the current working directory.
12    #[arg(long)]
13    pub source_root: Option<PathBuf>,
14    /// Product whose backups should be restored (`codex` or `claude`).
15    #[arg(long)]
16    pub product: String,
17    /// Absolute path of the runtime home (e.g. `~/.codex` / `~/.claude`).
18    /// Relative paths are rejected so restore never writes outside an
19    /// explicitly-chosen directory.
20    #[arg(long)]
21    pub live_home: PathBuf,
22    /// Absolute path of the state home (where backups live).
23    #[arg(long)]
24    pub state_home: PathBuf,
25    /// Backup run to restore from: `latest` or a unix-seconds timestamp
26    /// matching a directory under `<state_home>/backups/<product>/`.
27    /// Required — running without it exits non-zero with the available
28    /// timestamp list.
29    #[arg(long)]
30    pub from: Option<String>,
31    /// Filter to a single link-map entry id. Default: restore every
32    /// backup file in the run.
33    #[arg(long)]
34    pub surface: Option<String>,
35    /// Skip the optional `.private/link-map.overrides.yaml` overlay
36    /// merge. Default: merge if file exists, so overlay-discovered
37    /// entries are restored alongside the canonical link map.
38    #[arg(long, default_value_t = false)]
39    pub no_overlay: bool,
40    /// Override the overlay file location. When set, the conventional
41    /// `<source-root>/.private/link-map.overrides.yaml` is ignored.
42    #[arg(long, conflicts_with = "no_overlay")]
43    pub overlay_path: Option<PathBuf>,
44    /// Print the resolved plan; do not mutate the filesystem.
45    #[arg(long, conflicts_with = "apply")]
46    pub dry_run: bool,
47    /// Restore backup files into their original install destinations.
48    #[arg(long, conflicts_with = "dry_run")]
49    pub apply: bool,
50}
51
52pub fn run(args: RestoreBackupsArgs) -> anyhow::Result<u8> {
53    if !args.live_home.is_absolute() {
54        anyhow::bail!(
55            "agent-runtime restore-backups: --live-home must be absolute (got: {})",
56            args.live_home.display()
57        );
58    }
59    if !args.state_home.is_absolute() {
60        anyhow::bail!(
61            "agent-runtime restore-backups: --state-home must be absolute (got: {})",
62            args.state_home.display()
63        );
64    }
65    if !args.dry_run && !args.apply {
66        anyhow::bail!("agent-runtime restore-backups: pass --dry-run or --apply");
67    }
68    let from = match args.from.as_deref() {
69        Some(s) => s,
70        None => {
71            print_available_timestamps(&args.state_home, &args.product);
72            anyhow::bail!(
73                "agent-runtime restore-backups: --from is required (use `latest` or one of the timestamps printed above)"
74            );
75        }
76    };
77    let selector: BackupRunSelector = from.parse().map_err(|err: String| anyhow::anyhow!(err))?;
78
79    let mode = if args.apply {
80        Mode::Apply
81    } else {
82        Mode::DryRun
83    };
84
85    let root = SourceRoot::from_arg_or_cwd(args.source_root.as_deref())?;
86    let options = RestoreOptions {
87        selector: selector.clone(),
88        surface: args.surface.clone(),
89        overlay_enabled: !args.no_overlay,
90        overlay_path: args.overlay_path.clone(),
91    };
92
93    let outcome = match restore_backups::run(
94        &args.product,
95        root.path(),
96        &args.live_home,
97        &args.state_home,
98        mode,
99        &options,
100    ) {
101        Ok(o) => o,
102        Err(RestoreError::NoBackupRun {
103            root,
104            selector: _,
105            available,
106        }) => {
107            print_timestamps_list(&args.product, &available);
108            anyhow::bail!(
109                "agent-runtime restore-backups: no backup run matches --from {from} under {}",
110                root.display()
111            );
112        }
113        Err(err) => return Err(err.into()),
114    };
115
116    if let Some(s) = outcome.overlay.as_ref() {
117        eprintln!(
118            "agent-runtime restore-backups: overlay merged (dropped={} replaced={} added={})",
119            s.dropped, s.replaced, s.added,
120        );
121    }
122
123    eprintln!(
124        "agent-runtime restore-backups: product={} mode={} from={} actions={} restored={}",
125        outcome.plan.product,
126        if matches!(mode, Mode::Apply) {
127            "apply"
128        } else {
129            "dry-run"
130        },
131        outcome.backup_run.display(),
132        outcome.plan.actions.len(),
133        outcome
134            .changes
135            .iter()
136            .filter(|c| matches!(c, RestoredChange::FileRestored { .. }))
137            .count(),
138    );
139
140    for change in &outcome.changes {
141        print_change(change);
142    }
143
144    Ok(0)
145}
146
147fn print_available_timestamps(state_home: &std::path::Path, product: &str) {
148    let timestamps = restore_backups::list_available_timestamps(state_home, product);
149    print_timestamps_list(product, &timestamps);
150}
151
152fn print_timestamps_list(product: &str, timestamps: &[u64]) {
153    if timestamps.is_empty() {
154        eprintln!(
155            "agent-runtime restore-backups: no backup runs found under <state_home>/backups/{product}/"
156        );
157    } else {
158        eprintln!("agent-runtime restore-backups: available --from values for product={product}:");
159        for ts in timestamps {
160            eprintln!("  - {ts}");
161        }
162        eprintln!("  - latest (resolves to {})", timestamps.last().unwrap());
163    }
164}
165
166fn print_change(c: &RestoredChange) {
167    match c {
168        RestoredChange::FileRestored {
169            entry_id,
170            dest,
171            from_backup,
172        } => eprintln!(
173            "  + restore {} <- {} ({})",
174            dest.display(),
175            from_backup.display(),
176            entry_id
177        ),
178        RestoredChange::SkippedDestRegularFile {
179            entry_id,
180            dest,
181            from_backup: _,
182        } => eprintln!(
183            "  ? skip {} (regular file at dest; not overwriting; {})",
184            dest.display(),
185            entry_id
186        ),
187        RestoredChange::SkippedDestDirectory {
188            entry_id,
189            dest,
190            from_backup: _,
191        } => eprintln!(
192            "  ? skip {} (directory at dest; refuse to destroy; {})",
193            dest.display(),
194            entry_id
195        ),
196        RestoredChange::SkippedNoMatch {
197            entry_id,
198            from_backup,
199        } => eprintln!(
200            "  ? skip {} (no link-map match for entry; {})",
201            from_backup.display(),
202            entry_id
203        ),
204        RestoredChange::SkippedAmbiguous {
205            entry_id,
206            from_backup,
207            candidates,
208        } => {
209            eprintln!(
210                "  ? skip {} (ambiguous: {} candidates; {})",
211                from_backup.display(),
212                candidates.len(),
213                entry_id
214            );
215            for cand in candidates {
216                eprintln!("      candidate: {}", cand.display());
217            }
218        }
219        RestoredChange::SkippedSymlinkForeign {
220            entry_id,
221            dest,
222            actual_target,
223            expected_install_source,
224            from_backup: _,
225        } => eprintln!(
226            "  ? skip {} (foreign target: {}; expected: {}; {})",
227            dest.display(),
228            actual_target.display(),
229            expected_install_source.display(),
230            entry_id
231        ),
232    }
233}