Skip to main content

yui/
backup.rs

1//! Backup creation under `$DOTFILES/.yui/backup/`.
2//!
3//! Path scheme: mirror the absolute target path (drive colon stripped on
4//! Windows), then suffix the basename with a timestamp before the extension.
5//!
6//! ```text
7//!   target  C:\Users\u\.config\foo\bar.yml
8//!   backup  $DOTFILES/.yui/backup/C/Users/u/.config/foo/bar_20260429_143022123.yml
9//! ```
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::paths;
14use crate::{Error, Result};
15
16/// Format the current local time using a `jiff` strtime pattern.
17pub fn current_timestamp(format: &str) -> Result<String> {
18    let now = jiff::Zoned::now();
19    let bdt = jiff::fmt::strtime::BrokenDownTime::from(&now);
20    bdt.to_string(format)
21        .map_err(|e| Error::Other(anyhow::anyhow!("timestamp format '{format}': {e}")))
22}
23
24pub fn backup_path(backup_root: &Utf8Path, abs_target: &Utf8Path, timestamp: &str) -> Utf8PathBuf {
25    let mirrored = paths::mirror_into_backup(backup_root, abs_target);
26    paths::append_timestamp(&mirrored, timestamp)
27}
28
29/// Copy `src` (a regular file) to `dest`, creating parent dirs as needed.
30pub fn backup_file(src: &Utf8Path, dest: &Utf8Path) -> Result<()> {
31    if let Some(parent) = dest.parent() {
32        std::fs::create_dir_all(parent)?;
33    }
34    std::fs::copy(src, dest)?;
35    Ok(())
36}
37
38/// Recursively copy `src` (a directory tree) to `dest`. Symlinks within
39/// the tree are skipped (we'd be copying their targets again redundantly,
40/// and link semantics don't carry meaning in a backup).
41pub fn backup_dir(src: &Utf8Path, dest: &Utf8Path) -> Result<()> {
42    if let Some(parent) = dest.parent() {
43        std::fs::create_dir_all(parent)?;
44    }
45    copy_dir_recursive(src, dest)
46}
47
48fn copy_dir_recursive(src: &Utf8Path, dest: &Utf8Path) -> Result<()> {
49    std::fs::create_dir_all(dest)?;
50    for entry in std::fs::read_dir(src)? {
51        let entry = entry?;
52        let name_os = entry.file_name();
53        let Some(name) = name_os.to_str() else {
54            continue;
55        };
56        let src_path = src.join(name);
57        let dest_path = dest.join(name);
58        let ft = entry.file_type()?;
59        if ft.is_symlink() {
60            continue;
61        }
62        if ft.is_dir() {
63            copy_dir_recursive(&src_path, &dest_path)?;
64        } else if ft.is_file() {
65            std::fs::copy(&src_path, &dest_path)?;
66        }
67    }
68    Ok(())
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use tempfile::TempDir;
75
76    #[test]
77    fn backup_path_combines_mirror_and_timestamp() {
78        let r = backup_path(
79            Utf8Path::new("/dotfiles/.yui/backup"),
80            Utf8Path::new("/home/u/.config/foo.yml"),
81            "20260429_143022123",
82        );
83        assert_eq!(
84            r,
85            Utf8PathBuf::from("/dotfiles/.yui/backup/home/u/.config/foo_20260429_143022123.yml")
86        );
87    }
88
89    #[test]
90    fn current_timestamp_renders() {
91        let s = current_timestamp("%Y%m%d_%H%M%S").unwrap();
92        // Format: 8 digits underscore 6 digits.
93        assert_eq!(s.len(), 15);
94        assert!(s.chars().nth(8) == Some('_'));
95    }
96
97    #[test]
98    fn backup_file_creates_parent_dirs() {
99        let tmp = TempDir::new().unwrap();
100        let src = Utf8PathBuf::from_path_buf(tmp.path().join("input.txt")).unwrap();
101        std::fs::write(&src, "hello").unwrap();
102        let dest = Utf8PathBuf::from_path_buf(tmp.path().join("nested/dir/out.txt")).unwrap();
103        backup_file(&src, &dest).unwrap();
104        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello");
105    }
106
107    #[test]
108    fn backup_dir_copies_tree() {
109        let tmp = TempDir::new().unwrap();
110        let src = Utf8PathBuf::from_path_buf(tmp.path().join("src")).unwrap();
111        std::fs::create_dir_all(src.join("sub")).unwrap();
112        std::fs::write(src.join("a.txt"), "A").unwrap();
113        std::fs::write(src.join("sub/b.txt"), "B").unwrap();
114
115        let dest = Utf8PathBuf::from_path_buf(tmp.path().join("dest")).unwrap();
116        backup_dir(&src, &dest).unwrap();
117
118        assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "A");
119        assert_eq!(
120            std::fs::read_to_string(dest.join("sub/b.txt")).unwrap(),
121            "B"
122        );
123    }
124}