mod delete_files;
mod edit_file;
mod line_count;
mod list_files;
mod write_file;
pub(crate) use delete_files::DeleteFiles;
pub(crate) use edit_file::EditFile;
pub(crate) use line_count::LineCount;
pub(crate) use list_files::ListFiles;
pub use edit_file::{EditFileArgs, TextEditArgs, execute_edit_file_tool};
pub use list_files::{ListFilesArgs, execute_list_files_tool};
pub(crate) use write_file::WriteFile;
pub use write_file::{WriteFileArgs, execute_write_file_tool};
use crate::tools::ToolExecError;
use std::{fs::OpenOptions, io::Write};
use std::{io, path::Path};
use tracing::debug;
fn validate_nonempty_path(path: &str) -> Result<String, ToolExecError> {
let trimmed = path.trim();
if trimmed.is_empty() {
Err(ToolExecError(
"missing required string argument: path".to_string(),
))
} else {
Ok(trimmed.to_string())
}
}
fn ensure_parent_directories(path: &Path, create_parents: bool) -> Result<(), ToolExecError> {
if !create_parents {
return Ok(());
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
Ok(())
}
fn write_text_file(path: &Path, content: &str, overwrite: bool) -> io::Result<()> {
if !overwrite {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
let mut file = options.open(path)?;
file.write_all(content.as_bytes())?;
file.flush()?;
return Ok(());
}
atomic_write_text_file(path, content)
}
pub(crate) 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}")
}
fn atomic_write_text_file(path: &Path, content: &str) -> io::Result<()> {
let target = match std::fs::canonicalize(path) {
Ok(resolved) => resolved,
Err(_) => path.to_path_buf(),
};
let dir = target.parent().unwrap_or(Path::new("."));
let original_permissions = match std::fs::metadata(&target) {
Ok(m) => Some(m.permissions()),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
let preserved_mode = original_permissions.is_some();
if let Some(perms) = original_permissions {
tmp.as_file().set_permissions(perms)?;
}
debug!(path = %target.display(), preserved_mode, "atomic write: replacing file");
tmp.persist(&target).map_err(|e| e.error)?;
Ok(())
}
#[cfg(test)]
mod tests {
#[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_diff_with_backtick_context_line_never_closes_early() {
let diff = concat!(
"diff --git a/README.md b/README.md\n",
"--- a/README.md\n",
"+++ b/README.md\n",
"@@ -1,4 +1,4 @@\n",
"plain\n",
" ```\n",
"-old\n",
"+new\n",
"code\n",
);
let result = super::fence_content(diff, "diff");
assert!(
result.starts_with("````diff\n"),
"start: {}...",
result.get(..result.len().min(40)).unwrap_or("")
);
assert!(
result.ends_with("\n````"),
"end: {}...",
result.get(..result.len().min(40)).unwrap_or("")
);
assert!(
result.contains("\n ```\n"),
"context fence must stay inside: {result}"
);
}
#[test]
fn fence_content_diff_without_backticks_keeps_three_backtick_fence() {
let result = super::fence_content("diff --git a/f b/f\n-old\n+new", "diff");
assert!(result.starts_with("```diff\n"), "{}", result);
assert!(result.ends_with("\n```"), "{}", result);
}
#[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```");
}
}