use std::path::{Path, PathBuf};
use async_trait::async_trait;
use crate::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
use super::super::ctx::{ExecContext, ProgressEvent};
use super::ToolExecutor;
use super::path_safety::{AllowedRoots, ResolvedInRoot, resolve_in_roots};
fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
ToolDefinition {
name: name.to_string(),
description: description.to_string(),
input_schema,
}
}
const MAX_READ_AGGREGATE_CHARS: usize = crate::constants::MAX_RESPONSE_CHARS;
pub struct ReadFileTool;
#[async_trait]
impl ToolExecutor for ReadFileTool {
fn name(&self) -> &'static str {
"read_file"
}
fn schema(&self) -> ToolDefinition {
defn(
"read_file",
"Read the contents of one or more files from disk. Prefer relative paths; absolute paths must resolve inside the project directory or the session scratchpad or the call is rejected.",
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File to read (single)." },
"paths": {
"type": "array",
"items": { "type": "string" },
"description": "Multiple files to read sequentially, in order."
}
},
"oneOf": [
{ "required": ["path"] },
{ "required": ["paths"] }
]
}),
)
}
async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
let paths = match extract_paths(&args) {
Ok(p) => p,
Err(e) => return ToolOutcome::error(e, 0.0),
};
if paths.is_empty() {
return ToolOutcome::error("read_file requires at least one path", 0.0);
}
let start = std::time::Instant::now();
let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
let mut combined = String::new();
let mut any_truncated = false;
for (idx, raw_path) in paths.iter().enumerate() {
tokio::select! {
biased;
_ = ctx.token.cancelled() => {
return ToolOutcome::cancelled();
},
read = read_one(&roots, raw_path) => {
match read {
Ok((content, was_truncated)) => {
any_truncated |= was_truncated;
if paths.len() > 1 {
let _ = ctx.progress.send(ProgressEvent::Status(
format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
)).await;
combined.push_str(&format!(
"=== {} ===\n{}\n\n",
raw_path, content
));
} else {
combined = content;
}
},
Err(e) => {
return ToolOutcome::error(
format!("{}: {}", raw_path, e),
start.elapsed().as_secs_f64(),
);
},
}
},
}
}
if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
combined = crate::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
any_truncated = true;
}
let duration_secs = start.elapsed().as_secs_f64();
let line_count = combined.lines().count();
let byte_count = combined.len();
let truncated = any_truncated;
ToolOutcome::success(
combined,
format!(
"{} {} read",
line_count,
plural(line_count, "line", "lines")
),
duration_secs,
)
.with_metadata(ToolRunMetadata {
detail: ToolMetadata::ReadFile {
paths,
line_count,
byte_count,
truncated,
},
line_count: Some(line_count),
byte_count: Some(byte_count),
..ToolRunMetadata::default()
})
}
}
pub struct DeleteFileTool;
#[async_trait]
impl ToolExecutor for DeleteFileTool {
fn name(&self) -> &'static str {
"delete_file"
}
fn schema(&self) -> ToolDefinition {
defn(
"delete_file",
"Remove a file from disk. Paths must resolve inside the project directory or the session scratchpad. Fails on directories — use `execute_command rm -rf` for those.",
serde_json::json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
)
}
async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
return err("delete_file requires 'path'", 0.0);
};
let start = std::time::Instant::now();
let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
let ResolvedInRoot {
abs,
rel,
root,
in_scratchpad,
} = match resolve_in_roots(&roots, raw_path) {
Ok(r) => r,
Err(e) => return err(&format!("delete_file: {}", e), 0.0),
};
let pending_action = serde_json::json!({
"tool": "delete_file",
"args": { "path": raw_path },
"workdir": ctx.workdir.display().to_string(),
"turn_id": ctx.turn.0,
"call_id": ctx.call_id.0,
"task_id": ctx.task_id.clone(),
});
if let Some(outcome) = mutation_policy_outcome(
&ctx,
"delete_file",
raw_path,
std::slice::from_ref(&abs),
pending_action,
in_scratchpad,
)
.await
{
return outcome;
}
let _write_guard = tokio::select! {
biased;
_ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
g = super::path_lock::lock_path(&abs) => g,
};
if ctx.config.safety.checkpoint_on_mutation
&& !in_scratchpad
&& let Err(e) = crate::runtime::create_checkpoint_for_task(
&ctx.workdir,
std::slice::from_ref(&abs),
Some(serde_json::json!({
"tool": "delete_file",
"path": raw_path,
})),
ctx.checkpoint_origin(),
)
{
return err(&format!("delete_file checkpoint failed: {}", e), 0.0);
}
let display = raw_path.to_string();
tokio::select! {
biased;
_ = ctx.token.cancelled() => ToolOutcome::cancelled(),
result = tokio::task::spawn_blocking(move || crate::runtime::remove_file_beneath(&root, &rel)) => {
match result {
Ok(Ok(())) => {
let duration_secs = start.elapsed().as_secs_f64();
after_file_mutation(&ctx, "delete_file", &display);
ToolOutcome::success(
format!("Deleted {}", display),
"file deleted",
duration_secs,
)
.with_metadata(ToolRunMetadata {
detail: ToolMetadata::DeleteFile { path: display },
..ToolRunMetadata::default()
})
},
Ok(Err(e)) => err(&format!("delete_file({}): {}", display, e),
start.elapsed().as_secs_f64()),
Err(e) => err(&format!("delete_file join error: {}", e),
start.elapsed().as_secs_f64()),
}
}
}
}
}
pub struct CreateDirectoryTool;
#[async_trait]
impl ToolExecutor for CreateDirectoryTool {
fn name(&self) -> &'static str {
"create_directory"
}
fn schema(&self) -> ToolDefinition {
defn(
"create_directory",
"Create a directory (and any missing parents) at the given path, inside the project directory or the session scratchpad.",
serde_json::json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
)
}
async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
return err("create_directory requires 'path'", 0.0);
};
let start = std::time::Instant::now();
let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
let ResolvedInRoot {
abs,
rel,
root,
in_scratchpad,
} = match resolve_in_roots(&roots, raw_path) {
Ok(r) => r,
Err(e) => return err(&format!("create_directory: {}", e), 0.0),
};
let pending_action = serde_json::json!({
"tool": "create_directory",
"args": { "path": raw_path },
"workdir": ctx.workdir.display().to_string(),
"turn_id": ctx.turn.0,
"call_id": ctx.call_id.0,
"task_id": ctx.task_id.clone(),
});
if let Some(outcome) = mutation_policy_outcome(
&ctx,
"create_directory",
raw_path,
std::slice::from_ref(&abs),
pending_action,
in_scratchpad,
)
.await
{
return outcome;
}
let _write_guard = tokio::select! {
biased;
_ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
g = super::path_lock::lock_path(&abs) => g,
};
if ctx.config.safety.checkpoint_on_mutation
&& !in_scratchpad
&& let Err(e) = crate::runtime::create_checkpoint_for_task(
&ctx.workdir,
std::slice::from_ref(&abs),
Some(serde_json::json!({
"tool": "create_directory",
"path": raw_path,
})),
ctx.checkpoint_origin(),
)
{
return err(&format!("create_directory checkpoint failed: {}", e), 0.0);
}
let display = raw_path.to_string();
tokio::select! {
biased;
_ = ctx.token.cancelled() => ToolOutcome::cancelled(),
result = tokio::task::spawn_blocking(move || crate::runtime::create_dir_all_beneath(&root, &rel)) => {
match result {
Ok(Ok(())) => {
let duration_secs = start.elapsed().as_secs_f64();
after_file_mutation(&ctx, "create_directory", &display);
ToolOutcome::success(
format!("Created directory {}", display),
"directory created",
duration_secs,
)
.with_metadata(ToolRunMetadata {
detail: ToolMetadata::CreateDirectory { path: display },
..ToolRunMetadata::default()
})
},
Ok(Err(e)) => err(&format!("create_directory({}): {}", display, e),
start.elapsed().as_secs_f64()),
Err(e) => err(&format!("create_directory join error: {}", e),
start.elapsed().as_secs_f64()),
}
}
}
}
}
pub struct WriteFileTool;
#[async_trait]
impl ToolExecutor for WriteFileTool {
fn name(&self) -> &'static str {
"write_file"
}
fn schema(&self) -> ToolDefinition {
defn(
"write_file",
"Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Paths must resolve inside the project directory or the session scratchpad. Prefer `edit_file` for small targeted changes.",
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}),
)
}
async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
};
let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
};
let start = std::time::Instant::now();
let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
let ResolvedInRoot {
abs: abs_path,
rel,
root,
in_scratchpad,
} = match resolve_in_roots(&roots, path) {
Ok(r) => r,
Err(e) => return ToolOutcome::error(format!("write_file: {}", e), 0.0),
};
let pending_action = serde_json::json!({
"tool": "write_file",
"args": { "path": path, "content": content },
"workdir": ctx.workdir.display().to_string(),
"turn_id": ctx.turn.0,
"call_id": ctx.call_id.0,
"task_id": ctx.task_id.clone(),
});
if let Some(outcome) = mutation_policy_outcome(
&ctx,
"write_file",
path,
std::slice::from_ref(&abs_path),
pending_action,
in_scratchpad,
)
.await
{
return outcome;
}
let _write_guard = tokio::select! {
biased;
_ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
g = super::path_lock::lock_path(&abs_path) => g,
};
if ctx.config.safety.checkpoint_on_mutation
&& !in_scratchpad
&& let Err(e) = crate::runtime::create_checkpoint_for_task(
&ctx.workdir,
std::slice::from_ref(&abs_path),
Some(serde_json::json!({
"tool": "write_file",
"path": path,
})),
ctx.checkpoint_origin(),
)
{
return ToolOutcome::error(format!("write_file checkpoint failed: {}", e), 0.0);
}
let display_path = path.to_string();
let line_count = content.lines().count();
let byte_count = content.len();
let content = content.to_string();
tokio::select! {
biased;
_ = ctx.token.cancelled() => ToolOutcome::cancelled(),
result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
match result {
Ok(Ok(write)) => {
let duration_secs = start.elapsed().as_secs_f64();
after_file_mutation(&ctx, "write_file", &display_path);
ToolOutcome::success(
format!("Wrote {} ({} lines)", display_path, write.line_count),
format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
duration_secs,
)
.with_metadata(ToolRunMetadata {
detail: ToolMetadata::WriteFile {
path: display_path,
line_count,
byte_count,
created: Some(write.created),
},
line_count: Some(line_count),
byte_count: Some(byte_count),
display_diff: Some(write.diff.display_diff),
diff_truncated: write.diff.truncated,
lines_added: write.diff.added,
lines_removed: write.diff.removed,
..ToolRunMetadata::default()
})
},
Ok(Err(e)) => ToolOutcome::error(
format!("write_file({}): {}", display_path, e),
start.elapsed().as_secs_f64(),
),
Err(e) => ToolOutcome::error(
format!("write_file join error: {}", e),
start.elapsed().as_secs_f64(),
),
}
}
}
}
}
fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
return Ok(vec![p.to_string()]);
}
if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
if arr.len() > crate::constants::MAX_BATCH_TOOL_ITEMS {
return Err(format!(
"read_file: too many paths ({}); cap is {} per call — split the request",
arr.len(),
crate::constants::MAX_BATCH_TOOL_ITEMS
));
}
let mut out = Vec::with_capacity(arr.len());
for v in arr {
let Some(s) = v.as_str() else {
return Err("read_file 'paths' must be an array of strings".to_string());
};
out.push(s.to_string());
}
return Ok(out);
}
Err("read_file requires 'path' or 'paths'".to_string())
}
async fn read_one(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<(String, bool)> {
let ResolvedInRoot { rel, root, .. } = resolve_in_roots(roots, raw)
.map_err(|msg| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
let result = tokio::task::spawn_blocking(move || {
let file = crate::runtime::open_beneath(&root, &rel, crate::runtime::OpenIntent::Read)?;
let (data, truncated) = crate::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
let mut s = String::from_utf8_lossy(&data).into_owned();
if truncated {
let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
s.truncate(cut);
s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
}
Ok::<_, std::io::Error>((s, truncated))
})
.await
.map_err(|e| std::io::Error::other(e.to_string()))??;
Ok(result)
}
fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
if let Some(parent) = rel.parent()
&& !parent.as_os_str().is_empty()
{
crate::runtime::create_dir_all_beneath(root, parent)?;
}
crate::runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
Ok(content.lines().count())
}
struct WriteResult {
line_count: usize,
created: bool,
diff: crate::render::diff::DisplayDiff,
}
fn write_with_diff_blocking(
root: &Path,
abs_path: &Path,
rel: &Path,
content: &str,
) -> std::io::Result<WriteResult> {
let (old_content, created, elide_diff) =
match crate::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
Ok((_, true)) => (String::new(), false, true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
Err(_) => (String::new(), false, true),
};
let diff = if elide_diff {
crate::render::diff::DisplayDiff {
display_diff: format!(
"[diff preview skipped: existing file exceeds the {}-byte cap]",
MAX_FILE_READ_BYTES
),
added: 0,
removed: 0,
truncated: true,
}
} else {
crate::render::diff::generate_display_diff(&old_content, content)
};
let line_count = write_one_blocking(root, rel, content)?;
Ok(WriteResult {
line_count,
created,
diff,
})
}
pub(super) async fn mutation_policy_outcome(
ctx: &ExecContext,
tool: &str,
path: &str,
checkpoint_paths: &[PathBuf],
pending_action: serde_json::Value,
scratch_contained: bool,
) -> Option<ToolOutcome> {
let mut request = crate::runtime::ActionRequest::new(
tool,
crate::runtime::ToolCategory::Edit,
format!("{} {}", tool, path),
);
request.path = Some(path.to_string());
match super::policy_gate::gate(
ctx,
request,
checkpoint_paths,
pending_action,
true,
scratch_contained,
)
.await
{
super::policy_gate::Gate::Block(outcome) => Some(outcome),
super::policy_gate::Gate::Proceed { .. } => {
let _ = crate::runtime::run_plugin_hooks(
"before_file_mutation",
&serde_json::json!({
"task_id": ctx.task_id.clone(),
"turn_id": ctx.turn.0,
"call_id": ctx.call_id.0,
"tool": tool,
"path": path,
}),
);
None
},
}
}
pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
let _ = crate::runtime::run_plugin_hooks(
"after_file_mutation",
&serde_json::json!({
"task_id": ctx.task_id.clone(),
"turn_id": ctx.turn.0,
"call_id": ctx.call_id.0,
"tool": tool,
"path": path,
}),
);
}
fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
ToolOutcome::error(msg, duration_secs)
}
fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
if count == 1 { singular } else { plural }
}
pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
format!(
"+{} -{}, took {}",
added,
removed,
format_duration_for_diff(duration_secs)
)
}
fn format_duration_for_diff(seconds: f64) -> String {
if seconds < 1.0 {
format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
} else if seconds < 10.0 {
format!("{:.1}s", seconds)
} else {
format!("{}s", seconds.round() as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{ToolCallId, TurnId};
use crate::providers::ctx::test_exec_context;
use std::fs;
#[test]
fn resolve_in_roots_contains_to_workdir() {
let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("sub")).unwrap();
let roots = AllowedRoots::new(&root, None);
assert!(resolve_in_roots(&roots, "sub").is_ok());
let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
let canon_root = fs::canonicalize(&root).unwrap();
assert!(resolved.abs.starts_with(&canon_root));
assert!(resolve_in_roots(&roots, "../escape.txt").is_err());
assert!(resolve_in_roots(&roots, "../../etc/passwd").is_err());
let outside = std::env::temp_dir().join("definitely_outside.txt");
assert!(resolve_in_roots(&roots, &outside.display().to_string()).is_err());
let _ = fs::remove_dir_all(&root);
}
fn temp_root(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{}", name));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).expect("create tmpdir");
p
}
#[tokio::test]
async fn read_file_returns_content() {
let dir = temp_root("read_ok");
fs::write(dir.join("a.txt"), "hello").expect("write");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let tool = ReadFileTool;
let outcome = tool
.execute(serde_json::json!({"path": "a.txt"}), ctx)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
assert_eq!(outcome.output(), "hello");
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_missing_path_errors() {
let dir = temp_root("read_missing_path");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_nonexistent_errors() {
let dir = temp_root("read_nonex");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = ReadFileTool
.execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
.await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_with_multiple_paths_joins_contents() {
let dir = temp_root("read_multi");
fs::write(dir.join("a.txt"), "alpha").expect("write");
fs::write(dir.join("b.txt"), "beta").expect("write");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = ReadFileTool
.execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
let output = outcome.output();
assert!(output.contains("=== a.txt ==="));
assert!(output.contains("alpha"));
assert!(output.contains("=== b.txt ==="));
assert!(output.contains("beta"));
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_multi_aggregate_is_capped() {
let dir = temp_root("read_aggregate_cap");
let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
fs::write(dir.join("a.txt"), &chunk).expect("write a");
fs::write(dir.join("b.txt"), &chunk).expect("write b");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = ReadFileTool
.execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
let output = outcome.output();
assert!(
output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
"combined must be capped, got {} bytes",
output.len()
);
assert!(
output.contains("elided"),
"expected aggregate head+tail elision marker"
);
match &outcome.metadata.detail {
ToolMetadata::ReadFile { truncated, .. } => {
assert!(*truncated, "aggregate truncation must set truncated")
},
other => panic!("expected ReadFile metadata, got {:?}", other),
}
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn write_file_elides_diff_for_oversized_existing_file() {
let dir = temp_root("write_oversized_diff");
let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
fs::write(dir.join("big.txt"), &big).expect("write fixture");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = WriteFileTool
.execute(
serde_json::json!({"path": "big.txt", "content": "small\n"}),
ctx,
)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
let diff = outcome
.metadata
.display_diff
.as_deref()
.expect("display diff");
assert!(
diff.contains("diff preview skipped"),
"expected elision marker, got: {diff}"
);
assert!(
outcome.metadata.diff_truncated,
"oversized diff must set diff_truncated"
);
match &outcome.metadata.detail {
ToolMetadata::WriteFile { created, .. } => {
assert_eq!(*created, Some(false), "existing file is not 'created'")
},
other => panic!("expected WriteFile metadata, got {:?}", other),
}
let written = fs::read_to_string(dir.join("big.txt")).expect("read");
assert_eq!(written, "small\n");
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
let dir = temp_root("read_marker_content");
fs::write(
dir.join("a.txt"),
"before\n\n[TRUNCATED: file exceeded read cap]\nafter",
)
.expect("write");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = ReadFileTool
.execute(serde_json::json!({"path": "a.txt"}), ctx)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
match &outcome.metadata.detail {
ToolMetadata::ReadFile { truncated, .. } => assert!(
!truncated,
"a file whose content contains the marker must not be flagged truncated"
),
other => panic!("expected ReadFile metadata, got {:?}", other),
}
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_respects_cancellation() {
let dir = temp_root("read_cancel");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
ctx.token.cancel();
let outcome = ReadFileTool
.execute(serde_json::json!({"path": "x.txt"}), ctx)
.await;
assert!(outcome.was_cancelled());
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn write_file_creates_and_counts_lines() {
let dir = temp_root("write_ok");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = WriteFileTool
.execute(
serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
ctx,
)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
assert!(outcome.output().contains("3 lines"));
let written = fs::read_to_string(dir.join("out.txt")).expect("read");
assert!(written.contains("line1"));
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn concurrent_write_file_same_path_serializes_cleanly() {
let dir = temp_root("write_race");
let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
let a = "AAAA\nAAAA\n";
let b = "BBBB\nBBBB\n";
let (o1, o2) = tokio::join!(
WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
);
assert!(o1.is_success(), "first write failed: {o1:?}");
assert!(o2.is_success(), "second write failed: {o2:?}");
let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
assert!(
final_content == a || final_content == b,
"file must be exactly one clean write, got {final_content:?}"
);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn write_file_new_file_records_added_display_diff() {
let dir = temp_root("write_new_diff");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = WriteFileTool
.execute(
serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
ctx,
)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
let diff = outcome
.metadata
.display_diff
.as_deref()
.expect("display diff");
assert!(diff.contains("+ alpha"));
assert!(diff.contains("+ beta"));
assert!(
!diff.contains("@@"),
"diff should not carry hunk headers: {diff}"
);
assert!(!diff.contains("/dev/null"));
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn write_file_existing_file_records_added_and_removed_display_diff() {
let dir = temp_root("write_existing_diff");
fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = WriteFileTool
.execute(
serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
ctx,
)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
let diff = outcome
.metadata
.display_diff
.as_deref()
.expect("display diff");
assert!(diff.contains("- old"));
assert!(diff.contains("+ new"));
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn write_file_creates_parent_dirs() {
let dir = temp_root("write_parents");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = WriteFileTool
.execute(
serde_json::json!({
"path": "sub/nested/out.txt",
"content": "deep",
}),
ctx,
)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
assert!(dir.join("sub/nested/out.txt").exists());
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn write_file_missing_content_errors() {
let dir = temp_root("write_missing");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = WriteFileTool
.execute(serde_json::json!({"path": "x.txt"}), ctx)
.await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_rejects_absolute_path_outside_workdir() {
let dir = temp_root("read_abs_escape");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = ReadFileTool
.execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
.await;
let error = outcome.error_message().expect("expected error");
assert!(
error.contains("outside the project"),
"expected security reject, got: {}",
error
);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_file_accepts_absolute_path_inside_workdir() {
let dir = temp_root("read_abs_inside");
let file = dir.join("hello.txt");
fs::write(&file, "ok").expect("write fixture");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = ReadFileTool
.execute(
serde_json::json!({"path": file.to_string_lossy().to_string()}),
ctx,
)
.await;
assert!(outcome.is_success(), "expected success: {:?}", outcome);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn write_file_rejects_relative_parent_escape() {
let dir = temp_root("write_dotdot_escape");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = WriteFileTool
.execute(
serde_json::json!({
"path": "../escape.txt",
"content": "should not write",
}),
ctx,
)
.await;
let error = outcome.error_message().expect("expected error");
assert!(
error.contains("outside the project"),
"expected security reject, got: {}",
error
);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn create_directory_rejects_absolute_path_outside_workdir() {
let dir = temp_root("mkdir_abs_escape");
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let outcome = CreateDirectoryTool
.execute(
serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
ctx,
)
.await;
let error = outcome.error_message().expect("expected error");
assert!(
error.contains("outside the project"),
"expected security reject, got: {}",
error
);
let _ = fs::remove_dir_all(&dir);
}
fn scratch_ctx(
mode: crate::runtime::SafetyMode,
workdir: PathBuf,
scratchpad: Option<PathBuf>,
) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
let mut config = crate::app::Config::default();
config.safety.mode = mode;
let (tx, rx) = tokio::sync::mpsc::channel(8);
let mut ctx = ExecContext::new(
tokio_util::sync::CancellationToken::new(),
tx,
ToolCallId(1),
TurnId(1),
workdir,
std::sync::Arc::new(config),
String::new(),
None,
None,
None,
mode,
None,
None,
None,
None,
None,
);
ctx.scratchpad = scratchpad;
(ctx, rx)
}
fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
let base = std::env::temp_dir().join(format!(
"mermaid_fs_scratch_{}_{}",
name,
std::process::id()
));
let _ = fs::remove_dir_all(&base);
let project = base.join("project");
let scratch = base.join("scratch");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&scratch).unwrap();
(project, scratch)
}
fn any_checkpoint_mentions(marker: &str) -> bool {
let Ok(data) = crate::runtime::data_dir() else {
return false;
};
let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
return false;
};
entries.flatten().any(|entry| {
fs::read_to_string(entry.path().join("manifest.json"))
.is_ok_and(|manifest| manifest.contains(marker))
})
}
#[tokio::test]
async fn scratch_mutations_are_ungated_and_never_checkpointed() {
let (project, scratch) = scratch_fixture("ungated");
let marker = scratch.display().to_string();
let file = scratch.join("notes.txt");
let (ctx, _rx) = scratch_ctx(
crate::runtime::SafetyMode::Ask,
project.clone(),
Some(scratch.clone()),
);
let outcome = WriteFileTool
.execute(
serde_json::json!({
"path": file.to_str().unwrap(),
"content": "scratch note\n",
}),
ctx,
)
.await;
assert!(outcome.is_success(), "scratch write: {outcome:?}");
assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
let subdir = scratch.join("work/area");
let (ctx, _rx) = scratch_ctx(
crate::runtime::SafetyMode::Ask,
project.clone(),
Some(scratch.clone()),
);
let outcome = CreateDirectoryTool
.execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
.await;
assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
assert!(subdir.is_dir());
let (ctx, _rx) = scratch_ctx(
crate::runtime::SafetyMode::Ask,
project.clone(),
Some(scratch.clone()),
);
let outcome = DeleteFileTool
.execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
.await;
assert!(outcome.is_success(), "scratch delete: {outcome:?}");
assert!(!file.exists());
assert!(
!any_checkpoint_mentions(&marker),
"scratch mutation must not create a checkpoint"
);
let _ = fs::remove_dir_all(project.parent().unwrap());
}
#[tokio::test]
async fn scratch_mutation_blocked_in_read_only() {
let (project, scratch) = scratch_fixture("readonly");
let file = scratch.join("blocked.txt");
let (ctx, _rx) = scratch_ctx(
crate::runtime::SafetyMode::ReadOnly,
project.clone(),
Some(scratch.clone()),
);
let outcome = WriteFileTool
.execute(
serde_json::json!({
"path": file.to_str().unwrap(),
"content": "nope",
}),
ctx,
)
.await;
let error = outcome.error_message().expect("expected block");
assert!(
error.contains("blocked by policy"),
"expected policy block, got: {error}"
);
assert!(!file.exists());
let _ = fs::remove_dir_all(project.parent().unwrap());
}
#[tokio::test]
async fn write_outside_both_roots_is_rejected() {
let (project, scratch) = scratch_fixture("outside");
let outside = project.parent().unwrap().join("elsewhere/out.txt");
let (ctx, _rx) = scratch_ctx(
crate::runtime::SafetyMode::Ask,
project.clone(),
Some(scratch.clone()),
);
let outcome = WriteFileTool
.execute(
serde_json::json!({
"path": outside.to_str().unwrap(),
"content": "should not write",
}),
ctx,
)
.await;
let error = outcome.error_message().expect("expected error");
assert!(
error.contains("outside the project"),
"expected containment reject, got: {error}"
);
assert!(!outside.exists());
let _ = fs::remove_dir_all(project.parent().unwrap());
}
#[tokio::test]
async fn read_file_reads_from_scratchpad() {
let (project, scratch) = scratch_fixture("read");
let file = scratch.join("stash.txt");
fs::write(&file, "stashed").unwrap();
let (ctx, _rx) = scratch_ctx(
crate::runtime::SafetyMode::Ask,
project.clone(),
Some(scratch.clone()),
);
let outcome = ReadFileTool
.execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
.await;
assert!(outcome.is_success(), "scratch read: {outcome:?}");
assert_eq!(outcome.output(), "stashed");
let _ = fs::remove_dir_all(project.parent().unwrap());
}
}