use super::*;
use tempfile::TempDir;
fn at(stamp: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(stamp)
.expect("test stamp is valid RFC 3339")
.with_timezone(&Utc)
}
fn snapshots(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("readable temp dir")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| is_snapshot_of("settings.json", n))
.collect();
names.sort();
names
}
#[test]
fn snapshot_then_prune_copies_the_existing_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, "{\"hooks\":\"before\"}").unwrap();
let snapshot = snapshot_then_prune_at(&path, 3, at("2026-09-09T14:45:00Z"))
.expect("snapshot succeeds")
.expect("an existing file is snapshotted");
assert_eq!(
snapshot.file_name().unwrap().to_string_lossy(),
"settings.json.20260909T144500Z.bak"
);
assert_eq!(
std::fs::read_to_string(&snapshot).unwrap(),
"{\"hooks\":\"before\"}",
"the snapshot must be a byte-identical copy of the pre-rewrite file"
);
}
#[test]
fn snapshot_then_prune_ignores_a_missing_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
let outcome = snapshot_then_prune_at(&path, 3, at("2026-09-09T14:45:00Z"))
.expect("a missing source is not an error");
assert!(outcome.is_none(), "nothing to snapshot means no snapshot");
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
#[test]
fn snapshot_then_prune_suffixes_a_same_second_collision() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
let now = at("2026-09-09T14:45:00Z");
std::fs::write(&path, "first").unwrap();
snapshot_then_prune_at(&path, 3, now).unwrap().unwrap();
std::fs::write(&path, "second").unwrap();
snapshot_then_prune_at(&path, 3, now).unwrap().unwrap();
assert_eq!(
snapshots(tmp.path()),
vec![
"settings.json.20260909T144500Z-1.bak".to_string(),
"settings.json.20260909T144500Z.bak".to_string(),
]
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("settings.json.20260909T144500Z.bak")).unwrap(),
"first"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("settings.json.20260909T144500Z-1.bak")).unwrap(),
"second"
);
}
#[test]
fn snapshot_then_prune_keeps_only_the_newest_three() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
for second in 0..4 {
std::fs::write(&path, format!("state-{second}")).unwrap();
snapshot_then_prune_at(
&path,
HOOK_SETTINGS_SNAPSHOTS_KEPT,
at(&format!("2026-09-09T14:45:0{second}Z")),
)
.unwrap()
.unwrap();
}
assert_eq!(
snapshots(tmp.path()),
vec![
"settings.json.20260909T144501Z.bak".to_string(),
"settings.json.20260909T144502Z.bak".to_string(),
"settings.json.20260909T144503Z.bak".to_string(),
],
"the oldest snapshot must be the one pruned"
);
}
#[test]
fn snapshot_then_prune_leaves_foreign_backups_alone() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
let foreign = [
"settings.json.bak",
"settings.json.bak.4242.7",
"settings.json.tmp.4242.7",
"settings.local.json.20260909T144500Z.bak",
];
for name in foreign {
std::fs::write(tmp.path().join(name), name).unwrap();
}
for second in 0..4 {
std::fs::write(&path, format!("state-{second}")).unwrap();
snapshot_then_prune_at(&path, 1, at(&format!("2026-09-09T14:45:0{second}Z")))
.unwrap()
.unwrap();
}
for name in foreign {
let kept = tmp.path().join(name);
assert!(kept.exists(), "{name} must survive the prune");
assert_eq!(std::fs::read_to_string(&kept).unwrap(), name);
}
assert_eq!(
snapshots(tmp.path()),
vec!["settings.json.20260909T144503Z.bak".to_string()]
);
}
#[test]
fn snapshot_order_key_rejects_foreign_names() {
for name in [
"settings.json",
"settings.json.bak",
"settings.json.bak.4242.7",
"settings.json.tmp.4242.7",
"settings.json.20260909T144500.bak",
"settings.json.20260909T144500Z.txt",
"settings.json.2026-09-09T14:45:00Z.bak",
"settings.json.20260909T144500Z-.bak",
"settings.json.20260909T144500Z-x.bak",
"settings.local.json.20260909T144500Z.bak",
] {
assert!(
snapshot_order_key("settings.json", name).is_none(),
"{name} must not be treated as a snapshot"
);
}
assert_eq!(
snapshot_order_key("settings.json", "settings.json.20260909T144500Z.bak"),
Some(("20260909T144500Z".to_string(), 0))
);
assert_eq!(
snapshot_order_key("settings.json", "settings.json.20260909T144500Z-12.bak"),
Some(("20260909T144500Z".to_string(), 12))
);
}
#[cfg(unix)]
#[test]
fn snapshot_then_prune_errors_when_the_parent_is_read_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("claude");
std::fs::create_dir(&dir).unwrap();
let path = dir.join("settings.json");
std::fs::write(&path, "original").unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let result = snapshot_then_prune_at(&path, 3, at("2026-09-09T14:45:00Z"));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let err = result.expect_err("an unwritable parent must fail the snapshot");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"original",
"a failed snapshot must not disturb the source file"
);
assert!(
snapshots(&dir).is_empty(),
"a failed snapshot must leave no partial file"
);
}