1use std::{
2 fs,
3 path::{Path, PathBuf},
4};
5
6use chrono::Local;
7use doing_config::Config;
8use doing_error::Result;
9use doing_taskpaper::{Document, io as taskpaper_io};
10
11pub fn backup_prefix(source: &Path) -> String {
17 let stem = source.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
18 let canonical = source
19 .canonicalize()
20 .or_else(|_| {
21 source
22 .parent()
23 .and_then(|p| p.canonicalize().ok())
24 .map(|p| p.join(source.file_name().unwrap_or_default()))
25 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, ""))
26 })
27 .unwrap_or_else(|_| source.to_path_buf());
28
29 let hash = fnv1a_hash(canonical.to_string_lossy().as_bytes());
30
31 format!("{stem}_{hash:016x}_")
32}
33
34pub fn create_backup(source: &Path, backup_dir: &Path) -> Result<PathBuf> {
39 fs::create_dir_all(backup_dir)?;
40
41 let prefix = backup_prefix(source);
42 let timestamp = Local::now().format("%Y%m%d_%H%M%S_%6f");
43 let backup_name = format!("{prefix}{timestamp}.bak");
44 let backup_path = backup_dir.join(backup_name);
45
46 fs::copy(source, &backup_path)?;
47 Ok(backup_path)
48}
49
50pub fn list_backups(source: &Path, backup_dir: &Path) -> Result<Vec<PathBuf>> {
52 list_files_with_ext(source, backup_dir, ".bak")
53}
54
55pub fn list_undone(source: &Path, backup_dir: &Path) -> Result<Vec<PathBuf>> {
57 list_files_with_ext(source, backup_dir, ".undone")
58}
59
60pub fn prune_backups(source: &Path, backup_dir: &Path, history_size: u32) -> Result<()> {
66 let mut backups = list_backups(source, backup_dir)?;
67 if backups.len() <= history_size as usize {
68 return Ok(());
69 }
70
71 for old in backups.drain(history_size as usize..) {
72 fs::remove_file(old)?;
73 }
74
75 Ok(())
76}
77
78pub fn write_with_backup(doc: &Document, path: &Path, config: &Config) -> Result<()> {
85 if path.exists() {
86 create_backup(path, &config.backup_dir)?;
87 prune_backups(path, &config.backup_dir, config.history_size)?;
88 }
89
90 let mut doc = doc.clone();
91 doc.sort_entries(config.doing_file_sort == doing_config::SortOrder::Desc);
92 taskpaper_io::write_file(&doc, path)
93}
94
95fn fnv1a_hash(bytes: &[u8]) -> u64 {
97 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
98 const FNV_PRIME: u64 = 0x00000100000001B3;
99
100 let mut hash = FNV_OFFSET;
101 for &byte in bytes {
102 hash ^= byte as u64;
103 hash = hash.wrapping_mul(FNV_PRIME);
104 }
105 hash
106}
107
108fn list_files_with_ext(source: &Path, backup_dir: &Path, ext: &str) -> Result<Vec<PathBuf>> {
109 if !backup_dir.exists() {
110 return Ok(Vec::new());
111 }
112
113 let prefix = backup_prefix(source);
114 let mut backups: Vec<PathBuf> = fs::read_dir(backup_dir)?
115 .collect::<std::result::Result<Vec<_>, _>>()?
116 .into_iter()
117 .map(|entry| entry.path())
118 .filter(|path| {
119 path
120 .file_name()
121 .and_then(|n| n.to_str())
122 .map(|n| n.starts_with(&prefix) && n.ends_with(ext))
123 .unwrap_or(false)
124 })
125 .collect();
126
127 backups.sort_by(|a, b| b.cmp(a));
128 Ok(backups)
129}
130
131#[cfg(test)]
132mod test {
133 use doing_config::SortOrder;
134 use doing_taskpaper::{Entry, Note, Section, Tags};
135
136 use super::*;
137
138 fn sample_doc() -> Document {
139 let mut doc = Document::new();
140 let mut section = Section::new("Currently");
141 section.add_entry(Entry::new(
142 chrono::Local::now(),
143 "Test task",
144 Tags::new(),
145 Note::new(),
146 "Currently",
147 None::<String>,
148 ));
149 doc.add_section(section);
150 doc
151 }
152
153 mod backup_prefix {
154 use pretty_assertions::assert_eq;
155
156 use super::*;
157
158 #[test]
159 fn it_produces_deterministic_hash() {
160 let dir = tempfile::tempdir().unwrap();
161 let source = dir.path().join("test.md");
162 fs::write(&source, "").unwrap();
163
164 let prefix1 = backup_prefix(&source);
165 let prefix2 = backup_prefix(&source);
166
167 assert_eq!(prefix1, prefix2);
168 }
169 }
170
171 mod fnv1a_hash {
172 use pretty_assertions::assert_eq;
173
174 use super::super::fnv1a_hash;
175
176 #[test]
177 fn it_produces_known_output_for_known_input() {
178 let hash = fnv1a_hash(b"hello");
180
181 assert_eq!(hash, 0xa430d84680aabd0b);
182 }
183 }
184
185 mod create_backup {
186 use pretty_assertions::assert_eq;
187
188 use super::*;
189
190 #[test]
191 fn it_copies_file_to_backup_dir() {
192 let dir = tempfile::tempdir().unwrap();
193 let source = dir.path().join("test.md");
194 let backup_dir = dir.path().join("backups");
195 fs::write(&source, "content").unwrap();
196
197 let backup = create_backup(&source, &backup_dir).unwrap();
198
199 assert!(backup.exists());
200 assert_eq!(fs::read_to_string(&backup).unwrap(), "content");
201 }
202
203 #[test]
204 fn it_creates_backup_dir_if_missing() {
205 let dir = tempfile::tempdir().unwrap();
206 let source = dir.path().join("test.md");
207 let backup_dir = dir.path().join("nested/backups");
208 fs::write(&source, "content").unwrap();
209
210 create_backup(&source, &backup_dir).unwrap();
211
212 assert!(backup_dir.exists());
213 }
214
215 #[test]
216 fn it_uses_timestamped_bak_filename() {
217 let dir = tempfile::tempdir().unwrap();
218 let source = dir.path().join("doing.md");
219 let backup_dir = dir.path().join("backups");
220 fs::write(&source, "content").unwrap();
221
222 let backup = create_backup(&source, &backup_dir).unwrap();
223 let name = backup.file_name().unwrap().to_str().unwrap();
224 let prefix = backup_prefix(&source);
225
226 assert!(name.starts_with(&prefix));
227 assert!(name.ends_with(".bak"));
228 }
229 }
230
231 mod list_backups {
232 use pretty_assertions::assert_eq;
233
234 use super::*;
235
236 #[test]
237 fn it_isolates_backups_by_source_path() {
238 let dir = tempfile::tempdir().unwrap();
239 let backup_dir = dir.path().join("backups");
240 fs::create_dir_all(&backup_dir).unwrap();
241
242 let dir_a = dir.path().join("a");
243 let dir_b = dir.path().join("b");
244 fs::create_dir_all(&dir_a).unwrap();
245 fs::create_dir_all(&dir_b).unwrap();
246
247 let source_a = dir_a.join("doing.md");
248 let source_b = dir_b.join("doing.md");
249 fs::write(&source_a, "content a").unwrap();
250 fs::write(&source_b, "content b").unwrap();
251
252 create_backup(&source_a, &backup_dir).unwrap();
253 create_backup(&source_b, &backup_dir).unwrap();
254
255 let backups_a = list_backups(&source_a, &backup_dir).unwrap();
256 let backups_b = list_backups(&source_b, &backup_dir).unwrap();
257
258 assert_eq!(backups_a.len(), 1);
259 assert_eq!(backups_b.len(), 1);
260 assert_eq!(fs::read_to_string(&backups_a[0]).unwrap(), "content a");
261 assert_eq!(fs::read_to_string(&backups_b[0]).unwrap(), "content b");
262 }
263 }
264
265 mod prune_backups {
266 use super::*;
267
268 #[test]
269 fn it_does_nothing_when_under_limit() {
270 let dir = tempfile::tempdir().unwrap();
271 let source = dir.path().join("test.md");
272 let backup_dir = dir.path().join("backups");
273 fs::create_dir_all(&backup_dir).unwrap();
274 fs::write(&source, "").unwrap();
275
276 let prefix = backup_prefix(&source);
277 fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "").unwrap();
278
279 prune_backups(&source, &backup_dir, 5).unwrap();
280
281 let remaining = list_backups(&source, &backup_dir).unwrap();
282 assert_eq!(remaining.len(), 1);
283 }
284
285 #[test]
286 fn it_keeps_only_history_size_newest_backups() {
287 let dir = tempfile::tempdir().unwrap();
288 let source = dir.path().join("test.md");
289 let backup_dir = dir.path().join("backups");
290 fs::create_dir_all(&backup_dir).unwrap();
291 fs::write(&source, "").unwrap();
292
293 let prefix = backup_prefix(&source);
294 for i in 1..=5 {
295 let name = format!("{prefix}20240101_{:06}.bak", i);
296 fs::write(backup_dir.join(name), "").unwrap();
297 }
298
299 prune_backups(&source, &backup_dir, 2).unwrap();
300
301 let remaining = list_backups(&source, &backup_dir).unwrap();
302 assert_eq!(remaining.len(), 2);
303 }
304 }
305
306 mod write_with_backup {
307 use pretty_assertions::assert_eq;
308
309 use super::*;
310
311 #[test]
312 fn it_creates_backup_before_writing() {
313 let dir = tempfile::tempdir().unwrap();
314 let path = dir.path().join("test.md");
315 let backup_dir = dir.path().join("backups");
316 fs::write(&path, "old content\n").unwrap();
317
318 let mut config = Config::default();
319 config.backup_dir = backup_dir.clone();
320 config.doing_file_sort = SortOrder::Asc;
321
322 write_with_backup(&sample_doc(), &path, &config).unwrap();
323
324 let backups = list_backups(&path, &backup_dir).unwrap();
325 assert_eq!(backups.len(), 1);
326 assert_eq!(fs::read_to_string(&backups[0]).unwrap(), "old content\n");
327 }
328
329 #[test]
330 fn it_skips_backup_for_new_file() {
331 let dir = tempfile::tempdir().unwrap();
332 let path = dir.path().join("test.md");
333 let backup_dir = dir.path().join("backups");
334
335 let mut config = Config::default();
336 config.backup_dir = backup_dir.clone();
337 config.doing_file_sort = SortOrder::Asc;
338
339 write_with_backup(&sample_doc(), &path, &config).unwrap();
340
341 assert!(path.exists());
342 assert!(!backup_dir.exists());
343 }
344
345 #[test]
346 fn it_writes_document_content() {
347 let dir = tempfile::tempdir().unwrap();
348 let path = dir.path().join("test.md");
349
350 let mut config = Config::default();
351 config.backup_dir = dir.path().join("backups");
352 config.doing_file_sort = SortOrder::Asc;
353
354 write_with_backup(&sample_doc(), &path, &config).unwrap();
355
356 let content = fs::read_to_string(&path).unwrap();
357 assert!(content.contains("Currently:"));
358 assert!(content.contains("Test task"));
359 }
360 }
361}