agent_runtime/restore_backups/
plan.rs1use crate::install::plan::{InstallPlan, PlanAction};
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum BackupRunSelector {
16 Latest,
18 Exact(u64),
20}
21
22impl std::str::FromStr for BackupRunSelector {
23 type Err = String;
24 fn from_str(s: &str) -> Result<Self, Self::Err> {
25 if s.eq_ignore_ascii_case("latest") {
26 return Ok(Self::Latest);
27 }
28 s.parse::<u64>().map(Self::Exact).map_err(|err| {
29 format!("--from must be `latest` or a unix-seconds timestamp (got `{s}`): {err}")
30 })
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum RestoreAction {
39 RestoreFile {
47 entry_id: String,
48 source_backup: PathBuf,
49 dest: PathBuf,
50 expected_install_source: PathBuf,
51 },
52 SkippedNoMatch {
56 entry_id: String,
57 source_backup: PathBuf,
58 },
59 SkippedAmbiguous {
64 entry_id: String,
65 source_backup: PathBuf,
66 candidates: Vec<PathBuf>,
67 },
68}
69
70#[derive(Debug, Clone)]
73pub struct RestorePlan {
74 pub product: String,
75 pub home: PathBuf,
76 pub backup_run: PathBuf,
77 pub actions: Vec<RestoreAction>,
78}
79
80#[derive(Debug, Error)]
84pub enum RestorePlanError {
85 #[error("io error reading backup run {path}: {source}")]
86 Io {
87 path: PathBuf,
88 #[source]
89 source: std::io::Error,
90 },
91}
92
93impl RestorePlan {
94 pub fn from_backup_run(
103 backup_run: &Path,
104 install_plan: &InstallPlan,
105 surface_filter: Option<&str>,
106 ) -> Result<Self, RestorePlanError> {
107 let mut actions = Vec::new();
108 let entries = match std::fs::read_dir(backup_run) {
109 Ok(r) => r,
110 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
111 return Ok(Self {
112 product: install_plan.product.clone(),
113 home: install_plan.home.clone(),
114 backup_run: backup_run.to_path_buf(),
115 actions,
116 });
117 }
118 Err(source) => {
119 return Err(RestorePlanError::Io {
120 path: backup_run.to_path_buf(),
121 source,
122 });
123 }
124 };
125
126 let mut entry_dirs: Vec<(String, PathBuf)> = Vec::new();
127 for entry in entries {
128 let entry = entry.map_err(|source| RestorePlanError::Io {
129 path: backup_run.to_path_buf(),
130 source,
131 })?;
132 let file_type = entry.file_type().map_err(|source| RestorePlanError::Io {
133 path: entry.path(),
134 source,
135 })?;
136 if !file_type.is_dir() {
137 continue;
139 }
140 let name = match entry.file_name().into_string() {
141 Ok(s) => s,
142 Err(_) => continue,
143 };
144 entry_dirs.push((name, entry.path()));
145 }
146 entry_dirs.sort_by(|a, b| a.0.cmp(&b.0));
147
148 for (entry_id, entry_dir) in entry_dirs {
149 if let Some(filter) = surface_filter
150 && entry_id != filter
151 {
152 continue;
153 }
154 walk_backup_dir(&entry_id, &entry_dir, install_plan, &mut actions)?;
155 }
156
157 Ok(Self {
158 product: install_plan.product.clone(),
159 home: install_plan.home.clone(),
160 backup_run: backup_run.to_path_buf(),
161 actions,
162 })
163 }
164}
165
166fn walk_backup_dir(
167 entry_id: &str,
168 dir: &Path,
169 install_plan: &InstallPlan,
170 out: &mut Vec<RestoreAction>,
171) -> Result<(), RestorePlanError> {
172 let mut files: Vec<PathBuf> = Vec::new();
173 collect_files(dir, &mut files)?;
174 files.sort();
175 for backup_file in files {
176 let file_name = match backup_file.file_name() {
177 Some(n) => n.to_os_string(),
178 None => continue,
179 };
180 let candidates: Vec<(PathBuf, PathBuf)> = install_plan
181 .actions
182 .iter()
183 .filter_map(|a| match a {
184 PlanAction::Symlink {
185 entry_id: id,
186 source,
187 dest,
188 ..
189 } if id == entry_id && dest.file_name() == Some(file_name.as_ref()) => {
190 Some((dest.clone(), source.clone()))
191 }
192 _ => None,
193 })
194 .collect();
195 match candidates.len() {
196 1 => {
197 let (dest, expected_install_source) =
198 candidates.into_iter().next().expect("len==1");
199 out.push(RestoreAction::RestoreFile {
200 entry_id: entry_id.to_string(),
201 source_backup: backup_file,
202 dest,
203 expected_install_source,
204 });
205 }
206 0 => out.push(RestoreAction::SkippedNoMatch {
207 entry_id: entry_id.to_string(),
208 source_backup: backup_file,
209 }),
210 _ => out.push(RestoreAction::SkippedAmbiguous {
211 entry_id: entry_id.to_string(),
212 source_backup: backup_file,
213 candidates: candidates.into_iter().map(|(d, _)| d).collect(),
214 }),
215 }
216 }
217 Ok(())
218}
219
220fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), RestorePlanError> {
221 let read = std::fs::read_dir(dir).map_err(|source| RestorePlanError::Io {
222 path: dir.to_path_buf(),
223 source,
224 })?;
225 for entry in read {
226 let entry = entry.map_err(|source| RestorePlanError::Io {
227 path: dir.to_path_buf(),
228 source,
229 })?;
230 let file_type = entry.file_type().map_err(|source| RestorePlanError::Io {
231 path: entry.path(),
232 source,
233 })?;
234 if file_type.is_dir() {
235 collect_files(&entry.path(), out)?;
236 } else if file_type.is_file() {
237 out.push(entry.path());
238 }
239 }
240 Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn selector_parses_latest_case_insensitively() {
249 assert_eq!(
250 "latest".parse::<BackupRunSelector>().unwrap(),
251 BackupRunSelector::Latest
252 );
253 assert_eq!(
254 "LATEST".parse::<BackupRunSelector>().unwrap(),
255 BackupRunSelector::Latest
256 );
257 }
258
259 #[test]
260 fn selector_parses_unix_seconds() {
261 assert_eq!(
262 "1700000000".parse::<BackupRunSelector>().unwrap(),
263 BackupRunSelector::Exact(1_700_000_000)
264 );
265 }
266
267 #[test]
268 fn selector_rejects_garbage() {
269 assert!("yesterday".parse::<BackupRunSelector>().is_err());
270 assert!("-5".parse::<BackupRunSelector>().is_err());
271 }
272}