#[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 actual = line_hash(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 has_crlf = bytes.windows(2).any(|w| w == b"\r\n");
let content = String::from_utf8_lossy(&bytes).replace("\r\n", "\n");
let new_content = apply_line_edit(
&content,
args.start_line,
args.end_line,
&args.expected_hashes,
&args.new_text,
)
.map_err(ToolError::Msg)?;
let candidate = if has_crlf {
new_content.replace('\n', "\r\n")
} else {
new_content
};
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);
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> {
content
.lines()
.skip(start - 1)
.take(end - start + 1)
.map(line_hash)
.collect()
}
#[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("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("WRONG"), line_hash("b"), line_hash("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 rejects_wrong_hash_count() {
let c = "a\nb\nc\n";
let err = apply_line_edit(c, 1, 3, &[line_hash("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");
}
}