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