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