#[cfg(feature = "lsp")]
use std::sync::Arc;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use crate::agent::agent_loop::tool_input_repair::with_contract_hint;
use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::line_hash::line_hash;
use crate::agent::tools::{AskSender, EditLinesArgs, PermCheck, ToolError, require_and_resolve};
#[cfg(feature = "lsp")]
use crate::lsp::manager::LspManager;
pub struct EditLinesTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
cache: Option<ToolCache>,
#[cfg(feature = "lsp")]
#[allow(dead_code)]
lsp_manager: Option<Arc<LspManager>>,
}
impl EditLinesTool {
#[allow(dead_code)]
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
EditLinesTool {
permission,
ask_tx,
cache: None,
#[cfg(feature = "lsp")]
lsp_manager: None,
}
}
pub fn with_cache(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
cache: ToolCache,
#[cfg(feature = "lsp")] lsp_manager: Option<Arc<LspManager>>,
) -> Self {
EditLinesTool {
permission,
ask_tx,
cache: Some(cache),
#[cfg(feature = "lsp")]
lsp_manager,
}
}
}
pub(crate) fn apply_line_edit(
content: &str,
start_line: usize,
end_line: usize,
expected_hashes: &[String],
new_text: &str,
) -> Result<String, String> {
if start_line == 0 {
return Err("start_line is 1-indexed and must be >= 1".to_string());
}
if end_line < start_line {
return Err(format!(
"end_line ({end_line}) must be >= start_line ({start_line})"
));
}
let lines: Vec<&str> = content.lines().collect();
if end_line > lines.len() {
return Err(format!(
"end_line ({end_line}) is past the end of the file ({} lines). \
Re-read with line_hashes to get current line numbers.",
lines.len()
));
}
let span = end_line - start_line + 1;
if expected_hashes.len() != span {
return Err(format!(
"expected_hashes has {} entries but the range {start_line}..={end_line} \
covers {span} lines — pass exactly one hash per line in the range.",
expected_hashes.len()
));
}
let mut mismatches = Vec::new();
for (offset, expected) in expected_hashes.iter().enumerate() {
let line_no = start_line + offset;
let prev = (line_no >= 2).then(|| lines[line_no - 2]);
let actual = line_hash(prev, lines[line_no - 1]);
if &actual != expected {
mismatches.push(format!(
" line {line_no}: expected hash `{expected}`, found `{actual}` — \
current content: {:?}",
lines[line_no - 1]
));
}
}
if !mismatches.is_empty() {
return Err(format!(
"edit_lines rejected: {} line(s) changed since you read them. \
Re-read with line_hashes and retry.\n{}",
mismatches.len(),
mismatches.join("\n")
));
}
let nt = new_text.replace("\r\n", "\n");
let nt = nt.strip_suffix('\n').unwrap_or(&nt);
let mut out: Vec<&str> = Vec::with_capacity(lines.len());
out.extend_from_slice(&lines[..start_line - 1]);
if !new_text.is_empty() {
out.extend(nt.split('\n'));
}
out.extend_from_slice(&lines[end_line..]);
let mut output = out.join("\n");
if content.ends_with('\n') && !output.is_empty() {
output.push('\n');
}
Ok(output)
}
impl Tool for EditLinesTool {
const NAME: &'static str = "edit_lines";
type Error = ToolError;
type Args = EditLinesArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "edit_lines".to_string(),
description: with_contract_hint(
"edit_lines",
"Replace a range of lines by line number, guarded by per-line content hashes. \
First read the file with line_hashes=true to get `N hhh: ...` lines, then call \
edit_lines with start_line/end_line (1-indexed, inclusive), expected_hashes (one \
per line in the range, in order), and new_text (the replacement block; empty \
deletes the range). Cheaper than `edit` for large blocks — you don't retype the \
old text. The edit is rejected if any line changed since you read it.",
),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The absolute path to the file to edit (must be absolute, not relative)", "dirge-hints": {"semantic": "absolute_path"} },
"start_line": { "type": "integer", "description": "First line to replace (1-indexed, inclusive)" },
"end_line": { "type": "integer", "description": "Last line to replace (1-indexed, inclusive)" },
"expected_hashes": { "type": "array", "items": {"type": "string"}, "description": "The 3-char content hash for each line in [start_line, end_line], in order, exactly as shown by read(line_hashes=true)" },
"new_text": { "type": "string", "description": "Replacement text for the range. Empty string deletes the lines." }
},
"required": ["path", "start_line", "end_line", "expected_hashes", "new_text"]
}),
}
}
async fn call(&self, args: EditLinesArgs) -> Result<String, ToolError> {
let resolved_path = require_and_resolve(
&self.permission,
&self.ask_tx,
"edit",
&args.path,
"the edit path",
)
.await?;
if let Some(ref cache) = self.cache
&& !cache.has_been_read(std::path::Path::new(&resolved_path))
{
return Err(ToolError::Msg(format!(
"edit_lines was blocked because \"{}\" has not been read in this session yet. \
Call read(line_hashes=true) on this path first.",
args.path
)));
}
const MAX_EDIT_BYTES: u64 = 100 * 1024 * 1024;
if let Ok(meta) = tokio::fs::metadata(&resolved_path).await
&& meta.len() > MAX_EDIT_BYTES
{
return Err(ToolError::Msg(format!(
"file too large for edit_lines: {} bytes (cap {} bytes)",
meta.len(),
MAX_EDIT_BYTES,
)));
}
let bytes = tokio::fs::read(&resolved_path).await?;
let src = crate::agent::tools::text_io::decode_for_edit(&bytes).map_err(ToolError::Msg)?;
let content = &src.content;
let new_content = apply_line_edit(
content,
args.start_line,
args.end_line,
&args.expected_hashes,
&args.new_text,
)
.map_err(ToolError::Msg)?;
let reencoded = src.reencode(&new_content);
let mixed_ending_note = reencoded.note;
let candidate = reencoded.text;
let (output, syntax_note) =
crate::agent::tools::syntax_gate(std::path::Path::new(&resolved_path), &candidate)
.map_err(ToolError::Msg)?;
crate::agent::tools::snapshots::capture_bytes(std::path::Path::new(&resolved_path), &bytes);
crate::fs_atomic::atomic_write(std::path::Path::new(&resolved_path), output.as_bytes())
.await?;
crate::agent::tools::modified::mark_modified(std::path::Path::new(&resolved_path));
if let Some(ref cache) = self.cache {
cache.clear();
cache.mark_read(std::path::Path::new(&resolved_path));
}
let new_span = if args.new_text.is_empty() {
0
} else {
args.new_text
.replace("\r\n", "\n")
.trim_end_matches('\n')
.split('\n')
.count()
};
let mut msg = format!(
"Replaced lines {}-{} ({} line(s) → {} line(s)).",
args.start_line,
args.end_line,
args.end_line - args.start_line + 1,
new_span,
);
crate::agent::tools::append_repair_note(&mut msg, syntax_note);
if let Some(note) = mixed_ending_note {
msg.push('\n');
msg.push_str(¬e);
}
Ok(msg)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::tools::line_hash::line_hash;
fn hashes_for(content: &str, start: usize, end: usize) -> Vec<String> {
let lines: Vec<&str> = content.lines().collect();
(start..=end)
.map(|line_no| {
let prev = (line_no >= 2).then(|| lines[line_no - 2]);
line_hash(prev, lines[line_no - 1])
})
.collect()
}
struct TmpFile {
path: String,
}
impl TmpFile {
fn new(name: &str, bytes: &[u8]) -> Self {
let path = format!("/tmp/dirge-editlines-{}", name);
let _ = std::fs::remove_file(&path);
std::fs::write(&path, bytes).unwrap();
Self { path }
}
}
impl Drop for TmpFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[tokio::test]
async fn edits_line_one_of_a_bom_file_and_preserves_the_bom() {
let tf = TmpFile::new(
"bom-line1.txt",
"\u{FEFF}alpha\r\nbeta\r\ngamma\r\n".as_bytes(),
);
let h = hashes_for("alpha\nbeta\ngamma\n", 1, 1);
let tool = EditLinesTool::new(None, None);
let out = tool
.call(EditLinesArgs {
path: tf.path.clone(),
start_line: 1,
end_line: 1,
expected_hashes: h,
new_text: "ALPHA".to_string(),
})
.await
.expect("edit_lines should accept line 1 of a BOM file");
assert!(out.contains("Replaced lines 1-1"), "got: {out}");
let disk = std::fs::read(&tf.path).unwrap();
assert_eq!(disk, "\u{FEFF}ALPHA\r\nbeta\r\ngamma\r\n".as_bytes());
}
#[tokio::test]
async fn mixed_ending_file_is_not_flipped_wholesale() {
let tf = TmpFile::new("mixed.txt", b"a\nb\nc\r\nd\n");
let h = hashes_for("a\nb\nc\nd\n", 2, 2);
let tool = EditLinesTool::new(None, None);
let out = tool
.call(EditLinesArgs {
path: tf.path.clone(),
start_line: 2,
end_line: 2,
expected_hashes: h,
new_text: "B".to_string(),
})
.await
.unwrap();
let disk = std::fs::read(&tf.path).unwrap();
assert_eq!(disk, b"a\nB\nc\nd\n");
assert!(out.contains("mixed line endings"), "got: {out}");
}
#[tokio::test]
async fn non_utf8_file_is_refused() {
let tf = TmpFile::new("binary.bin", b"ok\n\xffbytes\n");
let tool = EditLinesTool::new(None, None);
let err = tool
.call(EditLinesArgs {
path: tf.path.clone(),
start_line: 1,
end_line: 1,
expected_hashes: vec![line_hash(None, "ok")],
new_text: "OK".to_string(),
})
.await
.expect_err("non-UTF-8 file must be refused");
let msg = format!("{err:?}");
assert!(msg.contains("not valid UTF-8"), "got: {msg}");
assert_eq!(std::fs::read(&tf.path).unwrap(), b"ok\n\xffbytes\n");
}
#[test]
fn replaces_single_line_on_matching_hash() {
let c = "a\nb\nc\n";
let h = hashes_for(c, 2, 2);
let out = apply_line_edit(c, 2, 2, &h, "B").unwrap();
assert_eq!(out, "a\nB\nc\n");
}
#[test]
fn replaces_multi_line_range_with_more_lines() {
let c = "one\ntwo\nthree\n";
let h = hashes_for(c, 1, 2);
let out = apply_line_edit(c, 1, 2, &h, "X\nY\nZ").unwrap();
assert_eq!(out, "X\nY\nZ\nthree\n");
}
#[test]
fn empty_new_text_deletes_the_range() {
let c = "keep1\ndrop\nkeep2\n";
let h = hashes_for(c, 2, 2);
let out = apply_line_edit(c, 2, 2, &h, "").unwrap();
assert_eq!(out, "keep1\nkeep2\n");
}
#[test]
fn rejects_on_hash_mismatch_without_mutating() {
let c = "a\nb\nc\n";
let stale = vec![line_hash(None, "OLD_b")];
let err = apply_line_edit(c, 2, 2, &stale, "B").unwrap_err();
assert!(err.contains("line 2"), "got: {err}");
assert!(err.contains("changed since you read"), "got: {err}");
}
#[test]
fn reports_every_drifted_line() {
let c = "a\nb\nc\n";
let mixed = vec![
line_hash(None, "WRONG"),
line_hash(Some("a"), "b"),
line_hash(None, "ALSO_WRONG"),
];
let err = apply_line_edit(c, 1, 3, &mixed, "x").unwrap_err();
assert!(err.contains("line 1"), "got: {err}");
assert!(err.contains("line 3"), "got: {err}");
assert!(!err.contains("line 2"), "line 2 matched; got: {err}");
}
#[test]
fn predecessor_drift_is_detected() {
let read = "a\nb\nc\n";
let echoed = hashes_for(read, 2, 2);
let drifted = "A\nb\nc\n";
let err = apply_line_edit(drifted, 2, 2, &echoed, "B").unwrap_err();
assert!(err.contains("line 2"), "got: {err}");
assert!(err.contains("changed since you read"), "got: {err}");
assert_eq!(drifted, "A\nb\nc\n");
}
#[test]
fn rejects_wrong_hash_count() {
let c = "a\nb\nc\n";
let err = apply_line_edit(c, 1, 3, &[line_hash(None, "a")], "x").unwrap_err();
assert!(err.contains("one hash per line"), "got: {err}");
}
#[test]
fn rejects_out_of_range() {
let c = "a\nb\n";
let err = apply_line_edit(c, 1, 9, &vec!["x".into(); 9], "z").unwrap_err();
assert!(err.contains("past the end"), "got: {err}");
}
#[test]
fn preserves_missing_trailing_newline() {
let c = "a\nb"; let h = hashes_for(c, 2, 2);
let out = apply_line_edit(c, 2, 2, &h, "B").unwrap();
assert_eq!(out, "a\nB");
}
#[tokio::test]
async fn call_round_trips_and_preserves_crlf() {
let dir = std::env::temp_dir();
let path = dir.join(format!("dirge_edit_lines_{}.txt", std::process::id()));
std::fs::write(&path, "a\r\nb\r\nc\r\n").unwrap();
let normalized = "a\nb\nc\n";
let h = hashes_for(normalized, 2, 2);
let tool = EditLinesTool::new(None, None); let out = tool
.call(EditLinesArgs {
path: path.to_string_lossy().into_owned(),
start_line: 2,
end_line: 2,
expected_hashes: h,
new_text: "B".to_string(),
})
.await
.expect("edit_lines call succeeds");
assert!(out.contains("Replaced lines 2-2"), "summary: {out}");
let after = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(after, "a\r\nB\r\nc\r\n", "CRLF must be preserved");
}
}