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