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 changes: vec![crate::ShellChange {
201 path: backup.original.clone(),
202 from: (!backup.directory).then(|| backup.backup.clone()),
203 text: "put back from the backup; the one there now is kept as a backup first"
204 .to_string(),
205 }],
206 }
207}
208
209pub fn plan_restore(backup: &Backup) -> Plan {
211 let step = restore_step(backup);
212 Plan {
213 requirements: step_requirements(&step),
214 privileged: backup.system,
215 risk: if backup.system {
216 Risk::Medium
217 } else {
218 Risk::Low
219 },
220 steps: vec![step],
221 depends: vec![Vec::new()],
222 ..Plan::default()
223 }
224}
225
226fn step_requirements(step: &Step) -> Vec<String> {
227 match step {
228 Step::Shell { requires, .. } => requires.clone(),
229 _ => Vec::new(),
230 }
231}
232
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
235pub enum Snapshots {
236 Snapper,
238 Timeshift,
239}
240
241impl Snapshots {
242 pub fn here() -> Option<Self> {
244 Self::detect(
245 &|path| Path::new(path).exists(),
246 &helixforge_platform::binary_exists,
247 )
248 }
249
250 pub fn detect(exists: &dyn Fn(&str) -> bool, on_path: &dyn Fn(&str) -> bool) -> Option<Self> {
252 if on_path("snapper") && exists("/etc/snapper/configs/root") {
253 Some(Snapshots::Snapper)
254 } else if on_path("timeshift") && exists("/etc/timeshift/timeshift.json") {
255 Some(Snapshots::Timeshift)
256 } else {
257 None
258 }
259 }
260
261 pub fn label(self) -> &'static str {
262 match self {
263 Snapshots::Snapper => "Snapper",
264 Snapshots::Timeshift => "Timeshift",
265 }
266 }
267
268 pub fn how_to_restore(self) -> &'static str {
270 match self {
271 Snapshots::Snapper => {
272 "Btrfs Assistant, or snapper list and snapper rollback, from a terminal or the boot menu's snapshots"
273 }
274 Snapshots::Timeshift => "Timeshift, or timeshift --restore from a terminal",
275 }
276 }
277
278 pub fn step(self, description: &str) -> Step {
280 let words: Vec<String> = match self {
281 Snapshots::Snapper => vec![
282 "sudo".into(),
283 "snapper".into(),
284 "-c".into(),
285 "root".into(),
286 "create".into(),
287 "--cleanup-algorithm".into(),
288 "number".into(),
289 "--description".into(),
290 description.into(),
291 ],
292 Snapshots::Timeshift => vec![
293 "sudo".into(),
294 "timeshift".into(),
295 "--create".into(),
296 "--comments".into(),
297 description.into(),
298 ],
299 };
300 let program = words
301 .iter()
302 .map(|word| quote(word))
303 .collect::<Vec<_>>()
304 .join(" ");
305 Step::Shell {
306 id: "snapshot".to_string(),
307 name: format!("Take a {} snapshot", self.label()),
308 program,
309 display: words.join(" "),
310 requires: vec![self.tool().to_string(), "sudo".to_string()],
311 changes: Vec::new(),
312 }
313 }
314
315 fn tool(self) -> &'static str {
316 match self {
317 Snapshots::Snapper => "snapper",
318 Snapshots::Timeshift => "timeshift",
319 }
320 }
321}
322
323pub fn worth_a_snapshot(plan: &Plan, flags: &dyn Fn(&str) -> Option<String>) -> bool {
326 plan.risk == Risk::High
327 || plan.steps.iter().any(|step| {
328 flags(step.id()).is_some_and(|flags| {
329 flags
330 .split_whitespace()
331 .any(|flag| matches!(flag, "PFM" | "K" | "D" | "SI"))
332 })
333 })
334}
335
336pub fn with_snapshot(plan: &mut Plan, tool: Snapshots) {
338 let names: Vec<&str> = plan.steps.iter().map(Step::name).collect();
339 let description = format!("HelixForge, before: {}", names.join(", "));
340 let step = tool.step(&description);
341 plan.requirements.extend(step_requirements(&step));
342 plan.requirements.sort();
343 plan.requirements.dedup();
344 plan.privileged = true;
345 plan.steps.insert(0, step);
346 let mut depends = vec![Vec::new()];
348 depends.extend(
349 plan.depends
350 .iter()
351 .map(|list| list.iter().map(|index| index + 1).collect()),
352 );
353 plan.depends = depends;
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::shell_program;
360 use std::process::Command;
361
362 #[test]
363 fn backup_names_give_the_original_and_the_time() {
364 assert_eq!(
365 parse_name("grub.commander-backup-20261015164340-4242"),
366 Some(("grub", "20261015164340-4242"))
367 );
368 assert_eq!(
369 parse_name("pacman.conf.helixforge-backup-20261015164340"),
370 Some(("pacman.conf", "20261015164340"))
371 );
372 assert_eq!(
373 parse_name("example.commander-backup-20261015142214095844"),
374 Some(("example", "20261015142214095844"))
375 );
376 assert_eq!(parse_name("grub.backup"), None);
377 assert_eq!(parse_name(".commander-backup-20261015164340"), None);
378 assert_eq!(parse_name("x.commander-backup-2026"), None);
379 assert_eq!(
380 readable("20261015164340-1").as_deref(),
381 Some("2026-10-15 16:43:40")
382 );
383 }
384
385 #[test]
386 fn backups_are_found_newest_first_and_links_are_left_alone() {
387 let root = tempfile::tempdir().unwrap();
388 let home = root.path().join("home");
389 let config = home.join(".config");
390 let etc = root.path().join("etc");
391 let write = |path: &Path| {
392 fs::create_dir_all(path.parent().unwrap()).unwrap();
393 fs::write(path, "x").unwrap();
394 };
395 write(&config.join("ghostty/config.commander-backup-20261001000000-1"));
396 write(&config.join("deep/a/b/c/d/e.helixforge-backup-20261003000000"));
397 fs::create_dir_all(config.join("nvim.commander-backup-20261002000000-7/lua")).unwrap();
398 write(&home.join(".bashrc.commander-backup-20260901000000-1"));
399 write(&config.join("node_modules/x.commander-backup-20261005000000-1"));
400 write(&etc.join("default/grub.commander-backup-20261004000000-9"));
401 write(&etc.join("a/b/c/d/too-deep.commander-backup-20261006000000-1"));
402 std::os::unix::fs::symlink(
403 etc.join("default/grub.commander-backup-20261004000000-9"),
404 config.join("linked.commander-backup-20261007000000-1"),
405 )
406 .unwrap();
407 let roots = Roots {
408 user: vec![config.clone()],
409 home: Some(home.clone()),
410 system: vec![(etc.clone(), 3)],
411 };
412 let found = discover(&roots);
413 let shown: Vec<(String, bool, bool)> = found
414 .iter()
415 .map(|backup| {
416 (
417 backup
418 .original
419 .strip_prefix(root.path())
420 .unwrap()
421 .display()
422 .to_string(),
423 backup.system,
424 backup.directory,
425 )
426 })
427 .collect();
428 assert_eq!(
429 shown,
430 [
431 ("etc/default/grub".to_string(), true, false),
432 ("home/.config/deep/a/b/c/d/e".to_string(), false, false),
433 ("home/.config/nvim".to_string(), false, true),
434 ("home/.config/ghostty/config".to_string(), false, false),
435 ("home/.bashrc".to_string(), false, false),
436 ]
437 );
438 assert_eq!(found[0].taken.as_deref(), Some("2026-10-04 00:00:00"));
439 }
440
441 #[test]
442 fn a_restore_keeps_the_current_one_and_can_be_undone() {
443 let root = tempfile::tempdir().unwrap();
444 let original = root.path().join("config");
445 let backup = root.path().join("config.commander-backup-20261001000000-1");
446 fs::write(&original, "current").unwrap();
447 fs::write(&backup, "older").unwrap();
448 let found = Backup {
449 backup: backup.clone(),
450 original: original.clone(),
451 taken: readable("20261001000000"),
452 system: false,
453 directory: false,
454 };
455 let plan = plan_restore(&found);
456 assert_eq!(plan.risk, Risk::Low);
457 assert!(!plan.privileged);
458 assert!(plan.steps[0].display().starts_with("cp -a "));
459 let run = |plan: &Plan| {
460 let output = Command::new("sh")
461 .arg("-c")
462 .arg(shell_program(plan, Path::new("/lib"), Path::new("/hf")))
463 .output()
464 .unwrap();
465 assert!(
466 output.status.success(),
467 "{}",
468 String::from_utf8_lossy(&output.stderr)
469 );
470 };
471 run(&plan);
472 assert_eq!(fs::read_to_string(&original).unwrap(), "older");
473 assert_eq!(
474 fs::read_to_string(&backup).unwrap(),
475 "older",
476 "the backup stays"
477 );
478 let kept: Vec<PathBuf> = fs::read_dir(root.path())
479 .unwrap()
480 .flatten()
481 .map(|item| item.path())
482 .filter(|path| path.to_string_lossy().contains(".helixforge-backup-"))
483 .collect();
484 assert_eq!(kept.len(), 1);
485 assert_eq!(fs::read_to_string(&kept[0]).unwrap(), "current");
486 let folder = root.path().join("nvim");
488 let saved = root.path().join("nvim.commander-backup-20261001000000-1");
489 fs::create_dir_all(saved.join("lua")).unwrap();
490 fs::write(saved.join("lua/init.lua"), "old").unwrap();
491 let plan = plan_restore(&Backup {
492 backup: saved,
493 original: folder.clone(),
494 taken: None,
495 system: false,
496 directory: true,
497 });
498 run(&plan);
499 assert_eq!(
500 fs::read_to_string(folder.join("lua/init.lua")).unwrap(),
501 "old"
502 );
503 let system = plan_restore(&Backup {
505 backup: PathBuf::from("/etc/default/grub.commander-backup-20261001000000-1"),
506 original: PathBuf::from("/etc/default/grub"),
507 taken: None,
508 system: true,
509 directory: false,
510 });
511 assert!(system.privileged);
512 assert_eq!(system.requirements, ["sudo"]);
513 assert!(system.steps[0].display().starts_with("sudo cp -a "));
514 }
515
516 #[test]
517 fn a_strange_name_is_shown_safely_and_restored_as_it_is() {
518 let backup = Backup {
519 backup: PathBuf::from("/home/me/.config/a\u{1b}[2Jb.commander-backup-20261001000000-1"),
520 original: PathBuf::from("/home/me/.config/a\u{1b}[2Jb"),
521 taken: None,
522 system: false,
523 directory: false,
524 };
525 let step = restore_step(&backup);
526 assert!(!step.display().contains('\u{1b}'));
527 assert!(!step.name().contains('\u{1b}'));
528 let Step::Shell { program, .. } = &step else {
529 panic!("expected a shell step");
530 };
531 assert!(
532 program.contains("'/home/me/.config/a\u{1b}[2Jb'"),
533 "the real name, quoted"
534 );
535 }
536
537 #[test]
538 fn snapshots_go_first_when_the_system_changes() {
539 let snapper = |path: &str| path == "/etc/snapper/configs/root";
540 assert_eq!(
541 Snapshots::detect(&snapper, &|_| true),
542 Some(Snapshots::Snapper)
543 );
544 assert_eq!(Snapshots::detect(&|_| false, &|_| true), None);
545 assert_eq!(
546 Snapshots::detect(
547 &|path| path == "/etc/timeshift/timeshift.json",
548 &|tool| tool == "timeshift"
549 ),
550 Some(Snapshots::Timeshift)
551 );
552 let mut plan = Plan {
553 steps: vec![
554 Step::Command {
555 id: "grub".into(),
556 name: "GRUB Theme".into(),
557 line: "true".into(),
558 },
559 Step::Command {
560 id: "after".into(),
561 name: "After".into(),
562 line: "true".into(),
563 },
564 ],
565 depends: vec![vec![], vec![0]],
566 risk: Risk::Medium,
567 ..Plan::default()
568 };
569 let flags = |id: &str| (id == "grub").then(|| "I PFM MP".to_string());
570 assert!(worth_a_snapshot(&plan, &flags));
571 assert!(!worth_a_snapshot(&plan, &|_| Some("I".to_string())));
572 with_snapshot(&mut plan, Snapshots::Snapper);
573 assert_eq!(plan.steps[0].name(), "Take a Snapper snapshot");
574 assert_eq!(
575 plan.steps[0].display(),
576 "sudo snapper -c root create --cleanup-algorithm number --description HelixForge, before: GRUB Theme, After"
577 );
578 assert_eq!(plan.depends, [vec![], vec![], vec![1]]);
579 assert!(plan.privileged);
580 assert!(plan.requirements.contains(&"snapper".to_string()));
581 }
582}