use super::*;
use std::fs;
use crate::containment::{AbsolutePathPolicy, PathGuard};
use tempfile::TempDir;
#[test]
fn doc_set_preview_does_not_write() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"version": "1.0"}"#).unwrap();
let result = doc_set(
&file,
"version",
serde_json::json!("2.0"),
ApplyMode::Preview,
None,
)
.unwrap();
assert!(result.changed);
assert!(!result.applied);
assert!(result.new_content.contains("2.0"));
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("1.0"));
}
#[test]
fn doc_set_apply_writes_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"version": "1.0"}"#).unwrap();
let result = doc_set(
&file,
"version",
serde_json::json!("2.0"),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
assert!(result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("2.0"));
}
#[test]
fn doc_set_respects_guard() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"version": "1.0"}"#).unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let result = doc_set(
&file,
"version",
serde_json::json!("2.0"),
ApplyMode::Apply,
Some(&guard),
)
.unwrap();
assert!(result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("2.0"));
}
#[test]
fn doc_get_reads_value() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"database": {"host": "localhost"}}"#).unwrap();
let value = doc_get(&file, "database.host").unwrap();
assert_eq!(value, serde_json::json!("localhost"));
}
#[test]
fn doc_has_returns_true_for_existing() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"version": "1.0"}"#).unwrap();
assert!(doc_has(&file, "version").unwrap());
assert!(!doc_has(&file, "nonexistent").unwrap());
}
#[test]
fn doc_delete_removes_key() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"a": 1, "b": 2}"#).unwrap();
let result = doc_delete(&file, "b", ApplyMode::Apply, None).unwrap();
assert!(result.changed);
assert!(result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(!on_disk.contains("\"b\""));
}
#[test]
fn doc_merge_merges_values() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"a": 1}"#).unwrap();
let result = doc_merge(&file, serde_json::json!({"b": 2}), ApplyMode::Apply, None).unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
assert_eq!(
parsed["a"],
serde_json::json!(1),
"merge must preserve existing keys"
);
assert_eq!(parsed["b"], serde_json::json!(2), "merge must add new keys");
}
#[test]
fn replace_text_preview() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "fn old_name() {}\n").unwrap();
let result = replace_text(
&file,
"old_name",
"new_name",
&ReplaceOptions::default(),
ApplyMode::Preview,
None,
)
.unwrap();
assert!(result.changed);
assert!(!result.applied);
assert!(result.new_content.contains("new_name"));
}
#[test]
fn replace_text_apply() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "fn old_name() {}\n").unwrap();
let result = replace_text(
&file,
"old_name",
"new_name",
&ReplaceOptions::default(),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
assert!(result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("new_name"));
}
#[test]
fn replace_text_with_relaxed_guard() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "fn old_name() {}\n").unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let result = replace_text(
&file,
"old_name",
"new_name",
&ReplaceOptions::default(),
ApplyMode::Apply,
Some(&guard),
)
.unwrap();
assert!(result.changed);
assert!(result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("new_name"));
}
#[cfg(unix)]
#[test]
fn file_create_writes_to_literal_tmp_under_allow_temp_guard() {
let dir = TempDir::new().unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let tmp_path = format!("/tmp/patchloom_api_tmp_test_{}.txt", std::process::id());
let _ = fs::remove_file(&tmp_path);
let result = file_create(
Path::new(&tmp_path),
"temp content via guard\n",
true, ApplyMode::Apply,
Some(&guard),
)
.unwrap();
assert!(result.applied);
assert!(result.changed);
let content = fs::read_to_string(&tmp_path).unwrap();
assert!(content.contains("temp content via guard"));
let _ = fs::remove_file(&tmp_path);
}
#[test]
fn md_replace_section_works() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("README.md");
fs::write(
&file,
"## Section A\n\nOld body.\n\n## Section B\n\nKeep this.\n",
)
.unwrap();
let result =
md_replace_section(&file, "Section A", "New body.\n", ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert!(result.new_content.contains("New body."));
assert!(result.new_content.contains("Keep this."));
}
#[test]
fn md_upsert_bullet_adds_new() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("CHANGELOG.md");
fs::write(&file, "# Changes\n\n- Item 1\n").unwrap();
let result =
md_upsert_bullet(&file, "# Changes", "- Item 2", ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert!(result.new_content.contains("- Item 2"));
}
#[test]
fn file_create_and_delete() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("new.txt");
let result = file_create(&file, "hello\n", false, ApplyMode::Apply, None).unwrap();
assert!(result.applied);
assert!(file.exists());
let result = file_delete(&file, ApplyMode::Apply, None).unwrap();
assert!(result.applied);
assert!(!file.exists());
}
#[test]
#[cfg(unix)]
fn file_create_force_propagates_read_error() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let file = dir.path().join("unreadable.txt");
fs::write(&file, "original").unwrap();
fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
if fs::read_to_string(&file).is_ok() {
fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let err = file_create(&file, "new", true, ApplyMode::Apply, None).unwrap_err();
fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(
err.to_string().contains("failed to read"),
"expected read error, got: {err}"
);
}
#[test]
fn file_rename_works() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("new.txt");
fs::write(&src, "content\n").unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let result = file_rename(&src, &dst, false, ApplyMode::Apply, Some(&guard)).unwrap();
assert!(result.applied);
assert_eq!(
result.dest_path.as_deref(),
Some(dst.to_string_lossy().as_ref())
);
assert!(!src.exists());
assert!(dst.exists());
let dst_content = fs::read_to_string(&dst).unwrap();
assert_eq!(
dst_content, "content\n",
"rename must preserve file content"
);
}
#[test]
fn file_rename_rejects_guard_on_destination() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("new.txt");
fs::write(&src, "content\n").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let abs_dst = dst.canonicalize().unwrap_or(dst);
let err = file_rename(&src, &abs_dst, false, ApplyMode::Apply, Some(&guard)).unwrap_err();
assert!(err.to_string().contains("path rejected by workspace guard"));
}
#[test]
fn apply_mode_check_does_not_write() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"version": "1.0"}"#).unwrap();
let result = doc_set(
&file,
"version",
serde_json::json!("2.0"),
ApplyMode::Check,
None,
)
.unwrap();
assert!(result.changed);
assert!(!result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("1.0"));
}
#[test]
fn doc_append_to_array() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("data.json");
fs::write(&file, r#"{"items": [1, 2]}"#).unwrap();
let result = doc_append(&file, "items", serde_json::json!(3), ApplyMode::Apply, None).unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
assert_eq!(parsed["items"], serde_json::json!([1, 2, 3]));
}
#[test]
fn doc_move_renames_key() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("data.json");
fs::write(&file, r#"{"old_key": "value"}"#).unwrap();
let result = doc_move(&file, "old_key", "new_key", ApplyMode::Apply, None).unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("new_key"));
assert!(!on_disk.contains("old_key"));
}
#[test]
fn replace_text_empty_pattern_rejected() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "content").unwrap();
let err = replace_text(
&file,
"",
"x",
&ReplaceOptions::default(),
ApplyMode::Preview,
None,
)
.unwrap_err();
assert!(err.to_string().contains("empty search pattern"));
}
#[test]
fn edit_result_unchanged_when_no_diff() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.yaml");
fs::write(&file, "version: \"1.0\"\n").unwrap();
let result = doc_set(
&file,
"version",
serde_json::json!("1.0"),
ApplyMode::Preview,
None,
)
.unwrap();
assert!(!result.changed);
assert!(result.diff.is_empty());
}
#[test]
fn file_append_with_guard_and_preview() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("log.txt");
fs::write(&file, "start\n").unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let result = file_append(&file, "more\n", ApplyMode::Preview, Some(&guard)).unwrap();
assert!(result.changed);
assert!(!result.applied);
assert!(result.diff.contains("+more"));
let result = file_append(&file, "more\n", ApplyMode::Apply, Some(&guard)).unwrap();
assert!(result.applied);
assert_eq!(fs::read_to_string(&file).unwrap(), "start\nmore\n");
}
#[test]
fn yaml_doc_set_preserves_comments() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.yaml");
fs::write(
&file,
"# Main config\nversion: \"1.0\"\n# Keep this comment\nname: test\n",
)
.unwrap();
let result = doc_set(
&file,
"version",
serde_json::json!("2.0"),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("2.0"));
assert!(on_disk.contains("# Main config"));
assert!(on_disk.contains("# Keep this comment"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn execute_plan_runs_operations() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "hello world\n").unwrap();
let plan_json = r#"{
"version": 1,
"operations": [
{
"op": "replace",
"path": "test.txt",
"mode": "literal",
"old": "hello",
"new": "goodbye"
},
{
"op": "file.append",
"path": "test.txt",
"content": "\n+appended"
}
]
}"#;
let plan = parse_plan(plan_json).unwrap();
let report: crate::api::PlanReport = execute_plan(plan, dir.path(), None).unwrap();
assert!(report.ok);
assert_eq!(report.status, "success");
assert!(report.ok);
assert!(!report.changes.is_empty()); assert!(
report
.changes
.iter()
.any(|c| c.action == "modified" || c.action == "created")
);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("goodbye"));
assert!(on_disk.contains("+appended"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn execute_plan_respects_relaxed_guard() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("plan.txt");
fs::write(&file, "old\n").unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let plan_json = format!(
r#"{{
"version": 1,
"operations": [
{{
"op": "replace",
"path": "{}",
"mode": "literal",
"old": "old",
"new": "new"
}}
]
}}"#,
file.to_string_lossy().replace('\\', "/")
);
let plan = parse_plan(&plan_json).unwrap();
let report = execute_plan(plan, dir.path(), Some(&guard)).unwrap();
assert!(report.ok);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("new"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn execute_plan_rejects_on_guard() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("guarded.txt");
fs::write(&file, "content\n").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let abs = file.canonicalize().unwrap_or_else(|_| file.clone());
let plan_json = format!(
r#"{{
"version": 1,
"operations": [
{{
"op": "file.delete",
"path": "{}"
}}
]
}}"#,
abs.to_string_lossy().replace('\\', "/")
);
let plan = parse_plan(&plan_json).unwrap();
let err = execute_plan(plan, dir.path(), Some(&guard)).unwrap_err();
assert!(err.to_string().contains("path rejected by workspace guard"));
assert!(fs::read_to_string(&file).unwrap().contains("content"));
}
#[test]
fn apply_patch_works() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "fn hello() {\n println!(\"hi\");\n}\n").unwrap();
let patch = format!(
"--- a/{f}\n+++ b/{f}\n@@ -1,3 +1,3 @@\n fn hello() {{\n- println!(\"hi\");\n+ println!(\"hello world\");\n }}\n",
f = "code.rs"
);
let result = apply_patch(&file, &patch, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert!(!result.applied);
assert!(result.new_content.contains("hello world"));
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("\"hi\""));
}
#[test]
fn md_table_append_adds_row() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("table.md");
fs::write(
&file,
"# Data\n\n| Name | Value |\n|------|-------|\n| a | 1 |\n",
)
.unwrap();
let result =
md_table_append(&file, "# Data", "| b | 2 |", ApplyMode::Apply, None).unwrap();
assert!(result.changed);
assert!(result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("| b | 2 |"));
}
#[test]
fn md_table_append_column_mismatch_gives_specific_error() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("table.md");
fs::write(
&file,
"# Data\n\n| A | B | C |\n|---|---|---|\n| 1 | 2 | 3 |\n",
)
.unwrap();
let err = md_table_append(&file, "# Data", "| x |", ApplyMode::Preview, None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("column"),
"error should mention column mismatch, got: {msg}"
);
assert!(
!msg.contains("heading not found"),
"error should NOT say 'heading not found': {msg}"
);
}
#[test]
fn md_table_append_no_table_gives_specific_error() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
fs::write(&file, "# Data\n\nJust text, no table.\n").unwrap();
let err = md_table_append(&file, "# Data", "| x |", ApplyMode::Preview, None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("no markdown table"),
"error should mention 'no markdown table', got: {msg}"
);
assert!(
!msg.contains("heading not found"),
"error should NOT say 'heading not found': {msg}"
);
}
#[test]
fn md_insert_before_heading_works() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
fs::write(&file, "# First\n\nBody 1.\n\n# Second\n\nBody 2.\n").unwrap();
let result = md_insert_before_heading(
&file,
"Second",
"Inserted before second.\n\n",
ApplyMode::Preview,
None,
)
.unwrap();
assert!(result.changed);
assert!(!result.applied);
assert!(result.new_content.contains("Inserted before second."));
}
#[test]
fn md_insert_after_heading_works() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
fs::write(&file, "# First\n\nBody 1.\n\n# Second\n\nBody 2.\n").unwrap();
let result = md_insert_after_heading(
&file,
"First",
"Inserted after first.\n\n",
ApplyMode::Preview,
None,
)
.unwrap();
assert!(result.changed);
assert!(!result.applied);
assert!(result.new_content.contains("Inserted after first."));
let pos_heading = result.new_content.find("# First").unwrap();
let pos_insertion = result.new_content.find("Inserted after first.").unwrap();
assert!(pos_insertion > pos_heading);
}
#[test]
fn apply_patch_file_applies_multi_file_patch() {
let dir = TempDir::new().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
fs::write(&a, "aaa\n").unwrap();
fs::write(&b, "bbb\n").unwrap();
let patch = "\
--- a/a.txt\n\
+++ b/a.txt\n\
@@ -1 +1 @@\n\
-aaa\n\
+AAA\n\
--- a/b.txt\n\
+++ b/b.txt\n\
@@ -1 +1 @@\n\
-bbb\n\
+BBB\n";
let results = apply_patch_file(patch, dir.path(), ApplyMode::Apply, None).unwrap();
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.changed && r.applied));
assert_eq!(fs::read_to_string(&a).unwrap(), "AAA\n");
assert_eq!(fs::read_to_string(&b).unwrap(), "BBB\n");
}
#[test]
fn apply_patch_path_matching_uses_path_components() {
let dir = TempDir::new().unwrap();
let sub = dir.path().join("notb");
fs::create_dir(&sub).unwrap();
let file = sub.join("foo.rs");
fs::write(&file, "original\n").unwrap();
let patch = "\
--- a/b/foo.rs\n\
+++ b/b/foo.rs\n\
@@ -1 +1 @@\n\
-original\n\
+changed\n";
let result = apply_patch(&file, patch, ApplyMode::Preview, None);
assert!(result.is_err() || !result.unwrap().changed);
}
#[test]
fn make_write_policy_maps_options() {
let opts = WritePolicyOptions {
ensure_final_newline: true,
normalize_eol: Some(EolMode::Lf),
trim_trailing_whitespace: true,
collapse_blanks: true,
};
let policy = make_write_policy(&opts);
assert!(policy.ensure_final_newline);
assert!(policy.trim_trailing_whitespace);
assert!(policy.collapse_blanks);
assert_eq!(
policy.normalize_eol,
EolMode::Lf,
"should map EolMode::Lf correctly"
);
let default_policy = make_write_policy(&WritePolicyOptions::default());
assert!(!default_policy.ensure_final_newline);
assert!(!default_policy.trim_trailing_whitespace);
assert!(!default_policy.collapse_blanks);
}
#[test]
fn doc_prepend_adds_to_front() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("data.json");
fs::write(&file, r#"{"items": [2, 3]}"#).unwrap();
let result = doc_prepend(&file, "items", serde_json::json!(1), ApplyMode::Apply, None).unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
assert_eq!(parsed["items"], serde_json::json!([1, 2, 3]));
}
#[test]
fn doc_update_changes_matching_values() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("data.json");
fs::write(
&file,
r#"{"items": [{"name": "a", "val": 1}, {"name": "b", "val": 2}]}"#,
)
.unwrap();
let result = doc_update(
&file,
"items[*].val",
serde_json::json!(99),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
assert_eq!(parsed["items"][0]["val"], serde_json::json!(99));
assert_eq!(parsed["items"][1]["val"], serde_json::json!(99));
}
#[test]
fn doc_ensure_sets_only_if_missing() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"existing": "keep"}"#).unwrap();
let result = doc_ensure(
&file,
"new_key",
serde_json::json!("added"),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("added"));
let result = doc_ensure(
&file,
"existing",
serde_json::json!("overwrite"),
ApplyMode::Preview,
None,
)
.unwrap();
assert!(!result.changed);
}
#[test]
fn doc_delete_where_removes_matching_elements() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("data.json");
fs::write(
&file,
r#"{"items": [{"name": "keep"}, {"name": "remove"}, {"name": "keep2"}]}"#,
)
.unwrap();
let result = doc_delete_where(&file, "items", "name=remove", ApplyMode::Apply, None).unwrap();
assert!(result.changed);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(!on_disk.contains("remove"));
assert!(on_disk.contains("keep"));
assert!(on_disk.contains("keep2"));
}
#[test]
fn md_move_section_reorders() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
fs::write(
&file,
"# A\n\nBody A.\n\n# B\n\nBody B.\n\n# C\n\nBody C.\n",
)
.unwrap();
let result =
md_move_section(&file, "A", ("after", "C"), None, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
let pos_b = result.new_content.find("# B").unwrap();
let pos_a = result.new_content.find("# A").unwrap();
assert!(
pos_a > pos_b,
"A should appear after B after moving to after C"
);
}
#[test]
fn md_move_section_cross_file_writes_dest() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src.md");
let dst = dir.path().join("dst.md");
fs::write(&src, "# Keep\n\nStay.\n\n# Move\n\nGoing.\n").unwrap();
fs::write(&dst, "# Target\n\nHere.\n").unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let result = md_move_section(
&src,
"Move",
("after", "Target"),
Some(&dst),
ApplyMode::Apply,
Some(&guard),
)
.unwrap();
assert_eq!(result.path, src.to_string_lossy());
assert_eq!(
result.dest_path.as_deref(),
Some(dst.to_string_lossy().as_ref())
);
let src_content = fs::read_to_string(&src).unwrap();
let dst_content = fs::read_to_string(&dst).unwrap();
assert!(
!src_content.contains("# Move"),
"section should be removed from source"
);
assert!(
dst_content.contains("# Move"),
"section should be inserted into destination"
);
}
#[test]
fn md_move_section_cross_file_rejects_guard_on_destination() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src.md");
let dst = dir.path().join("dst.md");
fs::write(&src, "# Keep\n\nStay.\n\n# Move\n\nGoing.\n").unwrap();
fs::write(&dst, "# Target\n\nHere.\n").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let abs_dst = dst.canonicalize().unwrap_or(dst);
let err = md_move_section(
&src,
"Move",
("after", "Target"),
Some(&abs_dst),
ApplyMode::Apply,
Some(&guard),
)
.unwrap_err();
assert!(err.to_string().contains("path rejected by workspace guard"));
let src_content = fs::read_to_string(&src).unwrap();
assert!(src_content.contains("# Move"));
}
#[test]
fn md_dedupe_headings_removes_duplicates() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
fs::write(
&file,
"# Title\n\nBody 1.\n\n# Title\n\nBody 2.\n\n## Other\n\nKeep.\n",
)
.unwrap();
let (result, removed) = md_dedupe_headings(&file, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert!(
removed.iter().any(|r| r.contains("Title")),
"should report removed duplicate heading"
);
}
#[test]
fn md_lint_agents_finds_issues() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("AGENTS.md");
fs::write(
&file,
"# Rules\n\nSome rules.\n\n# Rules\n\nDuplicate heading.\n",
)
.unwrap();
let issues = md_lint_agents(&file).unwrap();
assert!(
!issues.is_empty(),
"should find duplicate heading lint issue"
);
}
#[test]
fn tidy_normalizes_whitespace() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("messy.txt");
fs::write(&file, "line1 \nline2\t \n\n\n\nline3").unwrap();
let opts = WritePolicyOptions {
trim_trailing_whitespace: true,
ensure_final_newline: true,
collapse_blanks: true,
..Default::default()
};
let result = tidy(&file, &opts, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert!(
!result.new_content.contains(" \n"),
"trailing whitespace should be removed"
);
assert!(
result.new_content.ends_with('\n'),
"should end with newline"
);
assert!(
!result.new_content.contains("\n\n\n"),
"should collapse consecutive blanks"
);
}
#[test]
fn search_finds_matches() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "fn alpha() {}\nfn beta() {}\nfn alpha_beta() {}\n").unwrap();
let matches = search(&file, "alpha", false, false).unwrap();
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].line_number, 1);
assert_eq!(matches[1].line_number, 3);
}
#[test]
fn search_regex_mode() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "version = 1\nname = test\nversion = 2\n").unwrap();
let matches = search(&file, r"version = \d+", true, false).unwrap();
assert_eq!(matches.len(), 2);
}
#[test]
fn read_full_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "line1\nline2\nline3\n").unwrap();
let content = read(&file, None, None).unwrap();
assert_eq!(content, "line1\nline2\nline3\n");
}
#[test]
fn read_line_range() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "line1\nline2\nline3\nline4\n").unwrap();
let content = read(&file, Some(2), Some(3)).unwrap();
assert_eq!(content, "line2\nline3\n");
}
#[test]
fn search_empty_pattern_returns_error() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "hello\n").unwrap();
let err = search(&file, "", false, false).unwrap_err();
assert!(
err.to_string().contains("empty"),
"expected empty pattern error, got: {err}"
);
}
#[test]
fn search_case_insensitive() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "Hello World\nhello world\nHELLO\n").unwrap();
let matches = search(&file, "hello", false, true).unwrap();
assert_eq!(
matches.len(),
3,
"case-insensitive should match all 3 lines"
);
}
#[test]
fn replace_text_whole_line_mode() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "keep this\nremove me\nalso keep\n").unwrap();
let opts = ReplaceOptions {
whole_line: true,
..Default::default()
};
let result = replace_text(&file, "remove", "", &opts, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert!(!result.new_content.contains("remove me"));
assert!(result.new_content.contains("keep this"));
}
#[test]
fn replace_text_whole_line_with_range() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "a\nb\nc\nb\ne\n").unwrap();
let opts = ReplaceOptions {
whole_line: true,
range: Some((1, Some(3))),
..Default::default()
};
let result = replace_text(&file, "b", "", &opts, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
let lines: Vec<&str> = result.new_content.lines().collect();
assert!(
lines.contains(&"b"),
"b outside range should remain: {:?}",
lines
);
}
#[test]
fn replace_text_if_exists_no_error_on_miss() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "hello world\n").unwrap();
let opts = ReplaceOptions {
if_exists: true,
..Default::default()
};
let result = replace_text(&file, "missing", "x", &opts, ApplyMode::Preview, None).unwrap();
assert!(!result.changed);
assert!(!result.applied);
}
#[test]
fn replace_text_range_requires_whole_line() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "hello\n").unwrap();
let opts = ReplaceOptions {
range: Some((1, Some(5))),
..Default::default()
};
let err = replace_text(&file, "hello", "x", &opts, ApplyMode::Preview, None).unwrap_err();
assert!(err.to_string().contains("range requires whole_line"));
}
const _: () = {
fn _assert<T: Send + Sync>() {}
let _ = _assert::<EditResult>;
let _ = _assert::<ApplyMode>;
let _ = _assert::<ReplaceOptions>;
let _ = _assert::<WritePolicyOptions>;
let _ = _assert::<EolMode>;
};
#[test]
fn concurrent_edits_to_different_files() {
let dir = TempDir::new().unwrap();
let num_threads = 8;
for i in 0..num_threads {
let file = dir.path().join(format!("file_{i}.json"));
fs::write(&file, format!(r#"{{"value": {}}}"#, i + 1)).unwrap();
}
std::thread::scope(|s| {
let dir_path = dir.path();
for i in 0..num_threads {
s.spawn(move || {
let file = dir_path.join(format!("file_{i}.json"));
let result = doc_set(
&file,
"value",
serde_json::json!(i * 100 + 999),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
assert!(result.applied);
});
}
});
for i in 0..num_threads {
let file = dir.path().join(format!("file_{i}.json"));
let content = fs::read_to_string(&file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["value"], serde_json::json!(i * 100 + 999));
}
}
#[test]
fn concurrent_backup_sessions_no_collision() {
use crate::backup::{BackupSession, list_sessions};
let dir = TempDir::new().unwrap();
let num_threads = 16;
for i in 0..num_threads {
let file = dir.path().join(format!("backup_{i}.txt"));
fs::write(&file, format!("original_{i}")).unwrap();
}
let timestamps: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
std::thread::scope(|s| {
let dir_path = dir.path();
let ts_ref = ×tamps;
for i in 0..num_threads {
s.spawn(move || {
let file = dir_path.join(format!("backup_{i}.txt"));
let mut session = BackupSession::new(dir_path).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
ts_ref.lock().unwrap().push(ts);
});
}
});
let ts = timestamps.into_inner().unwrap();
let mut sorted = ts.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
num_threads,
"all backup session timestamps must be unique"
);
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), num_threads);
}
#[test]
fn concurrent_replace_text_different_files() {
let dir = TempDir::new().unwrap();
let num_threads = 8;
for i in 0..num_threads {
let file = dir.path().join(format!("code_{i}.rs"));
fs::write(&file, format!("fn func_{i}() {{}}\n")).unwrap();
}
std::thread::scope(|s| {
let dir_path = dir.path();
for i in 0..num_threads {
s.spawn(move || {
let file = dir_path.join(format!("code_{i}.rs"));
let result = replace_text(
&file,
&format!("func_{i}"),
&format!("renamed_{i}"),
&ReplaceOptions::default(),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
assert!(result.applied);
});
}
});
for i in 0..num_threads {
let file = dir.path().join(format!("code_{i}.rs"));
let content = fs::read_to_string(&file).unwrap();
assert!(
content.contains(&format!("renamed_{i}")),
"file {i} should contain renamed_{i}, got: {content}"
);
}
}
#[test]
fn concurrent_md_operations() {
let dir = TempDir::new().unwrap();
let num_threads = 4;
for i in 0..num_threads {
let file = dir.path().join(format!("doc_{i}.md"));
fs::write(&file, format!("# Section {i}\n\nOriginal body.\n")).unwrap();
}
std::thread::scope(|s| {
let dir_path = dir.path();
for i in 0..num_threads {
s.spawn(move || {
let file = dir_path.join(format!("doc_{i}.md"));
let result = md_replace_section(
&file,
&format!("Section {i}"),
&format!("Updated body {i}.\n"),
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.changed);
assert!(result.applied);
});
}
});
for i in 0..num_threads {
let file = dir.path().join(format!("doc_{i}.md"));
let content = fs::read_to_string(&file).unwrap();
assert!(content.contains(&format!("Updated body {i}.")));
}
}
#[test]
fn doc_get_zero_match_error() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"version": "1.0"}"#).unwrap();
let err = doc_get(&file, "nonexistent").unwrap_err();
assert!(
err.to_string().contains("matched nothing"),
"expected zero-match error, got: {err}"
);
}
#[test]
fn doc_get_multi_match_returns_array() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("data.json");
fs::write(&file, r#"{"items": [{"name": "a"}, {"name": "b"}]}"#).unwrap();
let value = doc_get(&file, "items[*].name").unwrap();
assert_eq!(value, serde_json::json!(["a", "b"]));
}
#[test]
fn replace_text_regex_mode() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "version = 1\nversion = 2\n").unwrap();
let opts = ReplaceOptions {
regex: true,
..ReplaceOptions::default()
};
let result = replace_text(
&file,
r"version = \d+",
"version = 99",
&opts,
ApplyMode::Preview,
None,
)
.unwrap();
assert!(result.changed);
assert_eq!(
result.new_content.matches("version = 99").count(),
2,
"regex should replace all occurrences"
);
}
#[test]
fn replace_text_nth_match() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "foo bar foo baz foo\n").unwrap();
let opts = ReplaceOptions {
nth: Some(2),
..ReplaceOptions::default()
};
let result = replace_text(&file, "foo", "qux", &opts, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert_eq!(result.new_content, "foo bar qux baz foo\n");
}
#[test]
fn replace_text_insert_after() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "hello world\n").unwrap();
let opts = ReplaceOptions {
insert_after: Some(" beautiful".to_string()),
..ReplaceOptions::default()
};
let result = replace_text(&file, "hello", "", &opts, ApplyMode::Preview, None).unwrap();
assert!(result.changed);
assert!(
result.new_content.contains("hello beautiful"),
"insert_after should add text after match: {}",
result.new_content
);
}
#[test]
fn concurrent_file_create() {
let dir = TempDir::new().unwrap();
let num_threads = 8;
std::thread::scope(|s| {
let dir_path = dir.path();
for i in 0..num_threads {
s.spawn(move || {
let file = dir_path.join(format!("new_{i}.txt"));
let result = file_create(
&file,
&format!("content_{i}\n"),
false,
ApplyMode::Apply,
None,
)
.unwrap();
assert!(result.applied);
});
}
});
for i in 0..num_threads {
let file = dir.path().join(format!("new_{i}.txt"));
assert!(file.exists());
let content = fs::read_to_string(&file).unwrap();
assert_eq!(content, format!("content_{i}\n"));
}
}
#[test]
fn search_literal_returns_correct_line_numbers() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "fn alpha() {}\nfn beta() {}\nfn alpha_2() {}\n").unwrap();
let matches = search(&file, "alpha", false, false).unwrap();
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].line_number, 1);
assert_eq!(matches[1].line_number, 3);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_directory_basic() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("test.rs");
fs::write(&f, "fn foo() {}\nfn bar() {}\nlet x = foo();\n").unwrap();
let opts = SearchOptions::default();
let results = search_directory(dir.path(), "foo", &opts).unwrap();
assert!(!results.is_empty());
assert!(results.iter().any(|r| r.line.contains("foo")));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_directory_with_context_and_globs() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(
src.join("main.rs"),
"fn main() { foo(); }\n// comment\nlet x = foo();\n",
)
.unwrap();
std::fs::write(src.join("lib.txt"), "foo in text\n").unwrap();
let opts = SearchOptions {
context: Some(1),
globs: vec!["*.rs".to_string()],
..Default::default()
};
let results = search_directory(dir.path(), "foo", &opts).unwrap();
assert_eq!(results.len(), 2);
assert!(results[0].line.contains("foo()"));
assert_eq!(results[0].context_before.len(), 0);
assert_eq!(results[0].context_after, vec!["// comment".to_string()]);
assert_eq!(results[0].column, 13); assert!(results[1].line.contains("foo()"));
assert_eq!(results[1].context_before, vec!["// comment".to_string()]);
assert_eq!(results[1].context_after.len(), 0);
assert_eq!(results[1].column, 9);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_directory_max_results_and_case() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a1.txt"), "Foo one\n").unwrap();
std::fs::write(dir.path().join("a2.txt"), "foo two\n").unwrap();
std::fs::write(dir.path().join("a3.txt"), "FOO three\n").unwrap();
let opts = SearchOptions {
case_insensitive: true,
max_results: 2,
..Default::default()
};
let results = search_directory(dir.path(), "foo", &opts).unwrap();
assert_eq!(results.len(), 2); assert!(
results
.iter()
.any(|r| r.line.contains("Foo") || r.line.contains("foo") || r.line.contains("FOO"))
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_directory_empty_pattern_errors() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("f.txt"), "content\n").unwrap();
let opts = SearchOptions::default();
let err = search_directory(dir.path(), "", &opts).unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_directory_invalid_glob_errors() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("f.txt"),
"content
",
)
.unwrap();
let opts = SearchOptions {
globs: vec!["[unclosed".to_string()],
..Default::default()
};
let err = search_directory(dir.path(), "foo", &opts).unwrap_err();
assert!(err.to_string().contains("parsing glob") || err.to_string().contains("unclosed"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_directory_exclude_patterns_and_custom_ignore() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("keep.txt"), "keep this\n").unwrap();
std::fs::write(dir.path().join("ignore.txt"), "ignore me\n").unwrap();
std::fs::write(dir.path().join("bline.txt"), "bline secret\n").unwrap();
std::fs::write(dir.path().join(".blineignore"), "bline.txt\n").unwrap();
let opts = SearchOptions {
regex: true,
exclude_patterns: vec!["*ignore*".to_string()],
custom_ignore_filenames: vec![".blineignore".to_string()],
..Default::default()
};
let results = search_directory(dir.path(), "keep|me|secret", &opts).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].line.contains("keep"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_parity_blineignore_across_api_cli_and_plan() {
let dir = TempDir::new().unwrap();
let root = dir.path();
std::fs::write(root.join("keep.rs"), "keep foo here\n").unwrap();
std::fs::write(root.join("skip.rs"), "skip foo\n").unwrap();
std::fs::write(root.join("bline.txt"), "bline foo secret\n").unwrap();
std::fs::write(root.join("other.txt"), "other foo\n").unwrap();
std::fs::create_dir_all(root.join("target")).unwrap();
std::fs::write(root.join("target/bad.rs"), "bad foo\n").unwrap();
std::fs::write(root.join(".blineignore"), "bline.txt\ntarget/\n").unwrap();
std::fs::write(root.join(".gitignore"), "").unwrap();
let pattern = "foo";
let opts = SearchOptions {
literal: true,
globs: vec!["*.rs".to_string()],
exclude_patterns: vec!["*skip*".to_string()],
custom_ignore_filenames: vec![".blineignore".to_string()],
max_results: 10,
..Default::default()
};
let api_res = search_directory(root, pattern, &opts).unwrap();
assert!(!api_res.is_empty(), "api should find keep");
let api_paths: Vec<_> = api_res
.iter()
.map(|r| r.path.file_name().unwrap().to_string_lossy().to_string())
.collect();
#[cfg(feature = "cli")]
let cli_total: usize = {
let mut g = crate::cli::global::GlobalFlags::test_default();
g.cwd = Some(root.to_string_lossy().into_owned());
g.glob = opts.globs.clone();
g.exclude = opts.exclude_patterns.clone();
g.ignore_file = opts.custom_ignore_filenames.clone();
let cli_args = crate::cmd::search::SearchArgs {
pattern: pattern.to_string(),
paths: vec![".".to_string()],
literal: true,
regex: false,
context: None,
before_context: None,
after_context: None,
files_with_matches: false,
count: false,
invert_match: false,
multiline: false,
case_insensitive: false,
assert_count: None,
max_results: opts.max_results,
};
let cli_results = crate::cmd::search::collect_matches(&cli_args, &g).unwrap();
cli_results.file_match_counts.values().sum()
};
#[cfg(not(feature = "cli"))]
let _cli_total: usize = api_res.len();
let plan_json = format!(
r#"{{
"version": 1,
"operations": [{{
"op": "search",
"path": ".",
"pattern": "{}",
"literal": true,
"globs": ["*.rs"],
"exclude_patterns": ["*skip*"],
"custom_ignore_filenames": [".blineignore"],
"max_results": 10
}}]
}}"#,
pattern
);
let plan = crate::plan::parse_plan_auto(&plan_json, None, None).unwrap();
let report = crate::api::execute_plan(plan, root, None).expect("plan exec with search");
let plan_total: usize = report.searches.iter().map(|s| s.match_count).sum();
assert!(
api_paths.iter().any(|p| p == "keep.rs"),
"api did not find keep: {:?}",
api_paths
);
#[cfg(feature = "cli")]
assert!(cli_total > 0, "cli should have matches");
assert!(plan_total > 0, "plan should have recorded matches");
assert!(
!api_paths.iter().any(|p| p.contains("bline")
|| p.contains("skip")
|| p.contains("target")
|| p.contains("bad")),
"api leaked ignored files: {:?}",
api_paths
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_file_and_format_and_context_builder_direct() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("x.rs");
fs::write(&f, "fn foo() {}\nlet y = foo();\n").unwrap();
let opts = SearchOptions {
context: Some(1),
..Default::default()
};
let res = search_file(&f, "foo", &opts).unwrap();
assert_eq!(res.len(), 2);
assert_eq!(res[0].column, 4); assert!(!res[0].context_after.is_empty() || !res[1].context_before.is_empty());
let res2 = search_one_file(&f, "foo", &opts, dir.path());
assert_eq!(res2.len(), 2);
assert_eq!(res2[0].column, 4);
let lines: Vec<&str> = "a\nb\nc\n".lines().collect();
let (b, a) = build_context_lines(&lines, 1, 1, 1);
assert_eq!(b, vec!["a".to_string()]);
assert_eq!(a, vec!["c".to_string()]);
}
#[test]
fn build_context_lines_asymmetric() {
let lines: Vec<&str> = "aaa\nbbb\nccc\nddd\neee".lines().collect();
let (b, a) = build_context_lines(&lines, 2, 0, 2);
assert!(b.is_empty());
assert_eq!(a, vec!["ddd".to_string(), "eee".to_string()]);
let (b, a) = build_context_lines(&lines, 2, 1, 0);
assert_eq!(b, vec!["bbb".to_string()]);
assert!(a.is_empty());
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_asymmetric_context_more_after() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.rs");
fs::write(
&file,
"// line 1\n// line 2\nfn main() {\n println!(\"hello\");\n return;\n}\n// line 7\n",
)
.unwrap();
let opts = SearchOptions {
before_context: Some(1),
after_context: Some(3),
..Default::default()
};
let results = search_one_file(&file, "fn main", &opts, dir.path());
assert_eq!(results.len(), 1);
assert_eq!(results[0].context_before.len(), 1);
assert_eq!(results[0].context_after.len(), 3);
assert!(results[0].context_before[0].contains("line 2"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_asymmetric_context_zero_before() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "aaa\nbbb\nccc\nddd\neee\n").unwrap();
let opts = SearchOptions {
before_context: Some(0),
after_context: Some(2),
..Default::default()
};
let results = search_one_file(&file, "ccc", &opts, dir.path());
assert_eq!(results.len(), 1);
assert!(results[0].context_before.is_empty());
assert_eq!(results[0].context_after.len(), 2);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_asymmetric_overrides_symmetric() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "aaa\nbbb\nccc\nddd\neee\n").unwrap();
let opts = SearchOptions {
context: Some(2),
before_context: Some(0),
after_context: Some(1),
..Default::default()
};
let results = search_one_file(&file, "ccc", &opts, dir.path());
assert!(results[0].context_before.is_empty());
assert_eq!(results[0].context_after.len(), 1);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_symmetric_context_still_works() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "aaa\nbbb\nccc\nddd\neee\n").unwrap();
let opts = SearchOptions {
context: Some(1),
..Default::default()
};
let results = search_one_file(&file, "ccc", &opts, dir.path());
assert_eq!(results[0].context_before.len(), 1);
assert_eq!(results[0].context_after.len(), 1);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_invert_match_excludes_matching_lines() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.py");
fs::write(
&file,
"import os\nimport sys\ndef hello():\n pass\nimport json\n",
)
.unwrap();
let opts = SearchOptions {
invert_match: true,
..Default::default()
};
let results = search_one_file(&file, "import", &opts, dir.path());
assert!(results.iter().all(|r| !r.line.contains("import")));
assert!(results.iter().any(|r| r.line.contains("def hello")));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_invert_match_count() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "aaa\nbbb\naaa\nbbb\naaa\n").unwrap();
let opts = SearchOptions {
invert_match: true,
..Default::default()
};
let results = search_one_file(&file, "aaa", &opts, dir.path());
assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.line == "bbb"));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_multiline_pattern_spans_lines() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.rs");
fs::write(&file, "struct Foo {\n x: i32,\n y: String,\n}\n").unwrap();
let opts = SearchOptions {
regex: true,
multiline: true,
..Default::default()
};
let results = search_one_file(&file, r"struct Foo \{[^}]+\}", &opts, dir.path());
assert_eq!(results.len(), 1);
assert_eq!(results[0].line_number, 1);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_format_results_human_and_json() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("x.rs");
fs::write(&file, "fn foo() {}\nfn bar() {}\n").unwrap();
let opts = SearchOptions::default();
let res = search_one_file(&file, "fn", &opts, dir.path());
let txt = format_search_results(&res, false);
assert!(txt.contains("foo") || txt.contains("x.rs") || !txt.trim().is_empty());
let js = format_search_results(&res, true);
assert!(js.contains("\"column\"") || js.contains("column"));
}
#[test]
fn search_format_results_context_before_appears_before_match() {
let results = vec![SearchResult {
path: std::path::PathBuf::from("file.rs"),
line_number: 5,
line: "fn handle_error()".to_string(),
column: 0,
context_before: vec!["fn validate_input() {".to_string()],
context_after: vec![" Ok(())".to_string()],
}];
let txt = format_search_results(&results, false);
let before_pos = txt.find("validate_input").expect("context_before missing");
let match_pos = txt.find("handle_error").expect("match line missing");
let after_pos = txt.find("Ok(())").expect("context_after missing");
assert!(
before_pos < match_pos,
"context_before must appear before match line"
);
assert!(
match_pos < after_pos,
"context_after must appear after match line"
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_multiline_literal_auto_escapes_metacharacters() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "foo.bar\nfooXbar\n").unwrap();
let opts = SearchOptions {
multiline: true,
regex: false,
..Default::default()
};
let results = search_one_file(&file, "foo.bar", &opts, dir.path());
assert_eq!(results.len(), 1);
assert_eq!(results[0].line, "foo.bar");
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn search_multiline_regex_does_not_escape() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "foo.bar\nfooXbar\n").unwrap();
let opts = SearchOptions {
multiline: true,
regex: true,
..Default::default()
};
let results = search_one_file(&file, "foo.bar", &opts, dir.path());
assert_eq!(results.len(), 2);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn collect_with_ignores_direct() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("keep.txt"), "keep\n").unwrap();
fs::write(dir.path().join("skip.txt"), "skip\n").unwrap();
fs::write(dir.path().join(".myignore"), "skip.txt\n").unwrap();
let custom_ignores = vec![".myignore".to_string()];
let exclude_patterns = vec!["*skip*".to_string()];
let paths = crate::files::collect_file_paths_with_ignores(
dir.path(),
&custom_ignores,
&exclude_patterns,
false,
)
.unwrap();
assert_eq!(paths.len(), 1);
assert!(paths[0].ends_with("keep.txt"));
}
#[test]
fn search_no_match_returns_empty() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("code.rs");
fs::write(&file, "fn hello() {}\n").unwrap();
let matches = search(&file, "nonexistent", false, false).unwrap();
assert!(matches.is_empty());
}
#[test]
fn file_append_and_prepend_basic() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("a.txt");
std::fs::write(&file, "hello").unwrap();
let res = file_append(&file, " world", ApplyMode::Apply, None).unwrap();
assert!(res.changed);
assert!(res.applied);
assert_eq!(res.action, "append");
assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello\n world");
let res2 = file_prepend(&file, ">> ", ApplyMode::Apply, None).unwrap();
assert!(res2.changed);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
">> \nhello\n world"
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn file_append_prepend_empty_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("empty.txt");
std::fs::write(&file, "").unwrap();
let res = file_append(&file, "first", ApplyMode::Apply, None).unwrap();
assert!(res.changed);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "first");
let res2 = file_prepend(&file, "zero\n", ApplyMode::Apply, None).unwrap();
assert!(res2.changed);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "zero\nfirst");
}
#[test]
fn file_append_preview_does_not_write() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("b.txt");
std::fs::write(&file, "x").unwrap();
let res = file_append(&file, "y", ApplyMode::Preview, None).unwrap();
assert!(res.changed);
assert!(!res.applied);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "x");
}
#[test]
fn file_append_check_reports_without_writing() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("c.txt");
std::fs::write(&file, "base").unwrap();
let res = file_append(&file, " +more", ApplyMode::Check, None).unwrap();
assert!(res.changed);
assert!(!res.applied);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "base"); assert!(res.new_content.contains("+more"));
}
#[test]
fn file_append_respects_guard_and_relaxed() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("g.txt");
std::fs::write(&file, "base").unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let res = file_append(&file, " +append", ApplyMode::Apply, Some(&guard)).unwrap();
assert!(res.applied);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "base\n +append");
let yolo = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let res2 = file_append(&file, " +yolo", ApplyMode::Apply, Some(&yolo)).unwrap();
assert!(res2.applied);
}
#[test]
fn search_nonexistent_file_fails() {
let err = search(
Path::new("/tmp/nonexistent_patchloom_search.txt"),
"x",
false,
false,
)
.unwrap_err();
assert!(err.to_string().contains("failed to read"));
}
#[test]
fn read_start_only_to_end() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("hello.txt");
fs::write(&file, "a\nb\nc\n").unwrap();
let content = read(&file, Some(2), None).unwrap();
assert_eq!(content, "b\nc\n");
}
#[test]
fn read_start_beyond_file_returns_empty() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("hello.txt");
fs::write(&file, "a\nb\n").unwrap();
let content = read(&file, Some(100), None).unwrap();
assert!(content.is_empty());
}
#[test]
fn read_nonexistent_file_fails() {
let err = read(Path::new("/tmp/nonexistent_patchloom_read.txt"), None, None).unwrap_err();
assert!(err.to_string().contains("failed to read"));
}
#[test]
fn make_write_policy_maps_crlf() {
let opts = WritePolicyOptions {
normalize_eol: Some(EolMode::Crlf),
..WritePolicyOptions::default()
};
let policy = make_write_policy(&opts);
assert_eq!(
policy.normalize_eol,
EolMode::Crlf,
"should map EolMode::Crlf correctly"
);
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn adapter_preview_does_not_write() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.json");
fs::write(&file, r#"{"key": "old"}"#).unwrap();
let op = crate::plan::Operation::DocSet {
path: file.to_string_lossy().into(),
selector: "key".into(),
value: serde_json::json!("new"),
};
let result =
super::execute_as_edit_result(op, ApplyMode::Preview, dir.path(), None, "doc.set", None)
.unwrap();
assert!(result.changed, "content should differ");
assert!(!result.applied, "preview should not write");
assert!(result.new_content.contains("new"));
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("old"), "file should be unchanged on disk");
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn adapter_apply_writes_to_disk() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.json");
fs::write(&file, r#"{"key": "old"}"#).unwrap();
let op = crate::plan::Operation::DocSet {
path: file.to_string_lossy().into(),
selector: "key".into(),
value: serde_json::json!("new"),
};
let result =
super::execute_as_edit_result(op, ApplyMode::Apply, dir.path(), None, "doc.set", None)
.unwrap();
assert!(result.changed);
assert!(result.applied);
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("new"), "file should be updated on disk");
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn adapter_check_does_not_write() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.json");
fs::write(&file, r#"{"key": "old"}"#).unwrap();
let op = crate::plan::Operation::DocSet {
path: file.to_string_lossy().into(),
selector: "key".into(),
value: serde_json::json!("new"),
};
let result =
super::execute_as_edit_result(op, ApplyMode::Check, dir.path(), None, "doc.set", None)
.unwrap();
assert!(result.changed);
assert!(!result.applied, "check should not write");
let on_disk = fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("old"), "file should be unchanged on disk");
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn adapter_respects_guard() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.json");
fs::write(&file, r#"{"key": "old"}"#).unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let op = crate::plan::Operation::DocSet {
path: file.to_string_lossy().into(),
selector: "key".into(),
value: serde_json::json!("new"),
};
let result = super::execute_as_edit_result(
op,
ApplyMode::Apply,
dir.path(),
Some(&guard),
"doc.set",
None,
)
.unwrap();
assert!(result.applied);
assert!(result.changed);
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn adapter_guard_rejects_outside_path() {
let dir = TempDir::new().unwrap();
let other = TempDir::new().unwrap();
let file = other.path().join("test.json");
fs::write(&file, r#"{"key": "old"}"#).unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.build()
.unwrap();
let op = crate::plan::Operation::DocSet {
path: file.to_string_lossy().into(),
selector: "key".into(),
value: serde_json::json!("new"),
};
let err = super::execute_as_edit_result(
op,
ApplyMode::Apply,
dir.path(),
Some(&guard),
"doc.set",
None,
);
assert!(err.is_err(), "guard should reject path outside workspace");
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn adapter_unchanged_returns_no_diff() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "hello world\n").unwrap();
let op = crate::plan::Operation::Replace {
path: Some(file.to_string_lossy().into()),
glob: None,
regex: false,
old: "nonexistent_pattern".into(),
new_text: Some("replacement".into()),
nth: None,
case_insensitive: false,
insert_before: None,
insert_after: None,
whole_line: false,
multiline: false,
range: None,
if_exists: true,
word_boundary: false,
before_context: None,
after_context: None,
unique: false,
};
let result =
super::execute_as_edit_result(op, ApplyMode::Preview, dir.path(), None, "replace", None)
.unwrap();
assert!(
!result.changed,
"should not be changed when pattern doesn't match"
);
assert!(!result.applied);
}
#[test]
fn replace_in_content_literal() {
let content = "fn hello() {}\nfn world() {}\n";
let result =
replace::replace_in_content(content, "hello", "greet", &ReplaceOptions::default()).unwrap();
assert!(result.changed);
assert!(result.new_content.contains("fn greet()"));
assert!(!result.new_content.contains("fn hello()"));
}
#[test]
fn replace_in_content_regex() {
let content = "version = \"1.2.3\"\n";
let opts = ReplaceOptions {
regex: true,
..Default::default()
};
let result = replace::replace_in_content(
content,
r#"version = "\d+\.\d+\.\d+""#,
"version = \"2.0.0\"",
&opts,
)
.unwrap();
assert!(result.changed);
assert!(result.new_content.contains("2.0.0"));
}
#[test]
fn replace_in_content_word_boundary() {
let content = "let setup = setup_config();\n";
let opts = ReplaceOptions {
word_boundary: true,
..Default::default()
};
let result = replace::replace_in_content(content, "setup", "init", &opts).unwrap();
assert!(result.changed);
assert_eq!(result.new_content, "let init = setup_config();\n");
}
#[test]
fn replace_in_content_nth() {
let content = "aaa bbb aaa bbb aaa\n";
let opts = ReplaceOptions {
nth: Some(2),
..Default::default()
};
let result = replace::replace_in_content(content, "aaa", "xxx", &opts).unwrap();
assert!(result.changed);
assert_eq!(result.new_content, "aaa bbb xxx bbb aaa\n");
}
#[test]
fn replace_in_content_insert_after() {
let content = "use std::io;\n\nfn main() {}\n";
let opts = ReplaceOptions {
insert_after: Some("\nuse std::fs;".to_string()),
..Default::default()
};
let result = replace::replace_in_content(content, "use std::io;", "", &opts).unwrap();
assert!(result.changed);
assert!(result.new_content.contains("use std::io;\nuse std::fs;"));
}
#[test]
fn replace_in_content_insert_before() {
let content = "use std::io;\n\nfn main() {}\n";
let opts = ReplaceOptions {
insert_before: Some("use std::fs;\n".to_string()),
..Default::default()
};
let result = replace::replace_in_content(content, "use std::io;", "", &opts).unwrap();
assert!(result.changed);
assert!(result.new_content.contains("use std::fs;\nuse std::io;"));
}
#[test]
fn replace_in_content_no_match_unchanged() {
let content = "fn hello() {}\n";
let result =
replace::replace_in_content(content, "nonexistent", "x", &ReplaceOptions::default())
.unwrap();
assert!(!result.changed);
assert_eq!(result.new_content, content);
}
#[test]
fn replace_in_content_whole_line() {
let content = "line one\nremove this\nline three\n";
let opts = ReplaceOptions {
whole_line: true,
..Default::default()
};
let result = replace::replace_in_content(content, "remove this", "", &opts).unwrap();
assert!(result.changed);
assert!(!result.new_content.contains("remove this"));
assert!(result.new_content.contains("line one"));
assert!(result.new_content.contains("line three"));
}
#[test]
fn replace_in_content_range() {
let content = "aaa\nbbb\naaa\nbbb\naaa\n";
let opts = ReplaceOptions {
whole_line: true,
range: Some((2, Some(4))),
..Default::default()
};
let result = replace::replace_in_content(content, "aaa", "xxx", &opts).unwrap();
assert!(result.changed);
let lines: Vec<&str> = result.new_content.lines().collect();
assert_eq!(lines[0], "aaa"); assert_eq!(lines[2], "xxx"); assert_eq!(lines[4], "aaa"); }
#[test]
fn replace_in_content_if_exists_no_match() {
let content = "fn hello() {}\n";
let opts = ReplaceOptions {
if_exists: true,
..Default::default()
};
let result = replace::replace_in_content(content, "nonexistent", "x", &opts).unwrap();
assert!(!result.changed);
assert_eq!(result.new_content, content);
}
#[test]
fn replace_in_content_case_insensitive() {
let content = "Hello World\n";
let opts = ReplaceOptions {
case_insensitive: true,
..Default::default()
};
let result = replace::replace_in_content(content, "hello", "Hi", &opts).unwrap();
assert!(result.changed);
assert!(result.new_content.contains("Hi World"));
}
#[test]
fn replace_in_content_empty_pattern_errors() {
let result = replace::replace_in_content("content", "", "x", &ReplaceOptions::default());
assert!(result.is_err());
}
#[test]
fn replace_in_content_diff_is_populated() {
let content = "old text\n";
let result =
replace::replace_in_content(content, "old", "new", &ReplaceOptions::default()).unwrap();
assert!(result.changed);
assert!(!result.diff.is_empty());
assert!(result.diff.contains("-old text"));
assert!(result.diff.contains("+new text"));
}
#[test]
fn replace_in_content_range_requires_whole_line() {
let opts = ReplaceOptions {
range: Some((1, Some(3))),
..Default::default()
};
let result = replace::replace_in_content("aaa\nbbb\n", "aaa", "xxx", &opts);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("range requires whole_line")
);
}
#[test]
fn replace_in_content_whole_line_multiline_conflict() {
let opts = ReplaceOptions {
whole_line: true,
multiline: true,
..Default::default()
};
let result = replace::replace_in_content("aaa\nbbb\n", "aaa", "xxx", &opts);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("whole_line and multiline cannot be combined")
);
}
#[test]
fn text_diff_identical_returns_empty() {
let result = text_diff("hello\nworld\n", "hello\nworld\n", None);
assert!(result.is_empty());
}
#[test]
fn text_diff_with_changes_returns_unified_diff() {
let result = text_diff("hello\nworld\n", "hello\nearth\n", None);
assert!(result.contains("-world"));
assert!(result.contains("+earth"));
assert!(result.contains("--- a/<content>"));
assert!(result.contains("+++ b/<content>"));
}
#[test]
fn text_diff_with_custom_path() {
let result = text_diff("old\n", "new\n", Some("src/main.rs"));
assert!(result.contains("--- a/src/main.rs"));
assert!(result.contains("+++ b/src/main.rs"));
}
#[test]
fn text_diff_empty_original() {
let result = text_diff("", "new content\n", Some("file.txt"));
assert!(!result.is_empty());
assert!(result.contains("+new content"));
}
#[test]
fn text_diff_empty_modified() {
let result = text_diff("old content\n", "", Some("file.txt"));
assert!(!result.is_empty());
assert!(result.contains("-old content"));
}
#[test]
fn replace_in_content_match_count_single() {
let result = replace::replace_in_content(
"hello world\n",
"hello",
"goodbye",
&ReplaceOptions::default(),
)
.unwrap();
assert_eq!(result.match_count, 1);
assert!(result.changed);
}
#[test]
fn replace_in_content_match_count_multiple() {
let result = replace::replace_in_content(
"aaa bbb aaa ccc aaa\n",
"aaa",
"xxx",
&ReplaceOptions::default(),
)
.unwrap();
assert_eq!(result.match_count, 3);
assert!(result.changed);
}
#[test]
fn replace_in_content_match_count_zero() {
let opts = ReplaceOptions {
if_exists: true,
..Default::default()
};
let result = replace::replace_in_content("hello world\n", "missing", "x", &opts).unwrap();
assert_eq!(result.match_count, 0);
assert!(!result.changed);
}
#[test]
fn replace_in_content_match_count_with_nth() {
let opts = ReplaceOptions {
nth: Some(2),
..Default::default()
};
let result = replace::replace_in_content("aaa bbb aaa\n", "aaa", "xxx", &opts).unwrap();
assert!(result.changed);
assert_eq!(result.match_count, 1);
}
#[test]
fn replace_in_content_unique_single_match_succeeds() {
let opts = ReplaceOptions {
unique: true,
..Default::default()
};
let result = replace::replace_in_content("hello world\n", "hello", "goodbye", &opts).unwrap();
assert!(result.changed);
assert_eq!(result.match_count, 1);
}
#[test]
fn replace_in_content_unique_multiple_matches_fails() {
let opts = ReplaceOptions {
unique: true,
..Default::default()
};
let err = replace::replace_in_content("aaa bbb aaa\n", "aaa", "xxx", &opts).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("ambiguous match"),
"expected ambiguous match error, got: {msg}"
);
assert!(
msg.contains("2 times"),
"should report match count, got: {msg}"
);
}
#[test]
fn replace_in_content_unique_no_match_not_ambiguous() {
let opts = ReplaceOptions {
unique: true,
if_exists: true,
..Default::default()
};
let result = replace::replace_in_content("hello world\n", "missing", "x", &opts).unwrap();
assert!(!result.changed);
assert_eq!(result.match_count, 0);
}
#[test]
fn replace_in_content_unique_with_word_boundary() {
let opts = ReplaceOptions {
unique: true,
word_boundary: true,
..Default::default()
};
let result = replace::replace_in_content("set the reset\n", "set", "get", &opts).unwrap();
assert!(result.changed);
assert_eq!(result.match_count, 1);
}
#[test]
fn replace_in_content_fuzzy_resolves_typo() {
let content = "fn setup() {}\nfn process_data(x: i32) {}\nfn cleanup() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
..Default::default()
};
let result = replace::replace_in_content(
content,
"fn proccess_data(x: i32) {}",
"fn handle_data(x: i32) {}",
&opts,
)
.unwrap();
assert!(result.changed, "fuzzy should find a match for the typo");
assert_eq!(result.match_count, 1);
assert!(
result.new_content.contains("handle_data"),
"replacement should be applied: {}",
result.new_content
);
assert!(
!result.new_content.contains("process_data"),
"original should be replaced: {}",
result.new_content
);
}
#[test]
fn replace_in_content_fuzzy_exact_match_preferred() {
let content = "fn hello() {}\nfn world() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
..Default::default()
};
let result =
replace::replace_in_content(content, "fn hello() {}", "fn greet() {}", &opts).unwrap();
assert!(result.changed);
assert_eq!(result.match_count, 1);
assert!(result.new_content.contains("fn greet()"));
}
#[test]
fn replace_in_content_fuzzy_no_match_returns_error_with_hints() {
let content = "fn alpha() {}\nfn beta() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
..Default::default()
};
let err = replace::replace_in_content(
content,
"fn completely_unrelated_name_xyz() {}",
"fn replaced() {}",
&opts,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("no matches"),
"error should say no matches: {msg}"
);
}
#[test]
fn replace_in_content_fuzzy_with_if_exists_suppresses_error() {
let content = "fn alpha() {}\nfn beta() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
if_exists: true,
..Default::default()
};
let result = replace::replace_in_content(
content,
"fn completely_unrelated_name_xyz() {}",
"fn replaced() {}",
&opts,
)
.unwrap();
assert!(!result.changed);
assert_eq!(result.match_count, 0);
}
#[test]
fn replace_in_content_fuzzy_disabled_by_default() {
let content = "fn process_data() {}\n";
let result = replace::replace_in_content(
content,
"fn proccess_data() {}",
"fn handle() {}",
&ReplaceOptions::default(),
)
.unwrap();
assert!(!result.changed);
assert_eq!(result.match_count, 0);
}
#[test]
fn replace_in_content_fuzzy_not_applied_in_regex_mode() {
let content = "fn process_data() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
regex: true,
..Default::default()
};
let result = replace::replace_in_content(
content,
"fn proccess_data\\(\\) \\{\\}",
"fn handle() {}",
&opts,
)
.unwrap();
assert!(!result.changed, "regex mode should bypass fuzzy");
}
#[test]
fn replace_in_content_fuzzy_with_unique() {
let content = "fn setup() {}\nfn process_data(x: i32) {}\nfn cleanup() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
unique: true,
..Default::default()
};
let result = replace::replace_in_content(
content,
"fn proccess_data(x: i32) {}",
"fn handle_data(x: i32) {}",
&opts,
)
.unwrap();
assert!(result.changed);
assert_eq!(result.match_count, 1);
}
#[test]
fn replace_in_content_fuzzy_with_insert_before() {
let content = "fn process_data() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
insert_before: Some("// TODO: refactor\n".to_string()),
..Default::default()
};
let result = replace::replace_in_content(
content,
"fn proccess_data() {}",
"", &opts,
)
.unwrap();
assert!(result.changed);
assert!(
result.new_content.contains("// TODO: refactor\n"),
"insert_before text should appear: {}",
result.new_content
);
assert!(
result.new_content.contains("fn process_data() {}"),
"original matched text should be preserved: {}",
result.new_content
);
}
#[test]
fn replace_in_content_fuzzy_with_insert_after() {
let content = "fn process_data() {}\n";
let opts = ReplaceOptions {
fuzzy: true,
insert_after: Some("\n// end".to_string()),
..Default::default()
};
let result = replace::replace_in_content(content, "fn proccess_data() {}", "", &opts).unwrap();
assert!(result.changed);
assert!(
result.new_content.contains("fn process_data() {}\n// end"),
"insert_after text should appear after matched text: {}",
result.new_content
);
}
#[test]
fn parse_unified_diff_basic() {
let diff = "\
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,3 @@
fn main() {
- println!(\"hello\");
+ println!(\"world\");
}
";
let files = parse_unified_diff(diff).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "src/main.rs");
assert_eq!(files[0].hunks.len(), 1);
let hunk = &files[0].hunks[0];
assert_eq!(hunk.old_start, 1);
assert_eq!(hunk.old_count, 3);
assert_eq!(hunk.new_start, 1);
assert_eq!(hunk.new_count, 3);
assert!(
hunk.lines
.contains(&PatchLine::Remove(" println!(\"hello\");".into()))
);
assert!(
hunk.lines
.contains(&PatchLine::Add(" println!(\"world\");".into()))
);
}
#[test]
fn parse_unified_diff_multiple_files() {
let diff = "\
--- a/foo.txt
+++ b/foo.txt
@@ -1 +1 @@
-old
+new
--- a/bar.txt
+++ b/bar.txt
@@ -1,2 +1,2 @@
keep
-remove
+add
";
let files = parse_unified_diff(diff).unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, "foo.txt");
assert_eq!(files[1].path, "bar.txt");
}
#[test]
fn parse_unified_diff_new_file() {
let diff = "\
--- /dev/null
+++ b/new_file.txt
@@ -0,0 +1,2 @@
+line one
+line two
";
let files = parse_unified_diff(diff).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].is_creation);
assert_eq!(files[0].path, "new_file.txt");
}
#[test]
fn parse_unified_diff_empty_input() {
let err = parse_unified_diff("").unwrap_err();
assert!(
err.contains("no files"),
"empty input should report no files, got: {err}"
);
}
#[test]
fn parse_unified_diff_deleted_file() {
let diff = "\
--- a/removed.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-line one
-line two
";
let files = parse_unified_diff(diff).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].is_deletion);
assert!(!files[0].is_creation);
assert_eq!(files[0].path, "removed.txt");
}
#[test]
fn parse_unified_diff_roundtrip_with_text_diff() {
let original = "hello\nworld\n";
let modified = "hello\nearth\n";
let diff_text = text_diff(original, modified, Some("test.txt"));
let files = parse_unified_diff(&diff_text).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "test.txt");
let hunk = &files[0].hunks[0];
assert!(hunk.lines.contains(&PatchLine::Remove("world".into())));
assert!(hunk.lines.contains(&PatchLine::Add("earth".into())));
}
#[test]
fn replace_text_unique_fails_on_ambiguity() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "foo bar foo baz foo\n").unwrap();
let opts = ReplaceOptions {
unique: true,
..Default::default()
};
let err = replace_text(&file, "foo", "x", &opts, ApplyMode::Preview, None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("ambiguous"),
"expected ambiguous error, got: {msg}"
);
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn replace_text_before_context_disambiguates() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "alpha\nTODO: fix\nbeta\ngamma\nTODO: fix\ndelta\n").unwrap();
let opts = ReplaceOptions {
before_context: Some("gamma".to_string()),
..Default::default()
};
let result = replace_text(&file, "TODO: fix", "DONE", &opts, ApplyMode::Apply, None).unwrap();
assert!(result.changed);
let content = fs::read_to_string(&file).unwrap();
assert!(
content.contains("TODO: fix"),
"first occurrence should remain"
);
assert!(content.contains("DONE"), "second should be replaced");
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn replace_text_after_context_disambiguates() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "alpha\nTODO: fix\nbeta\ngamma\nTODO: fix\ndelta\n").unwrap();
let opts = ReplaceOptions {
after_context: Some("beta".to_string()),
..Default::default()
};
let result = replace_text(&file, "TODO: fix", "DONE", &opts, ApplyMode::Apply, None).unwrap();
assert!(result.changed);
let content = fs::read_to_string(&file).unwrap();
assert!(
content.contains("TODO: fix"),
"second occurrence should remain"
);
assert!(content.contains("DONE"), "first should be replaced");
}