Skip to main content

doing_ops/
backup.rs

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
11/// Generate a backup prefix that uniquely identifies a source file by its canonical path.
12///
13/// Format: `{filename}_{path_hash}_` where `path_hash` is 16 hex characters derived from
14/// hashing the full canonical path. This ensures files with the same name at different
15/// locations get isolated backup histories.
16pub 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
34/// Create a timestamped backup of `source` in `backup_dir`.
35///
36/// The backup filename follows the pattern `{stem}_{YYYYMMDD}_{HHMMSS}_{ffffff}.bak`.
37/// Creates the backup directory if it does not exist.
38pub 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
50/// List backups for `source` in `backup_dir`, sorted newest-first.
51pub fn list_backups(source: &Path, backup_dir: &Path) -> Result<Vec<PathBuf>> {
52  list_files_with_ext(source, backup_dir, ".bak")
53}
54
55/// List undone (consumed) backups for `source` in `backup_dir`, sorted newest-first.
56pub fn list_undone(source: &Path, backup_dir: &Path) -> Result<Vec<PathBuf>> {
57  list_files_with_ext(source, backup_dir, ".undone")
58}
59
60/// Remove old backups for `source` that exceed `history_size`.
61///
62/// Backups are identified by the `{stem}_*.bak` glob pattern in `backup_dir`
63/// and sorted newest-first by filename (which embeds the timestamp).
64/// The newest `history_size` backups are kept; the rest are deleted.
65pub 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
78/// Atomically write a `Document` to `path`, creating a backup first.
79///
80/// Steps:
81/// 1. If the file already exists, create a timestamped backup.
82/// 2. Prune old backups beyond `history_size`.
83/// 3. Sort entries according to config, then write the document atomically.
84pub 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
95/// FNV-1a hash producing a stable 64-bit value regardless of Rust version.
96fn 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      // FNV-1a of "hello" is a well-known value
179      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}