use rig_core::tool::{FilesChanged, HunkLine, Tool, ToolContext};
use super::{Edit, EditArgs, Replacement, apply, batch_failure};
#[test]
fn a_misspelled_key_inside_an_edit_is_refused() {
let edit = serde_json::json!({"old_text": "a", "new_text": "b", "replaceAll": true});
let args = serde_json::json!({"path": "a.rs", "edits": [edit]});
let parsed = serde_json::from_value::<EditArgs>(args).map(|_| ());
assert!(parsed.is_err_and(|error| error.to_string().contains("replaceAll")));
}
#[test]
fn a_failure_names_every_failed_edit() {
let single = batch_failure("a.rs", 1, &["`old_text` was not found".to_owned()]);
assert_eq!(
single,
"`old_text` was not found. `a.rs` was not changed; fix the edit and send it again."
);
let one = batch_failure(
"a.rs",
3,
&["edits[1]: `old_text` was not found".to_owned()],
);
assert_eq!(
one,
"edits[1]: `old_text` was not found. No edit was applied, so `a.rs` was not changed. \
Send all 3 edits again, with this one fixed."
);
let failed = ["edits[0]: one".to_owned(), "edits[2]: two".to_owned()];
let two = batch_failure("a.rs", 3, &failed);
assert_eq!(
two,
"2 of the 3 edits failed:\nedits[0]: one.\nedits[2]: two.\nNo edit was applied, so \
`a.rs` was not changed. Send all 3 edits again, with these fixed."
);
}
#[test]
fn apply_gives_the_text_the_tool_writes() {
let edit_tool = Edit::default();
let path = std::env::temp_dir().join(format!("rig-tools-apply-{}.txt", std::process::id()));
let before = "\u{feff}let a = \u{201c}x\u{201d}; \r\nlet b = 2;\r\n";
std::fs::write(&path, before).ok();
let path = path.to_string_lossy().into_owned();
let edits = [Replacement::new("let a = \"x\";\n", "let a = 1;\n")];
let applied = apply(&path, before, &edits);
let mut context = ToolContext::new();
let edited = Tool::call(&edit_tool, &mut context, EditArgs::new(&path, edits));
let output = futures::executor::block_on(edited).unwrap_or_default();
let written = std::fs::read_to_string(&path).ok();
std::fs::remove_file(&path).ok();
let applied = applied.ok();
assert_eq!(
applied.as_ref().map(|applied| applied.text.as_str()),
Some("\u{feff}let a = 1;\r\nlet b = 2;\r\n")
);
assert_eq!(
applied.as_ref().map(|applied| applied.text.clone()),
written
);
assert_eq!(applied.as_ref().map(|applied| applied.notes.len()), Some(1));
assert!(
output.contains("Note: `old_text` matched only after"),
"{output}"
);
}
#[test]
fn an_edit_answers_where_it_changed_the_file() {
let edit_tool = Edit::default();
let path = std::env::temp_dir().join(format!("rig-tools-where-{}.txt", std::process::id()));
std::fs::write(&path, "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n").ok();
let path = path.to_string_lossy().into_owned();
let call = |edits: Vec<Replacement>| {
let mut context = ToolContext::new();
let edited = Tool::call(&edit_tool, &mut context, EditArgs::new(&path, edits));
let reply = futures::executor::block_on(edited).unwrap_or_default();
let said = reply.lines().next().unwrap_or_default().to_owned();
(said, context.result::<FilesChanged>().ok().flatten())
};
let (reply, published) = call(vec![
Replacement::new("b\n", "b1\nb2\nb3\n"),
Replacement::new("h\n", ""),
]);
assert_eq!(
reply,
format!("Edited {path}: lines 2-4, line 10 (+1 line).")
);
assert!(!reply.contains("@@"), "{reply}");
let hunks = published
.map(|FilesChanged(changes)| changes)
.unwrap_or_default();
let lines = hunks
.iter()
.flat_map(|change| &change.hunks)
.flat_map(|hunk| &hunk.lines);
assert!(
lines
.clone()
.any(|line| matches!(line, HunkLine::Same(text) if text == "a\n")),
"the published change keeps its context: {hunks:?}"
);
assert!(
lines
.clone()
.any(|line| matches!(line, HunkLine::Removed(text) if text == "h\n"))
);
let (reply, _) = call(vec![Replacement::new("j", "J").all()]);
assert_eq!(reply, format!("Edited {path}: line 11."));
std::fs::write(&path, "x\ny\nx\ny\nx\n").ok();
let (reply, _) = call(vec![Replacement::new("x", "z").all()]);
assert_eq!(
reply,
format!("Edited {path}: 3 replacements, lines 1, 3, 5.")
);
std::fs::write(&path, "a\nb\nc\nd\ne\nf\n").ok();
let (reply, _) = call(vec![
Replacement::new("b\n", ""),
Replacement::new("e\n", "E\n"),
]);
std::fs::remove_file(&path).ok();
assert_eq!(reply, format!("Edited {path}: line 2, 4 (-1 line)."));
}
#[test]
fn an_edit_shows_the_lines_around_each_change() {
let edit_tool = Edit::default();
let path = std::env::temp_dir().join(format!("rig-tools-around-{}.txt", std::process::id()));
let numbered = |lines: std::ops::RangeInclusive<usize>| -> String {
lines.map(|n| format!("{n}\n")).collect()
};
std::fs::write(&path, numbered(1..=100)).ok();
let path = path.to_string_lossy().into_owned();
let call = |edits: Vec<Replacement>| {
let mut context = ToolContext::new();
let edited = Tool::call(&edit_tool, &mut context, EditArgs::new(&path, edits));
futures::executor::block_on(edited).unwrap_or_default()
};
let long: String = (0..40).map(|n| format!("new {n}\n")).collect();
let reply = call(vec![
Replacement::new("\n10\n", "\nten\n"),
Replacement::new("\n12\n", "\ntwelve\n"),
Replacement::new("\n50\n", "\n"),
Replacement::new("\n80\n", format!("\n{long}")),
]);
let middle: String = (0..12).map(|n| format!("new {n}\n")).collect();
let end: String = (28..40).map(|n| format!("new {n}\n")).collect();
let expected = format!(
"Edited {path}: lines 10, 12, line 50, 79-118 (+38 lines).\n\
[lines 7-15]\n7\n8\n9\nten\n11\ntwelve\n13\n14\n15\n\
[lines 47-52]\n47\n48\n49\n51\n52\n53\n\
[lines 76-121]\n77\n78\n79\n{middle}[… 16 lines]\n{end}81\n82\n83"
);
std::fs::remove_file(&path).ok();
assert_eq!(reply, expected);
}
#[test]
fn a_missed_old_text_shows_the_closest_text_and_asks_for_a_read_only_after_a_change() {
let path = std::env::temp_dir().join(format!("rig-tools-missed-{}.rs", std::process::id()));
let text = "fn main() {\n let total = add(1, 2);\n println!(\"{total}\");\n}\n";
std::fs::write(&path, text).ok();
let path = path.to_string_lossy().into_owned();
let seen = crate::Seen::default();
let read = crate::Read {
seen: seen.clone(),
..crate::Read::default()
};
let edit = Edit {
seen,
..Edit::default()
};
let call = |edits: Vec<Replacement>| {
let args = EditArgs::new(&path, edits);
let mut context = ToolContext::new();
futures::executor::block_on(Tool::call(&edit, &mut context, args))
.err()
.map(|error| error.to_string())
.unwrap_or_default()
};
let read_it = || {
let args = crate::ReadArgs::new(&path);
let mut context = ToolContext::new();
futures::executor::block_on(Tool::call(&read, &mut context, args)).ok()
};
assert!(read_it().is_some());
let missed = call(vec![
Replacement::new("fn main() {", "fn main() -> () {"),
Replacement::new(
" let total = add(1, 3);\n",
" let total = add(2, 3);\n",
),
]);
assert!(
missed.contains("edits[1]: `old_text` was not found")
&& missed.contains("copy it from the closest text in the file, shown below")
&& missed.contains(
"The closest text to the `old_text` of edits[1] is line 2:\n let total = add(1, 2);"
)
&& !missed.contains("read it again"),
"{missed}"
);
std::fs::write(&path, text.replace("add(1, 2)", "sum(1, 2)")).ok();
let changed = call(vec![Replacement::new("add(1, 2)", "add(2, 3)")]);
assert!(
changed.contains("changed since it was last read, so read it again"),
"{changed}"
);
assert!(read_it().is_some());
let unrelated = call(vec![Replacement::new("nothing like it at all", "x")]);
std::fs::remove_file(&path).ok();
assert!(
unrelated.contains("nothing in the file is close to it")
&& !unrelated.contains("read it again"),
"{unrelated}"
);
}
#[test]
fn edits_apply_in_order_even_where_they_overlap() {
let text = "fn area(w: u32, h: u32) -> u32 {\n w * h\n}\n";
let same_lines = [
Replacement::new(
"fn area(w: u32, h: u32) -> u32 {\n w * h",
"fn area(w: u64, h: u64) -> u64 {\n w * h",
),
Replacement::new(" w * h", " w.saturating_mul(h)"),
];
let applied = apply("a.rs", text, &same_lines).map(|applied| applied.text);
assert_eq!(
applied.ok().as_deref(),
Some("fn area(w: u64, h: u64) -> u64 {\n w.saturating_mul(h)\n}\n")
);
let all_then_one = [
Replacement::new("u32", "u64").all(),
Replacement::new("-> u64 {", "-> Option<u64> {"),
];
let applied = apply("a.rs", text, &all_then_one).map(|applied| applied.replaced);
assert_eq!(applied.ok(), Some(4));
let taken = [
Replacement::new("u32", "u64").all(),
Replacement::new("-> u32 {", "-> Option<u32> {"),
];
let refused = apply("a.rs", text, &taken)
.err()
.map(|error| error.to_string())
.unwrap_or_default();
assert!(
refused.starts_with(
"edits[1]: `old_text` was in the file, but edits[0], applied before it, changed \
that text; match the text as edits[0] leaves it, or make this change in edits[0]. \
No edit was applied"
),
"{refused}"
);
}