use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::Result;
pub fn dir(issues_dir: &Path) -> PathBuf {
issues_dir
.parent()
.unwrap_or(issues_dir)
.join(".tix-backups")
}
pub fn save(issues_dir: &Path, filename: &str) -> Option<PathBuf> {
let path = issues_dir.join(filename);
let bytes = std::fs::read(&path).ok()?;
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())?;
let backup_dir = dir(issues_dir);
std::fs::create_dir_all(&backup_dir).ok()?;
let backup = backup_dir.join(format!("{stem}.{}.md", stamp()));
std::fs::write(&backup, bytes).ok()?;
Some(backup)
}
pub fn list(issues_dir: &Path) -> Vec<PathBuf> {
let mut found: Vec<(u64, PathBuf)> = std::fs::read_dir(dir(issues_dir))
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let path = entry.path();
let (_, nanos) = split_name(&path)?;
Some((nanos, path))
})
.collect();
found.sort_by_key(|a| std::cmp::Reverse(a.0));
found.into_iter().map(|(_, path)| path).collect()
}
pub fn prune(issues_dir: &Path, keep: usize) -> Result<usize> {
let entries = match std::fs::read_dir(dir(issues_dir)) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e.into()),
};
let mut groups: BTreeMap<String, Vec<(u64, PathBuf)>> = BTreeMap::new();
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
if let Some((stem, nanos)) = split_name(&entry.path()) {
groups.entry(stem).or_default().push((nanos, entry.path()));
}
}
let mut removed = 0;
for mut group in groups.into_values() {
group.sort_by_key(|a| std::cmp::Reverse(a.0));
for (_, path) in group.into_iter().skip(keep) {
std::fs::remove_file(&path)?;
removed += 1;
}
}
Ok(removed)
}
fn split_name(path: &Path) -> Option<(String, u64)> {
let name = path.file_name()?.to_str()?;
let stem_and_nanos = name.strip_suffix(".md")?;
let (stem, nanos) = stem_and_nanos.rsplit_once('.')?;
let nanos: u64 = nanos.parse().ok()?;
Some((stem.to_string(), nanos))
}
fn stamp() -> u64 {
static LAST: AtomicU64 = AtomicU64::new(0);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let mut last = LAST.load(Ordering::Relaxed);
loop {
let next = now.max(last + 1);
match LAST.compare_exchange_weak(last, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return next,
Err(observed) => last = observed,
}
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::fs;
use tempfile::TempDir;
fn fixture() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let issues = tmp.path().join("issues");
fs::create_dir_all(&issues).unwrap();
(tmp, issues)
}
#[test]
fn save_writes_a_timestamped_copy_into_the_backup_dir() {
let (_tmp, issues) = fixture();
fs::write(issues.join("2026-01-01-fix.md"), "previous bytes").unwrap();
let backup = save(&issues, "2026-01-01-fix.md").expect("a backup is written");
assert_eq!(backup.parent(), Some(dir(&issues).as_path()));
let name = backup.file_name().unwrap().to_string_lossy();
let (stem, nanos) = split_name(&backup).expect("the name is `<stem>.<nanos>.md`");
assert_eq!(stem, "2026-01-01-fix");
assert!(nanos > 0);
assert!(name.ends_with(".md"), "the copy keeps a .md name: {name}");
assert_eq!(fs::read_to_string(&backup).unwrap(), "previous bytes");
}
#[test]
fn save_leaves_no_scattered_backup_beside_the_issue() {
let (_tmp, issues) = fixture();
fs::write(issues.join("2026-01-01-fix.md"), "previous bytes").unwrap();
save(&issues, "2026-01-01-fix.md").unwrap();
assert!(
!issues.join("2026-01-01-fix.md.tixbak").exists(),
"no .tixbak beside the issue"
);
assert!(
!issues.join("2026-01-01-fix.md.bak").exists(),
"no .md.bak beside the issue"
);
assert_eq!(list(&issues).len(), 1, "the copy is in the centralized dir");
}
#[test]
fn list_returns_newest_first() {
let (_tmp, issues) = fixture();
fs::write(issues.join("2026-01-01-fix.md"), "v1").unwrap();
let first = save(&issues, "2026-01-01-fix.md").unwrap();
let second = save(&issues, "2026-01-01-fix.md").unwrap();
let third = save(&issues, "2026-01-01-fix.md").unwrap();
assert_eq!(list(&issues), vec![third, second, first]);
}
#[test]
fn prune_keeps_the_newest_per_issue_key() {
let (_tmp, issues) = fixture();
fs::write(issues.join("2026-01-01-a.md"), "a").unwrap();
fs::write(issues.join("2026-01-02-b.md"), "b").unwrap();
let a1 = save(&issues, "2026-01-01-a.md").unwrap();
let a2 = save(&issues, "2026-01-01-a.md").unwrap();
let a3 = save(&issues, "2026-01-01-a.md").unwrap();
let b1 = save(&issues, "2026-01-02-b.md").unwrap();
let b2 = save(&issues, "2026-01-02-b.md").unwrap();
let removed = prune(&issues, 1).unwrap();
assert_eq!(removed, 3, "a's older two and b's older one are removed");
assert_eq!(list(&issues), vec![b2, a3]);
assert!(!a1.exists() && !a2.exists() && !b1.exists());
assert!(issues.join("2026-01-01-a.md").exists());
assert!(issues.join("2026-01-02-b.md").exists());
assert_eq!(prune(&issues, 1).unwrap(), 0);
}
#[test]
fn prune_zero_removes_every_backup() {
let (tmp, issues) = fixture();
fs::write(issues.join("2026-01-01-a.md"), "a").unwrap();
save(&issues, "2026-01-01-a.md").unwrap();
save(&issues, "2026-01-01-a.md").unwrap();
assert_eq!(prune(&issues, 0).unwrap(), 2);
assert_eq!(list(&issues), [] as [std::path::PathBuf; 0]);
assert_eq!(prune(&tmp.path().join("absent"), 5).unwrap(), 0);
}
}