use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use crate::error::{Result, TixError};
use crate::storage::Store;
pub const BACKUP_SUFFIX: &str = ".tixbak";
pub const ISSUE_BACKUP_SUFFIX: &str = ".md.bak";
pub fn run(store: &Store, force: bool, keep: usize) -> Result<()> {
let pruned = crate::backup::prune(&store.issues_dir, keep)?;
if pruned > 0 {
println!(
"Pruned {pruned} backup file{} from {}",
plural(pruned),
crate::backup::dir(&store.issues_dir).display()
);
}
let files = collect_backups(&store.issues_dir)?;
if files.is_empty() {
if pruned == 0 {
println!("No backup files to clean");
}
return Ok(());
}
if !force && !std::io::stdin().is_terminal() {
return Err(TixError::InvalidInput(
"tix clean confirms each file; run it interactively or pass --force to remove every backup without prompting".into(),
));
}
let mut removed = 0;
for path in &files {
let delete = force
|| is_yes(&crate::editor::read_tty_line(&format!(
"Remove {}? [y/N] ",
path.display()
))?);
if delete {
std::fs::remove_file(path)?;
removed += 1;
}
}
println!(
"Removed {removed} of {} backup file{}",
files.len(),
plural(files.len())
);
Ok(())
}
fn collect_backups(dir: &Path) -> Result<Vec<PathBuf>> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut files = Vec::new();
for entry in entries {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
files.extend(collect_backups(&path)?);
} else if is_backup(&path) {
files.push(path);
}
}
Ok(files)
}
fn is_backup(path: &Path) -> bool {
path.file_name().is_some_and(|name| {
let name = name.to_string_lossy();
name.ends_with(BACKUP_SUFFIX) || name.ends_with(ISSUE_BACKUP_SUFFIX)
})
}
fn is_yes(answer: &str) -> bool {
answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::Config;
use std::fs;
use tempfile::TempDir;
fn store_in(dir: &Path) -> Store {
let mut config = Config::starter();
config.issues_dir = dir.to_path_buf();
Store::open(&config).unwrap()
}
#[test]
fn removes_backups_at_any_depth_and_leaves_other_files() {
let tmp = TempDir::new().unwrap();
let store = store_in(tmp.path());
let nested = tmp.path().join("herbert").join("default").join("todo");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("2026-01-01-a.md"), "an issue").unwrap();
fs::write(nested.join("2026-01-01-a.md.tixbak"), "old").unwrap();
fs::write(nested.join("2026-01-01-a.md.bak"), "old").unwrap();
fs::write(tmp.path().join("loose.md.tixbak"), "old").unwrap();
fs::write(tmp.path().join("keep.bak"), "not ours").unwrap();
fs::write(tmp.path().join("notes.txt.bak"), "not ours").unwrap();
run(&store, true, 10).unwrap();
assert!(!nested.join("2026-01-01-a.md.tixbak").exists());
assert!(!nested.join("2026-01-01-a.md.bak").exists());
assert!(!tmp.path().join("loose.md.tixbak").exists());
assert!(nested.join("2026-01-01-a.md").exists(), "issues are kept");
assert!(tmp.path().join("keep.bak").exists(), "a plain .bak is kept");
assert!(
tmp.path().join("notes.txt.bak").exists(),
"only .tixbak and issue backups (.md.bak) are removed"
);
}
#[test]
fn only_tix_backups_match() {
assert!(is_backup(Path::new("2026-01-01-a.md.tixbak")));
assert!(is_backup(Path::new("2026-01-01-a.md.bak")));
assert!(!is_backup(Path::new("2026-01-01-a.md")));
assert!(!is_backup(Path::new("keep.bak")));
assert!(!is_backup(Path::new("notes.txt.bak")));
assert!(!is_backup(Path::new("a.tixbak.md")));
}
#[test]
fn is_yes_is_explicit() {
assert!(is_yes("y"));
assert!(is_yes("Y"));
assert!(is_yes("yes"));
assert!(is_yes("Yes"));
assert!(!is_yes(""));
assert!(!is_yes("n"));
assert!(!is_yes("no"));
assert!(!is_yes("yeah"));
}
#[test]
fn a_missing_issues_dir_is_not_an_error() {
let tmp = TempDir::new().unwrap();
let store = store_in(&tmp.path().join("does-not-exist"));
run(&store, true, 10).unwrap();
}
#[test]
fn keep_one_prunes_central_backups_and_still_sweeps_legacy_files() {
let tmp = TempDir::new().unwrap();
let issues = tmp.path().join("issues");
fs::create_dir_all(&issues).unwrap();
let store = store_in(&issues);
fs::write(issues.join("2026-01-01-a.md"), "an issue").unwrap();
let first = crate::backup::save(&issues, "2026-01-01-a.md").unwrap();
let second = crate::backup::save(&issues, "2026-01-01-a.md").unwrap();
fs::write(issues.join("2026-01-01-a.md.tixbak"), "old").unwrap();
run(&store, true, 1).unwrap();
assert_eq!(
crate::backup::list(&issues),
vec![second],
"keep the newest per issue key"
);
assert!(!first.exists(), "the older central backup is pruned");
assert!(
issues.join("2026-01-01-a.md").exists(),
"issue files are untouched"
);
assert!(
!issues.join("2026-01-01-a.md.tixbak").exists(),
"the legacy scattered file is still swept"
);
}
}