1use crate::{Plan, Step, quote};
10use helixforge_core::{Risk, printable};
11use std::{
12 fs,
13 path::{Path, PathBuf},
14};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct Backup {
19 pub backup: PathBuf,
20 pub original: PathBuf,
21 pub taken: Option<String>,
23 pub system: bool,
25 pub directory: bool,
26}
27
28impl Backup {
29 fn stamp(&self) -> String {
31 self.taken.clone().unwrap_or_default()
32 }
33}
34
35pub fn parse_name(name: &str) -> Option<(&str, &str)> {
37 for marker in [".commander-backup-", ".helixforge-backup-"] {
38 if let Some((original, stamp)) = name.rsplit_once(marker)
39 && !original.is_empty()
40 && stamp.len() >= 14
41 && stamp
42 .chars()
43 .all(|character| character.is_ascii_digit() || character == '-')
44 {
45 return Some((original, stamp));
46 }
47 }
48 None
49}
50
51fn readable(stamp: &str) -> Option<String> {
53 let digits: String = stamp.chars().take_while(char::is_ascii_digit).collect();
54 if digits.len() < 14 {
55 return None;
56 }
57 Some(format!(
58 "{}-{}-{} {}:{}:{}",
59 &digits[0..4],
60 &digits[4..6],
61 &digits[6..8],
62 &digits[8..10],
63 &digits[10..12],
64 &digits[12..14]
65 ))
66}
67
68pub struct Roots {
72 pub user: Vec<PathBuf>,
74 pub home: Option<PathBuf>,
76 pub system: Vec<(PathBuf, usize)>,
78}
79
80impl Roots {
81 pub fn here() -> Self {
83 let home = std::env::var_os("HOME").map(PathBuf::from);
84 let xdg = |name: &str, fallback: &str| {
85 std::env::var_os(name)
86 .map(PathBuf::from)
87 .filter(|path| path.is_absolute())
88 .or_else(|| home.as_ref().map(|home| home.join(fallback)))
89 };
90 Roots {
91 user: [
92 xdg("XDG_CONFIG_HOME", ".config"),
93 xdg("XDG_DATA_HOME", ".local/share"),
94 home.as_ref().map(|home| home.join(".local/bin")),
95 ]
96 .into_iter()
97 .flatten()
98 .collect(),
99 home: home.clone(),
100 system: vec![
101 (PathBuf::from("/etc"), 3),
102 (PathBuf::from("/boot"), 3),
103 (PathBuf::from("/usr/share/sddm/themes"), 1),
104 (PathBuf::from("/usr/share/fonts"), 2),
105 ],
106 }
107 }
108}
109
110const SKIP: [&str; 8] = [
112 ".git",
113 "node_modules",
114 "Trash",
115 "containers",
116 "Steam",
117 "flatpak",
118 "helixforge",
119 "sources",
120];
121
122pub fn discover(roots: &Roots) -> Vec<Backup> {
124 let mut found = Vec::new();
125 if let Some(home) = &roots.home {
126 walk(home, 1, false, &mut found);
127 }
128 for root in &roots.user {
129 walk(root, usize::MAX, false, &mut found);
130 }
131 for (root, depth) in &roots.system {
132 walk(root, *depth, true, &mut found);
133 }
134 found.sort_by_key(|backup| std::cmp::Reverse(backup.stamp()));
135 found.dedup_by(|a, b| a.backup == b.backup);
136 found
137}
138
139fn walk(directory: &Path, depth: usize, system: bool, into: &mut Vec<Backup>) {
140 if depth == 0 {
141 return;
142 }
143 let Ok(listing) = fs::read_dir(directory) else {
144 return;
145 };
146 for item in listing.flatten() {
147 let path = item.path();
148 let Ok(kind) = item.file_type() else { continue };
150 if kind.is_symlink() {
151 continue;
152 }
153 let name = item.file_name().to_string_lossy().into_owned();
154 if let Some((original, stamp)) = parse_name(&name) {
155 into.push(Backup {
156 original: directory.join(original),
157 taken: readable(stamp),
158 system,
159 directory: kind.is_dir(),
160 backup: path,
161 });
162 continue;
163 }
164 if kind.is_dir() && !SKIP.contains(&name.as_str()) {
165 walk(&path, depth - 1, system, into);
166 }
167 }
168}
169
170pub fn restore_step(backup: &Backup) -> Step {
173 let sudo = if backup.system { "sudo " } else { "" };
174 let original = quote(&backup.original.to_string_lossy());
175 let copy = quote(&backup.backup.to_string_lossy());
176 let program = format!(
177 "hf_stamp=$(date +%Y%m%d%H%M%S)\n\
178 if [ -e {original} ] || [ -L {original} ]; then\n \
179 {sudo}mv -- {original} {original}.helixforge-backup-\"$hf_stamp\" || exit 1\n \
180 printf 'HelixForge: the current one is kept as %s\\n' {original}.helixforge-backup-\"$hf_stamp\"\n\
181 fi\n\
182 {sudo}cp -a -- {copy} {original} || exit 1\n\
183 printf 'HelixForge: restored %s\\n' {original}"
184 );
185 Step::Shell {
186 id: "restore".to_string(),
187 name: printable(&format!("Restore {}", backup.original.display())),
188 program,
189 display: printable(&format!(
191 "{sudo}cp -a {} {} (the current one is kept as a backup first)",
192 backup.backup.display(),
193 backup.original.display()
194 )),
195 requires: if backup.system {
196 vec!["sudo".to_string()]
197 } else {
198 Vec::new()
199 },
200 }
201}
202
203pub fn plan_restore(backup: &Backup) -> Plan {
205 let step = restore_step(backup);
206 Plan {
207 requirements: step_requirements(&step),
208 privileged: backup.system,
209 risk: if backup.system {
210 Risk::Medium
211 } else {
212 Risk::Low
213 },
214 steps: vec![step],
215 depends: vec![Vec::new()],
216 ..Plan::default()
217 }
218}
219
220fn step_requirements(step: &Step) -> Vec<String> {
221 match step {
222 Step::Shell { requires, .. } => requires.clone(),
223 _ => Vec::new(),
224 }
225}
226
227#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum Snapshots {
230 Snapper,
232 Timeshift,
233}
234
235impl Snapshots {
236 pub fn here() -> Option<Self> {
238 Self::detect(
239 &|path| Path::new(path).exists(),
240 &helixforge_platform::binary_exists,
241 )
242 }
243
244 pub fn detect(exists: &dyn Fn(&str) -> bool, on_path: &dyn Fn(&str) -> bool) -> Option<Self> {
246 if on_path("snapper") && exists("/etc/snapper/configs/root") {
247 Some(Snapshots::Snapper)
248 } else if on_path("timeshift") && exists("/etc/timeshift/timeshift.json") {
249 Some(Snapshots::Timeshift)
250 } else {
251 None
252 }
253 }
254
255 pub fn label(self) -> &'static str {
256 match self {
257 Snapshots::Snapper => "Snapper",
258 Snapshots::Timeshift => "Timeshift",
259 }
260 }
261
262 pub fn how_to_restore(self) -> &'static str {
264 match self {
265 Snapshots::Snapper => {
266 "Btrfs Assistant, or snapper list and snapper rollback, from a terminal or the boot menu's snapshots"
267 }
268 Snapshots::Timeshift => "Timeshift, or timeshift --restore from a terminal",
269 }
270 }
271
272 pub fn step(self, description: &str) -> Step {
274 let words: Vec<String> = match self {
275 Snapshots::Snapper => vec![
276 "sudo".into(),
277 "snapper".into(),
278 "-c".into(),
279 "root".into(),
280 "create".into(),
281 "--cleanup-algorithm".into(),
282 "number".into(),
283 "--description".into(),
284 description.into(),
285 ],
286 Snapshots::Timeshift => vec![
287 "sudo".into(),
288 "timeshift".into(),
289 "--create".into(),
290 "--comments".into(),
291 description.into(),
292 ],
293 };
294 let program = words
295 .iter()
296 .map(|word| quote(word))
297 .collect::<Vec<_>>()
298 .join(" ");
299 Step::Shell {
300 id: "snapshot".to_string(),
301 name: format!("Take a {} snapshot", self.label()),
302 program,
303 display: words.join(" "),
304 requires: vec![self.tool().to_string(), "sudo".to_string()],
305 }
306 }
307
308 fn tool(self) -> &'static str {
309 match self {
310 Snapshots::Snapper => "snapper",
311 Snapshots::Timeshift => "timeshift",
312 }
313 }
314}
315
316pub fn worth_a_snapshot(plan: &Plan, flags: &dyn Fn(&str) -> Option<String>) -> bool {
319 plan.risk == Risk::High
320 || plan.steps.iter().any(|step| {
321 flags(step.id()).is_some_and(|flags| {
322 flags
323 .split_whitespace()
324 .any(|flag| matches!(flag, "PFM" | "K" | "D" | "SI"))
325 })
326 })
327}
328
329pub fn with_snapshot(plan: &mut Plan, tool: Snapshots) {
331 let names: Vec<&str> = plan.steps.iter().map(Step::name).collect();
332 let description = format!("HelixForge, before: {}", names.join(", "));
333 let step = tool.step(&description);
334 plan.requirements.extend(step_requirements(&step));
335 plan.requirements.sort();
336 plan.requirements.dedup();
337 plan.privileged = true;
338 plan.steps.insert(0, step);
339 let mut depends = vec![Vec::new()];
341 depends.extend(
342 plan.depends
343 .iter()
344 .map(|list| list.iter().map(|index| index + 1).collect()),
345 );
346 plan.depends = depends;
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::shell_program;
353 use std::process::Command;
354
355 #[test]
356 fn backup_names_give_the_original_and_the_time() {
357 assert_eq!(
358 parse_name("grub.commander-backup-20261015164340-4242"),
359 Some(("grub", "20261015164340-4242"))
360 );
361 assert_eq!(
362 parse_name("pacman.conf.helixforge-backup-20261015164340"),
363 Some(("pacman.conf", "20261015164340"))
364 );
365 assert_eq!(
366 parse_name("example.commander-backup-20261015142214095844"),
367 Some(("example", "20261015142214095844"))
368 );
369 assert_eq!(parse_name("grub.backup"), None);
370 assert_eq!(parse_name(".commander-backup-20261015164340"), None);
371 assert_eq!(parse_name("x.commander-backup-2026"), None);
372 assert_eq!(
373 readable("20261015164340-1").as_deref(),
374 Some("2026-10-15 16:43:40")
375 );
376 }
377
378 #[test]
379 fn backups_are_found_newest_first_and_links_are_left_alone() {
380 let root = tempfile::tempdir().unwrap();
381 let home = root.path().join("home");
382 let config = home.join(".config");
383 let etc = root.path().join("etc");
384 let write = |path: &Path| {
385 fs::create_dir_all(path.parent().unwrap()).unwrap();
386 fs::write(path, "x").unwrap();
387 };
388 write(&config.join("ghostty/config.commander-backup-20261001000000-1"));
389 write(&config.join("deep/a/b/c/d/e.helixforge-backup-20261003000000"));
390 fs::create_dir_all(config.join("nvim.commander-backup-20261002000000-7/lua")).unwrap();
391 write(&home.join(".bashrc.commander-backup-20260901000000-1"));
392 write(&config.join("node_modules/x.commander-backup-20261005000000-1"));
393 write(&etc.join("default/grub.commander-backup-20261004000000-9"));
394 write(&etc.join("a/b/c/d/too-deep.commander-backup-20261006000000-1"));
395 std::os::unix::fs::symlink(
396 etc.join("default/grub.commander-backup-20261004000000-9"),
397 config.join("linked.commander-backup-20261007000000-1"),
398 )
399 .unwrap();
400 let roots = Roots {
401 user: vec![config.clone()],
402 home: Some(home.clone()),
403 system: vec![(etc.clone(), 3)],
404 };
405 let found = discover(&roots);
406 let shown: Vec<(String, bool, bool)> = found
407 .iter()
408 .map(|backup| {
409 (
410 backup
411 .original
412 .strip_prefix(root.path())
413 .unwrap()
414 .display()
415 .to_string(),
416 backup.system,
417 backup.directory,
418 )
419 })
420 .collect();
421 assert_eq!(
422 shown,
423 [
424 ("etc/default/grub".to_string(), true, false),
425 ("home/.config/deep/a/b/c/d/e".to_string(), false, false),
426 ("home/.config/nvim".to_string(), false, true),
427 ("home/.config/ghostty/config".to_string(), false, false),
428 ("home/.bashrc".to_string(), false, false),
429 ]
430 );
431 assert_eq!(found[0].taken.as_deref(), Some("2026-10-04 00:00:00"));
432 }
433
434 #[test]
435 fn a_restore_keeps_the_current_one_and_can_be_undone() {
436 let root = tempfile::tempdir().unwrap();
437 let original = root.path().join("config");
438 let backup = root.path().join("config.commander-backup-20261001000000-1");
439 fs::write(&original, "current").unwrap();
440 fs::write(&backup, "older").unwrap();
441 let found = Backup {
442 backup: backup.clone(),
443 original: original.clone(),
444 taken: readable("20261001000000"),
445 system: false,
446 directory: false,
447 };
448 let plan = plan_restore(&found);
449 assert_eq!(plan.risk, Risk::Low);
450 assert!(!plan.privileged);
451 assert!(plan.steps[0].display().starts_with("cp -a "));
452 let run = |plan: &Plan| {
453 let output = Command::new("sh")
454 .arg("-c")
455 .arg(shell_program(plan, Path::new("/lib"), Path::new("/hf")))
456 .output()
457 .unwrap();
458 assert!(
459 output.status.success(),
460 "{}",
461 String::from_utf8_lossy(&output.stderr)
462 );
463 };
464 run(&plan);
465 assert_eq!(fs::read_to_string(&original).unwrap(), "older");
466 assert_eq!(
467 fs::read_to_string(&backup).unwrap(),
468 "older",
469 "the backup stays"
470 );
471 let kept: Vec<PathBuf> = fs::read_dir(root.path())
472 .unwrap()
473 .flatten()
474 .map(|item| item.path())
475 .filter(|path| path.to_string_lossy().contains(".helixforge-backup-"))
476 .collect();
477 assert_eq!(kept.len(), 1);
478 assert_eq!(fs::read_to_string(&kept[0]).unwrap(), "current");
479 let folder = root.path().join("nvim");
481 let saved = root.path().join("nvim.commander-backup-20261001000000-1");
482 fs::create_dir_all(saved.join("lua")).unwrap();
483 fs::write(saved.join("lua/init.lua"), "old").unwrap();
484 let plan = plan_restore(&Backup {
485 backup: saved,
486 original: folder.clone(),
487 taken: None,
488 system: false,
489 directory: true,
490 });
491 run(&plan);
492 assert_eq!(
493 fs::read_to_string(folder.join("lua/init.lua")).unwrap(),
494 "old"
495 );
496 let system = plan_restore(&Backup {
498 backup: PathBuf::from("/etc/default/grub.commander-backup-20261001000000-1"),
499 original: PathBuf::from("/etc/default/grub"),
500 taken: None,
501 system: true,
502 directory: false,
503 });
504 assert!(system.privileged);
505 assert_eq!(system.requirements, ["sudo"]);
506 assert!(system.steps[0].display().starts_with("sudo cp -a "));
507 }
508
509 #[test]
510 fn a_strange_name_is_shown_safely_and_restored_as_it_is() {
511 let backup = Backup {
512 backup: PathBuf::from("/home/me/.config/a\u{1b}[2Jb.commander-backup-20261001000000-1"),
513 original: PathBuf::from("/home/me/.config/a\u{1b}[2Jb"),
514 taken: None,
515 system: false,
516 directory: false,
517 };
518 let step = restore_step(&backup);
519 assert!(!step.display().contains('\u{1b}'));
520 assert!(!step.name().contains('\u{1b}'));
521 let Step::Shell { program, .. } = &step else {
522 panic!("expected a shell step");
523 };
524 assert!(
525 program.contains("'/home/me/.config/a\u{1b}[2Jb'"),
526 "the real name, quoted"
527 );
528 }
529
530 #[test]
531 fn snapshots_go_first_when_the_system_changes() {
532 let snapper = |path: &str| path == "/etc/snapper/configs/root";
533 assert_eq!(
534 Snapshots::detect(&snapper, &|_| true),
535 Some(Snapshots::Snapper)
536 );
537 assert_eq!(Snapshots::detect(&|_| false, &|_| true), None);
538 assert_eq!(
539 Snapshots::detect(
540 &|path| path == "/etc/timeshift/timeshift.json",
541 &|tool| tool == "timeshift"
542 ),
543 Some(Snapshots::Timeshift)
544 );
545 let mut plan = Plan {
546 steps: vec![
547 Step::Command {
548 id: "grub".into(),
549 name: "GRUB Theme".into(),
550 line: "true".into(),
551 },
552 Step::Command {
553 id: "after".into(),
554 name: "After".into(),
555 line: "true".into(),
556 },
557 ],
558 depends: vec![vec![], vec![0]],
559 risk: Risk::Medium,
560 ..Plan::default()
561 };
562 let flags = |id: &str| (id == "grub").then(|| "I PFM MP".to_string());
563 assert!(worth_a_snapshot(&plan, &flags));
564 assert!(!worth_a_snapshot(&plan, &|_| Some("I".to_string())));
565 with_snapshot(&mut plan, Snapshots::Snapper);
566 assert_eq!(plan.steps[0].name(), "Take a Snapper snapshot");
567 assert_eq!(
568 plan.steps[0].display(),
569 "sudo snapper -c root create --cleanup-algorithm number --description HelixForge, before: GRUB Theme, After"
570 );
571 assert_eq!(plan.depends, [vec![], vec![], vec![1]]);
572 assert!(plan.privileged);
573 assert!(plan.requirements.contains(&"snapper".to_string()));
574 }
575}