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) -> 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
36/// Create a timestamped backup of `source` in `backup_dir`.
37///
38/// The backup filename follows the pattern `{stem}_{YYYYMMDD}_{HHMMSS}_{ffffff}.bak`.
39/// Creates the backup directory if it does not exist.
40pub 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
52/// List backups for `source` in `backup_dir`, sorted newest-first.
53pub fn list_backups(source: &Path, backup_dir: &Path) -> Result<Vec<PathBuf>> {
54  list_files_with_ext(source, backup_dir, ".bak")
55}
56
57/// List undone (consumed) backups for `source` in `backup_dir`, sorted newest-first.
58pub fn list_undone(source: &Path, backup_dir: &Path) -> Result<Vec<PathBuf>> {
59  list_files_with_ext(source, backup_dir, ".undone")
60}
61
62/// Remove old backups for `source` that exceed `history_size`.
63///
64/// Backups are identified by the `{stem}_*.bak` glob pattern in `backup_dir`
65/// and sorted newest-first by filename (which embeds the timestamp).
66/// The newest `history_size` backups are kept; the rest are deleted.
67pub 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
80/// Atomically write a `Document` to `path`, creating a backup first.
81///
82/// Steps:
83/// 1. If the file already exists, create a timestamped backup.
84/// 2. Prune old backups beyond `history_size`.
85/// 3. Sort entries according to config, then write the document atomically.
86pub 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
98/// FNV-1a hash producing a stable 64-bit value regardless of Rust version.
99fn 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      // FNV-1a of "hello" is a well-known value
182      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}