Skip to main content

tix/
backup.rs

1//! Centralized, prunable backups of issue files (#1581).
2//!
3//! Every write that replaces an issue's on-disk content — the TUI's editor /
4//! repair / conflict path and `tix import`'s overwrite of a newer remote issue —
5//! first copies the previous bytes here, into a hidden `.tix-backups/`
6//! directory **beside** the issues directory (the same sibling convention
7//! [`crate::storage`]'s number-claim ledger uses). One directory collects every
8//! backup, so they are discoverable in one place instead of scattered through
9//! the `{assignee}/{type}/{status}` segments, and [`prune`] bounds how many are
10//! kept.
11//!
12//! A backup is named `<stem>.<unix_nanos>.md`, where `<stem>` is the issue
13//! file's basename without its `.md` extension: the timestamp both orders the
14//! copies and keeps repeated saves of one issue distinct. `tix clean --keep N`
15//! prunes the directory to the newest `N` per stem.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use crate::error::Result;
23
24/// The hidden directory beside `issues_dir` that holds every backup.
25///
26/// Derived from `issues_dir` rather than hardcoded, so a repo that points
27/// `issues_dir` somewhere else still gets a ledger beside that — exactly how
28/// [`crate::storage`] derives its number-claim directory.
29pub fn dir(issues_dir: &Path) -> PathBuf {
30    issues_dir
31        .parent()
32        .unwrap_or(issues_dir)
33        .join(".tix-backups")
34}
35
36/// Copy `<issues_dir>/<filename>` into [`dir`] under a timestamped name,
37/// returning the backup path.
38///
39/// Returns `None` when the issue cannot be read or the copy cannot be written: a
40/// backup is best-effort, and the caller reports the path it gets rather than
41/// failing the write it was protecting.
42pub fn save(issues_dir: &Path, filename: &str) -> Option<PathBuf> {
43    let path = issues_dir.join(filename);
44    let bytes = std::fs::read(&path).ok()?;
45    let stem = Path::new(filename)
46        .file_stem()
47        .map(|s| s.to_string_lossy().to_string())?;
48    let backup_dir = dir(issues_dir);
49    std::fs::create_dir_all(&backup_dir).ok()?;
50    let backup = backup_dir.join(format!("{stem}.{}.md", stamp()));
51    std::fs::write(&backup, bytes).ok()?;
52    Some(backup)
53}
54
55/// Every backup under [`dir`], newest first by the timestamp in its name.
56pub fn list(issues_dir: &Path) -> Vec<PathBuf> {
57    let mut found: Vec<(u64, PathBuf)> = std::fs::read_dir(dir(issues_dir))
58        .into_iter()
59        .flatten()
60        .flatten()
61        .filter_map(|entry| {
62            let path = entry.path();
63            let (_, nanos) = split_name(&path)?;
64            Some((nanos, path))
65        })
66        .collect();
67    found.sort_by_key(|a| std::cmp::Reverse(a.0));
68    found.into_iter().map(|(_, path)| path).collect()
69}
70
71/// Keep the newest `keep` backups per issue stem, deleting the rest.
72///
73/// Grouping by the `<stem>` key makes each issue's retention independent: a busy
74/// issue never evicts a quiet one's only backup. `keep == 0` removes every
75/// backup. Returns how many files were deleted; a missing directory is `0`.
76pub fn prune(issues_dir: &Path, keep: usize) -> Result<usize> {
77    let entries = match std::fs::read_dir(dir(issues_dir)) {
78        Ok(entries) => entries,
79        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
80        Err(e) => return Err(e.into()),
81    };
82    let mut groups: BTreeMap<String, Vec<(u64, PathBuf)>> = BTreeMap::new();
83    for entry in entries {
84        let entry = entry?;
85        if !entry.file_type()?.is_file() {
86            continue;
87        }
88        if let Some((stem, nanos)) = split_name(&entry.path()) {
89            groups.entry(stem).or_default().push((nanos, entry.path()));
90        }
91    }
92    let mut removed = 0;
93    for mut group in groups.into_values() {
94        group.sort_by_key(|a| std::cmp::Reverse(a.0));
95        for (_, path) in group.into_iter().skip(keep) {
96            std::fs::remove_file(&path)?;
97            removed += 1;
98        }
99    }
100    Ok(removed)
101}
102
103/// The `<stem>` and timestamp of a backup file name, or `None` when the name is
104/// not a backup (`<stem>.<nanos>.md`).
105fn split_name(path: &Path) -> Option<(String, u64)> {
106    let name = path.file_name()?.to_str()?;
107    let stem_and_nanos = name.strip_suffix(".md")?;
108    let (stem, nanos) = stem_and_nanos.rsplit_once('.')?;
109    let nanos: u64 = nanos.parse().ok()?;
110    Some((stem.to_string(), nanos))
111}
112
113/// The next unique backup stamp: wall-clock nanoseconds, forced strictly
114/// increasing so two saves within one nanosecond (or under a coarse clock) never
115/// collide into a single name.
116fn stamp() -> u64 {
117    static LAST: AtomicU64 = AtomicU64::new(0);
118    let now = SystemTime::now()
119        .duration_since(UNIX_EPOCH)
120        .map(|d| d.as_nanos() as u64)
121        .unwrap_or(0);
122    let mut last = LAST.load(Ordering::Relaxed);
123    loop {
124        let next = now.max(last + 1);
125        match LAST.compare_exchange_weak(last, next, Ordering::Relaxed, Ordering::Relaxed) {
126            Ok(_) => return next,
127            Err(observed) => last = observed,
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    #[allow(clippy::wildcard_imports)]
135    use super::*;
136    use std::fs;
137    use tempfile::TempDir;
138
139    /// An isolated `(tmp, issues_dir)` pair: the issues directory is a child of
140    /// the tempdir, so the backup directory lands inside the tempdir too.
141    fn fixture() -> (TempDir, PathBuf) {
142        let tmp = TempDir::new().unwrap();
143        let issues = tmp.path().join("issues");
144        fs::create_dir_all(&issues).unwrap();
145        (tmp, issues)
146    }
147
148    #[test]
149    fn save_writes_a_timestamped_copy_into_the_backup_dir() {
150        let (_tmp, issues) = fixture();
151        fs::write(issues.join("2026-01-01-fix.md"), "previous bytes").unwrap();
152
153        let backup = save(&issues, "2026-01-01-fix.md").expect("a backup is written");
154
155        assert_eq!(backup.parent(), Some(dir(&issues).as_path()));
156        let name = backup.file_name().unwrap().to_string_lossy();
157        let (stem, nanos) = split_name(&backup).expect("the name is `<stem>.<nanos>.md`");
158        assert_eq!(stem, "2026-01-01-fix");
159        assert!(nanos > 0);
160        assert!(name.ends_with(".md"), "the copy keeps a .md name: {name}");
161        assert_eq!(fs::read_to_string(&backup).unwrap(), "previous bytes");
162    }
163
164    /// A save never leaves a scattered `.tixbak`/`.md.bak` beside the issue; the
165    /// only copy is the centralized one. Reverting `save` to the old
166    /// `<name>.md.tixbak` location makes this fail.
167    #[test]
168    fn save_leaves_no_scattered_backup_beside_the_issue() {
169        let (_tmp, issues) = fixture();
170        fs::write(issues.join("2026-01-01-fix.md"), "previous bytes").unwrap();
171
172        save(&issues, "2026-01-01-fix.md").unwrap();
173
174        assert!(
175            !issues.join("2026-01-01-fix.md.tixbak").exists(),
176            "no .tixbak beside the issue"
177        );
178        assert!(
179            !issues.join("2026-01-01-fix.md.bak").exists(),
180            "no .md.bak beside the issue"
181        );
182        assert_eq!(list(&issues).len(), 1, "the copy is in the centralized dir");
183    }
184
185    #[test]
186    fn list_returns_newest_first() {
187        let (_tmp, issues) = fixture();
188        fs::write(issues.join("2026-01-01-fix.md"), "v1").unwrap();
189        let first = save(&issues, "2026-01-01-fix.md").unwrap();
190        let second = save(&issues, "2026-01-01-fix.md").unwrap();
191        let third = save(&issues, "2026-01-01-fix.md").unwrap();
192
193        assert_eq!(list(&issues), vec![third, second, first]);
194    }
195
196    #[test]
197    fn prune_keeps_the_newest_per_issue_key() {
198        let (_tmp, issues) = fixture();
199        fs::write(issues.join("2026-01-01-a.md"), "a").unwrap();
200        fs::write(issues.join("2026-01-02-b.md"), "b").unwrap();
201        let a1 = save(&issues, "2026-01-01-a.md").unwrap();
202        let a2 = save(&issues, "2026-01-01-a.md").unwrap();
203        let a3 = save(&issues, "2026-01-01-a.md").unwrap();
204        let b1 = save(&issues, "2026-01-02-b.md").unwrap();
205        let b2 = save(&issues, "2026-01-02-b.md").unwrap();
206
207        // `tix clean --keep 1` (the acceptance criterion): one per stem, and the
208        // issue files themselves are untouched.
209        let removed = prune(&issues, 1).unwrap();
210
211        assert_eq!(removed, 3, "a's older two and b's older one are removed");
212        assert_eq!(list(&issues), vec![b2, a3]);
213        assert!(!a1.exists() && !a2.exists() && !b1.exists());
214        assert!(issues.join("2026-01-01-a.md").exists());
215        assert!(issues.join("2026-01-02-b.md").exists());
216
217        // A second run at `keep 1` is a no-op.
218        assert_eq!(prune(&issues, 1).unwrap(), 0);
219    }
220
221    #[test]
222    fn prune_zero_removes_every_backup() {
223        let (tmp, issues) = fixture();
224        fs::write(issues.join("2026-01-01-a.md"), "a").unwrap();
225        save(&issues, "2026-01-01-a.md").unwrap();
226        save(&issues, "2026-01-01-a.md").unwrap();
227
228        assert_eq!(prune(&issues, 0).unwrap(), 2);
229        assert_eq!(list(&issues), [] as [std::path::PathBuf; 0]);
230
231        // A missing directory is not an error.
232        assert_eq!(prune(&tmp.path().join("absent"), 5).unwrap(), 0);
233    }
234}