Skip to main content

light_fw_cli_core/
backup.rs

1//! Резервное копирование `Cargo.toml` и `light.lock` перед мутациями.
2//!
3//! Копии складываются в `target/.light/backup/` внутри проекта и именуются по
4//! RFC3339-времени (`Cargo.toml.2026-10-17T13-45-00Z`). Хранятся последние
5//! [`MAX_BACKUPS`] снимков; более старые удаляются. При ошибке мутации проект
6//! восстанавливается из снимка.
7
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::error::{Error, Result};
12
13/// Каталог резервных копий (относительно корня проекта).
14pub const BACKUP_DIR: &str = "target/.light/backup";
15
16/// Сколько снимков хранить.
17pub const MAX_BACKUPS: usize = 10;
18
19/// Файлы, которые бэкапим.
20const TRACKED_FILES: [&str; 2] = ["Cargo.toml", "light.lock"];
21
22/// Снимок состояния файлов проекта.
23#[derive(Debug, Clone)]
24pub struct Backup {
25    /// Корень проекта, к которому относится снимок.
26    pub project_root: PathBuf,
27    /// Метка времени снимка.
28    pub stamp: String,
29    /// Пары `(путь_в_проекте, путь_копии)`.
30    pub entries: Vec<(PathBuf, PathBuf)>,
31}
32
33fn now_stamp() -> String {
34    let secs = SystemTime::now()
35        .duration_since(UNIX_EPOCH)
36        .map(|d| d.as_secs())
37        .unwrap_or(0);
38    format_timestamp(secs)
39}
40
41/// Форматирует время Unix (UTC) как `2026-10-17T13-45-00Z`.
42///
43/// Двоеточия заменены на дефисы, чтобы метка была допустимым именем файла в
44/// том числе в Windows.
45pub fn format_timestamp(secs: u64) -> String {
46    let days = (secs / 86_400) as i64;
47    let rem = secs % 86_400;
48    let (year, month, day) = civil_from_days(days);
49    let hour = rem / 3_600;
50    let minute = (rem % 3_600) / 60;
51    let second = rem % 60;
52    format!("{year:04}-{month:02}-{day:02}T{hour:02}-{minute:02}-{second:02}Z")
53}
54
55/// Преобразует число дней от 1970-01-01 в `(год, месяц, день)`.
56///
57/// Алгоритм Говарда Хиннанта «civil_from_days» (public domain).
58fn civil_from_days(z: i64) -> (i64, i64, i64) {
59    let z = z + 719_468;
60    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
61    let doe = z - era * 146_097;
62    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
63    let year = yoe + era * 400;
64    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
65    let mp = (5 * doy + 2) / 153;
66    let day = doy - (153 * mp + 2) / 5 + 1;
67    let month = if mp < 10 { mp + 3 } else { mp - 9 };
68    (if month <= 2 { year + 1 } else { year }, month, day)
69}
70
71/// Создаёт снимок `Cargo.toml` (и `light.lock`, если есть).
72pub fn create(project_root: &Path) -> Result<Backup> {
73    let dir = project_root.join(BACKUP_DIR);
74    std::fs::create_dir_all(&dir)?;
75    let stamp = now_stamp();
76
77    let mut entries = Vec::new();
78    for file in TRACKED_FILES {
79        let original = project_root.join(file);
80        if !original.is_file() {
81            continue;
82        }
83        let target = dir.join(format!("{file}.{stamp}"));
84        std::fs::copy(&original, &target)
85            .map_err(|e| Error::Backup(format!("не удалось сохранить {file}: {e}")))?;
86        entries.push((original, target));
87    }
88
89    let backup = Backup {
90        project_root: project_root.to_path_buf(),
91        stamp,
92        entries,
93    };
94    prune(project_root)?;
95    Ok(backup)
96}
97
98/// Восстанавливает файлы из снимка.
99///
100/// Файлы, которые существовали на момент снимка, возвращаются к прежнему
101/// содержимому; файлы, которых тогда не было (например, только что созданный
102/// `light.lock`), удаляются, чтобы откат был полным.
103pub fn restore(backup: &Backup) -> Result<()> {
104    for (original, saved) in &backup.entries {
105        std::fs::copy(saved, original).map_err(|e| {
106            Error::Backup(format!(
107                "не удалось восстановить {}: {e}",
108                original.display()
109            ))
110        })?;
111    }
112    for file in TRACKED_FILES {
113        let original = backup.project_root.join(file);
114        let existed = backup.entries.iter().any(|(path, _)| path == &original);
115        if !existed && original.exists() {
116            std::fs::remove_file(&original).map_err(|e| {
117                Error::Backup(format!("не удалось удалить {}: {e}", original.display()))
118            })?;
119        }
120    }
121    Ok(())
122}
123
124/// Удаляет старые снимки, оставляя последние [`MAX_BACKUPS`].
125pub fn prune(project_root: &Path) -> Result<()> {
126    let dir = project_root.join(BACKUP_DIR);
127    if !dir.is_dir() {
128        return Ok(());
129    }
130    let mut stamps: Vec<String> = Vec::new();
131    for entry in std::fs::read_dir(&dir)? {
132        let entry = entry?;
133        if !entry.file_type()?.is_file() {
134            continue;
135        }
136        let name = entry.file_name().to_string_lossy().into_owned();
137        if let Some((_, stamp)) = name.rsplit_once('.') {
138            stamps.push(stamp.to_string());
139        }
140    }
141    stamps.sort();
142    stamps.dedup();
143    if stamps.len() <= MAX_BACKUPS {
144        return Ok(());
145    }
146    let remove_count = stamps.len() - MAX_BACKUPS;
147    let to_remove: Vec<String> = stamps.into_iter().take(remove_count).collect();
148    for entry in std::fs::read_dir(&dir)? {
149        let entry = entry?;
150        let name = entry.file_name().to_string_lossy().into_owned();
151        if let Some((_, stamp)) = name.rsplit_once('.')
152            && to_remove.iter().any(|s| s == stamp)
153        {
154            let _ = std::fs::remove_file(entry.path());
155        }
156    }
157    Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use std::fs;
164
165    fn temp_dir(name: &str) -> PathBuf {
166        let dir = std::env::temp_dir().join(format!("light-fw-cli-backup-{name}"));
167        let _ = fs::remove_dir_all(&dir);
168        fs::create_dir_all(&dir).expect("create temp dir");
169        dir
170    }
171
172    #[test]
173    fn creates_and_restores_backup() {
174        let dir = temp_dir("restore");
175        fs::write(dir.join("Cargo.toml"), "original").expect("write");
176        let backup = create(&dir).expect("create");
177        assert_eq!(backup.entries.len(), 1);
178
179        fs::write(dir.join("Cargo.toml"), "modified").expect("write");
180        restore(&backup).expect("restore");
181        assert_eq!(
182            fs::read_to_string(dir.join("Cargo.toml")).expect("read"),
183            "original"
184        );
185    }
186
187    #[test]
188    fn restore_removes_created_files() {
189        let dir = temp_dir("restore-new");
190        fs::write(dir.join("Cargo.toml"), "c").expect("write");
191        let backup = create(&dir).expect("create");
192        fs::write(dir.join("light.lock"), "generated").expect("write");
193        restore(&backup).expect("restore");
194        assert!(!dir.join("light.lock").exists(), "созданный lock удалён");
195    }
196
197    #[test]
198    fn backs_up_lockfile_when_present() {
199        let dir = temp_dir("lock");
200        fs::write(dir.join("Cargo.toml"), "c").expect("write");
201        fs::write(dir.join("light.lock"), "l").expect("write");
202        let backup = create(&dir).expect("create");
203        assert_eq!(backup.entries.len(), 2);
204    }
205
206    #[test]
207    fn prune_keeps_recent() {
208        let dir = temp_dir("prune");
209        let bdir = dir.join(BACKUP_DIR);
210        fs::create_dir_all(&bdir).expect("backup dir");
211        for i in 0..(MAX_BACKUPS + 5) {
212            fs::write(bdir.join(format!("Cargo.toml.{i:013}")), "x").expect("write");
213        }
214        prune(&dir).expect("prune");
215        let count = fs::read_dir(&bdir).expect("read").count();
216        assert_eq!(count, MAX_BACKUPS);
217    }
218
219    #[test]
220    fn formats_rfc3339_timestamp() {
221        assert_eq!(format_timestamp(0), "1970-01-01T00-00-00Z");
222        assert_eq!(format_timestamp(1_000_000_000), "2001-09-09T01-46-40Z");
223        assert_eq!(format_timestamp(1_795_268_700), "2026-11-21T13-45-00Z");
224    }
225
226    #[test]
227    fn stores_backups_under_target_light() {
228        let dir = temp_dir("location");
229        fs::write(dir.join("Cargo.toml"), "c").expect("write");
230        let backup = create(&dir).expect("create");
231        let saved = &backup.entries[0].1;
232        assert!(
233            saved.starts_with(dir.join("target").join(".light").join("backup")),
234            "копия внутри target/.light/backup: {}",
235            saved.display()
236        );
237    }
238}