1use std::path::{Path, PathBuf};
22
23use kernel::{
24 AxCode, AxError, B3Hash, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall,
25 ToolMeta, ToolName, ToolOutcome,
26};
27use serde_json::{Map, Value};
28
29pub struct EditTool {
30 city_root: PathBuf,
31 writable: kernel::WriteDomain,
34 limit: kernel::WriteLimit,
36 meta: ToolMeta,
37}
38
39pub fn version_of(bytes: &[u8]) -> String {
43 let full = B3Hash::digest(bytes).to_string();
44 full.get(..16).unwrap_or(&full).to_owned()
45}
46
47const CREATES: &str = "new";
51
52impl EditTool {
53 pub const NAME: &'static str = "edit";
55
56 pub fn new(
57 city_root: &Path,
58 domain: kernel::Address,
59 writable: kernel::WriteDomain,
60 limit: kernel::WriteLimit,
61 ) -> Result<EditTool, AxError> {
62 let params = Payload::of(&serde_json::json!({
63 "type": "object",
64 "properties": {
65 "path": { "type": "string",
66 "description": "file to edit, relative to the city root" },
67 "base_version": { "type": "string", "description":
68 "version you last saw; refuses if it moved. Pass \"new\" to create the file" },
69 "old": { "type": "string", "description":
70 "exact text to replace; must match exactly once. \"\" when creating" },
71 "new": { "type": "string",
72 "description": "replacement text, or the whole file when creating" },
73 },
74 "required": ["path", "base_version", "old", "new"],
75 }))?;
76 Ok(EditTool {
77 city_root: city_root.to_path_buf(),
78 writable,
79 limit,
80 meta: ToolMeta {
81 name: ToolName::parse(Self::NAME)?,
82 disclosure: format!(
83 "Replace an exact string in a file, guarded by the version you last saw: \
84 `old` must match exactly once or the call fails. A file that moved under \
85 you is refused and not overwritten; read it again and retry. A \
86 `base_version` of `new` creates the file.{}",
87 limited_by(limit)
88 ),
89 params,
90 effect: Effect::Write { domain },
91 cost_tier: CostTier::Light,
92 timeout: None,
93 render: RenderIntent::Diff {
94 locations: Vec::new(),
95 },
96 temporal: Temporal::Timeless,
97 },
98 })
99 }
100}
101
102fn limited_by(limit: kernel::WriteLimit) -> &'static str {
104 match limit {
105 kernel::WriteLimit::Full => "",
106 kernel::WriteLimit::Create => {
107 " This run creates new files and changes none: only a `base_version` of `new` \
108 is accepted, on a path where no file stands yet."
109 }
110 }
111}
112
113fn arg<'a>(args: &'a Map<String, Value>, key: &str) -> Result<&'a str, AxError> {
114 args.get(key).and_then(Value::as_str).ok_or_else(|| {
115 AxError::failure(
116 AxCode::InvalidArgs,
117 "edit file",
118 format!("missing string argument `{key}`"),
119 )
120 .with_recovery("pass path, base_version, old and new - all strings")
121 })
122}
123
124impl Tool for EditTool {
125 fn meta(&self) -> &ToolMeta {
126 &self.meta
127 }
128
129 fn writes(&self, call: &ToolCall) -> kernel::Writes {
134 match arg(call.args.as_map(), "path")
135 .and_then(|asked| super::chosen_path::within_city(&self.city_root, asked, "edit file"))
136 .and_then(|spelled| kernel::Address::parse(&spelled))
137 {
138 Ok(path) => kernel::Writes::Paths(vec![path]),
139 Err(_) => kernel::Writes::Domain,
140 }
141 }
142
143 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
144 if call.name != self.meta.name {
145 return Err(AxError::failure(
146 AxCode::InvalidArgs,
147 "edit file",
148 format!("call routed to the wrong tool: {}", call.name.as_str()),
149 )
150 .with_recovery(format!(
151 "call `{}`, the name this tool answers to",
152 self.meta.name.as_str()
153 )));
154 }
155 let args = call.args.as_map();
156 let spelled =
157 super::chosen_path::within_city(&self.city_root, arg(args, "path")?, "edit file")?;
158 let rel: &str = &spelled;
159 let base_version = arg(args, "base_version")?;
160 let old = arg(args, "old")?;
161 let new = arg(args, "new")?;
162
163 let target = kernel::Address::parse(rel).map_err(|err| {
170 AxError::failure(
171 AxCode::InvalidArgs,
172 "edit file",
173 format!("{rel}: {}", err.subject()),
174 )
175 .with_recovery("pass a city-relative path with no `..` and no leading slash")
176 })?;
177 match kernel::gate::domain(&self.writable, &target, &kernel::TaintSet::empty()) {
178 kernel::GateOutcome::Allow => {}
179 kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
182 kernel::GateOutcome::Ask { question } => return Err(*question),
183 }
184
185 let path = self.city_root.join(rel);
186 if base_version == CREATES {
187 return self.create(rel, &path, old, new);
188 }
189 match kernel::gate::replacing(self.limit, &target) {
191 kernel::GateOutcome::Allow => {}
192 kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
193 kernel::GateOutcome::Ask { question } => return Err(*question),
194 }
195 let bytes = std::fs::read(&path).map_err(|err| {
196 AxError::failure(AxCode::InvalidArgs, "edit file", format!("{rel}: {err}"))
197 .with_recovery(
198 "check the path, relative to the city root; to create a new file pass \
199 base_version:\"new\"",
200 )
201 })?;
202 let current_version = version_of(&bytes);
203 if current_version != base_version {
204 return Err(AxError::failure(
205 AxCode::VersionConflict,
206 "edit file",
207 format!("{rel} is at {current_version}, not {base_version}"),
208 )
209 .with_recovery("read the file again, then edit against the version you just saw"));
210 }
211 let text = String::from_utf8(bytes).map_err(|_| {
212 AxError::failure(
213 AxCode::InvalidArgs,
214 "edit file",
215 format!("{rel} is not valid UTF-8"),
216 )
217 .with_recovery(format!(
218 "edit {rel} in a program that reads its bytes; this tool changes text \
219 files and this one is not text"
220 ))
221 })?;
222 let hits = text.matches(old).count();
223 if hits != 1 {
224 return Err(AxError::failure(
225 AxCode::InvalidArgs,
226 "edit file",
227 format!("`old` matches {hits} times in {rel}, not once"),
228 )
229 .with_recovery("include enough surrounding text to name exactly one occurrence"));
230 }
231 let updated = text.replacen(old, new, 1);
232 storage::WriteTarget::within("edit file", &self.city_root, &path)
239 .and_then(|cleared| cleared.replace(updated.as_bytes()))
240 .map_err(storage::StorageError::into_ax)?;
241 let new_version = version_of(updated.as_bytes());
242
243 let mut result = Map::new();
244 result.insert("path".to_owned(), Value::String(rel.to_owned()));
245 result.insert(
246 "base_version".to_owned(),
247 Value::String(base_version.to_owned()),
248 );
249 result.insert("new_version".to_owned(), Value::String(new_version));
250 result.insert(
251 "diff".to_owned(),
252 Value::String(unified_diff(rel, &text, &updated)),
253 );
254 Ok(ToolOutcome {
255 result: Payload::new(result)?,
256 attachments: Vec::new(),
257 })
258 }
259}
260
261impl EditTool {
262 fn create(&self, rel: &str, path: &Path, old: &str, new: &str) -> Result<ToolOutcome, AxError> {
266 if !old.is_empty() {
267 return Err(AxError::failure(
268 AxCode::InvalidArgs,
269 "edit file",
270 "creating a file replaces nothing: `old` must be \"\"",
271 )
272 .with_recovery("pass old:\"\" and the whole file in `new`"));
273 }
274 storage::WriteTarget::within("edit file", &self.city_root, path)
279 .map_err(|err| err.into_ax())?;
280 if let Some(parent) = path.parent() {
281 std::fs::create_dir_all(parent).map_err(|err| {
282 AxError::failure(AxCode::StorageFatal, "edit file", format!("{rel}: {err}"))
283 .with_recovery(format!(
284 "free space on the disk holding the city, or remove the file \
285 standing where {rel}'s parent directory has to go"
286 ))
287 })?;
288 }
289 storage::WriteTarget::within("edit file", &self.city_root, path)
292 .and_then(|cleared| cleared.create(new.as_bytes()))
293 .map_err(|err| refused_create(err, rel, path))?;
294 let mut result = Map::new();
295 result.insert("path".to_owned(), Value::String(rel.to_owned()));
296 result.insert("base_version".to_owned(), Value::String(CREATES.to_owned()));
297 result.insert(
298 "new_version".to_owned(),
299 Value::String(version_of(new.as_bytes())),
300 );
301 result.insert("diff".to_owned(), Value::String(unified_diff(rel, "", new)));
302 Ok(ToolOutcome {
303 result: Payload::new(result)?,
304 attachments: Vec::new(),
305 })
306 }
307}
308
309fn refused_create(err: storage::StorageError, rel: &str, path: &Path) -> AxError {
312 if !matches!(err, storage::StorageError::NameTaken { .. }) {
313 return err.into_ax();
314 }
315 let standing = match std::fs::read(path) {
316 Ok(existing) => format!("{rel} already exists at {}, not new", version_of(&existing)),
317 Err(err) => format!("{rel} already exists, not new, and its version cannot be read: {err}"),
318 };
319 AxError::failure(AxCode::VersionConflict, "edit file", standing)
320 .with_recovery("read the file and edit against that version, or choose another path")
321}
322
323fn unified_diff(path: &str, before: &str, after: &str) -> String {
326 let old_lines: Vec<&str> = before.lines().collect();
327 let new_lines: Vec<&str> = after.lines().collect();
328 let prefix = common_prefix(&old_lines, &new_lines);
329 let suffix = common_suffix(&old_lines, &new_lines, prefix);
330 let old_end = old_lines.len().saturating_sub(suffix);
331 let new_end = new_lines.len().saturating_sub(suffix);
332
333 let mut out = format!("--- a/{path}\n+++ b/{path}\n");
334 let old_count = old_end.saturating_sub(prefix);
335 let new_count = new_end.saturating_sub(prefix);
336 out.push_str(&format!(
337 "@@ -{},{} +{},{} @@\n",
338 prefix.saturating_add(1),
339 old_count,
340 prefix.saturating_add(1),
341 new_count
342 ));
343 for line in old_lines.get(prefix..old_end).unwrap_or_default() {
344 out.push_str(&format!("-{line}\n"));
345 }
346 for line in new_lines.get(prefix..new_end).unwrap_or_default() {
347 out.push_str(&format!("+{line}\n"));
348 }
349 out
350}
351
352fn common_prefix(old: &[&str], new: &[&str]) -> usize {
353 let mut i = 0usize;
354 while let (Some(a), Some(b)) = (old.get(i), new.get(i)) {
355 if a != b {
356 break;
357 }
358 i = i.saturating_add(1);
359 }
360 i
361}
362
363fn common_suffix(old: &[&str], new: &[&str], prefix: usize) -> usize {
364 let mut i = 0usize;
365 loop {
366 let old_left = old.len().saturating_sub(i);
367 let new_left = new.len().saturating_sub(i);
368 if old_left <= prefix || new_left <= prefix {
369 break;
370 }
371 let (Some(a), Some(b)) = (
372 old.get(old_left.saturating_sub(1)),
373 new.get(new_left.saturating_sub(1)),
374 ) else {
375 break;
376 };
377 if a != b {
378 break;
379 }
380 i = i.saturating_add(1);
381 }
382 i
383}
384
385#[cfg(test)]
386#[allow(
387 clippy::unwrap_used,
388 clippy::expect_used,
389 clippy::panic,
390 clippy::indexing_slicing,
391 reason = "test code"
392)]
393mod tests;