use std::path::{Path, PathBuf};
use kernel::{
AxCode, AxError, B3Hash, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall,
ToolMeta, ToolName, ToolOutcome,
};
use serde_json::{Map, Value};
pub struct EditTool {
city_root: PathBuf,
writable: kernel::WriteDomain,
policy: crate::mode::PolicyReader,
meta: ToolMeta,
}
pub fn version_of(bytes: &[u8]) -> String {
kernel::Locator::cas(B3Hash::digest(bytes)).to_string()
}
const CREATES: &str = "new";
impl EditTool {
pub const NAME: &'static str = "edit";
pub fn new(
city_root: &Path,
domain: kernel::Address,
writable: kernel::WriteDomain,
policy: crate::mode::PolicyReader,
) -> Result<EditTool, AxError> {
let params = Payload::of(&serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string",
"description": "file to edit, relative to the city root" },
"base_version": { "type": "string", "description":
"the `version` read printed for this file; refuses if it moved. Pass \"new\" to create the file" },
"old": { "type": "string", "description":
"exact text to replace; must match exactly once. \"\" when creating" },
"new": { "type": "string",
"description": "replacement text, or the whole file when creating" },
},
"required": ["path", "base_version", "old", "new"],
}))?;
Ok(EditTool {
city_root: city_root.to_path_buf(),
writable,
policy,
meta: ToolMeta {
name: ToolName::parse(Self::NAME)?,
disclosure: "Replace an exact string in a file, guarded by the version you \
last saw: `old` must match exactly once or the call fails. A file \
that moved under you is refused and not overwritten; read it \
again and retry. A `base_version` of `new` creates the file."
.to_owned(),
params,
effect: Effect::Write { domain },
cost_tier: CostTier::Light,
timeout: None,
render: RenderIntent::Diff {
locations: Vec::new(),
},
temporal: Temporal::Timeless,
},
})
}
}
fn arg<'a>(args: &'a Map<String, Value>, key: &str) -> Result<&'a str, AxError> {
args.get(key).and_then(Value::as_str).ok_or_else(|| {
AxError::failure(
AxCode::InvalidArgs,
"edit file",
format!("missing string argument `{key}`"),
)
.with_recovery("pass path, base_version, old and new - all strings")
})
}
impl Tool for EditTool {
fn meta(&self) -> &ToolMeta {
&self.meta
}
fn writes(&self, call: &ToolCall) -> kernel::Writes {
match arg(call.args.as_map(), "path")
.and_then(|asked| super::chosen_path::within_city(&self.city_root, asked, "edit file"))
.and_then(|spelled| kernel::Address::parse(&spelled))
{
Ok(path) => kernel::Writes::Paths(vec![path]),
Err(_) => kernel::Writes::Domain,
}
}
fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
if call.name != self.meta.name {
return Err(AxError::failure(
AxCode::InvalidArgs,
"edit file",
format!("call routed to the wrong tool: {}", call.name.as_str()),
)
.with_recovery(format!(
"call `{}`, the name this tool answers to",
self.meta.name.as_str()
)));
}
let policy = self.policy.now();
crate::mode::mode_admits(&policy, Self::NAME)?;
let args = call.args.as_map();
let spelled =
super::chosen_path::within_city(&self.city_root, arg(args, "path")?, "edit file")?;
let rel: &str = &spelled;
let base_version = arg(args, "base_version")?;
let old = arg(args, "old")?;
let new = arg(args, "new")?;
let target = kernel::Address::parse(rel).map_err(|err| {
AxError::failure(
AxCode::InvalidArgs,
"edit file",
format!("{rel}: {}", err.subject()),
)
.with_recovery("pass a city-relative path with no `..` and no leading slash")
})?;
match kernel::gate::domain(&self.writable, &target, &kernel::TaintSet::empty()) {
kernel::GateOutcome::Allow => {}
kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
kernel::GateOutcome::Ask { question } => return Err(*question),
}
let path = self.city_root.join(rel);
if base_version == CREATES {
return self.create(rel, &path, old, new);
}
match kernel::gate::replacing(policy.write, &target) {
kernel::GateOutcome::Allow => {}
kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
kernel::GateOutcome::Ask { question } => return Err(*question),
}
let bytes = std::fs::read(&path).map_err(|err| {
AxError::failure(AxCode::InvalidArgs, "edit file", format!("{rel}: {err}"))
.with_recovery(
"check the path, relative to the city root; to create a new file pass \
base_version:\"new\"",
)
})?;
let current_version = version_of(&bytes);
if current_version != base_version {
return Err(AxError::failure(
AxCode::VersionConflict,
"edit file",
format!("{rel} is at {current_version}, not {base_version}"),
)
.with_recovery("read the file again, then edit against the version you just saw"));
}
let text = String::from_utf8(bytes).map_err(|_| {
AxError::failure(
AxCode::InvalidArgs,
"edit file",
format!("{rel} is not valid UTF-8"),
)
.with_recovery(format!(
"edit {rel} in a program that reads its bytes; this tool changes text \
files and this one is not text"
))
})?;
let hits = text.matches(old).count();
if hits != 1 {
return Err(AxError::failure(
AxCode::InvalidArgs,
"edit file",
format!("`old` matches {hits} times in {rel}, not once"),
)
.with_recovery("include enough surrounding text to name exactly one occurrence"));
}
let updated = text.replacen(old, new, 1);
storage::WriteTarget::within("edit file", &self.city_root, &path)
.and_then(|cleared| cleared.replace(updated.as_bytes()))
.map_err(storage::StorageError::into_ax)?;
let new_version = version_of(updated.as_bytes());
let mut result = Map::new();
result.insert("path".to_owned(), Value::String(rel.to_owned()));
result.insert(
"base_version".to_owned(),
Value::String(base_version.to_owned()),
);
result.insert("new_version".to_owned(), Value::String(new_version));
result.insert(
"diff".to_owned(),
Value::String(unified_diff(rel, &text, &updated)),
);
Ok(ToolOutcome {
result: Payload::new(result)?,
attachments: Vec::new(),
})
}
}
impl EditTool {
fn create(&self, rel: &str, path: &Path, old: &str, new: &str) -> Result<ToolOutcome, AxError> {
if !old.is_empty() {
return Err(AxError::failure(
AxCode::InvalidArgs,
"edit file",
"creating a file replaces nothing: `old` must be \"\"",
)
.with_recovery("pass old:\"\" and the whole file in `new`"));
}
storage::WriteTarget::within("edit file", &self.city_root, path)
.map_err(|err| err.into_ax())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
AxError::failure(AxCode::StorageFatal, "edit file", format!("{rel}: {err}"))
.with_recovery(format!(
"free space on the disk holding the city, or remove the file \
standing where {rel}'s parent directory has to go"
))
})?;
}
storage::WriteTarget::within("edit file", &self.city_root, path)
.and_then(|cleared| cleared.create(new.as_bytes()))
.map_err(|err| refused_create(err, rel, path))?;
let mut result = Map::new();
result.insert("path".to_owned(), Value::String(rel.to_owned()));
result.insert("base_version".to_owned(), Value::String(CREATES.to_owned()));
result.insert(
"new_version".to_owned(),
Value::String(version_of(new.as_bytes())),
);
result.insert("diff".to_owned(), Value::String(unified_diff(rel, "", new)));
Ok(ToolOutcome {
result: Payload::new(result)?,
attachments: Vec::new(),
})
}
}
fn refused_create(err: storage::StorageError, rel: &str, path: &Path) -> AxError {
if !matches!(err, storage::StorageError::NameTaken { .. }) {
return err.into_ax();
}
let standing = match std::fs::read(path) {
Ok(existing) => format!("{rel} already exists at {}, not new", version_of(&existing)),
Err(err) => format!("{rel} already exists, not new, and its version cannot be read: {err}"),
};
AxError::failure(AxCode::VersionConflict, "edit file", standing)
.with_recovery("read the file and edit against that version, or choose another path")
}
fn unified_diff(path: &str, before: &str, after: &str) -> String {
let old_lines: Vec<&str> = before.lines().collect();
let new_lines: Vec<&str> = after.lines().collect();
let prefix = common_prefix(&old_lines, &new_lines);
let suffix = common_suffix(&old_lines, &new_lines, prefix);
let old_end = old_lines.len().saturating_sub(suffix);
let new_end = new_lines.len().saturating_sub(suffix);
let mut out = format!("--- a/{path}\n+++ b/{path}\n");
let old_count = old_end.saturating_sub(prefix);
let new_count = new_end.saturating_sub(prefix);
out.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
prefix.saturating_add(1),
old_count,
prefix.saturating_add(1),
new_count
));
for line in old_lines.get(prefix..old_end).unwrap_or_default() {
out.push_str(&format!("-{line}\n"));
}
for line in new_lines.get(prefix..new_end).unwrap_or_default() {
out.push_str(&format!("+{line}\n"));
}
out
}
fn common_prefix(old: &[&str], new: &[&str]) -> usize {
let mut i = 0usize;
while let (Some(a), Some(b)) = (old.get(i), new.get(i)) {
if a != b {
break;
}
i = i.saturating_add(1);
}
i
}
fn common_suffix(old: &[&str], new: &[&str], prefix: usize) -> usize {
let mut i = 0usize;
loop {
let old_left = old.len().saturating_sub(i);
let new_left = new.len().saturating_sub(i);
if old_left <= prefix || new_left <= prefix {
break;
}
let (Some(a), Some(b)) = (
old.get(old_left.saturating_sub(1)),
new.get(new_left.saturating_sub(1)),
) else {
break;
};
if a != b {
break;
}
i = i.saturating_add(1);
}
i
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "test code"
)]
mod tests;