use super::{ensure_parent_directories, validate_nonempty_path, write_text_file};
use crate::tools::{ToolExecError, resolve_path};
use schemars::JsonSchema;
use serde::Deserialize;
use std::{io, path::Path};
use tracing::{info, warn};
#[derive(Debug, Deserialize, JsonSchema)]
pub struct WriteFileArgs {
pub path: String,
pub content: String,
pub overwrite: Option<bool>,
pub create_parents: Option<bool>,
}
pub fn execute_write_file_tool(
args: &WriteFileArgs,
working_dir: Option<&Path>,
) -> Result<String, ToolExecError> {
let path = validate_nonempty_path(&args.path)?;
let resolved = resolve_path(&path, working_dir);
ensure_parent_directories(&resolved, args.create_parents.unwrap_or(true))?;
match write_text_file(&resolved, &args.content, args.overwrite.unwrap_or(true)) {
Ok(()) => {
info!(path = %resolved.display(), bytes = args.content.len(), "write_file: wrote file");
let lang = ext_to_lang(&resolved.display().to_string());
let fenced = fence_content(&args.content, lang);
Ok(format!("wrote file: {}\n\n{}", resolved.display(), fenced))
}
Err(error) => {
let overwrite = args.overwrite.unwrap_or(true);
if !overwrite && error.kind() == io::ErrorKind::AlreadyExists {
warn!(path = %resolved.display(), "write_file: refusing to overwrite existing file");
Err(ToolExecError(format!(
"refusing to overwrite existing file: {}",
resolved.display()
)))
} else {
warn!(path = %resolved.display(), error = %error, "write_file: failed to write file");
Err(ToolExecError(format!("{error}")))
}
}
}
}
fn ext_to_lang(path: &str) -> &'static str {
let p = std::path::Path::new(path);
if let Some(fname) = p.file_name().and_then(|n| n.to_str())
&& fname.eq_ignore_ascii_case("dockerfile")
{
return "dockerfile";
}
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext.to_lowercase().as_str() {
"rs" => "rust",
"py" => "python",
"js" => "javascript",
"ts" | "tsx" | "mts" | "cts" => "javascript",
"json" => "json",
"toml" => "toml",
"yaml" | "yml" => "yaml",
"md" | "mdown" | "markdown" => "markdown",
"html" | "htm" => "html",
"css" => "css",
"scss" | "sass" => "scss",
"sh" | "bash" | "zsh" => "bash",
"c" => "c",
"h" => "c",
"cpp" | "cc" | "cxx" | "hpp" => "cpp",
"go" => "go",
"rb" => "ruby",
"java" => "java",
"sql" => "sql",
"xml" => "xml",
"dockerfile" => "dockerfile",
"makefile" | "mk" => "makefile",
"lua" => "lua",
"zig" => "zig",
"swift" => "swift",
"kt" | "kts" => "kotlin",
"pl" | "pm" => "perl",
"tex" => "latex",
"proto" => "protobuf",
_ => "",
}
}
fn fence_content(content: &str, lang: &str) -> String {
let trimmed = content.trim_end_matches('\n');
let max_run = trimmed
.chars()
.fold((0usize, 0usize), |(max_run, current), c| {
if c == '`' {
(max_run.max(current + 1), current + 1)
} else {
(max_run, 0)
}
})
.0;
let fence_len = (max_run + 1).max(3);
let fence = "`".repeat(fence_len);
format!("{fence}{lang}\n{trimmed}\n{fence}")
}
pub fn describe_write_file_invocation(args: &WriteFileArgs) -> String {
format!(
"Writing {} bytes to file `{}` (overwrite: {}, create_parents: {}).",
args.content.len(),
args.path,
args.overwrite.unwrap_or(false),
args.create_parents.unwrap_or(false)
)
}
pub(crate) struct WriteFile;
define_tool!(
WriteFile,
"write_file",
"Write a UTF-8 text file to the local workspace.",
WriteFileArgs,
execute_write_file_tool,
"core",
describe_write_file_invocation
);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describe_write_file_invocation() {
let args = WriteFileArgs {
path: "output.txt".into(),
content: "hello world".into(),
overwrite: Some(true),
create_parents: Some(false),
};
let desc = super::describe_write_file_invocation(&args);
assert_eq!(
desc,
"Writing 11 bytes to file `output.txt` (overwrite: true, create_parents: false)."
);
}
#[test]
fn ext_to_lang_rust() {
assert_eq!(super::ext_to_lang("src/main.rs"), "rust");
}
#[test]
fn ext_to_lang_python() {
assert_eq!(super::ext_to_lang("script.py"), "python");
}
#[test]
fn ext_to_lang_javascript() {
assert_eq!(super::ext_to_lang("app.js"), "javascript");
}
#[test]
fn ext_to_lang_typescript_mapped_to_javascript() {
assert_eq!(super::ext_to_lang("app.ts"), "javascript");
assert_eq!(super::ext_to_lang("app.tsx"), "javascript");
assert_eq!(super::ext_to_lang("app.mts"), "javascript");
assert_eq!(super::ext_to_lang("app.cts"), "javascript");
}
#[test]
fn ext_to_lang_dockerfile_detected_by_filename() {
assert_eq!(super::ext_to_lang("Dockerfile"), "dockerfile");
assert_eq!(super::ext_to_lang("path/to/Dockerfile"), "dockerfile");
}
#[test]
fn ext_to_lang_dockerfile_extension() {
assert_eq!(super::ext_to_lang("config.dockerfile"), "dockerfile");
}
#[test]
fn ext_to_lang_unknown_extension() {
assert_eq!(super::ext_to_lang("file.xyzzy"), "");
}
#[test]
fn ext_to_lang_no_extension() {
assert_eq!(super::ext_to_lang("Makefile"), "");
}
#[test]
fn ext_to_lang_markdown() {
assert_eq!(super::ext_to_lang("README.md"), "markdown");
}
#[test]
fn ext_to_lang_shell() {
assert_eq!(super::ext_to_lang("script.sh"), "bash");
assert_eq!(super::ext_to_lang("script.bash"), "bash");
}
#[test]
fn ext_to_lang_protobuf() {
assert_eq!(super::ext_to_lang("message.proto"), "protobuf");
}
#[test]
fn ext_to_lang_toml() {
assert_eq!(super::ext_to_lang("Cargo.toml"), "toml");
}
#[test]
fn ext_to_lang_yaml() {
assert_eq!(super::ext_to_lang("config.yaml"), "yaml");
assert_eq!(super::ext_to_lang("config.yml"), "yaml");
}
#[test]
fn fence_content_basic() {
let result = super::fence_content("hello", "rust");
assert_eq!(result, "```rust\nhello\n```");
}
#[test]
fn fence_content_no_lang() {
let result = super::fence_content("plain text", "");
assert_eq!(result, "```\nplain text\n```");
}
#[test]
fn fence_content_with_backticks() {
let result = super::fence_content("`code`", "text");
assert!(result.starts_with("``"));
assert!(result.ends_with("``"));
assert!(result.contains("`code`"));
}
#[test]
fn fence_content_triple_backticks() {
let result = super::fence_content("```\ncode\n```", "text");
assert!(result.starts_with("````"));
assert!(result.ends_with("````"));
}
#[test]
fn fence_content_empty_content() {
let result = super::fence_content("", "json");
assert_eq!(result, "```json\n\n```");
}
#[test]
fn fence_content_trailing_newline_stripped() {
let result = super::fence_content("hello\n", "text");
assert_eq!(result, "```text\nhello\n```");
}
#[test]
fn fence_content_multiple_trailing_newlines_stripped() {
let result = super::fence_content("a\nb\n\n", "text");
assert_eq!(result, "```text\na\nb\n```");
}
}