pub mod approval;
pub mod sandbox;
#[cfg(feature = "local-tools")]
pub mod local;
#[cfg(feature = "e2b")]
pub mod e2b;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ToolInvocation {
pub id: String,
pub name: String,
pub input: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolOutcome {
pub output: Result<Value, ToolFailure>,
pub attachments: Vec<crate::model::UserAttachment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolFailureKind {
InvalidInput,
NotFound,
NonZeroExit,
Timeout,
Runtime,
Denied,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolFailure {
pub kind: ToolFailureKind,
pub message: String,
}
impl ToolFailure {
pub fn new(kind: ToolFailureKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
impl std::fmt::Display for ToolFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}: {}", self.kind, self.message)
}
}
pub fn invalid_input_failure(tool: &str, message: impl AsRef<str>, input: &Value) -> ToolFailure {
ToolFailure::new(
ToolFailureKind::InvalidInput,
format_invalid_input_message(tool, message.as_ref(), input),
)
}
pub fn format_invalid_input_message(tool: &str, detail: &str, input: &Value) -> String {
let received = received_fields(input);
let summaries = summarize_input_fields(input);
let mut message = format!(
"The {tool} tool was called with invalid arguments: {detail}. \
Please rewrite the input so it satisfies the expected schema."
);
if !received.is_empty() {
message.push_str(&format!(" Received fields: {}.", received.join(", ")));
}
if !summaries.is_empty() {
message.push_str(&format!(" Field summary: {}.", summaries.join("; ")));
}
message
}
fn received_fields(input: &Value) -> Vec<String> {
let Some(obj) = input.as_object() else {
return vec![json_type(input).to_string()];
};
let mut keys: Vec<String> = obj.keys().cloned().collect();
keys.sort();
keys
}
fn summarize_input_fields(input: &Value) -> Vec<String> {
let Some(obj) = input.as_object() else {
return vec![format!("input: {}", summarize_value(input))];
};
let mut entries: Vec<_> = obj.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
entries
.into_iter()
.take(12)
.map(|(key, value)| format!("{key}: {}", summarize_value(value)))
.collect()
}
fn summarize_value(value: &Value) -> String {
match value {
Value::String(s) => {
let preview: String = s.chars().take(80).collect();
let suffix = if s.chars().count() > 80 { "..." } else { "" };
format!("string({} chars, preview={:?}{suffix})", s.chars().count(), preview)
}
Value::Array(a) => format!("array({} items)", a.len()),
Value::Object(o) => format!("object({} keys)", o.len()),
Value::Bool(_) => "boolean".into(),
Value::Number(_) => "number".into(),
Value::Null => "null".into(),
}
}
fn json_type(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[derive(Debug, thiserror::Error)]
pub enum ToolRuntimeError {
#[error("unknown tool {0}")]
UnknownTool(String),
#[error("invalid input for {tool}: {message}")]
InvalidInput { tool: String, message: String },
#[error("tool timed out: {0}")]
Timeout(String),
#[error("tool runtime failed: {0}")]
Runtime(String),
}
#[async_trait]
pub trait ToolRuntime: Send + Sync {
fn specs(&self) -> Vec<ToolSpec>;
async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError>;
async fn invoke_cancellable(
&self,
invocation: ToolInvocation,
_cancel: Option<&tokio_util::sync::CancellationToken>,
) -> Result<ToolOutcome, ToolRuntimeError> {
self.invoke(invocation).await
}
}
#[derive(Debug, Default, Clone)]
pub struct MockToolRuntime {
files: Arc<Mutex<HashMap<String, String>>>,
}
impl MockToolRuntime {
pub fn new() -> Self {
Self::default()
}
pub fn with_file(self, path: impl Into<String>, content: impl Into<String>) -> Self {
self.files
.lock()
.unwrap()
.insert(path.into(), content.into());
self
}
}
#[async_trait]
impl ToolRuntime for MockToolRuntime {
fn specs(&self) -> Vec<ToolSpec> {
builtin_tool_specs()
}
async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
match invocation.name.as_str() {
"bash" => {
let command = required_str(&invocation, "command")?;
Ok(ToolOutcome {
output: Ok(json!({
"command": command,
"stdout": format!("mock executed: {command}\n"),
"stderr": "",
"exit_code": 0,
})),
attachments: vec![],
})
}
"read" => {
let path = required_str(&invocation, "path")?;
let files = self.files.lock().unwrap();
match files.get(path) {
Some(content) => Ok(ToolOutcome {
output: Ok(json!({"path": path, "content": content})),
attachments: vec![],
}),
None => Ok(ToolOutcome {
output: Err(ToolFailure::new(
ToolFailureKind::NotFound,
format!("file not found: {path}"),
)),
attachments: vec![],
}),
}
}
"write" => {
let path = required_str(&invocation, "path")?.to_string();
let content = required_str(&invocation, "content")?.to_string();
self.files.lock().unwrap().insert(path.clone(), content);
Ok(ToolOutcome {
output: Ok(json!({"path": path, "written": true})),
attachments: vec![],
})
}
"edit" => {
let path = required_str(&invocation, "path")?.to_string();
let old_string = required_str(&invocation, "old_string")?.to_string();
let new_string = invocation
.input
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let replace_all = invocation
.input
.get("replace_all")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut files = self.files.lock().unwrap();
let Some(content) = files.get(&path).cloned() else {
return Ok(ToolOutcome {
output: Err(ToolFailure::new(
ToolFailureKind::NotFound,
format!("file not found: {path}"),
)),
attachments: vec![],
});
};
let resolved = match resolve_edit_search(
&content,
&old_string,
&new_string,
replace_all,
) {
Ok(r) => r,
Err(e) => {
let message = match e {
EditSearchError::NotFound => {
format!("Could not find old_string in the file. It must match exactly, including whitespace and indentation. Read the file again before retrying.")
}
EditSearchError::EscapedNotFound => format!(
"Could not find old_string in the file, even after checking for JSON-escaped text. It must match exactly, including whitespace and indentation. Read the file again before retrying."
),
EditSearchError::Ambiguous { occurrences } => format!(
"Found {occurrences} exact matches for old_string. Provide more surrounding context or set replace_all=true."
),
EditSearchError::EscapedAmbiguous { occurrences } => format!(
"old_string appears JSON-escaped and matches {occurrences} occurrences after unescaping. Provide more surrounding context or set replace_all=true."
),
};
return Ok(ToolOutcome {
output: Err(ToolFailure::new(ToolFailureKind::InvalidInput, message)),
attachments: vec![],
});
}
};
let next = if replace_all {
content.replace(&resolved.old_string, &resolved.new_string)
} else {
content.replacen(&resolved.old_string, &resolved.new_string, 1)
};
let replaced = if replace_all { resolved.occurrences } else { 1 };
files.insert(path.clone(), next);
let mut result = json!({"path": path, "replaced": replaced});
if let Some(repair) = resolved.repair {
result["repair"] = json!(repair);
}
Ok(ToolOutcome {
output: Ok(result),
attachments: vec![],
})
}
"grep" => {
let pattern = required_str(&invocation, "pattern")?.to_string();
let case_insensitive = invocation
.input
.get("case_insensitive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let needle = if case_insensitive {
pattern.to_lowercase()
} else {
pattern.clone()
};
let files = self.files.lock().unwrap();
let mut matches = Vec::new();
for (path, content) in files.iter() {
for (idx, line) in content.lines().enumerate() {
let hay = if case_insensitive {
line.to_lowercase()
} else {
line.to_string()
};
if hay.contains(&needle) {
matches.push(json!({
"path": path,
"line": idx + 1,
"text": line,
}));
}
}
}
Ok(ToolOutcome {
output: Ok(json!({"pattern": pattern, "matches": matches})),
attachments: vec![],
})
}
"glob" => {
let pattern = required_str(&invocation, "pattern")?.to_string();
let files = self.files.lock().unwrap();
let matches: Vec<&str> = files
.keys()
.filter(|k| simple_glob_match(&pattern, k))
.map(|k| k.as_str())
.collect();
Ok(ToolOutcome {
output: Ok(json!({"pattern": pattern, "matches": matches})),
attachments: vec![],
})
}
other => Err(ToolRuntimeError::UnknownTool(other.into())),
}
}
}
#[derive(Debug)]
pub struct ResolvedEditSearch {
pub old_string: String,
pub new_string: String,
pub occurrences: usize,
pub repair: Option<&'static str>,
}
#[derive(Debug, PartialEq)]
pub enum EditSearchError {
NotFound,
EscapedNotFound,
Ambiguous { occurrences: usize },
EscapedAmbiguous { occurrences: usize },
}
pub fn resolve_edit_search(
content: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
) -> Result<ResolvedEditSearch, EditSearchError> {
let direct = content.matches(old_string).count();
if direct > 0 {
if !replace_all && direct > 1 {
return Err(EditSearchError::Ambiguous {
occurrences: direct,
});
}
return Ok(ResolvedEditSearch {
old_string: old_string.to_string(),
new_string: new_string.to_string(),
occurrences: direct,
repair: None,
});
}
if !has_literal_escaped_controls(old_string) {
return Err(EditSearchError::NotFound);
}
let unescaped_old = unescape_literal_controls(old_string);
if unescaped_old == old_string {
return Err(EditSearchError::NotFound);
}
let count = content.matches(&unescaped_old).count();
if count == 0 {
return Err(EditSearchError::EscapedNotFound);
}
if !replace_all && count > 1 {
return Err(EditSearchError::EscapedAmbiguous { occurrences: count });
}
Ok(ResolvedEditSearch {
old_string: unescaped_old,
new_string: unescape_literal_controls(new_string),
occurrences: count,
repair: Some("json_escape_unwrapped"),
})
}
fn has_literal_escaped_controls(s: &str) -> bool {
s.contains("\\n") || s.contains("\\t") || s.contains("\\r")
}
fn unescape_literal_controls(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
if bytes[i + 1] == b'r'
&& i + 3 < bytes.len()
&& bytes[i + 2] == b'\\'
&& bytes[i + 3] == b'n'
{
out.push(b'\n');
i += 4;
continue;
}
let replacement = match bytes[i + 1] {
b'n' => Some(b'\n'),
b'r' => Some(b'\r'),
b't' => Some(b'\t'),
_ => None,
};
if let Some(ch) = replacement {
out.push(ch);
i += 2;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
pub fn simple_glob_match(pattern: &str, candidate: &str) -> bool {
if pattern.contains('{') {
return expand_braces(pattern)
.iter()
.any(|p| simple_glob_match_single(p, candidate));
}
simple_glob_match_single(pattern, candidate)
}
const MAX_BRACE_EXPANSIONS: usize = 128;
fn expand_braces(pattern: &str) -> Vec<String> {
let chars: Vec<char> = pattern.chars().collect();
let Some(open) = chars.iter().position(|&c| c == '{') else {
return vec![pattern.to_string()];
};
let mut depth = 0usize;
let mut close = None;
for (i, &c) in chars.iter().enumerate().skip(open) {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
close = Some(i);
break;
}
}
_ => {}
}
}
let Some(close) = close else {
return vec![pattern.to_string()]; };
let prefix: String = chars[..open].iter().collect();
let suffix: String = chars[close + 1..].iter().collect();
let mut alts: Vec<String> = Vec::new();
let mut cur = String::new();
let mut d = 0usize;
for &c in &chars[open + 1..close] {
match c {
'{' => {
d += 1;
cur.push(c);
}
'}' => {
d -= 1;
cur.push(c);
}
',' if d == 0 => alts.push(std::mem::take(&mut cur)),
_ => cur.push(c),
}
}
alts.push(cur);
let mut out = Vec::new();
for alt in alts {
for expanded in expand_braces(&format!("{prefix}{alt}{suffix}")) {
out.push(expanded);
if out.len() >= MAX_BRACE_EXPANSIONS {
return out;
}
}
}
out
}
fn simple_glob_match_single(pattern: &str, candidate: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let cand: Vec<char> = candidate.chars().collect();
fn walk(pat: &[char], cand: &[char]) -> bool {
let mut p = 0usize;
let mut c = 0usize;
while p < pat.len() {
match pat[p] {
'*' if pat.get(p + 1) == Some(&'*') => {
let rest = &pat[p + 2..];
for end in c..=cand.len() {
if walk(rest, &cand[end..]) {
return true;
}
}
return false;
}
'*' => {
let rest = &pat[p + 1..];
while c <= cand.len() {
if walk(rest, &cand[c..]) {
return true;
}
if c == cand.len() || cand[c] == '/' {
return false;
}
c += 1;
}
return false;
}
'?' => {
if c >= cand.len() || cand[c] == '/' {
return false;
}
c += 1;
p += 1;
}
ch => {
if c >= cand.len() || cand[c] != ch {
return false;
}
c += 1;
p += 1;
}
}
}
c == cand.len()
}
walk(&pat, &cand)
}
pub const FS_GLOB_IGNORED_DIRS: &[&str] = &[
"node_modules",
"target",
".git",
"dist",
"build",
"vendor",
".next",
"__pycache__",
".venv",
];
pub const MAX_FS_GLOB_RESULTS: usize = 2000;
pub const MAX_OUTPUT_BYTES: usize = 50_000;
pub fn fs_glob(pattern: &str, base_dir: &std::path::Path) -> Vec<String> {
fs_glob_bounded(pattern, base_dir).0
}
pub fn fs_glob_bounded(pattern: &str, base_dir: &std::path::Path) -> (Vec<String>, bool) {
let mut matches = Vec::new();
let mut truncated = false;
let mut stack = vec![base_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
let rel = match path.strip_prefix(base_dir) {
Ok(r) => r.to_string_lossy().replace('\\', "/"),
Err(_) => continue,
};
let first = rel.split('/').next().unwrap_or("");
if first.starts_with('.') && !pattern.starts_with('.') {
continue;
}
if !path.is_symlink() && path.is_dir() {
let name = entry.file_name();
if FS_GLOB_IGNORED_DIRS.iter().any(|d| name.as_os_str() == *d) {
continue;
}
stack.push(path);
} else if !path.is_dir() && simple_glob_match(pattern, &rel) {
if matches.len() >= MAX_FS_GLOB_RESULTS {
truncated = true;
break;
}
matches.push(rel);
}
}
if truncated {
break;
}
}
matches.sort();
(matches, truncated)
}
pub fn builtin_tool_specs() -> Vec<ToolSpec> {
vec![
ToolSpec {
name: "bash".into(),
description: "Run a shell command inside the sandbox working directory. \
Returns structured command status + stdout/stderr, including non-zero \
exits and timeouts. Bounded by `timeout_ms` \
(default 120 000 ms, max 600 000 ms) — on timeout the process \
is terminated and any captured output is returned. For commands \
that may run longer than 10 min, use `nohup … &` writing to a \
file, then poll the file with the read tool across turns."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute. Local runtimes prefer /bin/bash -lc when available and fall back to /bin/sh -lc."
},
"timeout_ms": {
"type": "integer",
"description": "Optional timeout in milliseconds (default 120000, max 600000).",
"minimum": 1000,
"maximum": 600000
},
"soft_timeout_ms": {
"type": "integer",
"description": "Optional no-output timeout in milliseconds (default 10000). Streaming output resets this timer.",
"minimum": 1000,
"maximum": 600000
}
},
"required": ["command"],
"additionalProperties": false
}),
},
ToolSpec {
name: "read".into(),
description:
"Read a UTF-8 file from the sandbox. Paginated by line: returns up to `limit` \
lines starting at `offset` (a 0-based line index). When the result is \
`truncated`, read the next page with the returned `next_offset`. Overlong \
lines are clipped."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"offset": {
"type": "integer",
"description": "0-based line index to start from. Default 0.",
"minimum": 0
},
"limit": {
"type": "integer",
"description": "Max lines to return. Default 2000.",
"minimum": 1
}
},
"required": ["path"],
"additionalProperties": false
}),
},
ToolSpec {
name: "write".into(),
description: "Write UTF-8 content to a file in the sandbox.".into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"}
},
"required": ["path", "content"],
"additionalProperties": false
}),
},
ToolSpec {
name: "edit".into(),
description:
"Edit a UTF-8 file by replacing an exact substring. By default `old_string` must \
appear exactly once; set `replace_all=true` to substitute every occurrence."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"old_string": {
"type": "string",
"description": "Substring to replace; must match verbatim including whitespace."
},
"new_string": {
"type": "string",
"description": "Replacement text. Empty string deletes the match."
},
"replace_all": {
"type": "boolean",
"description": "When true, replace every occurrence. Default false (must be unique)."
}
},
"required": ["path", "old_string", "new_string"],
"additionalProperties": false
}),
},
ToolSpec {
name: "grep".into(),
description:
"Search file contents under a path using `grep -rnE` (extended regex). Returns \
matching lines as `path:line:text`. Dependency and build directories \
(node_modules, target, …) are skipped; the match count is capped and overlong \
lines are clipped — a `truncated` flag signals when to narrow the pattern or path."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression to search for (passed to grep)."
},
"path": {
"type": "string",
"description": "Directory or file to search under. Default: current cwd."
},
"case_insensitive": {
"type": "boolean",
"description": "When true, pass -i to grep. Default false."
}
},
"required": ["pattern"],
"additionalProperties": false
}),
},
ToolSpec {
name: "glob".into(),
description:
"Find files matching a shell-style name pattern (e.g. `*.rs`). Returns relative \
paths under the search root, one per line. Dependency and build directories \
(node_modules, target, dist, …) are skipped, and the result count is capped — \
a `truncated` flag signals when to narrow the pattern or search a subdirectory."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Shell glob like `*.rs` or `**/Cargo.toml`."
},
"path": {
"type": "string",
"description": "Directory to search under. Default: current cwd."
}
},
"required": ["pattern"],
"additionalProperties": false
}),
},
]
}
fn required_str<'a>(
invocation: &'a ToolInvocation,
key: &str,
) -> Result<&'a str, ToolRuntimeError> {
invocation
.input
.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolRuntimeError::InvalidInput {
tool: invocation.name.clone(),
message: format!("missing string field {key}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_glob_matches_star_and_doublestar() {
assert!(simple_glob_match("*.rs", "main.rs"));
assert!(!simple_glob_match("*.rs", "main.rs.bak"));
assert!(!simple_glob_match("*.rs", "src/main.rs"));
assert!(simple_glob_match("**/*.rs", "src/main.rs"));
assert!(simple_glob_match("**/*.rs", "a/b/c.rs"));
assert!(simple_glob_match("Cargo.toml", "Cargo.toml"));
assert!(!simple_glob_match("Cargo.toml", "Cargo.lock"));
}
#[test]
fn simple_glob_matches_brace_alternation() {
assert!(simple_glob_match("**/*.{ts,tsx}", "src/main.ts"));
assert!(simple_glob_match("**/*.{ts,tsx}", "src/components/App.tsx"));
assert!(!simple_glob_match("**/*.{ts,tsx}", "src/main.rs"));
assert!(simple_glob_match("{src,lib}/*.{ts,js}", "lib/util.js"));
assert!(!simple_glob_match("{src,lib}/*.{ts,js}", "bin/util.js"));
assert!(simple_glob_match("*.{t{s,sx}}", "x.tsx"));
assert!(simple_glob_match("*.{t{s,sx}}", "x.ts"));
assert!(simple_glob_match("*.{rs}", "main.rs"));
assert!(simple_glob_match("a{,b}c", "ac"));
assert!(simple_glob_match("a{,b}c", "abc"));
assert!(simple_glob_match("a{b", "a{b"));
assert!(!simple_glob_match("a{b", "ab"));
}
#[test]
fn expand_braces_caps_pathological_patterns() {
let pat = "{a,b,c,d}{a,b,c,d}{a,b,c,d}{a,b,c,d}";
assert_eq!(expand_braces(pat).len(), MAX_BRACE_EXPANSIONS);
}
#[tokio::test]
async fn mock_runtime_edit_replaces_unique_substring() {
let rt = MockToolRuntime::new().with_file("a.txt", "hello world");
let out = rt
.invoke(ToolInvocation {
id: "tc_edit".into(),
name: "edit".into(),
input: json!({
"path": "a.txt",
"old_string": "world",
"new_string": "rust",
}),
})
.await
.unwrap()
.output
.unwrap();
assert_eq!(out["replaced"], 1);
let after = rt
.invoke(ToolInvocation {
id: "tc_read".into(),
name: "read".into(),
input: json!({"path": "a.txt"}),
})
.await
.unwrap()
.output
.unwrap();
assert_eq!(after["content"], "hello rust");
}
#[tokio::test]
async fn mock_runtime_edit_rejects_ambiguous_match() {
let rt = MockToolRuntime::new().with_file("a.txt", "foo foo");
let failure = rt
.invoke(ToolInvocation {
id: "tc_edit".into(),
name: "edit".into(),
input: json!({"path": "a.txt", "old_string": "foo", "new_string": "bar"}),
})
.await
.unwrap()
.output
.unwrap_err();
assert_eq!(failure.kind, ToolFailureKind::InvalidInput);
}
#[test]
fn unescape_literal_controls_handles_sequences() {
assert_eq!(unescape_literal_controls(r"a\nb"), "a\nb");
assert_eq!(unescape_literal_controls(r"a\tb"), "a\tb");
assert_eq!(unescape_literal_controls(r"a\rb"), "a\rb");
assert_eq!(unescape_literal_controls(r"a\r\nb"), "a\nb");
assert_eq!(unescape_literal_controls(r"a\\nb"), "a\\\nb");
assert_eq!(unescape_literal_controls("plain"), "plain");
}
#[test]
fn resolve_edit_search_prefers_direct_match() {
let r = resolve_edit_search("say \\n here", r"\n", "x", false).unwrap();
assert!(r.repair.is_none());
assert_eq!(r.old_string, r"\n");
}
#[test]
fn resolve_edit_search_unescapes_literal_controls() {
let r = resolve_edit_search("line1\nline2", r"line1\nline2", r"a\tb", false).unwrap();
assert_eq!(r.repair, Some("json_escape_unwrapped"));
assert_eq!(r.old_string, "line1\nline2");
assert_eq!(r.new_string, "a\tb"); assert_eq!(r.occurrences, 1);
}
#[test]
fn resolve_edit_search_escaped_not_found() {
assert_eq!(
resolve_edit_search("other", r"line1\nline2", "x", false).unwrap_err(),
EditSearchError::EscapedNotFound
);
}
#[test]
fn resolve_edit_search_escaped_ambiguous_without_replace_all() {
let content = "a\nb a\nb";
assert_eq!(
resolve_edit_search(content, r"a\nb", "x", false).unwrap_err(),
EditSearchError::EscapedAmbiguous { occurrences: 2 }
);
let r = resolve_edit_search(content, r"a\nb", "x", true).unwrap();
assert_eq!(r.occurrences, 2);
assert_eq!(r.repair, Some("json_escape_unwrapped"));
}
#[tokio::test]
async fn mock_runtime_edit_repairs_json_escaped_old_string() {
let rt = MockToolRuntime::new().with_file("a.txt", "line1\nline2\nline3");
let out = rt
.invoke(ToolInvocation {
id: "tc_edit".into(),
name: "edit".into(),
input: json!({"path": "a.txt", "old_string": "line1\\nline2", "new_string": "merged"}),
})
.await
.unwrap()
.output
.unwrap();
assert_eq!(out["replaced"], 1);
assert_eq!(out["repair"], "json_escape_unwrapped");
let after = rt
.invoke(ToolInvocation {
id: "tc_read".into(),
name: "read".into(),
input: json!({"path": "a.txt"}),
})
.await
.unwrap()
.output
.unwrap();
assert_eq!(after["content"], "merged\nline3");
}
#[tokio::test]
async fn mock_runtime_grep_finds_matches() {
let rt = MockToolRuntime::new()
.with_file("a.txt", "alpha\nbeta\nALPHA")
.with_file("b.txt", "gamma");
let out = rt
.invoke(ToolInvocation {
id: "tc_grep".into(),
name: "grep".into(),
input: json!({"pattern": "alpha", "case_insensitive": true}),
})
.await
.unwrap()
.output
.unwrap();
let matches = out["matches"].as_array().unwrap();
assert_eq!(matches.len(), 2);
}
#[tokio::test]
async fn mock_runtime_glob_matches_by_pattern() {
let rt = MockToolRuntime::new()
.with_file("src/main.rs", "")
.with_file("src/lib.rs", "")
.with_file("Cargo.toml", "");
let out = rt
.invoke(ToolInvocation {
id: "tc_glob".into(),
name: "glob".into(),
input: json!({"pattern": "**/*.rs"}),
})
.await
.unwrap()
.output
.unwrap();
let matches = out["matches"].as_array().unwrap();
assert_eq!(matches.len(), 2);
}
#[tokio::test]
async fn mock_runtime_supports_bash_read_write() {
let rt = MockToolRuntime::new().with_file("README.md", "hello");
let read = rt
.invoke(ToolInvocation {
id: "tc_read".into(),
name: "read".into(),
input: json!({"path":"README.md"}),
})
.await
.unwrap();
assert_eq!(read.output.unwrap()["content"], "hello");
let write = rt
.invoke(ToolInvocation {
id: "tc_write".into(),
name: "write".into(),
input: json!({"path":"out.txt", "content":"ok"}),
})
.await
.unwrap();
assert_eq!(write.output.unwrap()["written"], true);
let bash = rt
.invoke(ToolInvocation {
id: "tc_bash".into(),
name: "bash".into(),
input: json!({"command":"pwd"}),
})
.await
.unwrap();
assert_eq!(bash.output.unwrap()["exit_code"], 0);
}
#[test]
fn fs_glob_prunes_dependency_dirs() {
use std::fs;
let root = std::env::temp_dir().join(format!("harness_fsglob_{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for sub in ["src", "node_modules/dep", "target/debug"] {
fs::create_dir_all(root.join(sub)).unwrap();
}
fs::write(root.join("keep.rs"), "").unwrap();
fs::write(root.join("src/lib.rs"), "").unwrap();
fs::write(root.join("node_modules/dep/skip.rs"), "").unwrap();
fs::write(root.join("target/debug/skip.rs"), "").unwrap();
let (matches, truncated) = fs_glob_bounded("**.rs", &root);
let _ = fs::remove_dir_all(&root);
assert!(!truncated);
assert!(matches.iter().any(|m| m == "keep.rs"), "{matches:?}");
assert!(matches.iter().any(|m| m == "src/lib.rs"), "{matches:?}");
assert!(
!matches
.iter()
.any(|m| m.contains("node_modules") || m.contains("target")),
"pruned dirs leaked into results: {matches:?}"
);
}
}