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 taskpaper_io::write_file(&doc, path)
95}
96
97fn fnv1a_hash(bytes: &[u8]) -> u64 {
99 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
100 const FNV_PRIME: u64 = 0x00000100000001B3;
101
102 let mut hash = FNV_OFFSET;
103 for &byte in bytes {
104 hash ^= byte as u64;
105 hash = hash.wrapping_mul(FNV_PRIME);
106 }
107 hash
108}
109
110fn list_files_with_ext(source: &Path, backup_dir: &Path, ext: &str) -> Result<Vec<PathBuf>> {
111 if !backup_dir.exists() {
112 return Ok(Vec::new());
113 }
114
115 let prefix = backup_prefix(source)?;
116 let mut backups: Vec<PathBuf> = fs::read_dir(backup_dir)?
117 .collect::<std::result::Result<Vec<_>, _>>()?
118 .into_iter()
119 .map(|entry| entry.path())
120 .filter(|path| {
121 path
122 .file_name()
123 .and_then(|n| n.to_str())
124 .map(|n| n.starts_with(&prefix) && n.ends_with(ext))
125 .unwrap_or(false)
126 })
127 .collect();
128
129 backups.sort_by(|a, b| b.cmp(a));
130 Ok(backups)
131}
132
133#[cfg(test)]
134mod test {
135 use doing_config::SortOrder;
136 use doing_taskpaper::{Entry, Note, Section, Tags};
137
138 use super::*;
139
140 fn sample_doc() -> Document {
141 let mut doc = Document::new();
142 let mut section = Section::new("Currently");
143 section.add_entry(Entry::new(
144 chrono::Local::now(),
145 "Test task",
146 Tags::new(),
147 Note::new(),
148 "Currently",
149 None::<String>,
150 ));
151 doc.add_section(section);
152 doc
153 }
154
155 mod backup_prefix {
156 use pretty_assertions::assert_eq;
157
158 use super::*;
159
160 #[test]
161 fn it_produces_deterministic_hash() {
162 let dir = tempfile::tempdir().unwrap();
163 let source = dir.path().join("test.md");
164 fs::write(&source, "").unwrap();
165
166 let prefix1 = backup_prefix(&source).unwrap();
167 let prefix2 = backup_prefix(&source).unwrap();
168
169 assert_eq!(prefix1, prefix2);
170 }
171 }
172
173 mod fnv1a_hash {
174 use pretty_assertions::assert_eq;
175
176 use super::super::fnv1a_hash;
177
178 #[test]
179 fn it_produces_known_output_for_known_input() {
180 let hash = fnv1a_hash(b"hello");
182
183 assert_eq!(hash, 0xa430d84680aabd0b);
184 }
185 }
186
187 mod create_backup {
188 use pretty_assertions::assert_eq;
189
190 use super::*;
191
192 #[test]
193 fn it_copies_file_to_backup_dir() {
194 let dir = tempfile::tempdir().unwrap();
195 let source = dir.path().join("test.md");
196 let backup_dir = dir.path().join("backups");
197 fs::write(&source, "content").unwrap();
198
199 let backup = create_backup(&source, &backup_dir).unwrap();
200
201 assert!(backup.exists());
202 assert_eq!(fs::read_to_string(&backup).unwrap(), "content");
203 }
204
205 #[test]
206 fn it_creates_backup_dir_if_missing() {
207 let dir = tempfile::tempdir().unwrap();
208 let source = dir.path().join("test.md");
209 let backup_dir = dir.path().join("nested/backups");
210 fs::write(&source, "content").unwrap();
211
212 create_backup(&source, &backup_dir).unwrap();
213
214 assert!(backup_dir.exists());
215 }
216
217 #[test]
218 fn it_uses_timestamped_bak_filename() {
219 let dir = tempfile::tempdir().unwrap();
220 let source = dir.path().join("doing.md");
221 let backup_dir = dir.path().join("backups");
222 fs::write(&source, "content").unwrap();
223
224 let backup = create_backup(&source, &backup_dir).unwrap();
225 let name = backup.file_name().unwrap().to_str().unwrap();
226 let prefix = backup_prefix(&source).unwrap();
227
228 assert!(name.starts_with(&prefix));
229 assert!(name.ends_with(".bak"));
230 }
231 }
232
233 mod list_backups {
234 use pretty_assertions::assert_eq;
235
236 use super::*;
237
238 #[test]
239 fn it_isolates_backups_by_source_path() {
240 let dir = tempfile::tempdir().unwrap();
241 let backup_dir = dir.path().join("backups");
242 fs::create_dir_all(&backup_dir).unwrap();
243
244 let dir_a = dir.path().join("a");
245 let dir_b = dir.path().join("b");
246 fs::create_dir_all(&dir_a).unwrap();
247 fs::create_dir_all(&dir_b).unwrap();
248
249 let source_a = dir_a.join("doing.md");
250 let source_b = dir_b.join("doing.md");
251 fs::write(&source_a, "content a").unwrap();
252 fs::write(&source_b, "content b").unwrap();
253
254 create_backup(&source_a, &backup_dir).unwrap();
255 create_backup(&source_b, &backup_dir).unwrap();
256
257 let backups_a = list_backups(&source_a, &backup_dir).unwrap();
258 let backups_b = list_backups(&source_b, &backup_dir).unwrap();
259
260 assert_eq!(backups_a.len(), 1);
261 assert_eq!(backups_b.len(), 1);
262 assert_eq!(fs::read_to_string(&backups_a[0]).unwrap(), "content a");
263 assert_eq!(fs::read_to_string(&backups_b[0]).unwrap(), "content b");
264 }
265 }
266
267 mod prune_backups {
268 use super::*;
269
270 #[test]
271 fn it_does_nothing_when_under_limit() {
272 let dir = tempfile::tempdir().unwrap();
273 let source = dir.path().join("test.md");
274 let backup_dir = dir.path().join("backups");
275 fs::create_dir_all(&backup_dir).unwrap();
276 fs::write(&source, "").unwrap();
277
278 let prefix = backup_prefix(&source).unwrap();
279 fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "").unwrap();
280
281 prune_backups(&source, &backup_dir, 5).unwrap();
282
283 let remaining = list_backups(&source, &backup_dir).unwrap();
284 assert_eq!(remaining.len(), 1);
285 }
286
287 #[test]
288 fn it_keeps_only_history_size_newest_backups() {
289 let dir = tempfile::tempdir().unwrap();
290 let source = dir.path().join("test.md");
291 let backup_dir = dir.path().join("backups");
292 fs::create_dir_all(&backup_dir).unwrap();
293 fs::write(&source, "").unwrap();
294
295 let prefix = backup_prefix(&source).unwrap();
296 for i in 1..=5 {
297 let name = format!("{prefix}20240101_{:06}.bak", i);
298 fs::write(backup_dir.join(name), "").unwrap();
299 }
300
301 prune_backups(&source, &backup_dir, 2).unwrap();
302
303 let remaining = list_backups(&source, &backup_dir).unwrap();
304 assert_eq!(remaining.len(), 2);
305 }
306 }
307
308 mod write_with_backup {
309 use pretty_assertions::assert_eq;
310
311 use super::*;
312
313 #[test]
314 fn it_creates_backup_before_writing() {
315 let dir = tempfile::tempdir().unwrap();
316 let path = dir.path().join("test.md");
317 let backup_dir = dir.path().join("backups");
318 fs::write(&path, "old content\n").unwrap();
319
320 let mut config = Config::default();
321 config.backup_dir = backup_dir.clone();
322 config.doing_file_sort = SortOrder::Asc;
323
324 write_with_backup(&sample_doc(), &path, &config).unwrap();
325
326 let backups = list_backups(&path, &backup_dir).unwrap();
327 assert_eq!(backups.len(), 1);
328 assert_eq!(fs::read_to_string(&backups[0]).unwrap(), "old content\n");
329 }
330
331 #[test]
332 fn it_skips_backup_for_new_file() {
333 let dir = tempfile::tempdir().unwrap();
334 let path = dir.path().join("test.md");
335 let backup_dir = dir.path().join("backups");
336
337 let mut config = Config::default();
338 config.backup_dir = backup_dir.clone();
339 config.doing_file_sort = SortOrder::Asc;
340
341 write_with_backup(&sample_doc(), &path, &config).unwrap();
342
343 assert!(path.exists());
344 assert!(!backup_dir.exists());
345 }
346
347 #[test]
348 fn it_writes_document_content() {
349 let dir = tempfile::tempdir().unwrap();
350 let path = dir.path().join("test.md");
351
352 let mut config = Config::default();
353 config.backup_dir = dir.path().join("backups");
354 config.doing_file_sort = SortOrder::Asc;
355
356 write_with_backup(&sample_doc(), &path, &config).unwrap();
357
358 let content = fs::read_to_string(&path).unwrap();
359 assert!(content.contains("Currently:"));
360 assert!(content.contains("Test task"));
361 }
362 }
363}