use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use edgecrab_types::{ToolError, ToolSchema};
use crate::edit_contract::{
DEFAULT_MAX_MUTATION_PAYLOAD_BYTES, DEFAULT_MAX_MUTATION_PAYLOAD_KIB,
enforce_write_payload_limit_with_max,
};
use crate::path_utils::{jail_write_path, jail_write_path_create_dirs};
use crate::registry::{ToolContext, ToolHandler};
use crate::tools::checkpoint::ensure_checkpoint;
pub struct WriteFileTool;
#[derive(Deserialize, Default, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum IfExists {
#[default]
Overwrite,
Abort,
}
#[derive(Deserialize)]
struct Args {
path: String,
content: String,
#[serde(default)]
create_dirs: bool,
#[serde(default)]
if_exists: IfExists,
}
#[async_trait]
impl ToolHandler for WriteFileTool {
fn name(&self) -> &'static str {
"write_file"
}
fn toolset(&self) -> &'static str {
"file"
}
fn emoji(&self) -> &'static str {
"✏️"
}
fn path_arguments(&self) -> &'static [&'static str] {
&["path"]
}
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "write_file".into(),
description: format!(
"Creates or fully replaces a file. Hard content limit: {DEFAULT_MAX_MUTATION_PAYLOAD_BYTES} bytes ({DEFAULT_MAX_MUTATION_PAYLOAD_KIB} KiB) per call. \
\n\
WHEN TO USE: \
(a) Brand-new file — set if_exists=\"abort\" so a path collision fails CHEAPLY (~120 bytes, no content preview); pick a different path. \
(b) Replace an existing file — set if_exists=\"overwrite\" (the default). On collision the rejection includes a short content preview AND records a session snapshot, so calling write_file AGAIN with the same args succeeds — you do NOT need a separate read_file round trip. \
\n\
PARTIAL EDITS: For ANY targeted modification to an existing file, use patch/apply_patch instead — far more token-efficient and avoids regenerating unchanged content. \
\n\
CONTRACT: `content` MUST be a string — use \"\" for an empty scaffold. For larger new files, write a small scaffold first then extend with patch/apply_patch."
),
parameters: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "File path relative to working directory"
},
"content": {
"type": "string",
"description": "Full content to write to the file. Use an empty string \"\" to create an empty scaffold."
},
"create_dirs": {
"type": "boolean",
"description": "Create parent directories if they don't exist. Set explicitly to true or false."
},
"if_exists": {
"type": "string",
"enum": ["overwrite", "abort"],
"description": "Intent when the file already exists. 'overwrite' (default): rejection on collision returns a content preview and records a session snapshot — retry the SAME call to succeed. 'abort': cheap rejection with no preview and no snapshot — use when intent is to create a NEW file and a different path should be chosen on collision."
}
},
"required": ["path", "content", "create_dirs"]
}),
strict: Some(true),
}
}
async fn execute(
&self,
args: serde_json::Value,
ctx: &ToolContext,
) -> Result<String, ToolError> {
let args: Args = serde_json::from_value(args).map_err(|e| ToolError::InvalidArgs {
tool: "write_file".into(),
message: e.to_string(),
})?;
ensure_checkpoint(ctx, &format!("before write_file: {}", args.path));
let path_policy = ctx.config.file_path_policy(&ctx.cwd);
let resolved = if args.create_dirs {
jail_write_path_create_dirs(&args.path, &path_policy)?
} else {
jail_write_path(&args.path, &path_policy)?
};
let file_exists = resolved.exists();
let existing_file_is_empty = file_exists
&& std::fs::metadata(&resolved)
.map(|meta| meta.is_file() && meta.len() == 0)
.unwrap_or(false);
if file_exists
&& !existing_file_is_empty
&& !crate::read_tracker::has_file_snapshot(&ctx.session_id, &resolved)
{
match args.if_exists {
IfExists::Abort => {
let size = std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
return Err(ToolError::InvalidArgs {
tool: "write_file".into(),
message: format!(
"'{path}' already exists ({size} bytes). \
Pick a different path, or call write_file again with \
if_exists=\"overwrite\" (the default) to replace it.",
path = args.path
),
});
}
IfExists::Overwrite => {
const PREVIEW_LIMIT: usize = 600;
let current_content = std::fs::read_to_string(&resolved).unwrap_or_default();
let preview = crate::safe_truncate(¤t_content, PREVIEW_LIMIT);
let truncated = preview.len() < current_content.len();
let trunc_note = if truncated {
format!(
"\n[...truncated — file has {} total bytes; \
read_file gives full content if needed.]",
current_content.len()
)
} else {
String::new()
};
let _ = crate::read_tracker::record_file_snapshot(&ctx.session_id, &resolved);
return Err(ToolError::InvalidArgs {
tool: "write_file".into(),
message: format!(
"'{path}' already exists. \
Snapshot recorded — retry the SAME write_file call to overwrite \
(no extra read_file needed). \
For targeted edits prefer patch/apply_patch — far more token-efficient.\n\
\n\
Current file content (preview):\n\
---\n\
{preview}{trunc_note}\n\
---",
path = args.path
),
});
}
}
}
if file_exists {
crate::read_tracker::guard_file_freshness(
&ctx.session_id,
"write_file",
&args.path,
&resolved,
)?;
}
let pre_write_snap = if file_exists {
tokio::fs::metadata(&resolved)
.await
.ok()
.map(|m| (m.len(), m.modified().ok()))
} else {
None
};
let content = args.content;
enforce_write_payload_limit_with_max(
"write_file",
&args.path,
&resolved,
&content,
ctx.config.max_write_payload_bytes(),
)?;
if let Some((size_snap, mtime_snap)) = pre_write_snap
&& let Ok(current) = tokio::fs::metadata(&resolved).await
{
let changed = current.len() != size_snap || current.modified().ok() != mtime_snap;
if changed {
return Err(ToolError::ContentMismatch {
tool: "write_file".into(),
path: args.path.clone(),
message: format!(
"'{}' was modified by another process between freshness check and \
write (TOCTOU). Re-read with read_file and retry.",
args.path
),
});
}
}
let bytes_written = content.len();
tokio::fs::write(&resolved, &content)
.await
.map_err(|e| ToolError::Other(format!("Cannot write '{}': {}", args.path, e)))?;
let _ = crate::read_tracker::record_file_snapshot(&ctx.session_id, &resolved);
if bytes_written == 0 {
Ok(format!(
"Created empty scaffold at '{}'. Add content next with patch/apply_patch or another write_file call.",
args.path
))
} else {
let action = if file_exists { "overwrite" } else { "create" };
Ok(serde_json::json!({
"ok": true,
"action": action,
"bytes": bytes_written,
"path": args.path,
})
.to_string())
}
}
}
inventory::submit!(&WriteFileTool as &dyn ToolHandler);
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::file_read::ReadFileTool;
use tempfile::TempDir;
fn ctx_in(dir: &std::path::Path) -> ToolContext {
let mut ctx = ToolContext::test_context();
ctx.cwd = dir.to_path_buf();
ctx
}
#[tokio::test]
async fn write_file_creates_new() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = WriteFileTool
.execute(json!({"path": "new.txt", "content": "hello world"}), &ctx)
.await
.expect("write");
let v: serde_json::Value = serde_json::from_str(&result).expect("JSON");
assert_eq!(v["ok"], true);
assert_eq!(v["bytes"], 11);
assert_eq!(v["action"], "create");
let content = std::fs::read_to_string(dir.path().join("new.txt")).expect("read");
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn write_file_allows_new_empty_scaffold_with_empty_string() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = WriteFileTool
.execute(
json!({"path": "scaffold.md", "content": "", "create_dirs": false}),
&ctx,
)
.await
.expect("create scaffold");
assert!(result.contains("Created empty scaffold"));
let content = std::fs::read_to_string(dir.path().join("scaffold.md")).expect("read");
assert_eq!(content, "");
}
#[test]
fn write_file_schema_is_strict_and_content_is_required_string() {
let schema = WriteFileTool.schema();
assert_eq!(schema.strict, Some(true));
assert_eq!(schema.parameters["type"], "object");
assert_eq!(schema.parameters["additionalProperties"], false);
assert_eq!(
schema.parameters["required"],
json!(["path", "content", "create_dirs"])
);
assert_eq!(
schema.parameters["properties"]["content"]["type"],
json!("string"),
"content schema type must be 'string', not nullable. \
See specs/improve_plan/06-write-file-fix.md for rationale."
);
}
#[tokio::test]
async fn write_file_rejects_missing_content_field() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = WriteFileTool
.execute(json!({"path": "test.txt", "create_dirs": false}), &ctx)
.await;
assert!(result.is_err(), "missing content field should be rejected");
}
#[tokio::test]
async fn write_file_allows_empty_string_on_existing_empty_scaffold() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("audit.md"), "").expect("seed empty scaffold");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "write-file-empty-scaffold".into();
let result = WriteFileTool
.execute(
json!({"path": "audit.md", "content": "", "create_dirs": false}),
&ctx,
)
.await
.expect("existing empty scaffold should be reusable with empty string");
assert!(result.contains("Created empty scaffold"));
let content = std::fs::read_to_string(dir.path().join("audit.md")).expect("read");
assert_eq!(content, "");
}
#[tokio::test]
async fn write_file_creates_dirs() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = WriteFileTool
.execute(
json!({"path": "sub/dir/file.txt", "content": "nested", "create_dirs": true}),
&ctx,
)
.await
.expect("write");
let v: serde_json::Value = serde_json::from_str(&result).expect("JSON");
assert_eq!(v["ok"], true);
assert_eq!(v["bytes"], 6);
let content = std::fs::read_to_string(dir.path().join("sub/dir/file.txt")).expect("read");
assert_eq!(content, "nested");
}
#[tokio::test]
async fn write_file_overwrites() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("existing.txt"), "old").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "write-file-overwrite".into();
ReadFileTool
.execute(json!({"path": "existing.txt", "line_numbers": false}), &ctx)
.await
.expect("read existing file");
WriteFileTool
.execute(json!({"path": "existing.txt", "content": "new"}), &ctx)
.await
.expect("write");
let content = std::fs::read_to_string(dir.path().join("existing.txt")).expect("read");
assert_eq!(content, "new");
}
#[tokio::test]
async fn write_file_allows_absolute_path_in_configured_allowed_root() {
let dir = TempDir::new().expect("workspace");
let extra = TempDir::new().expect("extra");
let target = extra.path().join("shared.txt");
let mut ctx = ctx_in(dir.path());
ctx.config.file_allowed_roots = vec![extra.path().to_path_buf()];
WriteFileTool
.execute(
json!({"path": target.to_string_lossy(), "content": "shared write"}),
&ctx,
)
.await
.expect("write");
let content = std::fs::read_to_string(&target).expect("read");
assert_eq!(content, "shared write");
}
#[tokio::test]
async fn write_file_blocks_denylisted_subtree() {
let dir = TempDir::new().expect("workspace");
std::fs::create_dir_all(dir.path().join("secrets")).expect("create secrets");
let mut ctx = ctx_in(dir.path());
ctx.config.path_restrictions = vec![std::path::PathBuf::from("secrets")];
let result = WriteFileTool
.execute(
json!({"path": "secrets/token.txt", "content": "nope", "create_dirs": true}),
&ctx,
)
.await;
assert!(matches!(result, Err(ToolError::PermissionDenied(_))));
}
#[tokio::test]
async fn write_file_maps_absolute_tmp_into_edgecrab_temp_root() {
let dir = TempDir::new().expect("workspace");
let edgecrab_home = TempDir::new().expect("edgecrab_home");
let mut ctx = ctx_in(dir.path());
ctx.config.edgecrab_home = edgecrab_home.path().to_path_buf();
WriteFileTool
.execute(
json!({"path": "/tmp/summary.md", "content": "hello tmp"}),
&ctx,
)
.await
.expect("write virtual tmp");
let content = std::fs::read_to_string(edgecrab_home.path().join("tmp/files/summary.md"))
.expect("read mapped tmp file");
assert_eq!(content, "hello tmp");
}
#[tokio::test]
async fn write_file_rejects_oversized_payloads() {
let dir = TempDir::new().expect("workspace");
let ctx = ctx_in(dir.path());
let oversized = "x".repeat(DEFAULT_MAX_MUTATION_PAYLOAD_BYTES + 1);
let result = WriteFileTool
.execute(json!({"path": "big.rs", "content": oversized}), &ctx)
.await;
let err = result.expect_err("oversized write must be rejected");
assert!(
err.to_string()
.contains("Large single-call mutation payloads are unreliable")
);
}
#[tokio::test]
async fn write_file_rejects_oversized_overwrites() {
let dir = TempDir::new().expect("workspace");
std::fs::write(dir.path().join("existing.rs"), "fn main() {}\n").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "write-file-oversized-overwrite".into();
ReadFileTool
.execute(json!({"path": "existing.rs", "line_numbers": false}), &ctx)
.await
.expect("read existing file");
let oversized = "y".repeat(DEFAULT_MAX_MUTATION_PAYLOAD_BYTES + 10);
let result = WriteFileTool
.execute(json!({"path": "existing.rs", "content": oversized}), &ctx)
.await;
let err = result.expect_err("oversized overwrite must be rejected");
assert!(
err.to_string()
.contains("Refusing overwrite via write_file")
);
}
#[tokio::test]
async fn write_file_rejects_overwrite_when_file_changed_after_read() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("stale.txt"), "before\n").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "write-file-stale-guard".into();
ReadFileTool
.execute(json!({"path": "stale.txt", "line_numbers": false}), &ctx)
.await
.expect("read");
std::fs::write(dir.path().join("stale.txt"), "external change\n").expect("modify");
let err = WriteFileTool
.execute(
json!({"path": "stale.txt", "content": "replacement\n"}),
&ctx,
)
.await
.expect_err("stale overwrite should be rejected");
assert!(err.to_string().contains("modified since you last read it"));
let content = std::fs::read_to_string(dir.path().join("stale.txt")).expect("read current");
assert_eq!(content, "external change\n");
}
#[tokio::test]
async fn write_file_rejects_blind_overwrite_without_prior_read() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("blind.txt"), "before\n").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "write-file-blind-overwrite".into();
let err = WriteFileTool
.execute(
json!({"path": "blind.txt", "content": "replacement\n"}),
&ctx,
)
.await
.expect_err("blind overwrite should be rejected");
let msg = err.to_string();
assert!(
msg.contains("already exists"),
"expected existence message; got: {msg}"
);
assert!(
msg.contains("Snapshot recorded"),
"expected snapshot-recorded directive; got: {msg}"
);
assert!(
msg.contains("retry the SAME write_file call"),
"expected explicit retry directive; got: {msg}"
);
let content = std::fs::read_to_string(dir.path().join("blind.txt")).expect("read current");
assert_eq!(content, "before\n");
}
#[tokio::test]
async fn write_file_allows_overwrite_after_reread_refreshes_snapshot() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("fresh.txt"), "before\n").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "write-file-reread".into();
ReadFileTool
.execute(json!({"path": "fresh.txt", "line_numbers": false}), &ctx)
.await
.expect("read");
std::fs::write(dir.path().join("fresh.txt"), "external change\n").expect("modify");
ReadFileTool
.execute(json!({"path": "fresh.txt", "line_numbers": false}), &ctx)
.await
.expect("reread");
WriteFileTool
.execute(
json!({"path": "fresh.txt", "content": "replacement\n"}),
&ctx,
)
.await
.expect("fresh overwrite after reread");
let content = std::fs::read_to_string(dir.path().join("fresh.txt")).expect("read final");
assert_eq!(content, "replacement\n");
}
#[tokio::test]
async fn fp55_overwrite_rejection_records_snapshot_so_immediate_retry_succeeds() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("doc.md"), "# original\nbody\n").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "fp55-retry-no-read".into();
let err = WriteFileTool
.execute(
json!({"path": "doc.md", "content": "# replacement\n", "create_dirs": false}),
&ctx,
)
.await
.expect_err("first call should be rejected");
assert!(err.to_string().contains("Snapshot recorded"));
assert!(err.to_string().contains("Current file content (preview)"));
assert_eq!(
std::fs::read_to_string(dir.path().join("doc.md")).expect("read"),
"# original\nbody\n"
);
WriteFileTool
.execute(
json!({"path": "doc.md", "content": "# replacement\n", "create_dirs": false}),
&ctx,
)
.await
.expect("retry should succeed without an extra read_file");
assert_eq!(
std::fs::read_to_string(dir.path().join("doc.md")).expect("read final"),
"# replacement\n"
);
}
#[tokio::test]
async fn fp55_abort_returns_cheap_error_no_preview_no_snapshot() {
let dir = TempDir::new().expect("tmpdir");
let original = "x".repeat(2_000); std::fs::write(dir.path().join("taken.txt"), &original).expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "fp55-abort-cheap".into();
let err = WriteFileTool
.execute(
json!({
"path": "taken.txt",
"content": "fresh content",
"create_dirs": false,
"if_exists": "abort",
}),
&ctx,
)
.await
.expect_err("abort mode must reject existing path");
let msg = err.to_string();
assert!(msg.contains("already exists"), "got: {msg}");
assert!(msg.contains("2000 bytes"), "must include stat: {msg}");
assert!(
!msg.contains("Current file content"),
"abort path must NOT include preview; got: {msg}"
);
assert!(
!msg.contains("Snapshot recorded"),
"abort path must NOT record snapshot; got: {msg}"
);
let resolved = dir.path().join("taken.txt");
assert!(
!crate::read_tracker::has_file_snapshot(&ctx.session_id, &resolved),
"abort mode must not populate snapshot"
);
}
#[tokio::test]
async fn fp55_abort_then_overwrite_retry_records_snapshot_and_then_succeeds() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("seq.txt"), "old\n").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "fp55-abort-then-overwrite".into();
WriteFileTool
.execute(
json!({
"path": "seq.txt",
"content": "new\n",
"create_dirs": false,
"if_exists": "abort",
}),
&ctx,
)
.await
.expect_err("abort rejects collision");
WriteFileTool
.execute(
json!({
"path": "seq.txt",
"content": "new\n",
"create_dirs": false,
"if_exists": "overwrite",
}),
&ctx,
)
.await
.expect_err("overwrite first call rejects + snapshots");
WriteFileTool
.execute(
json!({
"path": "seq.txt",
"content": "new\n",
"create_dirs": false,
"if_exists": "overwrite",
}),
&ctx,
)
.await
.expect("overwrite retry must succeed after snapshot");
assert_eq!(
std::fs::read_to_string(dir.path().join("seq.txt")).expect("read"),
"new\n"
);
}
#[tokio::test]
async fn fp55_new_file_succeeds_under_either_mode() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
WriteFileTool
.execute(
json!({"path": "new1.txt", "content": "a", "if_exists": "abort"}),
&ctx,
)
.await
.expect("abort + new file must succeed");
WriteFileTool
.execute(
json!({"path": "new2.txt", "content": "b", "if_exists": "overwrite"}),
&ctx,
)
.await
.expect("overwrite + new file must succeed");
}
#[tokio::test]
async fn fp55_external_modification_between_reject_and_retry_fails_freshness() {
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("race.txt"), "v1\n").expect("seed");
let mut ctx = ctx_in(dir.path());
ctx.session_id = "fp55-race".into();
WriteFileTool
.execute(
json!({"path": "race.txt", "content": "v2\n", "create_dirs": false}),
&ctx,
)
.await
.expect_err("first call rejects");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(dir.path().join("race.txt"), "external\n").expect("modify");
let err = WriteFileTool
.execute(
json!({"path": "race.txt", "content": "v2\n", "create_dirs": false}),
&ctx,
)
.await
.expect_err("retry must fail because file changed externally");
assert!(err.to_string().contains("modified since you last read it"));
}
#[test]
fn fp55_schema_includes_if_exists_optional_enum() {
let schema = WriteFileTool.schema();
assert_eq!(schema.strict, Some(true));
let if_exists = &schema.parameters["properties"]["if_exists"];
assert_eq!(if_exists["type"], "string");
assert_eq!(if_exists["enum"], json!(["overwrite", "abort"]));
let required = schema.parameters["required"]
.as_array()
.expect("required arr");
assert!(
!required.iter().any(|v| v == "if_exists"),
"if_exists must remain optional to avoid breaking existing callers"
);
}
#[test]
fn fp55_schema_description_documents_retry_protocol() {
let desc = WriteFileTool.schema().description;
assert!(
desc.contains("if_exists=\"abort\""),
"description must document abort mode"
);
assert!(
desc.contains("if_exists=\"overwrite\""),
"description must document overwrite mode"
);
assert!(
desc.to_lowercase().contains("retry")
|| desc.to_lowercase().contains("again")
|| desc.to_lowercase().contains("call write_file"),
"description must document the retry protocol"
);
assert!(
desc.contains("patch"),
"description must still steer toward patch for partial edits"
);
}
#[tokio::test]
async fn fp55_unknown_if_exists_value_is_rejected() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let err = WriteFileTool
.execute(
json!({
"path": "x.txt",
"content": "v",
"create_dirs": false,
"if_exists": "skip", }),
&ctx,
)
.await
.expect_err("unknown enum variant must be rejected");
assert!(matches!(err, ToolError::InvalidArgs { .. }));
}
}