1use 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
24pub fn dir(issues_dir: &Path) -> PathBuf {
30 issues_dir
31 .parent()
32 .unwrap_or(issues_dir)
33 .join(".tix-backups")
34}
35
36pub 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
55pub 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
71pub 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
103fn 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
113fn 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 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 #[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 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 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 assert_eq!(prune(&tmp.path().join("absent"), 5).unwrap(), 0);
233 }
234}