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