mod common;
fn count_bak_files_for(dir: &std::path::Path, filename: &str) -> usize {
let prefix = format!("{filename}.bak.");
std::fs::read_dir(dir)
.expect("read dir")
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with(&prefix))
})
.count()
}
#[test]
fn batch_retention_caps_write_backups_within_single_manifest() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = common::manifest(&[
serde_json::json!({"op": "write", "target": "f.txt", "content": "v0\n"}),
serde_json::json!({"op": "write", "target": "f.txt", "content": "v1\n"}),
serde_json::json!({"op": "write", "target": "f.txt", "content": "v2\n"}),
serde_json::json!({"op": "write", "target": "f.txt", "content": "v3\n"}),
]);
let output = common::atomwrite()
.env_remove("ATOMWRITE_BACKUP")
.args([
"--workspace",
dir.path().to_str().unwrap(),
"batch",
"--keep-backup",
"--retention",
"2",
])
.write_stdin(manifest)
.output()
.expect("run");
assert!(
output.status.success(),
"batch --retention 2 --keep-backup must succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
"v3\n"
);
let bak_count = count_bak_files_for(dir.path(), "f.txt");
assert!(
bak_count <= 2,
"batch --retention 2 must limit accumulated f.txt backups to at most 2, got {bak_count}"
);
assert!(
bak_count >= 1,
"at least 1 backup should have been created (3 of 4 writes hit an existing target)"
);
}
#[test]
fn batch_explicit_backup_flag_forces_backup_for_op_without_own_flag() {
let dir = tempfile::tempdir().expect("tempdir");
common::create_test_file(dir.path(), "f.txt", "bye\n");
let manifest = common::manifest(&[serde_json::json!({
"op": "delete",
"target": "f.txt",
})]);
let output = common::atomwrite()
.env_remove("ATOMWRITE_BACKUP")
.args([
"--workspace",
dir.path().to_str().unwrap(),
"batch",
"--backup",
])
.write_stdin(manifest)
.output()
.expect("run");
assert!(
output.status.success(),
"batch --backup with op missing backup field must succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!dir.path().join("f.txt").exists());
assert_eq!(
count_bak_files_for(dir.path(), "f.txt"),
1,
"explicit batch --backup must force backup even when op.backup is absent/false"
);
}
#[test]
fn batch_no_backup_overrides_op_level_backup_true() {
let dir = tempfile::tempdir().expect("tempdir");
common::create_test_file(dir.path(), "f.txt", "bye\n");
let manifest = common::manifest(&[serde_json::json!({
"op": "delete",
"target": "f.txt",
"backup": true,
})]);
let output = common::atomwrite()
.env_remove("ATOMWRITE_BACKUP")
.args([
"--workspace",
dir.path().to_str().unwrap(),
"batch",
"--no-backup",
])
.write_stdin(manifest)
.output()
.expect("run");
assert!(
output.status.success(),
"batch --no-backup with op.backup=true must succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!dir.path().join("f.txt").exists());
assert_eq!(
count_bak_files_for(dir.path(), "f.txt"),
0,
"batch --no-backup must override op.backup=true (regression)"
);
}
#[test]
fn batch_transaction_pre_backup_honors_retention() {
let dir = tempfile::tempdir().expect("tempdir");
common::create_test_file(dir.path(), "f.txt", "seed\n");
for i in 0..3 {
let manifest = common::manifest(&[serde_json::json!({
"op": "write",
"target": "f.txt",
"content": format!("v{i}\n"),
})]);
let output = common::atomwrite()
.env_remove("ATOMWRITE_BACKUP")
.args([
"--workspace",
dir.path().to_str().unwrap(),
"batch",
"--transaction",
"--retention",
"1",
])
.write_stdin(manifest)
.output()
.expect("run");
assert!(
output.status.success(),
"batch --transaction --retention 1 iteration {i} must succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let bak_count = count_bak_files_for(dir.path(), "f.txt");
assert!(
bak_count <= 1,
"batch --transaction --retention 1 must limit accumulated transactional pre-backups to at most 1, got {bak_count}"
);
}
#[test]
fn batch_delete_op_backup_honors_retention() {
let dir = tempfile::tempdir().expect("tempdir");
let mut ops = Vec::new();
for i in 0..4 {
ops.push(serde_json::json!({
"op": "write",
"target": "f.txt",
"content": format!("v{i}\n"),
}));
ops.push(serde_json::json!({
"op": "delete",
"target": "f.txt",
"backup": true,
}));
}
let manifest = common::manifest(&ops);
let output = common::atomwrite()
.env_remove("ATOMWRITE_BACKUP")
.args([
"--workspace",
dir.path().to_str().unwrap(),
"batch",
"--retention",
"2",
])
.write_stdin(manifest)
.output()
.expect("run");
assert!(
output.status.success(),
"write+delete cycles with --retention 2 must succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!dir.path().join("f.txt").exists());
let bak_count = count_bak_files_for(dir.path(), "f.txt");
assert!(
bak_count <= 2,
"delete op with backup must honor --retention 2 (not the old hardcoded 5), got {bak_count}"
);
assert!(
bak_count >= 1,
"at least 1 delete backup should exist"
);
}
#[test]
fn batch_backup_and_no_backup_together_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"batch",
"--backup",
"--no-backup",
])
.write_stdin("")
.output()
.expect("run");
assert_eq!(
output.status.code(),
Some(2),
"batch --backup --no-backup must be rejected by clap with exit 2: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}