use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use lingshu_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 {
#[serde(alias = "file_path")]
path: String,
#[serde(alias = "file_content", alias = "text", alias = "body")]
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"]
}),
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 create_dirs = args.create_dirs || ctx.config.local_write_create_dirs;
let resolved = if 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(crate::recovery_catalog::write_file_path_exists_abort(
args.path.clone(),
size,
));
}
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 _ = crate::read_tracker::record_file_snapshot(&ctx.session_id, &resolved);
return Err(crate::recovery_catalog::write_file_overwrite_guard(
args.path.clone(),
preview.to_string(),
truncated,
));
}
}
}
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;
let lsp_hook = crate::lsp_gate::LspWriteHook::capture_before(ctx, &resolved).await;
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(crate::recovery_catalog::write_file_content_mismatch(
args.path.clone(),
));
}
}
let bytes_written = content.len();
let new_lines = content.lines().count();
let old_lines = if file_exists {
tokio::fs::read_to_string(&resolved)
.await
.map(|s| s.lines().count())
.unwrap_or(0)
} else {
0
};
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);
ctx.record_mutation(crate::mutations::MutationRecord {
path: args.path.clone(),
kind: if file_exists {
crate::mutations::MutationKind::Modify
} else {
crate::mutations::MutationKind::Add
},
lines_added: new_lines.saturating_sub(old_lines) as u32,
lines_removed: old_lines.saturating_sub(new_lines) as u32,
});
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" };
let lines = new_lines;
let mut payload = serde_json::json!({
"ok": true,
"action": action,
"bytes": bytes_written,
"lines": lines,
"path": args.path,
});
lsp_hook
.attach_after(ctx, &resolved, &mut payload, &content)
.await;
Ok(payload.to_string())
}
}
}
inventory::submit!(&WriteFileTool as &dyn ToolHandler);
#[cfg(test)]
mod tests {
use super::*;
use crate::read_tracker;
use crate::tools::file_read::ReadFileTool;
use tempfile::TempDir;
use uuid::Uuid;
fn ctx_in(dir: &std::path::Path) -> ToolContext {
let mut ctx = ToolContext::test_context();
ctx.cwd = dir.to_path_buf();
ctx
}
fn ctx_with_mutations(dir: &std::path::Path) -> ToolContext {
let mut ctx = ctx_in(dir);
ctx.mutation_turn = Some(std::sync::Arc::new(
crate::mutations::MutationTurnState::new(),
));
ctx
}
#[tokio::test]
async fn write_file_records_mutation_for_footer() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_with_mutations(dir.path());
let turn = ctx.mutation_turn.as_ref().expect("mutation turn");
WriteFileTool
.execute(
json!({"path": "tracked.rs", "content": "line1\nline2\n"}),
&ctx,
)
.await
.expect("write");
let records = turn.drain_success();
assert_eq!(records.len(), 1);
assert_eq!(records[0].path, "tracked.rs");
assert_eq!(records[0].kind, crate::mutations::MutationKind::Add);
assert_eq!(records[0].lines_added, 2);
}
#[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");
assert_eq!(v["lines"], 1, "lines must reflect actual line count");
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"]));
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 lh40_local_write_create_dirs_default_writes_nested_path() {
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ctx_in(dir.path());
ctx.config.local_write_create_dirs = true;
let result = WriteFileTool
.execute(
json!({"path": "tmp/pptx_builder/script.py", "content": "print('ok')\n"}),
&ctx,
)
.await
.expect("nested write with local_write_create_dirs");
let v: serde_json::Value = serde_json::from_str(&result).expect("JSON");
assert_eq!(v["ok"], true);
assert!(
dir.path()
.join("tmp/pptx_builder/script.py")
.exists()
);
}
#[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_relative_tmp_files_into_lingshu_temp_root() {
let dir = TempDir::new().expect("workspace");
let lingshu_home = TempDir::new().expect("lingshu_home");
let mut ctx = ctx_in(dir.path());
ctx.config.lingshu_home = lingshu_home.path().to_path_buf();
WriteFileTool
.execute(
json!({"path": "tmp/files/raphael_osint_report.md", "content": "# OSINT\n"}),
&ctx,
)
.await
.expect("write relative tmp/files alias");
let content = std::fs::read_to_string(
lingshu_home
.path()
.join("tmp/files/raphael_osint_report.md"),
)
.expect("read mapped tmp file");
assert_eq!(content, "# OSINT\n");
}
#[tokio::test]
async fn write_file_maps_absolute_tmp_into_lingshu_temp_root() {
let dir = TempDir::new().expect("workspace");
let lingshu_home = TempDir::new().expect("lingshu_home");
let mut ctx = ctx_in(dir.path());
ctx.config.lingshu_home = lingshu_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(lingshu_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("Payload exceeds the per-call mutation limit")
);
}
#[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("Payload exceeds the per-call mutation limit")
);
}
#[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("changed since it was last read"));
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 for freshness"),
"expected snapshot-recorded 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 = format!("write-file-reread-{}", uuid::Uuid::new_v4().simple()).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 for freshness"));
assert!(err.to_string().contains("--- 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("changed since it was last read"));
}
#[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 { .. }));
}
}