light_fw_cli_core/
backup.rs1use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::error::{Error, Result};
12
13pub const BACKUP_DIR: &str = "target/.light/backup";
15
16pub const MAX_BACKUPS: usize = 10;
18
19const TRACKED_FILES: [&str; 2] = ["Cargo.toml", "light.lock"];
21
22#[derive(Debug, Clone)]
24pub struct Backup {
25 pub project_root: PathBuf,
27 pub stamp: String,
29 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
41pub 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
55fn 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
71pub 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
98pub 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
124pub 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}