use std::path::{Path, PathBuf};
use polyc_llm::ToolSpec;
use serde_json::{Value, json};
use super::{err, truncate_to_bytes, workspace};
const MAX_READ_BYTES: usize = 100_000;
fn require_str(args: &Value, key: &str) -> Result<String, String> {
args.get(key)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| format!("`{key}` (string) is required"))
}
fn resolved_path(root: &Path, args_json: &str) -> Result<(String, PathBuf), String> {
let args = serde_json::from_str::<Value>(args_json)
.map_err(|_| "arguments must be a JSON object".to_owned())?;
let path = require_str(&args, "path")?;
let resolved = workspace::resolve(root, &path)?;
Ok((path, resolved))
}
#[must_use]
pub(super) fn read_spec() -> ToolSpec {
ToolSpec::new(
"file_read",
"Read a UTF-8 text file from the workspace and return its contents. \
Paths are relative to the workspace root.",
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Workspace-relative file path." }
},
"required": ["path"],
"additionalProperties": false
}),
)
.titled("Read a file")
.read_only()
.cacheable_approval()
}
#[must_use]
pub(super) fn write_spec() -> ToolSpec {
ToolSpec::new(
"file_write",
"Create or overwrite a UTF-8 text file in the workspace (parent \
directories are created as needed).",
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Workspace-relative file path." },
"content": { "type": "string", "description": "Full file contents to write." }
},
"required": ["path", "content"],
"additionalProperties": false
}),
)
.titled("Write a file")
.destructive()
}
#[must_use]
pub(super) fn edit_spec() -> ToolSpec {
ToolSpec::new(
"file_edit",
"Replace an exact string in a workspace file. By default the old string \
must occur exactly once; set replace_all to replace every occurrence.",
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Workspace-relative file path." },
"old_string": { "type": "string", "description": "Exact text to replace." },
"new_string": { "type": "string", "description": "Replacement text." },
"replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)." }
},
"required": ["path", "old_string", "new_string"],
"additionalProperties": false
}),
)
.titled("Edit a file")
.destructive()
}
async fn blocking<F: FnOnce() -> String + Send + 'static>(f: F) -> String {
tokio::task::spawn_blocking(f)
.await
.unwrap_or_else(|_| err("filesystem task failed"))
}
pub(super) async fn read(root: &Path, args_json: &str) -> String {
let (path, resolved) = match resolved_path(root, args_json) {
Ok(p) => p,
Err(e) => return err(e),
};
blocking(move || match std::fs::read_to_string(&resolved) {
Ok(content) => {
let (body, truncated) = truncate_to_bytes(&content, MAX_READ_BYTES);
json!({ "path": path, "content": body, "truncated": truncated }).to_string()
}
Err(e) => err(format!("read `{path}`: {e}")),
})
.await
}
pub(super) async fn write(root: &Path, args_json: &str) -> String {
let Ok(args) = serde_json::from_str::<Value>(args_json) else {
return err("arguments must be a JSON object");
};
let (path, resolved) = match resolved_path(root, args_json) {
Ok(p) => p,
Err(e) => return err(e),
};
let content = match require_str(&args, "content") {
Ok(c) => c,
Err(e) => return err(e),
};
blocking(move || {
if let Some(parent) = resolved.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return err(format!("create parent of `{path}`: {e}"));
}
match std::fs::write(&resolved, content.as_bytes()) {
Ok(()) => json!({ "path": path, "bytes_written": content.len() }).to_string(),
Err(e) => err(format!("write `{path}`: {e}")),
}
})
.await
}
pub(super) async fn edit(root: &Path, args_json: &str) -> String {
let Ok(args) = serde_json::from_str::<Value>(args_json) else {
return err("arguments must be a JSON object");
};
let (path, resolved) = match resolved_path(root, args_json) {
Ok(p) => p,
Err(e) => return err(e),
};
let old = match require_str(&args, "old_string") {
Ok(s) => s,
Err(e) => return err(e),
};
let new = match require_str(&args, "new_string") {
Ok(s) => s,
Err(e) => return err(e),
};
let replace_all = args
.get("replace_all")
.and_then(Value::as_bool)
.unwrap_or(false);
blocking(move || {
let content = match std::fs::read_to_string(&resolved) {
Ok(c) => c,
Err(e) => return err(format!("read `{path}`: {e}")),
};
let count = content.matches(&old).count();
if count == 0 {
return err(format!("`old_string` not found in `{path}`"));
}
if count > 1 && !replace_all {
return err(format!(
"`old_string` occurs {count} times in `{path}`; pass replace_all=true or \
provide a more specific string"
));
}
let updated = if replace_all {
content.replace(&old, &new)
} else {
content.replacen(&old, &new, 1)
};
match std::fs::write(&resolved, updated.as_bytes()) {
Ok(()) => json!({ "path": path, "replacements": count }).to_string(),
Err(e) => err(format!("write `{path}`: {e}")),
}
})
.await
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn tmp_root() -> std::path::PathBuf {
super::super::tmp_dir("coding-test")
}
#[tokio::test]
async fn write_then_read_round_trips() {
let root = tmp_root();
let w = write(&root, r#"{"path":"a/b.txt","content":"hello"}"#).await;
assert!(w.contains("bytes_written"), "{w}");
let r = read(&root, r#"{"path":"a/b.txt"}"#).await;
let v: Value = serde_json::from_str(&r).unwrap();
assert_eq!(v["content"], "hello");
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn edit_requires_unique_match_unless_replace_all() {
let root = tmp_root();
write(&root, r#"{"path":"f.txt","content":"x x x"}"#).await;
let one = edit(
&root,
r#"{"path":"f.txt","old_string":"x","new_string":"y"}"#,
)
.await;
assert!(one.contains("error"), "ambiguous edit must error: {one}");
let all = edit(
&root,
r#"{"path":"f.txt","old_string":"x","new_string":"y","replace_all":true}"#,
)
.await;
assert!(all.contains("replacements"), "{all}");
let r = read(&root, r#"{"path":"f.txt"}"#).await;
assert!(serde_json::from_str::<Value>(&r).unwrap()["content"] == "y y y");
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn paths_outside_workspace_are_rejected() {
let root = tmp_root();
let r = read(&root, r#"{"path":"../../../etc/passwd"}"#).await;
assert!(r.contains("error"), "traversal must be rejected: {r}");
let w = write(&root, r#"{"path":"/etc/evil","content":"x"}"#).await;
assert!(w.contains("error"), "absolute path must be rejected: {w}");
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn read_truncates_on_byte_budget() {
let root = tmp_root();
let big = "a".repeat(MAX_READ_BYTES + 10);
write(
&root,
&json!({ "path": "big.txt", "content": big }).to_string(),
)
.await;
let r = read(&root, r#"{"path":"big.txt"}"#).await;
let v: Value = serde_json::from_str(&r).unwrap();
assert_eq!(v["truncated"], true, "{r:.80}");
assert!(v["content"].as_str().unwrap().len() <= MAX_READ_BYTES);
std::fs::remove_dir_all(&root).ok();
}
}