- 1
use async_trait::async_trait; - 2
use serde_json::Value; - 3
use similar::TextDiff; - 4
- 5
use crate::{Tool, ToolContext, ToolOutput}; - 6
- 7
pub struct EditTool; - 8
- 9
struct PreparedEdit { - 10
old: String, - 11
new: String, - 12
} - 13
- 14
#[async_trait] - 15
impl Tool for EditTool { - 16
fn name(&self) -> &str { - 17
"edit" - 18
} - 19
- 20
fn serves(&self) -> &'static [&'static str] { - 21
&["documents"] - 22
} - 23
- 24
fn description(&self) -> &str { - 25
"Apply exact string replacements to a text file. All edits are atomic: every old_text must match exactly once or the whole operation fails without changes. Word, Excel and PowerPoint files are not text: change them with office_apply." - 26
} - 27
- 28
fn schema(&self) -> Value { - 29
serde_json::json!({ - 30
"type": "object", - 31
"properties": { - 32
"path": {"type": "string", "description": "File path (relative to cwd or absolute)"}, - 33
"edits": { - 34
"type": "array", - 35
"minItems": 1, - 36
"items": { - 37
"type": "object", - 38
"properties": { - 39
"old_text": {"type": "string", "description": "Exact text to replace; must be unique in the file"}, - 40
"new_text": {"type": "string", "description": "Replacement text"} - 41
}, - 42
"required": ["old_text", "new_text"] - 43
} - 44
} - 45
}, - 46
"required": ["path", "edits"] - 47
}) - 48
} - 49
- 50
fn refusal(&self, args: &Value) -> Option<String> { - 51
let path = args.get("path").and_then(Value::as_str)?; - 52
crate::office_apply::text_tool_refusal(std::path::Path::new(path), "edit") - 53
} - 54
- 55
fn claims(&self, args: &Value) -> crate::ResourceClaims { - 56
crate::ResourceClaims { - 57
exclusive: false, - 58
read_only: false, - 59
paths: args - 60
.get("path") - 61
.and_then(|p| p.as_str()) - 62
.map(|p| vec![p.to_string()]) - 63
.unwrap_or_default(), - 64
} - 65
} - 66
- 67
async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { - 68
let Some(path_str) = args.get("path").and_then(|p| p.as_str()) else { - 69
return ToolOutput::error("missing required parameter: path"); - 70
}; - 71
let Some(edits_val) = args.get("edits").and_then(|e| e.as_array()) else { - 72
return ToolOutput::error("missing required parameter: edits"); - 73
}; - 74
let path = ctx.resolve(std::path::Path::new(path_str)); - 75
if let Some(refusal) = crate::office_apply::text_tool_refusal(&path, "edit") { - 76
return ToolOutput::error(refusal); - 77
} - 78
- 79
let bytes = match tokio::fs::read(&path).await { - 80
Ok(b) => b, - 81
Err(e) => return ToolOutput::error(format!("cannot read {}: {e}", path.display())), - 82
}; - 83
- 84
// Editing is a text operation on the exact bytes on disk; silently - 85
// replacing invalid sequences would corrupt binary or mixed-encoding - 86
// files on write-back. - 87
if std::str::from_utf8(&bytes).is_err() { - 88
return ToolOutput::error(format!( - 89
"{} is not valid UTF-8 (binary or non-UTF-8 encoding); edit refused", - 90
path.display() - 91
)); - 92
} - 93
- 94
let bom = bytes.starts_with(&[0xEF, 0xBB, 0xBF]); - 95
let raw = if bom { - 96
String::from_utf8_lossy(&bytes[3..]).into_owned() - 97
} else { - 98
String::from_utf8(bytes).unwrap_or_default() - 99
}; - 100
let crlf = raw.contains("\r\n"); - 101
- 102
let mut prepared: Vec<PreparedEdit> = Vec::with_capacity(edits_val.len()); - 103
for (i, e) in edits_val.iter().enumerate() { - 104
let Some(old) = e.get("old_text").and_then(|o| o.as_str()) else { - 105
return ToolOutput::error(format!("edit {i}: missing old_text")); - 106
}; - 107
let Some(new) = e.get("new_text").and_then(|n| n.as_str()) else { - 108
return ToolOutput::error(format!("edit {i}: missing new_text")); - 109
}; - 110
let (old, new) = if crlf && !old.contains("\r\n") { - 111
(old.replace('\n', "\r\n"), new.replace('\n', "\r\n")) - 112
} else { - 113
(old.to_string(), new.to_string()) - 114
}; - 115
prepared.push(PreparedEdit { old, new }); - 116
} - 117
- 118
let mut buf = raw.clone(); - 119
for (i, e) in prepared.iter().enumerate() { - 120
let count = buf.matches(&e.old).count(); - 121
if count == 0 { - 122
return ToolOutput::error(format!( - 123
"edit {i}: old_text not found in {} (no changes applied)", - 124
path.display() - 125
)); - 126
} - 127
if count > 1 { - 128
return ToolOutput::error(format!( - 129
"edit {i}: old_text matches {count} locations in {}; make it unique (no changes applied)", - 130
path.display() - 131
)); - 132
} - 133
buf = buf.replacen(&e.old, &e.new, 1); - 134
} - 135
- 136
let final_bytes = if bom { - 137
let mut b = vec![0xEF, 0xBB, 0xBF]; - 138
b.extend_from_slice(buf.as_bytes()); - 139
b - 140
} else { - 141
buf.as_bytes().to_vec() - 142
}; - 143
// Temp file + rename: a crash mid-write cannot truncate the target. - 144
let tmp = path.with_extension(format!( - 145
"{}vak-tmp", - 146
path.extension() - 147
.map(|e| format!("{}.", e.to_string_lossy())) - 148
.unwrap_or_default() - 149
)); - 150
let write_res = tokio::fs::write(&tmp, &final_bytes).await; - 151
let rename_res = match write_res { - 152
Ok(()) => { - 153
let r = std::fs::rename(&tmp, &path); - 154
if r.is_err() { - 155
let _ = std::fs::remove_file(&tmp); - 156
} - 157
r - 158
} - 159
Err(e) => Err(e), - 160
}; - 161
if let Err(e) = rename_res { - 162
return ToolOutput::error(format!("cannot write {}: {e}", path.display())); - 163
} - 164
crate::artifact::emit_file(ctx.sandbox_sink.as_ref(), &path, &ctx.cwd); - 165
- 166
let diff = TextDiff::from_lines(&raw, &buf); - 167
let mut out = format!("edited {}", path.display()); - 168
let mut changed = 0usize; - 169
let mut diff_text = String::new(); - 170
for hunk in diff.unified_diff().iter_hunks() { - 171
changed += 1; - 172
diff_text.push_str(&format!("{hunk}\n")); - 173
} - 174
if changed > 0 { - 175
out.push_str(&format!(" ({changed} hunks)\n\n{diff_text}")); - 176
} else { - 177
out.push_str(" (no changes)"); - 178
} - 179
ToolOutput::ok(out) - 180
} - 181
} - 182
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.