use super::*;
use crate::ops::replace::replacement_text;
use crate::tx::{read_and_probe, read_file_content, update_file_content, validate_operation};
use crate::write::WritePolicy;
use std::collections::{HashMap, HashSet};
use std::fs;
use tempfile::TempDir;
fn shell_true() -> &'static str {
#[cfg(windows)]
{
"exit /b 0"
}
#[cfg(not(windows))]
{
"true"
}
}
fn shell_false() -> &'static str {
#[cfg(windows)]
{
"exit /b 1"
}
#[cfg(not(windows))]
{
"false"
}
}
fn shell_stderr_spam() -> &'static str {
#[cfg(windows)]
{
"powershell -NoProfile -Command \"[Console]::Error.Write('x' * 800000); exit 0\""
}
#[cfg(not(windows))]
{
"python3 -c \"import sys; sys.stderr.write('x' * (1024 * 1024)); sys.stderr.flush()\""
}
}
fn portable_path_str(p: &std::path::Path) -> String {
p.to_str().unwrap().replace('\\', "/")
}
mod basic {
use super::*;
#[test]
fn read_file_content_populates_pending_from_disk() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("existing.txt");
fs::write(&path, "hello from disk\n").unwrap();
let mut pending = HashMap::new();
let mut existed = HashSet::new();
let content = read_file_content(&mut pending, &mut existed, &path).unwrap();
assert_eq!(content, "hello from disk\n");
assert_eq!(
pending.get(&path),
Some(&(
"hello from disk\n".to_string(),
"hello from disk\n".to_string()
))
);
}
#[test]
fn read_and_probe_returns_true_for_text() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("text.rs");
fs::write(&path, "fn main() {}\n").unwrap();
let mut pending = HashMap::new();
let mut existed = HashSet::new();
assert!(read_and_probe(&mut pending, &mut existed, &path).unwrap());
assert!(pending.contains_key(&path));
assert_eq!(pending[&path].1, "fn main() {}\n");
}
#[test]
fn update_file_content_inserts_missing_pending_entry() {
let mut pending = HashMap::new();
let mut deletions = HashSet::new();
let mut write_targets = HashSet::new();
let path = PathBuf::from("created.txt");
update_file_content(
&mut pending,
&mut deletions,
&mut write_targets,
&path,
"brand new".to_string(),
);
assert_eq!(
pending.get(&path),
Some(&(String::new(), "brand new".to_string()))
);
}
#[test]
fn multi_op_plan() {
let dir = TempDir::new().unwrap();
let txt = dir.path().join("test.txt");
fs::write(&txt, "hello world\n").unwrap();
let json_file = dir.path().join("config.json");
fs::write(&json_file, r#"{"name": "old"}"#).unwrap();
let no_nl = dir.path().join("no_nl.txt");
fs::write(&no_nl, "content").unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [
{
"op": "replace",
"path": txt.to_str().unwrap(),
"old": "hello",
"new": "hi"
},
{
"op": "doc.set",
"path": json_file.to_str().unwrap(),
"selector": "name",
"value": "new"
},
{
"op": "tidy.fix",
"path": no_nl.to_str().unwrap(),
"ensure_final_newline": true
}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert_eq!(fs::read_to_string(&txt).unwrap(), "hi world\n");
let config: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&json_file).unwrap()).unwrap();
assert_eq!(config["name"], serde_json::json!("new"));
assert!(fs::read_to_string(&no_nl).unwrap().ends_with('\n'));
}
#[test]
fn build_error_output_produces_expected_shape() {
let output = build_error_output("rollback", "disk full", None);
assert!(!output.ok);
assert_eq!(output.status, "error".to_string());
assert_eq!(output.error_kind, Some("rollback".to_string()));
assert_eq!(output.error, Some("rollback: disk full".to_string()));
assert_eq!(output.files_changed, 0);
}
#[test]
fn validation_pass() {
let dir = TempDir::new().unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [],
"validate": [
{"cmd": shell_true(), "required": true}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
global.cwd = Some(dir.path().to_string_lossy().into_owned());
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
}
#[test]
fn parse_plan_json_string() {
let plan_json = r#"{"version": 1, "operations": [{"op": "replace", "path": "test.txt", "old": "a", "new": "b"}]}"#;
let plan = plan::parse_plan(plan_json).unwrap();
assert_eq!(plan.operations.len(), 1);
}
#[test]
fn regex_insert_before_uses_match_anchor_in_replacement_text() {
let text = replacement_text("b+", &None, &Some("X".to_string()), &None, true, true);
assert_eq!(text, "X${0}");
}
#[test]
fn regex_insert_after_uses_match_anchor_in_replacement_text() {
let text = replacement_text("b+", &None, &None, &Some("X".to_string()), true, true);
assert_eq!(text, "${0}X");
}
#[test]
fn tx_file_create_in_plan() {
let dir = TempDir::new().unwrap();
let new_file = dir.path().join("created.txt");
let plan_json = serde_json::json!({
"version": 1,
"operations": [
{
"op": "file.create",
"path": new_file.to_str().unwrap(),
"content": "brand new file\n"
}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(new_file.exists());
assert_eq!(fs::read_to_string(&new_file).unwrap(), "brand new file\n");
}
#[test]
fn tx_file_rename_basic() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
fs::write(&src, "content\n").unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "file.rename",
"from": portable_path_str(&src),
"to": portable_path_str(&dir.path().join("new.txt"))
}]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!src.exists());
assert_eq!(
fs::read_to_string(dir.path().join("new.txt")).unwrap(),
"content\n"
);
}
#[test]
fn tx_file_rename_dst_exists_with_force() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
fs::write(&src, "source\n").unwrap();
fs::write(&dst, "dest\n").unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "file.rename",
"from": portable_path_str(&src),
"to": portable_path_str(&dst),
"force": true
}]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "source\n");
}
#[test]
fn tx_create_then_rename() {
let dir = TempDir::new().unwrap();
let created = dir.path().join("created.txt");
let renamed = dir.path().join("renamed.txt");
let plan_json = serde_json::json!({
"version": 1,
"operations": [
{
"op": "file.create",
"path": portable_path_str(&created),
"content": "new file\n"
},
{
"op": "file.rename",
"from": portable_path_str(&created),
"to": portable_path_str(&renamed)
}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!created.exists());
assert_eq!(fs::read_to_string(&renamed).unwrap(), "new file\n");
}
#[test]
fn undo_list_json_output() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("a.txt");
fs::write(&file, "v1").unwrap();
let mut session = crate::backup::BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
session.finalize().unwrap().unwrap();
let args = crate::cmd::undo::UndoArgs {
list: true,
session: None,
apply: false,
};
let global = GlobalFlags::with_cwd_and_json(dir.path());
let code = crate::cmd::undo::run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let global = GlobalFlags::with_cwd_and_jsonl(dir.path());
let args2 = crate::cmd::undo::UndoArgs {
list: true,
session: None,
apply: false,
};
let code2 = crate::cmd::undo::run(args2, &global).unwrap();
assert_eq!(code2, exit::SUCCESS);
}
#[test]
fn undo_dry_run_json_output() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("b.txt");
fs::write(&file, "v1").unwrap();
let mut session = crate::backup::BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
fs::write(&file, "v2").unwrap();
let args = crate::cmd::undo::UndoArgs {
list: false,
session: Some(ts),
apply: false,
};
let global = GlobalFlags::with_cwd_and_json(dir.path());
let code = crate::cmd::undo::run(args, &global).unwrap();
assert_eq!(code, exit::CHANGES_DETECTED);
}
#[test]
fn path_err_formats_flat_error_with_path_prefix() {
let err = crate::tx::path_err("config.toml")(anyhow::anyhow!("key not found"));
let msg = err.to_string();
assert_eq!(msg, "config.toml: key not found");
}
#[test]
fn tx_yaml_plan_format() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("test_file.txt");
let plan_yaml = format!(
"version: 1\noperations:\n - op: file.create\n path: \"{}\"\n content: \"hello from yaml plan\"\n",
portable_path_str(&target)
);
let plan_file = dir.path().join("plan.yaml");
fs::write(&plan_file, &plan_yaml).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS, "YAML plan should succeed");
assert_eq!(
fs::read_to_string(&target).unwrap(),
"hello from yaml plan",
"file should contain content from YAML plan"
);
}
#[test]
fn tx_toml_plan_format() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("test_file.txt");
let plan_toml = format!(
"version = 1\n\n[[operations]]\nop = \"file.create\"\npath = \"{}\"\ncontent = \"hello from toml plan\"\n",
portable_path_str(&target)
);
let plan_file = dir.path().join("plan.toml");
fs::write(&plan_file, &plan_toml).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS, "TOML plan should succeed");
assert_eq!(
fs::read_to_string(&target).unwrap(),
"hello from toml plan",
"file should contain content from TOML plan"
);
}
}
mod edge_cases {
use super::*;
#[test]
fn read_and_probe_returns_false_for_binary() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("data.bin");
fs::write(&path, b"ELF\x00\x01\x02\x03").unwrap();
let mut pending = HashMap::new();
let mut existed = HashSet::new();
assert!(!read_and_probe(&mut pending, &mut existed, &path).unwrap());
assert!(!pending.contains_key(&path));
}
#[test]
fn read_and_probe_returns_false_for_invalid_utf8() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.txt");
fs::write(&path, [0x80, 0x81, 0x82]).unwrap();
let mut pending = HashMap::new();
let mut existed = HashSet::new();
assert!(!read_and_probe(&mut pending, &mut existed, &path).unwrap());
assert!(!pending.contains_key(&path));
}
#[test]
fn create_then_delete_in_same_tx_becomes_noop() {
let dir = TempDir::new().unwrap();
let new_file = dir.path().join("new.txt");
let plan_json = serde_json::json!({
"version": 1,
"operations": [
{"op": "file.create", "path": new_file.to_str().unwrap(), "content": "hello"},
{"op": "file.delete", "path": new_file.to_str().unwrap()}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.check = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!new_file.exists());
}
#[test]
fn tx_file_rename_same_path_noop() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("same.txt");
fs::write(&f, "data\n").unwrap();
let path_str = portable_path_str(&f);
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "file.rename",
"from": path_str,
"to": path_str
}]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert_eq!(fs::read_to_string(&f).unwrap(), "data\n");
}
#[test]
fn tx_delete_then_create_without_force_succeeds() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("target.txt");
fs::write(&f, "original\n").unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [
{
"op": "file.delete",
"path": portable_path_str(&f)
},
{
"op": "file.create",
"path": portable_path_str(&f),
"content": "recreated\n"
}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert_eq!(fs::read_to_string(&f).unwrap(), "recreated\n");
}
#[test]
fn tx_doc_delete_missing_key_is_idempotent() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("data.json");
fs::write(&f, r#"{"a": 1}"#).unwrap();
let plan = format!(
r#"{{
"version": 1,
"operations": [
{{"op": "doc.delete", "path": "{}", "selector": "nonexistent"}}
]
}}"#,
portable_path_str(&f)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::SUCCESS,
"doc.delete on missing key should succeed (idempotent)"
);
}
#[test]
fn tx_doc_delete_where_no_match_is_idempotent() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("data.json");
fs::write(&f, r#"{"items": [{"name": "a"}]}"#).unwrap();
let plan = format!(
r#"{{
"version": 1,
"operations": [
{{"op": "doc.delete_where", "path": "{}", "selector": "items", "predicate": "name=nonexistent"}}
]
}}"#,
portable_path_str(&f)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::SUCCESS,
"doc.delete_where with zero matches should succeed (idempotent)"
);
}
#[test]
fn validation_pass_with_large_stderr_output() {
let dir = TempDir::new().unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [],
"validate": [
{"cmd": shell_stderr_spam(), "required": true, "timeout": 10}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
global.cwd = Some(dir.path().to_string_lossy().into_owned());
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
}
}
mod error_handling {
use super::*;
#[test]
fn malformed_plan_returns_parse_error() {
let dir = TempDir::new().unwrap();
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, "not json at all {{{").unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let global = GlobalFlags::test_default();
let code = run(args, &global).unwrap();
assert_eq!(code, exit::PARSE_ERROR);
}
#[test]
fn replace_requires_replacement_mode() {
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "replace",
"path": "test.txt",
"old": "hello"
}]
})
.to_string();
let plan = plan::parse_plan(&plan_json).unwrap();
let err = validate_operation(&plan.operations[0]).unwrap_err();
assert_eq!(
err.to_string(),
"replace: one of 'to', 'insert_before', or 'insert_after' must be provided"
);
}
#[test]
fn replace_conflicting_insert_fields_return_parse_error() {
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "replace",
"path": "test.txt",
"old": "hello",
"insert_before": "X",
"insert_after": "Y"
}]
})
.to_string();
let plan = plan::parse_plan(&plan_json).unwrap();
let err = validate_operation(&plan.operations[0]).unwrap_err();
assert_eq!(
err.to_string(),
"replace: 'insert_before' and 'insert_after' cannot be combined"
);
}
#[test]
fn replace_to_with_insert_returns_parse_error() {
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "replace",
"path": "test.txt",
"old": "hello",
"new": "world",
"insert_before": "X"
}]
})
.to_string();
let plan = plan::parse_plan(&plan_json).unwrap();
let err = validate_operation(&plan.operations[0]).unwrap_err();
assert_eq!(
err.to_string(),
"replace: 'to' cannot be combined with 'insert_before' or 'insert_after'"
);
}
#[test]
fn tx_file_create_existing_fails() {
let dir = TempDir::new().unwrap();
let existing = dir.path().join("existing.txt");
fs::write(&existing, "original content\n").unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [
{
"op": "file.create",
"path": existing.to_str().unwrap(),
"content": "should not overwrite\n"
}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::OPERATION_FAILED);
assert_eq!(fs::read_to_string(&existing).unwrap(), "original content\n");
}
#[test]
fn validate_operation_rejects_empty_from() {
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "replace",
"path": "test.txt",
"old": "",
"new": "x"
}]
})
.to_string();
let plan = plan::parse_plan(&plan_json).unwrap();
let err = validate_operation(&plan.operations[0]).unwrap_err();
assert!(
err.to_string().contains("search pattern must not be empty"),
"expected 'search pattern must not be empty' error, got: {}",
err
);
}
#[test]
fn validate_operation_rejects_nth_zero() {
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "replace",
"path": "test.txt",
"old": "hello",
"new": "world",
"nth": 0
}]
})
.to_string();
let plan = plan::parse_plan(&plan_json).unwrap();
let err = validate_operation(&plan.operations[0]).unwrap_err();
assert!(
err.to_string().contains("1-based"),
"expected '1-based' error, got: {}",
err
);
}
#[test]
fn validate_operation_rejects_invert_match_with_multiline() {
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "search",
"path": "test.txt",
"pattern": "foo",
"invert_match": true,
"multiline": true
}]
})
.to_string();
let plan = plan::parse_plan(&plan_json).unwrap();
let err = validate_operation(&plan.operations[0]).unwrap_err();
assert!(
err.to_string()
.contains("invert_match and multiline cannot be combined"),
"unexpected error: {}",
err
);
}
#[test]
fn validate_operation_rejects_literal_with_regex() {
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "search",
"path": "test.txt",
"pattern": "foo",
"literal": true,
"regex": true
}]
})
.to_string();
let plan = plan::parse_plan(&plan_json).unwrap();
let err = validate_operation(&plan.operations[0]).unwrap_err();
assert!(
err.to_string()
.contains("literal and regex cannot be combined"),
"unexpected error: {}",
err
);
}
#[test]
fn tx_unsupported_plan_version() {
let dir = TempDir::new().unwrap();
let plan_json = serde_json::json!({
"version": "99",
"operations": []
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let global = GlobalFlags::test_default();
let code = run(args, &global).unwrap();
assert_eq!(code, exit::PARSE_ERROR);
}
#[test]
fn parse_eol_mode_invalid_value_returns_error() {
let result = crate::write::parse_eol_mode("bogus");
let msg = result
.expect_err("invalid eol value should fail")
.to_string();
assert!(
msg.contains("invalid normalize_eol"),
"error should name the field: {msg}"
);
}
#[test]
fn tx_invalid_normalize_eol_in_plan() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("file.txt");
fs::write(&f, "hello\n").unwrap();
let plan = format!(
r#"{{
"version": 1,
"operations": [
{{"op": "tidy.fix", "path": "{}", "normalize_eol": "bogus"}}
]
}}"#,
portable_path_str(&f)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::OPERATION_FAILED,
"invalid normalize_eol should fail before commit"
);
}
#[test]
fn validation_fail_required() {
let dir = TempDir::new().unwrap();
let plan_json = serde_json::json!({
"version": 1,
"strict": false,
"operations": [],
"validate": [
{"cmd": shell_false(), "required": true}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
global.cwd = Some(dir.path().to_string_lossy().into_owned());
let code = run(args, &global).unwrap();
assert_eq!(code, exit::VALIDATION_FAILED);
}
}
mod integrity {
use super::*;
#[test]
fn rollback_on_failure() {
let dir = TempDir::new().unwrap();
let txt = dir.path().join("test.txt");
fs::write(&txt, "hello world\n").unwrap();
let nonexistent = dir.path().join("nonexistent.json");
let plan_json = serde_json::json!({
"version": 1,
"operations": [
{
"op": "replace",
"path": txt.to_str().unwrap(),
"old": "hello",
"new": "hi"
},
{
"op": "doc.set",
"path": nonexistent.to_str().unwrap(),
"selector": "name",
"value": "test"
}
]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::OPERATION_FAILED);
assert_eq!(fs::read_to_string(&txt).unwrap(), "hello world\n");
}
#[test]
fn tx_file_rename_dst_exists_without_force_rolls_back() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
fs::write(&src, "source\n").unwrap();
fs::write(&dst, "dest\n").unwrap();
let plan_json = serde_json::json!({
"version": 1,
"operations": [{
"op": "file.rename",
"from": portable_path_str(&src),
"to": portable_path_str(&dst)
}]
});
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, serde_json::to_string(&plan_json).unwrap()).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::OPERATION_FAILED);
assert_eq!(fs::read_to_string(&src).unwrap(), "source\n");
assert_eq!(fs::read_to_string(&dst).unwrap(), "dest\n");
}
#[test]
fn rollback_strict_restores_modified_files() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("a.txt");
fs::write(&f, "original").unwrap();
let mut pending = HashMap::new();
pending.insert(f.clone(), ("original".to_string(), "changed".to_string()));
let deletions = HashSet::new();
let existed_before: HashSet<PathBuf> = [f.clone()].into();
crate::write::atomic_write(&f, "changed", &WritePolicy::default()).unwrap();
assert_eq!(fs::read_to_string(&f).unwrap(), "changed");
crate::tx::rollback_strict(
&[(f.clone(), "original".to_string(), "changed".to_string())],
&pending,
&deletions,
&existed_before,
);
assert_eq!(
fs::read_to_string(&f).unwrap(),
"original",
"rollback_strict should restore the original content"
);
}
#[test]
fn tx_doc_append_to_non_array_rolls_back() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("data.json");
fs::write(&f, r#"{"items": "not-an-array"}"#).unwrap();
let plan = format!(
r#"{{
"version": 1,
"operations": [
{{"op": "doc.append", "path": "{}", "selector": "items", "value": 42}}
]
}}"#,
portable_path_str(&f)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::OPERATION_FAILED,
"doc.append to non-array should fail before commit"
);
}
#[test]
fn tx_doc_prepend_to_non_array_rolls_back() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("data.json");
fs::write(&f, r#"{"items": "not-an-array"}"#).unwrap();
let plan = format!(
r#"{{
"version": 1,
"operations": [
{{"op": "doc.prepend", "path": "{}", "selector": "items", "value": 99}}
]
}}"#,
portable_path_str(&f)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::OPERATION_FAILED,
"doc.prepend to non-array should fail before commit"
);
}
#[test]
fn tx_doc_delete_where_on_non_array_rolls_back() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("data.json");
fs::write(&f, r#"{"items": "not-an-array"}"#).unwrap();
let plan = format!(
r#"{{
"version": 1,
"operations": [
{{"op": "doc.delete_where", "path": "{}", "selector": "items", "predicate": "x=1"}}
]
}}"#,
portable_path_str(&f)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::OPERATION_FAILED,
"doc.delete_where on non-array should fail before commit"
);
}
#[test]
fn tx_strict_mode_rollback_on_operation_failure() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("data.txt");
fs::write(&f, "original content").unwrap();
let plan = format!(
r#"{{
"version": 1,
"strict": true,
"operations": [
{{"op": "replace", "path": "{}", "old": "original", "new": "modified"}},
{{"op": "read", "path": "nonexistent-file.txt"}}
]
}}"#,
portable_path_str(&f)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::OPERATION_FAILED,
"strict plan with failing op should fail before commit"
);
let content = fs::read_to_string(&f).unwrap();
assert_eq!(content, "original content", "file should remain unchanged");
}
#[test]
fn handle_commit_error_rollback_failed_exits_failure() {
let err = CommitError {
message: "write failed".into(),
rollback_ok: false,
backup_session: Some("1234567890".into()),
};
let code = handle_commit_error(err, true, false).unwrap();
assert_eq!(code, exit::FAILURE);
}
#[test]
fn commit_changes_reports_rollback_failed_when_restore_fails() {
let dir = TempDir::new().unwrap();
let f1 = dir.path().join("a.txt");
let blocker = dir.path().join("blocker");
fs::write(&f1, "original-a").unwrap();
fs::write(&blocker, "blocker-is-file").unwrap();
let f2 = blocker.join("b.txt");
let changes = vec![
(f1.clone(), "original-a".to_string(), "new-a".to_string()),
(f2.clone(), String::new(), "new-b".to_string()),
];
let deletions = HashSet::new();
let existed_before: HashSet<_> = [f1.clone()].into();
let _guard = RestoreFailGuard::engage();
let err = crate::tx::commit_changes(&changes, &deletions, &existed_before, dir.path())
.unwrap_err();
assert!(!err.rollback_ok, "restore should be reported as failed");
let _session = err
.backup_session
.expect("backup session should be present on failure");
assert_eq!(fs::read_to_string(&f1).unwrap(), "new-a");
}
#[test]
fn commit_changes_rolls_back_on_mid_write_failure() {
let dir = TempDir::new().unwrap();
let f1 = dir.path().join("a.txt");
let blocker = dir.path().join("blocker");
fs::write(&f1, "original-a").unwrap();
fs::write(&blocker, "blocker-is-file").unwrap();
let f2 = blocker.join("b.txt");
let changes = vec![
(f1.clone(), "original-a".to_string(), "new-a".to_string()),
(f2.clone(), String::new(), "new-b".to_string()),
];
let deletions = HashSet::new();
let existed_before: HashSet<_> = [f1.clone()].into();
let err = crate::tx::commit_changes(&changes, &deletions, &existed_before, dir.path())
.unwrap_err();
assert!(err.rollback_ok, "rollback should succeed");
assert!(
err.backup_session.is_some(),
"backup session should be recorded"
);
assert_eq!(fs::read_to_string(&f1).unwrap(), "original-a");
assert!(!f2.exists());
}
#[test]
fn tx_strict_rollback_removes_created_file() {
let dir = TempDir::new().unwrap();
let created = dir.path().join("new_file.txt");
let plan = format!(
r#"{{
"version": 1,
"strict": true,
"operations": [
{{"op": "file.create", "path": "{}", "content": "should be rolled back"}},
{{"op": "read", "path": "nonexistent-file.txt"}}
]
}}"#,
portable_path_str(&created)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::OPERATION_FAILED,
"strict plan with failing op should fail"
);
assert!(
!created.exists(),
"created file should be removed after rollback"
);
}
#[test]
fn tx_strict_rollback_restores_deleted_file() {
let dir = TempDir::new().unwrap();
let victim = dir.path().join("victim.txt");
fs::write(&victim, "precious content").unwrap();
let plan = format!(
r#"{{
"version": 1,
"strict": true,
"operations": [
{{"op": "file.delete", "path": "{}"}},
{{"op": "read", "path": "nonexistent-file.txt"}}
]
}}"#,
portable_path_str(&victim)
);
let plan_file = dir.path().join("plan.json");
fs::write(&plan_file, &plan).unwrap();
let args = TxArgs {
plan: plan_file.to_str().unwrap().to_string(),
plan_format: None,
no_strict: false,
verify: Vec::new(),
write: Default::default(),
};
let mut global = GlobalFlags::test_default();
global.apply = true;
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::OPERATION_FAILED,
"strict plan with failing op should fail"
);
assert!(
victim.exists(),
"deleted file should be restored after rollback"
);
assert_eq!(
fs::read_to_string(&victim).unwrap(),
"precious content",
"restored file should have original content"
);
}
}