use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use std::path::Path;
use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm_path_resolve};
const MAX_CREATE_SIZE: usize = 1_048_576;
#[derive(Deserialize, Debug, Clone)]
#[serde(tag = "action")]
pub enum PatchOp {
#[serde(rename = "create")]
Create { path: String, content: String },
#[serde(rename = "update")]
Update {
path: String,
old_text: String,
new_text: String,
},
#[serde(rename = "delete")]
Delete { path: String },
#[serde(rename = "rename")]
Rename { path: String, new_path: String },
}
pub struct ApplyPatchTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
cache: Option<ToolCache>,
}
impl ApplyPatchTool {
#[allow(dead_code)]
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
Self {
permission,
ask_tx,
cache: None,
}
}
pub fn with_cache(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
cache: ToolCache,
) -> Self {
Self {
permission,
ask_tx,
cache: Some(cache),
}
}
}
#[derive(Deserialize)]
pub struct ApplyPatchArgs {
pub operations: Vec<PatchOp>,
}
const MAX_APPLY_PATCH_BYTES: u64 = 100 * 1024 * 1024;
async fn apply_create(path: &str, content: &str) -> Result<String, String> {
let p = Path::new(path);
if tokio::fs::try_exists(p).await.unwrap_or(false) {
return Err(format!("file already exists: {}", path));
}
if let Some(parent) = p.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("failed to create parent dir: {}", e))?;
}
if content.len() as u64 > MAX_APPLY_PATCH_BYTES {
return Err(format!(
"create content too large: {} bytes (cap {} bytes)",
content.len(),
MAX_APPLY_PATCH_BYTES,
));
}
#[cfg(feature = "semantic")]
if let Err(errors) = crate::semantic::syntax_validator::check_syntax(p, content) {
return Err(crate::semantic::syntax_validator::format_errors(
p, content, &errors,
));
}
crate::agent::tools::snapshots::capture(p);
crate::fs_atomic::atomic_write(p, content.as_bytes())
.await
.map_err(|e| format!("write failed: {}", e))?;
Ok(format!("created {}", path))
}
async fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result<String, String> {
if let Ok(meta) = tokio::fs::metadata(path).await
&& meta.len() > MAX_APPLY_PATCH_BYTES
{
return Err(format!(
"file too large for apply_patch: {} bytes (cap {} bytes); use bash + sed/awk for huge files",
meta.len(),
MAX_APPLY_PATCH_BYTES,
));
}
let original = tokio::fs::read_to_string(path)
.await
.map_err(|e| format!("read failed: {}", e))?;
let crlf = original.contains("\r\n");
let normalized = if crlf {
original.replace("\r\n", "\n")
} else {
original.clone()
};
let needle = old_text.replace("\r\n", "\n");
if !normalized.contains(&needle) {
return Err(format!("text not found in {}", path));
}
let matches: Vec<_> = normalized.match_indices(&needle).collect();
if matches.len() > 1 {
return Err(format!(
"text matches {} locations in {} — provide more context to make unique",
matches.len(),
path
));
}
let replacement = if crlf {
new_text.replace("\r\n", "\n")
} else {
new_text.to_string()
};
let updated_normalized = normalized.replacen(&needle, &replacement, 1);
let to_write = if crlf {
updated_normalized.replace('\n', "\r\n")
} else {
updated_normalized
};
#[cfg(feature = "semantic")]
if let Err(errors) =
crate::semantic::syntax_validator::check_syntax(std::path::Path::new(path), &to_write)
{
return Err(crate::semantic::syntax_validator::format_errors(
std::path::Path::new(path),
&to_write,
&errors,
));
}
crate::agent::tools::snapshots::capture_bytes(std::path::Path::new(path), original.as_bytes());
crate::fs_atomic::atomic_write(std::path::Path::new(path), to_write.as_bytes())
.await
.map_err(|e| format!("write failed: {}", e))?;
Ok(format!("updated {}", path))
}
async fn apply_delete(path: &str) -> Result<String, String> {
crate::agent::tools::snapshots::capture(std::path::Path::new(path));
tokio::fs::remove_file(path)
.await
.map_err(|e| format!("delete failed: {}", e))?;
Ok(format!("deleted {}", path))
}
async fn apply_rename(path: &str, new_path: &str) -> Result<String, String> {
crate::agent::tools::snapshots::capture(std::path::Path::new(path));
crate::agent::tools::snapshots::capture(std::path::Path::new(new_path));
tokio::fs::rename(path, new_path)
.await
.map_err(|e| format!("rename failed: {}", e))?;
Ok(format!("renamed {} -> {}", path, new_path))
}
impl Tool for ApplyPatchTool {
const NAME: &'static str = "apply_patch";
type Error = ToolError;
type Args = ApplyPatchArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "apply_patch".to_string(),
description: crate::agent::agent_loop::tool_input_repair::with_contract_hint(
"apply_patch",
"Apply multiple file operations in a single call. Supports create, update (by exact text match), delete, and rename. Operations execute in order and stop on first failure — prior operations that succeeded remain applied.",
),
parameters: serde_json::json!({
"type": "object",
"properties": {
"operations": {
"type": "array",
"description": "Ordered list of file operations to execute",
"items": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "update", "delete", "rename"],
"description": "The type of operation"
},
"path": {
"type": "string",
"description": "Target file path"
},
"content": {
"type": "string",
"description": "File content (required for create)"
},
"old_text": {
"type": "string",
"description": "Exact text to find and replace (required for update)"
},
"new_text": {
"type": "string",
"description": "Replacement text (required for update)"
},
"new_path": {
"type": "string",
"description": "New file path (required for rename)"
}
},
"required": ["action", "path"]
}
}
},
"required": ["operations"]
}),
}
}
async fn call(&self, args: ApplyPatchArgs) -> Result<String, ToolError> {
if args.operations.is_empty() {
return Err(ToolError::Msg("no operations provided".to_string()));
}
let mut results = Vec::new();
for op in &args.operations {
let op_path: &str = match op {
PatchOp::Create { path, .. }
| PatchOp::Update { path, .. }
| PatchOp::Delete { path }
| PatchOp::Rename { path, .. } => path,
};
crate::agent::tools::require_absolute_path(op_path, "the apply_patch path")
.map_err(ToolError::Msg)?;
if let PatchOp::Rename { new_path, .. } = op {
crate::agent::tools::require_absolute_path(
new_path,
"the apply_patch rename target",
)
.map_err(ToolError::Msg)?;
}
let resolved_path = match op {
PatchOp::Create { path, .. }
| PatchOp::Update { path, .. }
| PatchOp::Delete { path }
| PatchOp::Rename { path, .. } => {
check_perm_path_resolve(&self.permission, &self.ask_tx, "apply_patch", path)
.await?
}
};
let resolved_new_path = if let PatchOp::Rename { new_path, .. } = op {
Some(
check_perm_path_resolve(
&self.permission,
&self.ask_tx,
"apply_patch",
new_path,
)
.await?,
)
} else {
None
};
if let PatchOp::Create { content, .. } = op
&& content.len() > MAX_CREATE_SIZE
{
results.push(format!(
"FAILED: create content exceeds {} bytes ({} bytes provided)",
MAX_CREATE_SIZE,
content.len()
));
break;
}
if let PatchOp::Update { .. } = op
&& let Some(ref cache) = self.cache
&& !cache.has_been_read(std::path::Path::new(&resolved_path))
{
results.push(format!(
"FAILED: \"{}\" has not been read in this session yet; read it first so the \
update matches the current on-disk contents",
op_path
));
break;
}
let result = match op {
PatchOp::Create { content, .. } => apply_create(&resolved_path, content).await,
PatchOp::Update {
old_text, new_text, ..
} => apply_update(&resolved_path, old_text, new_text).await,
PatchOp::Delete { .. } => apply_delete(&resolved_path).await,
PatchOp::Rename { .. } => {
apply_rename(
&resolved_path,
resolved_new_path
.as_deref()
.expect("resolved_new_path set for Rename"),
)
.await
}
};
match result {
Ok(msg) => {
match op {
PatchOp::Create { .. }
| PatchOp::Update { .. }
| PatchOp::Delete { .. } => {
let p = std::path::Path::new(&resolved_path);
crate::agent::tools::modified::mark_modified(p);
if let Some(ref cache) = self.cache {
cache.mark_read(p);
}
}
PatchOp::Rename { .. } => {
if let Some(ref np) = resolved_new_path {
let p = std::path::Path::new(np);
crate::agent::tools::modified::mark_modified(p);
if let Some(ref cache) = self.cache {
cache.mark_read(p);
}
}
}
}
results.push(msg);
}
Err(e) => {
results.push(format!("FAILED: {}", e));
break;
}
}
}
if let Some(ref cache) = self.cache {
cache.clear();
}
Ok(results.join("\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestFile {
path: String,
}
impl TestFile {
fn new(name: &str) -> Self {
let path = format!("/tmp/dirge-test-{}", name);
let _ = std::fs::remove_file(&path);
Self { path }
}
}
impl Drop for TestFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[tokio::test]
async fn test_create_and_read() {
let tf = TestFile::new("create-test.txt");
let result = apply_create(&tf.path, "hello world").await;
assert!(result.is_ok());
let content = std::fs::read_to_string(&tf.path).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn test_create_existing_file_fails() {
let tf = TestFile::new("create-exists.txt");
std::fs::write(&tf.path, "existing").unwrap();
let result = apply_create(&tf.path, "new").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_update_text() {
let tf = TestFile::new("update-test.txt");
std::fs::write(&tf.path, "before after").unwrap();
let result = apply_update(&tf.path, "before", "replaced").await;
assert!(result.is_ok());
let content = std::fs::read_to_string(&tf.path).unwrap();
assert_eq!(content, "replaced after");
}
#[tokio::test]
async fn test_update_text_not_found() {
let tf = TestFile::new("update-notfound.txt");
std::fs::write(&tf.path, "some content").unwrap();
let result = apply_update(&tf.path, "nonexistent", "replacement").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_delete_file() {
let tf = TestFile::new("delete-test.txt");
std::fs::write(&tf.path, "to delete").unwrap();
assert!(Path::new(&tf.path).exists());
let result = apply_delete(&tf.path).await;
assert!(result.is_ok());
assert!(!Path::new(&tf.path).exists());
}
#[tokio::test]
async fn test_rename_file() {
let src = TestFile::new("rename-src.txt");
let dst = "/tmp/dirge-test-rename-dst.txt";
let _ = std::fs::remove_file(dst);
std::fs::write(&src.path, "rename me").unwrap();
let result = apply_rename(&src.path, dst).await;
assert!(result.is_ok());
assert!(!Path::new(&src.path).exists());
assert!(Path::new(dst).exists());
let _ = std::fs::remove_file(dst);
}
#[tokio::test]
async fn test_rejects_empty_operations() {
let tool = ApplyPatchTool::new(None, None);
let result = tool.call(ApplyPatchArgs { operations: vec![] }).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no operations"));
}
#[tokio::test]
async fn test_definition_has_correct_name() {
let tool = ApplyPatchTool::new(None, None);
let def = tool.definition(String::new()).await;
assert_eq!(def.name, "apply_patch");
}
#[tokio::test]
async fn regression_update_rejects_multiple_matches() {
let tf = TestFile::new("update-ambiguous.txt");
std::fs::write(&tf.path, "foo bar foo baz foo").unwrap();
let result = apply_update(&tf.path, "foo", "qux").await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("3 locations"), "got: {msg}");
assert_eq!(
std::fs::read_to_string(&tf.path).unwrap(),
"foo bar foo baz foo"
);
}
#[tokio::test]
async fn regression_multi_op_stops_on_failure_prior_ops_remain() {
let a = TestFile::new("multi-op-a.txt");
let b_existing = TestFile::new("multi-op-b.txt");
let c_should_not_exist = TestFile::new("multi-op-c.txt");
std::fs::write(&b_existing.path, "already here").unwrap();
let tool = ApplyPatchTool::new(None, None);
let result = tool
.call(ApplyPatchArgs {
operations: vec![
PatchOp::Create {
path: a.path.clone(),
content: "A content".into(),
},
PatchOp::Create {
path: b_existing.path.clone(),
content: "B content".into(),
},
PatchOp::Create {
path: c_should_not_exist.path.clone(),
content: "C content".into(),
},
],
})
.await
.unwrap();
assert!(Path::new(&a.path).exists(), "A must remain applied");
assert_eq!(std::fs::read_to_string(&a.path).unwrap(), "A content");
assert_eq!(
std::fs::read_to_string(&b_existing.path).unwrap(),
"already here"
);
assert!(
!Path::new(&c_should_not_exist.path).exists(),
"C must not run after failure"
);
assert!(result.contains("created"), "got: {result}");
assert!(result.contains("FAILED"), "got: {result}");
}
#[tokio::test]
async fn regression_create_rejects_oversized_content() {
let tf = TestFile::new("oversize.txt");
let too_big = "x".repeat(1_048_577);
let tool = ApplyPatchTool::new(None, None);
let result = tool
.call(ApplyPatchArgs {
operations: vec![PatchOp::Create {
path: tf.path.clone(),
content: too_big,
}],
})
.await
.unwrap();
assert!(result.contains("FAILED"), "got: {result}");
assert!(result.contains("exceeds"), "got: {result}");
assert!(
!Path::new(&tf.path).exists(),
"no file should exist after size-limit rejection"
);
}
#[tokio::test]
async fn create_accepts_content_at_size_limit() {
let tf = TestFile::new("at-limit.txt");
let at_limit = "x".repeat(1_048_576);
let tool = ApplyPatchTool::new(None, None);
let result = tool
.call(ApplyPatchArgs {
operations: vec![PatchOp::Create {
path: tf.path.clone(),
content: at_limit,
}],
})
.await
.unwrap();
assert!(!result.contains("FAILED"), "got: {result}");
assert!(Path::new(&tf.path).exists());
assert_eq!(std::fs::metadata(&tf.path).unwrap().len(), 1_048_576);
}
#[tokio::test]
async fn create_creates_parent_dirs() {
let dir = std::env::temp_dir().join(format!("dirge-test-nested-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let nested = dir.join("a/b/c/file.txt");
let path_str = nested.to_str().unwrap();
let result = apply_create(path_str, "deep content").await;
assert!(result.is_ok());
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "deep content");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn delete_missing_file_returns_err() {
let path = format!("/tmp/dirge-test-delete-ghost-{}.txt", std::process::id());
let _ = std::fs::remove_file(&path);
let result = apply_delete(&path).await;
assert!(result.is_err());
}
#[tokio::test]
async fn multi_op_happy_path_executes_in_order() {
let a = TestFile::new("multi-happy-a.txt");
let b = TestFile::new("multi-happy-b.txt");
let renamed = format!(
"/tmp/dirge-test-multi-happy-renamed-{}.txt",
std::process::id()
);
let _ = std::fs::remove_file(&renamed);
let tool = ApplyPatchTool::new(None, None);
let result = tool
.call(ApplyPatchArgs {
operations: vec![
PatchOp::Create {
path: a.path.clone(),
content: "hello".into(),
},
PatchOp::Update {
path: a.path.clone(),
old_text: "hello".into(),
new_text: "HELLO".into(),
},
PatchOp::Create {
path: b.path.clone(),
content: "scratch".into(),
},
PatchOp::Rename {
path: a.path.clone(),
new_path: renamed.clone(),
},
PatchOp::Delete {
path: b.path.clone(),
},
],
})
.await
.unwrap();
assert!(!result.contains("FAILED"), "got: {result}");
assert!(!Path::new(&a.path).exists()); assert!(!Path::new(&b.path).exists()); assert_eq!(std::fs::read_to_string(&renamed).unwrap(), "HELLO");
let _ = std::fs::remove_file(&renamed);
assert_eq!(
result.lines().filter(|l| !l.is_empty()).count(),
5,
"report: {result}"
);
}
#[test]
fn patch_op_deserializes_each_variant() {
let json = serde_json::json!([
{"action": "create", "path": "/tmp/x", "content": "hi"},
{"action": "update", "path": "/tmp/x", "old_text": "a", "new_text": "b"},
{"action": "delete", "path": "/tmp/x"},
{"action": "rename", "path": "/tmp/x", "new_path": "/tmp/y"},
]);
let ops: Vec<PatchOp> = serde_json::from_value(json).unwrap();
assert!(matches!(ops[0], PatchOp::Create { .. }));
assert!(matches!(ops[1], PatchOp::Update { .. }));
assert!(matches!(ops[2], PatchOp::Delete { .. }));
assert!(matches!(ops[3], PatchOp::Rename { .. }));
}
}