use crate::common::Result;
use serde::Deserialize;
use tokio::fs;
#[derive(Debug, Deserialize)]
pub struct FileWriteInput {
pub path: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct FileEditInput {
pub path: String,
pub old_string: String,
pub new_string: String,
#[serde(default)]
pub replace_all: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EditConfidence {
Exact,
Normalized,
}
pub fn write_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "file_write",
"description": "Write content to a file, creating it if it doesn't exist.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file"
},
"content": {
"type": "string",
"description": "Content to write to the file"
}
},
"required": ["path", "content"]
}
}
})
}
pub fn edit_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "file_edit",
"description": "Perform a surgical edit: replace an exact string in a file with new content.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file"
},
"old_string": {
"type": "string",
"description": "The exact string to find and replace"
},
"new_string": {
"type": "string",
"description": "The replacement string"
},
"replace_all": {
"type": "boolean",
"description": "If true, replace ALL occurrences of old_string. Default: false (requires unique match)."
}
},
"required": ["path", "old_string", "new_string"]
}
}
})
}
pub async fn execute_write(
input: FileWriteInput,
working_dir: &str,
lsp_manager: Option<&crate::lsp::manager::LspManager>,
) -> Result<String> {
let path = resolve_path(&input.path, working_dir);
if let Some(parent) = std::path::Path::new(&path).parent() {
fs::create_dir_all(parent).await?;
}
let is_new_file = !std::path::Path::new(&path).exists();
fs::write(&path, &input.content).await?;
if is_new_file {
let _ = tokio::process::Command::new("git")
.args(["add", "-N", &path])
.current_dir(working_dir)
.output()
.await;
}
let mut result = format!(
"Successfully wrote {} bytes to {}",
input.content.len(),
path
);
if let Some(lsp) = lsp_manager {
let version_before = lsp.diag_version(&path).await;
if let Err(e) = lsp.notify_file_change(&path, &input.content).await {
tracing::warn!("LSP notification failed for {}: {}", path, e);
}
if let Err(e) = lsp.notify_file_save(&path).await {
tracing::warn!("LSP save notification failed for {}: {}", path, e);
}
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(1_500);
let mut poll_interval = std::time::Duration::from_millis(25);
loop {
tokio::time::sleep(poll_interval).await;
if lsp.diag_version(&path).await > version_before {
break;
}
if tokio::time::Instant::now() >= deadline {
break;
}
poll_interval = poll_interval
.mul_f32(1.5)
.min(std::time::Duration::from_millis(200));
}
if let Ok(diagnostics) = lsp.get_diagnostics(&path).await
&& !diagnostics.is_empty()
{
result.push_str("\n\n⚠️ LSP Diagnostics:\n");
for diag in diagnostics.iter().take(10) {
result.push_str(&format!(
" Line {}: {}\n",
diag.range.start.line + 1,
diag.message
));
}
}
}
Ok(result)
}
pub async fn execute_edit(
input: FileEditInput,
working_dir: &str,
lsp_manager: Option<&crate::lsp::manager::LspManager>,
) -> Result<String> {
let path = resolve_path(&input.path, working_dir);
let content = fs::read_to_string(&path).await?;
let (new_content, confidence) = apply_edit(&content, &input)?;
fs::write(&path, &new_content).await?;
let mut result = match confidence {
EditConfidence::Exact => format!("Successfully edited {}", path),
EditConfidence::Normalized => format!(
"Successfully edited {} (matched after whitespace normalization)",
path
),
};
if let Some(syntax_err) = validate_syntax(&path, &new_content) {
result.push_str(&format!("\n\nSYNTAX WARNING: {syntax_err}"));
}
if let Some(lsp) = lsp_manager {
let version_before = lsp.diag_version(&path).await;
if let Err(e) = lsp.notify_file_change(&path, &new_content).await {
tracing::warn!("LSP notification failed for {}: {}", path, e);
}
if let Err(e) = lsp.notify_file_save(&path).await {
tracing::warn!("LSP save notification failed for {}: {}", path, e);
}
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(1_500);
let mut poll_interval = std::time::Duration::from_millis(25);
loop {
tokio::time::sleep(poll_interval).await;
if lsp.diag_version(&path).await > version_before {
break;
}
if tokio::time::Instant::now() >= deadline {
break;
}
poll_interval = poll_interval
.mul_f32(1.5)
.min(std::time::Duration::from_millis(200));
}
if let Ok(diagnostics) = lsp.get_diagnostics(&path).await
&& !diagnostics.is_empty()
{
let errors = diagnostics
.iter()
.filter(|d| {
matches!(
d.severity,
Some(crate::lsp::protocol::DiagnosticSeverity::Error)
)
})
.count();
let warnings = diagnostics.len() - errors;
result.push_str(&format!(
"\n\nPOST-EDIT DIAGNOSTICS: {} errors, {} warnings\n",
errors, warnings
));
for diag in diagnostics.iter().take(10) {
result.push_str(&format!(
" Line {}: {}\n",
diag.range.start.line + 1,
diag.message
));
}
if errors > 0 {
result.push_str("IMPORTANT: Fix all errors before proceeding to next task.\n");
}
}
if let Some(symbols) = lsp.symbols_for_file(&path).await {
tracing::trace!(
path = %path,
symbol_count = symbols.len(),
"LSP: symbol index refreshed after edit"
);
}
}
Ok(result)
}
fn apply_edit(content: &str, input: &FileEditInput) -> Result<(String, EditConfidence)> {
let count = content.matches(&input.old_string).count();
if input.replace_all && count > 0 {
let new_content = content.replace(&input.old_string, &input.new_string);
return Ok((new_content, EditConfidence::Exact));
}
if count == 1 {
let new_content = content.replacen(&input.old_string, &input.new_string, 1);
return Ok((new_content, EditConfidence::Exact));
}
if count > 1 {
return Err(crate::common::AgentError::InvalidArgument(format!(
"old_string found {} times in file - must be unique. \
Include more surrounding context to disambiguate.",
count
)));
}
let normalized_old = normalize_whitespace(&input.old_string);
let lines: Vec<&str> = content.lines().collect();
let old_lines: Vec<&str> = input.old_string.lines().collect();
let old_line_count = old_lines.len();
if old_line_count > 0 && old_line_count <= lines.len() {
let mut matches = Vec::new();
for start in 0..=lines.len().saturating_sub(old_line_count) {
let window: String = lines[start..start + old_line_count].join("\n");
if normalize_whitespace(&window) == normalized_old {
matches.push(start);
}
}
if matches.len() == 1 {
let start = matches[0];
let mut new_lines: Vec<&str> = Vec::new();
new_lines.extend_from_slice(&lines[..start]);
for line in input.new_string.lines() {
new_lines.push(line);
}
new_lines.extend_from_slice(&lines[start + old_line_count..]);
let new_content = new_lines.join("\n");
let new_content = if content.ends_with('\n') && !new_content.ends_with('\n') {
format!("{new_content}\n")
} else {
new_content
};
return Ok((new_content, EditConfidence::Normalized));
}
}
let diff_hint = find_closest_match(content, &input.old_string);
let hint_msg = match diff_hint {
Some(hint) => format!(
"old_string not found in file (even after whitespace normalization).\n\
Closest match found at line {}:\n\
--- expected (your old_string) ---\n{}\n\
--- actual (file content) ---\n{}\n\
Use file_read to verify the current file content before editing.",
hint.line,
truncate_for_hint(&input.old_string, 300),
truncate_for_hint(&hint.actual, 300),
),
None => "old_string not found in file (even after whitespace normalization). \
Use file_read to verify the current file content before editing."
.to_string(),
};
Err(crate::common::AgentError::InvalidArgument(hint_msg))
}
struct ClosestMatch {
line: usize,
actual: String,
}
fn find_closest_match(content: &str, needle: &str) -> Option<ClosestMatch> {
let content_lines: Vec<&str> = content.lines().collect();
let needle_lines: Vec<String> = needle
.lines()
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
.collect();
let window = needle_lines.len();
if window == 0 || window > content_lines.len() {
return None;
}
let mut best_score = 0usize;
let mut best_start = 0usize;
for start in 0..=content_lines.len().saturating_sub(window) {
let score = (0..window)
.filter(|&i| {
let actual_norm: String = content_lines[start + i]
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
actual_norm == needle_lines[i]
})
.count();
if score > best_score {
best_score = score;
best_start = start;
}
}
if best_score * 10 < window * 3 {
return None;
}
let actual = content_lines[best_start..best_start + window].join("\n");
Some(ClosestMatch {
line: best_start + 1,
actual,
})
}
fn truncate_for_hint(s: &str, max_chars: usize) -> String {
if s.len() <= max_chars {
s.to_string()
} else {
format!("{}...", crate::util::truncate_bytes(s, max_chars))
}
}
fn normalize_whitespace(s: &str) -> String {
s.lines()
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n")
}
fn validate_syntax(path: &str, content: &str) -> Option<String> {
let ext = std::path::Path::new(path).extension()?.to_str()?;
let language = match ext {
"rs" => tree_sitter_rust::LANGUAGE,
"py" => tree_sitter_python::LANGUAGE,
"ts" | "tsx" | "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
_ => return None,
};
let mut parser = tree_sitter::Parser::new();
parser.set_language(&language.into()).ok()?;
let tree = parser.parse(content, None)?;
if tree.root_node().has_error() {
let root = tree.root_node();
if let Some(err_node) = find_error_node(root) {
let start = err_node.start_position();
Some(format!(
"Syntax error at line {}, column {} — check the generated code.",
start.row + 1,
start.column + 1
))
} else {
Some("Syntax error detected in generated code.".to_string())
}
} else {
None
}
}
fn find_error_node(node: tree_sitter::Node<'_>) -> Option<tree_sitter::Node<'_>> {
if node.is_error() || node.is_missing() {
return Some(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(err) = find_error_node(child) {
return Some(err);
}
}
None
}
fn resolve_path(path: &str, working_dir: &str) -> String {
let candidate = if path.starts_with('/') {
std::path::PathBuf::from(path)
} else {
std::path::Path::new(working_dir).join(path)
};
crate::agent::approval::normalize_path_lexical(&candidate)
.to_string_lossy()
.into_owned()
}