Skip to main content

doing_ops/
undo.rs

1use std::{
2  fs,
3  path::{Path, PathBuf},
4};
5
6use chrono::Local;
7use doing_error::{Error, Result};
8
9use crate::backup::{self, backup_prefix};
10
11/// Restore from the Nth most recent consumed (`.undone`) backup (1-indexed),
12/// reversing the last N undo operations.
13///
14/// After restoration all consumed backups are converted back to `.bak`,
15/// fully resetting the undo state.
16pub fn redo(source: &Path, backup_dir: &Path, count: usize) -> Result<()> {
17  let undone = backup::list_undone(source, backup_dir)?;
18  if count == 0 || count > undone.len() {
19    return Err(Error::HistoryLimit("end of redo history".into()));
20  }
21
22  // Atomic restore: write to a unique temp file, then rename into place
23  let parent = source.parent().ok_or_else(|| {
24    Error::Io(std::io::Error::new(
25      std::io::ErrorKind::InvalidInput,
26      "source path has no parent directory",
27    ))
28  })?;
29  let tmp = tempfile::NamedTempFile::new_in(parent)?;
30  fs::copy(&undone[count - 1], tmp.path())?;
31  tmp.persist(source).map_err(|e| Error::Io(e.error))?;
32
33  unconsume_all(source, backup_dir)?;
34  Ok(())
35}
36
37/// Restore the doing file from the Nth most recent unconsumed backup (1-indexed).
38///
39/// Before restoring, a consumed snapshot of the current `source` is created so
40/// that [`redo`] can reverse the undo. The restored backup and all newer backups
41/// are also marked as consumed (renamed from `.bak` to `.undone`) so that
42/// subsequent calls walk backwards through history. Returns an error if fewer
43/// than `count` unconsumed backups exist.
44pub fn undo(source: &Path, backup_dir: &Path, count: usize) -> Result<()> {
45  let backups = backup::list_backups(source, backup_dir)?;
46  if count == 0 || count > backups.len() {
47    return Err(Error::HistoryLimit("end of undo history".into()));
48  }
49
50  create_undone(source, backup_dir)?;
51
52  // Atomic restore: write to a unique temp file in the same directory, then rename into place
53  let parent = source.parent().ok_or_else(|| {
54    Error::Io(std::io::Error::new(
55      std::io::ErrorKind::InvalidInput,
56      "source path has no parent directory",
57    ))
58  })?;
59  let tmp = tempfile::NamedTempFile::new_in(parent)?;
60  fs::copy(&backups[count - 1], tmp.path())?;
61  tmp.persist(source).map_err(|e| Error::Io(e.error))?;
62
63  for backup in &backups[..count] {
64    consume(backup)?;
65  }
66
67  Ok(())
68}
69
70/// Rename a `.bak` file to `.undone`, marking it as consumed by undo.
71fn consume(path: &Path) -> Result<()> {
72  let undone = path.with_extension("undone");
73  fs::rename(path, undone)?;
74  Ok(())
75}
76
77/// Create an `.undone` snapshot of `source` in `backup_dir`.
78///
79/// Uses microsecond-precision timestamps to avoid filename collisions with
80/// consumed `.bak` files that share the same second.
81fn create_undone(source: &Path, backup_dir: &Path) -> Result<PathBuf> {
82  fs::create_dir_all(backup_dir)?;
83
84  let prefix = backup_prefix(source)?;
85  let timestamp = Local::now().format("%Y%m%d_%H%M%S_%6f");
86  let name = format!("{prefix}{timestamp}.undone");
87  let path = backup_dir.join(name);
88
89  fs::copy(source, &path)?;
90  Ok(path)
91}
92
93/// Rename all `.undone` files back to `.bak`, restoring them as available backups.
94fn unconsume_all(source: &Path, backup_dir: &Path) -> Result<()> {
95  for undone in backup::list_undone(source, backup_dir)? {
96    let bak = undone.with_extension("bak");
97    fs::rename(undone, bak)?;
98  }
99  Ok(())
100}
101
102#[cfg(test)]
103mod test {
104  use std::fs;
105
106  use super::*;
107
108  mod redo {
109    use pretty_assertions::assert_eq;
110
111    use super::*;
112
113    #[test]
114    fn it_converts_all_undone_files_back_to_bak() {
115      let dir = tempfile::tempdir().unwrap();
116      let source = dir.path().join("doing.md");
117      let backup_dir = dir.path().join("backups");
118      fs::create_dir_all(&backup_dir).unwrap();
119      fs::write(&source, "current").unwrap();
120
121      let prefix = backup_prefix(&source).unwrap();
122      fs::write(backup_dir.join(format!("{prefix}20240101_000002.undone")), "newer").unwrap();
123      fs::write(backup_dir.join(format!("{prefix}20240101_000001.undone")), "older").unwrap();
124
125      redo(&source, &backup_dir, 1).unwrap();
126
127      let undone = backup::list_undone(&source, &backup_dir).unwrap();
128      assert!(undone.is_empty());
129
130      let bak = backup::list_backups(&source, &backup_dir).unwrap();
131      assert_eq!(bak.len(), 2);
132    }
133
134    #[test]
135    fn it_restores_from_newest_undone_file() {
136      let dir = tempfile::tempdir().unwrap();
137      let source = dir.path().join("doing.md");
138      let backup_dir = dir.path().join("backups");
139      fs::create_dir_all(&backup_dir).unwrap();
140      fs::write(&source, "current").unwrap();
141
142      let prefix = backup_prefix(&source).unwrap();
143      fs::write(
144        backup_dir.join(format!("{prefix}20240101_000001.undone")),
145        "older undone",
146      )
147      .unwrap();
148      fs::write(
149        backup_dir.join(format!("{prefix}20240101_000002.undone")),
150        "newest undone",
151      )
152      .unwrap();
153
154      redo(&source, &backup_dir, 1).unwrap();
155
156      assert_eq!(fs::read_to_string(&source).unwrap(), "newest undone");
157    }
158
159    #[test]
160    fn it_returns_error_when_count_is_zero() {
161      let dir = tempfile::tempdir().unwrap();
162      let source = dir.path().join("doing.md");
163      let backup_dir = dir.path().join("backups");
164      fs::create_dir_all(&backup_dir).unwrap();
165      fs::write(&source, "current").unwrap();
166
167      let prefix = backup_prefix(&source).unwrap();
168      fs::write(backup_dir.join(format!("{prefix}20240101_000001.undone")), "undone").unwrap();
169
170      let result = redo(&source, &backup_dir, 0);
171
172      assert!(result.is_err());
173    }
174
175    #[test]
176    fn it_returns_error_when_no_undone_files() {
177      let dir = tempfile::tempdir().unwrap();
178      let source = dir.path().join("doing.md");
179      let backup_dir = dir.path().join("backups");
180      fs::create_dir_all(&backup_dir).unwrap();
181
182      let result = redo(&source, &backup_dir, 1);
183
184      assert!(result.is_err());
185      assert!(result.unwrap_err().to_string().contains("redo history"));
186    }
187  }
188
189  mod undo {
190    use pretty_assertions::assert_eq;
191
192    use super::*;
193
194    #[test]
195    fn it_consumes_backup_after_restoring() {
196      let dir = tempfile::tempdir().unwrap();
197      let source = dir.path().join("doing.md");
198      let backup_dir = dir.path().join("backups");
199      fs::create_dir_all(&backup_dir).unwrap();
200      fs::write(&source, "current state").unwrap();
201
202      let prefix = backup_prefix(&source).unwrap();
203      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup1").unwrap();
204
205      undo(&source, &backup_dir, 1).unwrap();
206
207      let remaining_bak = backup::list_backups(&source, &backup_dir).unwrap();
208      assert!(remaining_bak.is_empty());
209
210      let undone = backup::list_undone(&source, &backup_dir).unwrap();
211      assert_eq!(undone.len(), 2);
212    }
213
214    #[test]
215    fn it_creates_undone_snapshot_of_current_state() {
216      let dir = tempfile::tempdir().unwrap();
217      let source = dir.path().join("doing.md");
218      let backup_dir = dir.path().join("backups");
219      fs::create_dir_all(&backup_dir).unwrap();
220      fs::write(&source, "current state").unwrap();
221
222      let prefix = backup_prefix(&source).unwrap();
223      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup1").unwrap();
224
225      undo(&source, &backup_dir, 1).unwrap();
226
227      let undone = backup::list_undone(&source, &backup_dir).unwrap();
228      let newest_undone = &undone[0];
229      assert_eq!(fs::read_to_string(newest_undone).unwrap(), "current state");
230    }
231
232    #[test]
233    fn it_restores_atomically_without_temp_file_residue() {
234      let dir = tempfile::tempdir().unwrap();
235      let source = dir.path().join("doing.md");
236      let backup_dir = dir.path().join("backups");
237      fs::create_dir_all(&backup_dir).unwrap();
238      fs::write(&source, "current").unwrap();
239
240      let prefix = backup_prefix(&source).unwrap();
241      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup").unwrap();
242
243      undo(&source, &backup_dir, 1).unwrap();
244
245      // Source should be restored and no temp file should remain
246      assert_eq!(fs::read_to_string(&source).unwrap(), "backup");
247      assert!(!source.with_extension("tmp").exists());
248    }
249
250    #[test]
251    fn it_restores_from_most_recent_by_default() {
252      let dir = tempfile::tempdir().unwrap();
253      let source = dir.path().join("doing.md");
254      let backup_dir = dir.path().join("backups");
255      fs::create_dir_all(&backup_dir).unwrap();
256      fs::write(&source, "current").unwrap();
257
258      let prefix = backup_prefix(&source).unwrap();
259      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "oldest").unwrap();
260      fs::write(backup_dir.join(format!("{prefix}20240101_000002.bak")), "newest").unwrap();
261
262      undo(&source, &backup_dir, 1).unwrap();
263
264      assert_eq!(fs::read_to_string(&source).unwrap(), "newest");
265    }
266
267    #[test]
268    fn it_restores_from_nth_backup() {
269      let dir = tempfile::tempdir().unwrap();
270      let source = dir.path().join("doing.md");
271      let backup_dir = dir.path().join("backups");
272      fs::create_dir_all(&backup_dir).unwrap();
273      fs::write(&source, "current").unwrap();
274
275      let prefix = backup_prefix(&source).unwrap();
276      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "oldest").unwrap();
277      fs::write(backup_dir.join(format!("{prefix}20240101_000002.bak")), "middle").unwrap();
278      fs::write(backup_dir.join(format!("{prefix}20240101_000003.bak")), "newest").unwrap();
279
280      undo(&source, &backup_dir, 2).unwrap();
281
282      assert_eq!(fs::read_to_string(&source).unwrap(), "middle");
283    }
284
285    #[test]
286    fn it_returns_error_when_count_exceeds_history() {
287      let dir = tempfile::tempdir().unwrap();
288      let source = dir.path().join("doing.md");
289      let backup_dir = dir.path().join("backups");
290      fs::create_dir_all(&backup_dir).unwrap();
291      fs::write(&source, "current").unwrap();
292
293      let prefix = backup_prefix(&source).unwrap();
294      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup").unwrap();
295
296      let result = undo(&source, &backup_dir, 5);
297
298      assert!(result.is_err());
299      assert!(result.unwrap_err().to_string().contains("undo history"));
300    }
301
302    #[test]
303    fn it_returns_error_when_count_is_zero() {
304      let dir = tempfile::tempdir().unwrap();
305      let source = dir.path().join("doing.md");
306      let backup_dir = dir.path().join("backups");
307      fs::create_dir_all(&backup_dir).unwrap();
308      fs::write(&source, "current").unwrap();
309
310      let prefix = backup_prefix(&source).unwrap();
311      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup").unwrap();
312
313      let result = undo(&source, &backup_dir, 0);
314
315      assert!(result.is_err());
316    }
317
318    #[test]
319    fn it_walks_backwards_on_sequential_calls() {
320      let dir = tempfile::tempdir().unwrap();
321      let source = dir.path().join("doing.md");
322      let backup_dir = dir.path().join("backups");
323      fs::create_dir_all(&backup_dir).unwrap();
324      fs::write(&source, "current").unwrap();
325
326      let prefix = backup_prefix(&source).unwrap();
327      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "oldest").unwrap();
328      fs::write(backup_dir.join(format!("{prefix}20240101_000002.bak")), "middle").unwrap();
329      fs::write(backup_dir.join(format!("{prefix}20240101_000003.bak")), "newest").unwrap();
330
331      undo(&source, &backup_dir, 1).unwrap();
332      assert_eq!(fs::read_to_string(&source).unwrap(), "newest");
333
334      undo(&source, &backup_dir, 1).unwrap();
335      assert_eq!(fs::read_to_string(&source).unwrap(), "middle");
336
337      undo(&source, &backup_dir, 1).unwrap();
338      assert_eq!(fs::read_to_string(&source).unwrap(), "oldest");
339
340      let result = undo(&source, &backup_dir, 1);
341      assert!(result.is_err());
342    }
343  }
344}