use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
use super::diff_format::make_unified_diff;
use super::file::{
EXPECTED_HASH_DESCRIPTION, PATCH_PARAMS, PATH_ALIASES, apply_param_aliases, content_hash,
};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
lsp_diagnostics_for_paths, optional_bool, optional_str, optional_u64,
};
const MAX_FUZZ: usize = 50;
const DEFAULT_FUZZ: usize = 3;
const MIN_ANCHOR_LINES: usize = 4;
fn reassemble_preserving_newlines(lines: &[String], base_content: &str) -> String {
if lines.is_empty() {
return String::new();
}
let terminator = if base_content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let trailing = base_content.is_empty() || base_content.ends_with('\n');
let mut out = lines.join(terminator);
if trailing {
out.push_str(terminator);
}
out
}
const HUNK_PREVIEW_LINES: usize = 4;
const SNIPPET_RADIUS: usize = 2;
const FILE_LIST_LIMIT: usize = 6;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchResult {
pub success: bool,
pub files_applied: usize,
pub files_total: usize,
pub hunks_applied: usize,
pub hunks_total: usize,
pub fuzz_used: usize,
#[serde(default)]
pub hunks_with_fuzz: usize,
#[serde(default, skip_serializing_if = "is_zero")]
pub hunks_relocated: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub touched_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub file_summaries: Vec<FileSummary>,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSummary {
pub path: String,
pub hunks: usize,
pub hunks_applied: usize,
pub fuzz_used: usize,
pub hunks_with_fuzz: usize,
#[serde(default, skip_serializing_if = "is_zero")]
pub hunks_relocated: usize,
pub created: bool,
pub deleted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyPatchPreflight {
pub touched_files: Vec<String>,
pub files_total: usize,
pub hunks_total: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub creates: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deletes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_override: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header_path_mismatch: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Hunk {
pub old_start: usize,
#[allow(dead_code)]
pub old_count: usize,
#[allow(dead_code)]
pub new_start: usize,
#[allow(dead_code)]
pub new_count: usize,
pub lines: Vec<HunkLine>,
}
#[derive(Debug, Clone)]
pub enum HunkLine {
Context(String),
Add(String),
Remove(String),
}
pub struct ApplyPatchTool;
#[derive(Debug, Clone)]
struct FilePatch {
path: String,
hunks: Vec<Hunk>,
delete_after: bool,
create_if_missing: bool,
}
#[derive(Debug, Clone)]
struct PendingWrite {
path: PathBuf,
content: Option<String>,
original: Option<String>,
}
#[derive(Debug, Default, Clone, Copy)]
struct PatchStats {
files_applied: usize,
files_total: usize,
hunks_applied: usize,
hunks_total: usize,
fuzz_used: usize,
hunks_with_fuzz: usize,
hunks_relocated: usize,
}
fn is_zero(value: &usize) -> bool {
*value == 0
}
#[derive(Debug, Default, Clone)]
struct PatchStatsExt {
stats: PatchStats,
touched_files: Vec<String>,
file_summaries: Vec<FileSummary>,
header_path_mismatch: Option<String>,
}
#[derive(Debug, Default, Clone)]
struct PatchShape {
has_hunks: bool,
header_files: Vec<String>,
}
impl PatchShape {
fn file_count(&self) -> usize {
self.header_files.len()
}
}
#[derive(Debug, Default, Clone, Copy)]
struct HunkApplyStats {
hunks_applied: usize,
fuzz_used: usize,
hunks_with_fuzz: usize,
hunks_relocated: usize,
}
#[derive(Debug, Default, Clone, Copy)]
struct HunkApplyOutcome {
fuzz_used: usize,
relocated: bool,
}
#[derive(Debug, Clone)]
enum ApplyPatchPreflightKind {
Replace,
PathOverride { path: String, hunks: Vec<Hunk> },
FilePatches(Vec<FilePatch>),
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum NormalizedApplyPatchInput<'a> {
Patch(&'a str),
Replacement {
entries: &'a [Value],
source_field: &'static str,
},
}
pub(crate) fn normalize_apply_patch_input(
input: &Value,
) -> Result<NormalizedApplyPatchInput<'_>, ToolError> {
let provided: Vec<&'static str> = ["patch", "replace", "changes"]
.into_iter()
.filter(|field| input.get(*field).is_some())
.collect();
if provided.len() > 1 {
let fields = provided
.iter()
.map(|field| format!("`{field}`"))
.collect::<Vec<_>>()
.join(", ");
return Err(ToolError::invalid_input(format!(
"Cannot use {fields} simultaneously. Choose exactly one of `patch`, `replace`, or the deprecated `changes` alias."
)));
}
let Some(field) = provided.first().copied() else {
return Err(ToolError::missing_field(
"patch, replace, or deprecated changes",
));
};
if field == "patch" {
let patch = input
.get(field)
.and_then(Value::as_str)
.ok_or_else(|| ToolError::invalid_input("`patch` must be a string"))?;
return Ok(NormalizedApplyPatchInput::Patch(patch));
}
let entries = input.get(field).and_then(Value::as_array).ok_or_else(|| {
ToolError::invalid_input(format!(
"`{field}` must be an array of objects like {{path, content}}"
))
})?;
if entries.is_empty() {
return Err(ToolError::invalid_input(format!(
"`{field}` cannot be empty"
)));
}
Ok(NormalizedApplyPatchInput::Replacement {
entries,
source_field: field,
})
}
#[derive(Debug, Clone)]
struct ApplyPatchPreflightPlan {
summary: ApplyPatchPreflight,
kind: ApplyPatchPreflightKind,
}
#[derive(Debug, Error)]
enum ApplyHunkError {
#[error(
"Failed to find matching location for hunk (expected at line {expected_line}, adjusted to {adjusted_line} with offset {offset:+})"
)]
NoMatch {
expected_line: usize,
adjusted_line: usize,
offset: isize,
},
#[error(
"Hunk context is ambiguous: matches at multiple locations {candidate_lines:?}, expected around line {expected_line}"
)]
ContextAmbiguous {
expected_line: usize,
candidate_lines: Vec<usize>,
},
}
#[async_trait]
impl ToolSpec for ApplyPatchTool {
fn name(&self) -> &'static str {
"apply_patch"
}
fn model_visible(&self) -> bool {
true
}
fn description(&self) -> &'static str {
"Apply a transactional unified-diff patch across one or more files, with fuzzy context matching and a rendered diff."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to patch (relative to workspace)"
},
"patch": {
"type": "string",
"description": "Unified diff patch content"
},
"replace": {
"type": "array",
"description": "Optional full file replacements (path + content).",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
},
"changes": {
"type": "array",
"description": "Deprecated compatibility alias for `replace` (full file replacements by path + content).",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
},
"fuzz": {
"type": "integer",
"description": "Maximum fuzz factor for fuzzy matching (default: 3)"
},
"create_if_missing": {
"type": "boolean",
"description": "Create the file if it doesn't exist (for new file patches)"
},
"expected_hash": {
"type": "string",
"description": format!(
"{EXPECTED_HASH_DESCRIPTION} Verifies the patch target — the `path` argument when given, otherwise the first file the patch touches; other files in a multi-file patch are not hash-checked."
)
}
},
"oneOf": [
{ "required": ["patch"] },
{ "required": ["replace"] },
{ "required": ["changes"] }
]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![
ToolCapability::WritesFiles,
ToolCapability::Sandboxable,
ToolCapability::RequiresApproval,
]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Suggest
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let mut input = input;
apply_param_aliases(&mut input, PATH_ALIASES, "File patch")?;
PATCH_PARAMS.reject_unknown(&input)?;
let input = input;
let fuzz = optional_u64(&input, "fuzz", DEFAULT_FUZZ as u64)?.min(MAX_FUZZ as u64);
let fuzz = usize::try_from(fuzz).unwrap_or(DEFAULT_FUZZ);
let normalized = normalize_apply_patch_input(&input)?;
let create_if_missing = optional_bool(&input, "create_if_missing", false)?;
let preflight = preflight_apply_patch_plan(&input, normalized)?;
verify_patch_expected_hash(&input, &preflight.summary, context)?;
if let NormalizedApplyPatchInput::Replacement {
entries,
source_field,
} = normalized
{
let (pending, stats) =
build_pending_writes_from_replace(entries, source_field, context)?;
apply_pending_writes(&pending)?;
let abs_paths: Vec<PathBuf> = pending.iter().map(|p| p.path.clone()).collect();
let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
let result = PatchResult {
success: true,
files_applied: stats.stats.files_applied,
files_total: stats.stats.files_total,
hunks_applied: stats.stats.hunks_applied,
hunks_total: stats.stats.hunks_total,
fuzz_used: stats.stats.fuzz_used,
hunks_with_fuzz: stats.stats.hunks_with_fuzz,
hunks_relocated: stats.stats.hunks_relocated,
touched_files: stats.touched_files.clone(),
file_summaries: stats.file_summaries.clone(),
message: build_summary_message(&stats),
};
let mut tool_result = ToolResult::json(&result)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
tool_result = tool_result.with_metadata(apply_patch_result_metadata(
&preflight.summary,
&pending,
&stats,
));
if !diag_block.is_empty() {
tool_result.content.push('\n');
tool_result.content.push_str(&diag_block);
}
return Ok(tool_result);
}
let file_patches = match preflight.kind {
ApplyPatchPreflightKind::Replace => {
unreachable!("replace input returned before patch execution")
}
ApplyPatchPreflightKind::PathOverride { path, hunks } => vec![FilePatch {
path,
hunks,
delete_after: false,
create_if_missing,
}],
ApplyPatchPreflightKind::FilePatches(file_patches) => file_patches,
};
let (pending, mut stats) = build_pending_writes_from_patches(file_patches, context, fuzz)?;
stats.header_path_mismatch = preflight.summary.header_path_mismatch.clone();
apply_pending_writes(&pending)?;
let abs_paths: Vec<PathBuf> = pending
.iter()
.filter(|p| p.content.is_some()) .map(|p| p.path.clone())
.collect();
let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
let result = PatchResult {
success: true,
files_applied: stats.stats.files_applied,
files_total: stats.stats.files_total,
hunks_applied: stats.stats.hunks_applied,
hunks_total: stats.stats.hunks_total,
fuzz_used: stats.stats.fuzz_used,
hunks_with_fuzz: stats.stats.hunks_with_fuzz,
hunks_relocated: stats.stats.hunks_relocated,
touched_files: stats.touched_files.clone(),
file_summaries: stats.file_summaries.clone(),
message: build_summary_message(&stats),
};
let mut tool_result =
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
tool_result = tool_result.with_metadata(apply_patch_result_metadata(
&preflight.summary,
&pending,
&stats,
));
if !diag_block.is_empty() {
tool_result.content.push('\n');
tool_result.content.push_str(&diag_block);
}
Ok(tool_result)
}
}
fn verify_patch_expected_hash(
input: &Value,
summary: &ApplyPatchPreflight,
context: &ToolContext,
) -> Result<(), ToolError> {
let Some(expected) = optional_str(input, "expected_hash")? else {
return Ok(());
};
let Some(target) = summary
.path_override
.as_deref()
.or_else(|| summary.touched_files.first().map(String::as_str))
else {
return Err(ToolError::execution_failed(
"File `patch` refused: expected_hash was supplied but the patch names no target file to verify it against, so nothing was written.".to_string(),
));
};
let resolved = context.resolve_path(target)?;
if !resolved.exists() {
return Err(ToolError::execution_failed(format!(
"File `patch` refused: expected_hash was supplied but {target} does not exist, so there is no snapshot to verify and nothing was written. Recovery: drop `expected_hash` when creating files."
)));
}
let current = fs::read(&resolved).map_err(|e| {
ToolError::execution_failed(format!(
"File `patch` refused: could not read {target} to verify expected_hash ({e}); nothing was written."
))
})?;
let actual = content_hash(¤t);
if actual == expected {
return Ok(());
}
Err(ToolError::execution_failed(format!(
"File `patch` refused: {target} changed since it was read. \
expected_hash was {expected} but the file is now {actual}, so nothing was written. \
Recovery: call File with action=\"read\" path=\"{target}\" to get the current contents \
and its content_hash, then rebuild the patch against them."
)))
}
pub fn preflight_apply_patch(input: &Value) -> Result<ApplyPatchPreflight, ToolError> {
let normalized = normalize_apply_patch_input(input)?;
Ok(preflight_apply_patch_plan(input, normalized)?.summary)
}
fn preflight_apply_patch_plan(
input: &Value,
normalized: NormalizedApplyPatchInput<'_>,
) -> Result<ApplyPatchPreflightPlan, ToolError> {
let create_if_missing = optional_bool(input, "create_if_missing", false)?;
if let NormalizedApplyPatchInput::Replacement {
entries,
source_field,
} = normalized
{
return Ok(ApplyPatchPreflightPlan {
summary: preflight_replace(entries, source_field)?,
kind: ApplyPatchPreflightKind::Replace,
});
}
let NormalizedApplyPatchInput::Patch(patch_text) = normalized else {
unreachable!("replacement input returned before patch parsing")
};
let path_override = optional_str(input, "path")?;
let patch_shape = inspect_patch_shape(patch_text);
validate_patch_shape(&patch_shape, path_override)?;
let header_path_mismatch =
path_override.and_then(|path| diff_header_mismatch(path, &patch_shape));
if let Some(path) = path_override {
let hunks = parse_unified_diff(patch_text)?;
if hunks.is_empty() {
return Err(ToolError::invalid_input(
"Patch did not contain any hunks (`@@ ... @@`). Provide a unified diff hunk.",
));
}
return Ok(ApplyPatchPreflightPlan {
summary: ApplyPatchPreflight {
touched_files: vec![path.to_string()],
files_total: 1,
hunks_total: hunks.len(),
creates: if create_if_missing {
vec![path.to_string()]
} else {
Vec::new()
},
deletes: Vec::new(),
path_override: Some(path.to_string()),
header_path_mismatch,
},
kind: ApplyPatchPreflightKind::PathOverride {
path: path.to_string(),
hunks,
},
});
}
let file_patches = parse_unified_diff_files(patch_text, create_if_missing)?;
if file_patches.is_empty() {
return Err(ToolError::invalid_input(
"No valid file patches found. Ensure the patch includes `---`/`+++` headers or provide `path`.",
));
}
let mut touched_files = Vec::new();
let mut creates = Vec::new();
let mut deletes = Vec::new();
let mut hunks_total = 0;
for file_patch in &file_patches {
if file_patch.hunks.is_empty() {
return Err(ToolError::invalid_input(format!(
"Patch section for `{}` has no hunks (`@@ ... @@`).",
file_patch.path
)));
}
push_unique(&mut touched_files, file_patch.path.clone());
hunks_total += file_patch.hunks.len();
if file_patch.create_if_missing && !file_patch.delete_after {
push_unique(&mut creates, file_patch.path.clone());
}
if file_patch.delete_after {
push_unique(&mut deletes, file_patch.path.clone());
}
}
Ok(ApplyPatchPreflightPlan {
summary: ApplyPatchPreflight {
files_total: file_patches.len(),
touched_files,
hunks_total,
creates,
deletes,
path_override: None,
header_path_mismatch,
},
kind: ApplyPatchPreflightKind::FilePatches(file_patches),
})
}
fn preflight_replace(
changes: &[Value],
source_field: &str,
) -> Result<ApplyPatchPreflight, ToolError> {
let mut touched_files = Vec::new();
for change in changes {
let path = change
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::missing_field(format!("{source_field}[].path")))?;
let _content = change
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::missing_field(format!("{source_field}[].content")))?;
push_unique(&mut touched_files, path.to_string());
}
Ok(ApplyPatchPreflight {
files_total: changes.len(),
touched_files,
hunks_total: 0,
creates: Vec::new(),
deletes: Vec::new(),
path_override: None,
header_path_mismatch: None,
})
}
fn apply_patch_result_metadata(
preflight: &ApplyPatchPreflight,
pending: &[PendingWrite],
stats: &PatchStatsExt,
) -> Value {
let mut metadata =
serde_json::to_value(preflight).expect("ApplyPatchPreflight should serialize");
if let Some(object) = metadata.as_object_mut() {
object.insert("event".to_string(), json!("apply_patch.preflight"));
object.insert(
"mutation".to_string(),
build_mutation_metadata(pending, &stats.file_summaries),
);
}
metadata
}
fn build_mutation_metadata(pending: &[PendingWrite], summaries: &[FileSummary]) -> Value {
let mut matched = HashSet::new();
let mut renames = Vec::new();
for (delete_index, (deleted, delete_summary)) in pending.iter().zip(summaries).enumerate() {
if !delete_summary.deleted || matched.contains(&delete_index) {
continue;
}
let Some(old_content) = deleted.original.as_deref() else {
continue;
};
let Some((create_index, (_, create_summary))) = pending
.iter()
.zip(summaries)
.enumerate()
.find(|(index, (created, summary))| {
!matched.contains(index)
&& summary.created
&& created.content.as_deref() == Some(old_content)
})
else {
continue;
};
matched.insert(delete_index);
matched.insert(create_index);
renames.push(json!({
"from": delete_summary.path,
"to": create_summary.path,
}));
}
let mut files = Vec::new();
for (index, summary) in summaries.iter().enumerate() {
if matched.contains(&index) {
continue;
}
let outcome = if summary.created {
"created"
} else if summary.deleted {
"deleted"
} else {
"updated"
};
files.push(json!({ "path": summary.path, "outcome": outcome }));
}
let mut diff_parts = Vec::new();
for rename in &renames {
let from = rename["from"].as_str().unwrap_or("<file>");
let to = rename["to"].as_str().unwrap_or("<file>");
diff_parts.push(format!(
"diff --git a/{from} b/{to}\nsimilarity index 100%\nrename from {from}\nrename to {to}\n"
));
}
for (index, (write, summary)) in pending.iter().zip(summaries).enumerate() {
if matched.contains(&index) {
continue;
}
let old = write.original.as_deref().unwrap_or("");
let new = write.content.as_deref().unwrap_or("");
let diff = make_unified_diff(&summary.path, old, new);
if !diff.is_empty() {
diff_parts.push(format!(
"diff --git a/{path} b/{path}\n{diff}",
path = summary.path
));
}
}
json!({
"diff": diff_parts.join("\n"),
"files": files,
"renames": renames,
})
}
fn parse_unified_diff(patch: &str) -> Result<Vec<Hunk>, ToolError> {
let mut hunks = Vec::new();
let mut lines = patch.lines().peekable();
while let Some(line) = lines.peek() {
if line.starts_with("@@") {
break;
}
lines.next();
}
while let Some(line) = lines.next() {
if line.starts_with("@@") {
let hunk = parse_hunk_header(line, &mut lines)?;
hunks.push(hunk);
}
}
Ok(hunks)
}
fn parse_unified_diff_files(
patch: &str,
create_if_missing: bool,
) -> Result<Vec<FilePatch>, ToolError> {
let mut files = Vec::new();
let mut lines = patch.lines().peekable();
let mut current: Option<FilePatch> = None;
let mut old_path: Option<String> = None;
while let Some(line) = lines.next() {
if line.starts_with("diff --git ") {
if let Some(file) = current.take() {
files.push(file);
}
old_path = None;
continue;
}
if let Some(stripped) = line.strip_prefix("--- ") {
old_path = Some(stripped.trim().to_string());
continue;
}
if let Some(stripped) = line.strip_prefix("+++ ") {
let new_path = Some(stripped.trim().to_string());
let (path, delete_after, create_flag) =
resolve_diff_paths(old_path.as_deref(), new_path.as_deref(), create_if_missing)?;
old_path = None;
if let Some(file) = current.take() {
files.push(file);
}
current = Some(FilePatch {
path,
hunks: Vec::new(),
delete_after,
create_if_missing: create_flag,
});
continue;
}
if line.starts_with("@@") {
let Some(file) = current.as_mut() else {
if let Some(path) = old_path.as_deref() {
return Err(ToolError::invalid_input(format!(
"Patch hunk encountered after `--- {path}` but before a matching `+++` header. Each file section must include both headers."
)));
}
return Err(ToolError::invalid_input(
"Patch hunk encountered before any file header. Add `---`/`+++` headers or provide `path`.",
));
};
let hunk = parse_hunk_header(line, &mut lines)?;
file.hunks.push(hunk);
}
}
if let Some(file) = current {
files.push(file);
}
Ok(files)
}
fn resolve_diff_paths(
old_path: Option<&str>,
new_path: Option<&str>,
create_if_missing: bool,
) -> Result<(String, bool, bool), ToolError> {
let old_norm = old_path.and_then(normalize_diff_path);
let new_norm = new_path.and_then(normalize_diff_path);
let delete_after = new_norm.is_none();
let create_flag = create_if_missing || old_norm.is_none();
let path = new_norm
.or(old_norm)
.ok_or_else(|| ToolError::invalid_input("Patch is missing both old and new file paths"))?;
Ok((path, delete_after, create_flag))
}
fn normalize_diff_path(raw: &str) -> Option<String> {
let raw = raw.split_once('\t').map_or(raw, |(path, _timestamp)| path);
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if raw == "/dev/null" || raw == "dev/null" {
return None;
}
let raw = raw
.strip_prefix("a/")
.or_else(|| raw.strip_prefix("b/"))
.unwrap_or(raw);
Some(raw.to_string())
}
fn parse_hunk_header<'a, I>(
header: &str,
lines: &mut std::iter::Peekable<I>,
) -> Result<Hunk, ToolError>
where
I: Iterator<Item = &'a str>,
{
let parts: Vec<&str> = header.split_whitespace().collect();
if parts.len() < 3 {
return Err(ToolError::invalid_input(format!(
"Invalid hunk header: {header}. Expected numeric unified-diff form `@@ -old_start,old_count +new_start,new_count @@` (example: `@@ -12,3 +12,5 @@`)."
)));
}
let old_range = parts[1].trim_start_matches('-');
let new_range = parts[2].trim_start_matches('+');
let (old_start, old_count) = parse_range(old_range)?;
let (new_start, new_count) = parse_range(new_range)?;
let mut hunk_lines = Vec::new();
let expected_lines = old_count.max(new_count) + old_count.min(new_count);
for _ in 0..expected_lines * 2 {
match lines.peek() {
Some(line) if line.starts_with("@@") => break,
Some(line) if line.starts_with('-') => {
hunk_lines.push(HunkLine::Remove(line[1..].to_string()));
lines.next();
}
Some(line) if line.starts_with('+') => {
hunk_lines.push(HunkLine::Add(line[1..].to_string()));
lines.next();
}
Some(line) if line.starts_with(' ') || line.is_empty() => {
let content = if line.is_empty() { "" } else { &line[1..] };
hunk_lines.push(HunkLine::Context(content.to_string()));
lines.next();
}
Some(line)
if line.starts_with("diff ")
|| line.starts_with("--- ")
|| line.starts_with("+++ ") =>
{
break;
}
Some(line) if !line.starts_with('\\') => {
hunk_lines.push(HunkLine::Context((*line).to_string()));
lines.next();
}
Some(_) => {
lines.next(); }
None => break,
}
}
Ok(Hunk {
old_start,
old_count,
new_start,
new_count,
lines: hunk_lines,
})
}
fn parse_range(range: &str) -> Result<(usize, usize), ToolError> {
let parts: Vec<&str> = range.split(',').collect();
let start = parts[0].parse::<usize>().map_err(|_| {
ToolError::invalid_input(format!(
"Invalid line number `{}` in hunk header. Expected numeric unified-diff form `@@ -old_start,old_count +new_start,new_count @@` (example: `@@ -12,3 +12,5 @@`); use positive integers like `12` or `12,3`.",
parts[0]
))
})?;
let count = if parts.len() > 1 {
parts[1].parse::<usize>().map_err(|_| {
ToolError::invalid_input(format!(
"Invalid line count `{}` in hunk header. Expected numeric unified-diff form `@@ -old_start,old_count +new_start,new_count @@` (example: `@@ -12,3 +12,5 @@`); use positive integers like `3`.",
parts[1]
))
})?
} else {
1
};
Ok((start, count))
}
fn inspect_patch_shape(patch: &str) -> PatchShape {
let mut shape = PatchShape::default();
let mut seen = HashSet::new();
let mut old_path: Option<String> = None;
let mut hunk_old_remaining = 0usize;
let mut hunk_new_remaining = 0usize;
for line in patch.lines() {
if line.starts_with("@@") {
shape.has_hunks = true;
if let Some((old_count, new_count)) = hunk_line_counts_for_shape(line) {
hunk_old_remaining = old_count;
hunk_new_remaining = new_count;
}
continue;
}
if hunk_old_remaining > 0 || hunk_new_remaining > 0 {
advance_hunk_shape_counts(line, &mut hunk_old_remaining, &mut hunk_new_remaining);
continue;
}
if let Some(stripped) = line.strip_prefix("--- ") {
old_path = normalize_diff_path(stripped);
continue;
}
if let Some(stripped) = line.strip_prefix("+++ ") {
let new_path = normalize_diff_path(stripped);
let resolved = new_path.or(old_path.clone());
if let Some(path) = resolved
&& seen.insert(path.clone())
{
shape.header_files.push(path);
}
old_path = None;
}
}
shape
}
fn hunk_line_counts_for_shape(header: &str) -> Option<(usize, usize)> {
let parts: Vec<&str> = header.split_whitespace().collect();
if parts.len() < 3 {
return None;
}
let (_, old_count) = parse_range(parts[1].trim_start_matches('-')).ok()?;
let (_, new_count) = parse_range(parts[2].trim_start_matches('+')).ok()?;
Some((old_count, new_count))
}
fn advance_hunk_shape_counts(line: &str, old_remaining: &mut usize, new_remaining: &mut usize) {
if line.starts_with('\\') {
return;
}
if line.starts_with('+') {
*new_remaining = new_remaining.saturating_sub(1);
} else if line.starts_with('-') {
*old_remaining = old_remaining.saturating_sub(1);
} else {
*old_remaining = old_remaining.saturating_sub(1);
*new_remaining = new_remaining.saturating_sub(1);
}
}
fn validate_patch_shape(shape: &PatchShape, path_override: Option<&str>) -> Result<(), ToolError> {
if !shape.has_hunks {
return Err(ToolError::invalid_input(
"Patch must include at least one hunk header in numeric unified-diff form (`@@ -old_start,old_count +new_start,new_count @@`, example: `@@ -12,3 +12,5 @@`).",
));
}
match path_override {
Some(_) if shape.file_count() > 1 => Err(ToolError::invalid_input(format!(
"Patch references multiple files ({}) but `path` was provided. Remove `path` to apply a multi-file patch, or provide a single-file patch.",
format_file_list(&shape.header_files),
))),
None if shape.file_count() == 0 => Err(ToolError::invalid_input(
"Patch contains hunks but no file headers (`---`/`+++`). Provide `path` or add headers.",
)),
_ => Ok(()),
}
}
fn diff_header_mismatch(path_override: &str, shape: &PatchShape) -> Option<String> {
if shape.file_count() != 1 {
return None;
}
let header_path = &shape.header_files[0];
let override_norm = normalize_diff_path(path_override).unwrap_or_else(|| path_override.into());
if &override_norm == header_path {
None
} else {
Some(format!(
"Note: patch headers reference `{header_path}` but `path` overrides to `{override_norm}`."
))
}
}
fn build_summary_message(stats: &PatchStatsExt) -> String {
let mut parts = Vec::new();
if stats.stats.hunks_total > 0 {
parts.push(format!(
"Applied {}/{} hunks across {} file(s).",
stats.stats.hunks_applied, stats.stats.hunks_total, stats.stats.files_applied
));
} else {
parts.push(format!(
"Applied {} file change(s).",
stats.stats.files_applied
));
}
if !stats.touched_files.is_empty() {
parts.push(format!(
"Files: {}.",
format_file_list(&stats.touched_files)
));
}
if stats.stats.fuzz_used > 0 {
parts.push(format!(
"Fuzz used on {} hunk(s) (total fuzz: {}).",
stats.stats.hunks_with_fuzz, stats.stats.fuzz_used
));
}
if stats.stats.hunks_relocated > 0 {
parts.push(format!(
"{} hunk(s) applied with stale line numbers (auto-relocated to unique context).",
stats.stats.hunks_relocated
));
}
if let Some(note) = stats.header_path_mismatch.as_deref() {
parts.push(note.to_string());
}
parts.join(" ")
}
fn format_file_list(files: &[String]) -> String {
if files.is_empty() {
return "<none>".to_string();
}
let mut shown: Vec<String> = files.iter().take(FILE_LIST_LIMIT).cloned().collect();
let remaining = files.len().saturating_sub(shown.len());
if remaining > 0 {
shown.push(format!("... (+{remaining} more)"));
}
shown.join(", ")
}
fn push_unique(target: &mut Vec<String>, value: String) {
if !target.iter().any(|existing| existing == &value) {
target.push(value);
}
}
fn build_pending_writes_from_replace(
changes: &[Value],
source_field: &str,
context: &ToolContext,
) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
let mut pending = Vec::new();
let mut stats = PatchStatsExt::default();
for change in changes {
let path = change
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::missing_field(format!("{source_field}[].path")))?;
let content = change
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::missing_field(format!("{source_field}[].content")))?;
let resolved = context.resolve_path(path)?;
let original = if resolved.exists() {
Some(read_file_content(&resolved)?)
} else {
None
};
let created = original.is_none();
pending.push(PendingWrite {
path: resolved,
content: Some(content.to_string()),
original,
});
stats.stats.files_total += 1;
stats.stats.files_applied += 1;
push_unique(&mut stats.touched_files, path.to_string());
stats.file_summaries.push(FileSummary {
path: path.to_string(),
hunks: 0,
hunks_applied: 0,
fuzz_used: 0,
hunks_with_fuzz: 0,
hunks_relocated: 0,
created,
deleted: false,
});
}
Ok((pending, stats))
}
fn build_pending_writes_from_patches(
file_patches: Vec<FilePatch>,
context: &ToolContext,
fuzz: usize,
) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
let mut pending = Vec::new();
let mut stats = PatchStatsExt::default();
stats.stats.files_total = file_patches.len();
for file_patch in file_patches {
if file_patch.hunks.is_empty() {
return Err(ToolError::invalid_input(format!(
"Patch section for `{}` has no hunks (`@@ ... @@`).",
file_patch.path
)));
}
let resolved = context.resolve_path(&file_patch.path)?;
let original = if resolved.exists() {
Some(read_file_content(&resolved)?)
} else {
None
};
if original.is_none() && !file_patch.create_if_missing {
return Err(ToolError::execution_failed(format!(
"File `{}` does not exist at `{}`. Set create_if_missing=true for new files or include headers for file creation.",
file_patch.path,
resolved.display(),
)));
}
if file_patch.delete_after && original.is_none() {
return Err(ToolError::execution_failed(format!(
"File `{}` does not exist at `{}` to delete.",
file_patch.path,
resolved.display(),
)));
}
let base_content = original.clone().unwrap_or_default();
let mut lines: Vec<String> = if base_content.is_empty() {
Vec::new()
} else {
base_content.lines().map(String::from).collect()
};
let apply_stats =
apply_hunks_to_lines(&mut lines, &file_patch.hunks, fuzz, &file_patch.path)?;
stats.stats.hunks_applied += apply_stats.hunks_applied;
stats.stats.hunks_total += file_patch.hunks.len();
stats.stats.fuzz_used += apply_stats.fuzz_used;
stats.stats.hunks_with_fuzz += apply_stats.hunks_with_fuzz;
stats.stats.hunks_relocated += apply_stats.hunks_relocated;
stats.stats.files_applied += 1;
push_unique(&mut stats.touched_files, file_patch.path.clone());
stats.file_summaries.push(FileSummary {
path: file_patch.path.clone(),
hunks: file_patch.hunks.len(),
hunks_applied: apply_stats.hunks_applied,
fuzz_used: apply_stats.fuzz_used,
hunks_with_fuzz: apply_stats.hunks_with_fuzz,
hunks_relocated: apply_stats.hunks_relocated,
created: original.is_none() && !file_patch.delete_after,
deleted: file_patch.delete_after,
});
if file_patch.delete_after {
pending.push(PendingWrite {
path: resolved,
content: None,
original,
});
} else {
let new_content = reassemble_preserving_newlines(&lines, &base_content);
pending.push(PendingWrite {
path: resolved,
content: Some(new_content),
original,
});
}
}
Ok((pending, stats))
}
fn apply_pending_writes(pending: &[PendingWrite]) -> Result<(), ToolError> {
let mut applied = Vec::new();
for entry in pending {
let result = if let Some(content) = entry.content.as_ref() {
let parent_result = if let Some(parent) = entry.path.parent() {
fs::create_dir_all(parent).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to create directory {}: {}",
parent.display(),
e
))
})
} else {
Ok(())
};
parent_result.and_then(|()| {
crate::utils::write_atomic_workspace(&entry.path, content.as_bytes()).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to write {}: {}",
entry.path.display(),
e
))
})
})
} else if entry.path.exists() {
fs::remove_file(&entry.path).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to delete {}: {}",
entry.path.display(),
e
))
})
} else {
Ok(())
};
if let Err(err) = result {
rollback_pending_writes(&applied);
return Err(err);
}
applied.push(entry.clone());
}
Ok(())
}
fn rollback_pending_writes(applied: &[PendingWrite]) {
for entry in applied.iter().rev() {
match entry.original.as_ref() {
Some(content) => {
let _ = crate::utils::write_atomic_workspace(&entry.path, content.as_bytes());
}
None => {
let _ = fs::remove_file(&entry.path);
}
}
}
}
fn read_file_content(path: &PathBuf) -> Result<String, ToolError> {
fs::read_to_string(path).map_err(|e| {
ToolError::execution_failed(format!("Failed to read {}: {}", path.display(), e))
})
}
fn preview_expected_lines(hunk: &Hunk, limit: usize) -> Vec<String> {
let mut preview = Vec::new();
for line in hunk.lines.iter().filter_map(|line| match line {
HunkLine::Context(s) => Some((" ", s)),
HunkLine::Remove(s) => Some(("-", s)),
HunkLine::Add(_) => None,
}) {
if preview.len() >= limit {
break;
}
preview.push(format!(" {}{}", line.0, line.1));
}
if preview.is_empty() {
preview.push(" <no context lines in hunk>".to_string());
}
preview
}
fn snippet_around(lines: &[String], line_1_based: usize, radius: usize) -> Vec<String> {
if lines.is_empty() {
return vec![" <empty file>".to_string()];
}
let center = line_1_based
.saturating_sub(1)
.min(lines.len().saturating_sub(1));
let start = center.saturating_sub(radius);
let end = (center + radius).min(lines.len().saturating_sub(1));
lines[start..=end]
.iter()
.enumerate()
.map(|(idx, line)| {
let line_no = start + idx + 1;
format!(" {line_no:>4}: {line}")
})
.collect()
}
fn format_hunk_no_match_error(
lines: &[String],
hunk: &Hunk,
err: &ApplyHunkError,
max_fuzz: usize,
) -> String {
match err {
ApplyHunkError::NoMatch {
expected_line,
adjusted_line,
offset,
} => {
let expected_preview = preview_expected_lines(hunk, HUNK_PREVIEW_LINES).join("\n");
let file_preview = snippet_around(lines, *adjusted_line, SNIPPET_RADIUS).join("\n");
format!(
"could not find matching context near line {expected_line} (searched around line {adjusted_line} with offset {offset:+} and fuzz up to {max_fuzz}). Expected context preview:\n{expected_preview}\nFile snippet near line {adjusted_line}:\n{file_preview}\nHints: the line numbers may be stale after earlier edits — call File with action=\"read\" to re-check the current contents, ensure the patch matches the file, increase `fuzz`, or regenerate the patch."
)
}
ApplyHunkError::ContextAmbiguous {
expected_line,
candidate_lines,
} => {
let candidates = candidate_lines
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join(", ");
format!(
"could not find matching context near line {expected_line}: the hunk's context appears at multiple locations (lines {candidates}), and the line numbers may be stale after earlier edits, so it is not safe to relocate automatically. Hints: call File with action=\"read\" to inspect the candidate locations above, then regenerate the patch with more surrounding context lines that uniquely identify the target block."
)
}
}
}
fn apply_hunks_to_lines(
lines: &mut Vec<String>,
hunks: &[Hunk],
fuzz: usize,
file_label: &str,
) -> Result<HunkApplyStats, ToolError> {
let mut stats = HunkApplyStats::default();
let mut cumulative_offset: isize = 0;
for (idx, hunk) in hunks.iter().enumerate() {
match apply_hunk(lines, hunk, fuzz, &mut cumulative_offset) {
Ok(outcome) => {
stats.hunks_applied += 1;
if outcome.fuzz_used > 0 {
stats.fuzz_used += outcome.fuzz_used;
stats.hunks_with_fuzz += 1;
}
if outcome.relocated {
stats.hunks_relocated += 1;
}
}
Err(e) => {
let detail = format_hunk_no_match_error(lines, hunk, &e, fuzz);
return Err(ToolError::execution_failed(format!(
"Failed to apply hunk {}/{} for `{}`: {}",
idx + 1,
hunks.len(),
file_label,
detail
)));
}
}
}
Ok(stats)
}
fn apply_hunk(
lines: &mut Vec<String>,
hunk: &Hunk,
max_fuzz: usize,
cumulative_offset: &mut isize,
) -> Result<HunkApplyOutcome, ApplyHunkError> {
let old_lines: Vec<&str> = hunk
.lines
.iter()
.filter_map(|line| match line {
HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
HunkLine::Add(_) => None,
})
.collect();
let new_lines: Vec<String> = hunk
.lines
.iter()
.filter_map(|line| match line {
HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
HunkLine::Remove(_) => None,
})
.collect();
let base_idx = if hunk.old_start > 0 {
hunk.old_start - 1
} else {
0
};
let start_idx = base_idx
.checked_add_signed(*cumulative_offset)
.unwrap_or(0)
.min(lines.len());
for fuzz in 0..=max_fuzz {
let search_range = if fuzz == 0 {
vec![start_idx]
} else {
let min = start_idx.saturating_sub(fuzz);
let max = (start_idx + fuzz).min(lines.len());
(min..=max).collect()
};
for pos in search_range {
if matches_at_position(lines, &old_lines, pos) {
let end_pos = pos + old_lines.len();
lines.splice(pos..end_pos, new_lines.clone());
let delta = new_lines.len() as isize - old_lines.len() as isize;
*cumulative_offset += delta;
return Ok(HunkApplyOutcome {
fuzz_used: fuzz,
relocated: false,
});
}
}
}
if old_lines.is_empty() && (lines.is_empty() || start_idx >= lines.len()) {
let delta = new_lines.len() as isize;
lines.extend(new_lines);
*cumulative_offset += delta;
return Ok(HunkApplyOutcome {
fuzz_used: 0,
relocated: false,
});
}
let anchor_matches: Vec<usize> = if old_lines.len() >= MIN_ANCHOR_LINES {
(0..=lines.len().saturating_sub(old_lines.len()))
.filter(|&pos| matches_at_position(lines, &old_lines, pos))
.collect()
} else {
Vec::new()
};
match anchor_matches.as_slice() {
[pos] => {
let end_pos = pos + old_lines.len();
lines.splice(*pos..end_pos, new_lines.clone());
let delta = new_lines.len() as isize - old_lines.len() as isize;
*cumulative_offset += delta;
Ok(HunkApplyOutcome {
fuzz_used: 0,
relocated: true,
})
}
[] => Err(ApplyHunkError::NoMatch {
expected_line: hunk.old_start,
adjusted_line: start_idx + 1, offset: *cumulative_offset,
}),
multiple => Err(ApplyHunkError::ContextAmbiguous {
expected_line: hunk.old_start,
candidate_lines: multiple.iter().map(|&p| p + 1).collect(),
}),
}
}
fn matches_at_position(lines: &[String], old_lines: &[&str], pos: usize) -> bool {
if pos + old_lines.len() > lines.len() {
return false;
}
for (i, old_line) in old_lines.iter().enumerate() {
let file_line = lines[pos + i].trim_end();
let expected = old_line.trim_end();
if file_line != expected {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn parse_patch_result(result: ToolResult) -> PatchResult {
serde_json::from_str(&result.content).expect("patch result json")
}
#[test]
fn test_parse_range() {
assert_eq!(parse_range("10,5").unwrap(), (10, 5));
assert_eq!(parse_range("10").unwrap(), (10, 1));
assert_eq!(parse_range("1,0").unwrap(), (1, 0));
}
#[test]
fn test_parse_unified_diff() {
let patch = r"--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
line1
-line2
+modified line2
line3
";
let hunks = parse_unified_diff(patch).unwrap();
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_start, 1);
assert_eq!(hunks[0].old_count, 3);
assert_eq!(hunks[0].new_start, 1);
assert_eq!(hunks[0].new_count, 3);
}
#[test]
fn input_schema_exposes_replace_and_deprecated_changes_alias() {
let schema = ApplyPatchTool.input_schema();
assert_eq!(schema["properties"]["replace"]["type"], "array");
assert_eq!(schema["properties"]["changes"]["type"], "array");
assert!(
schema["properties"]["changes"]["description"]
.as_str()
.is_some_and(|description| description.contains("Deprecated"))
);
assert_eq!(
schema["oneOf"],
json!([
{ "required": ["patch"] },
{ "required": ["replace"] },
{ "required": ["changes"] }
])
);
}
#[test]
fn test_preflight_apply_patch_with_path_override() {
let patch = r"@@ -1,2 +1,2 @@
old
-value
+new-value
";
let preflight = preflight_apply_patch(&json!({
"path": "src/lib.rs",
"patch": patch
}))
.expect("preflight");
assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
assert_eq!(preflight.files_total, 1);
assert_eq!(preflight.hunks_total, 1);
assert_eq!(preflight.path_override.as_deref(), Some("src/lib.rs"));
}
#[test]
fn test_preflight_apply_patch_multi_file_create_and_delete() {
let patch = r"diff --git a/new.rs b/new.rs
--- /dev/null
+++ b/new.rs
@@ -0,0 +1 @@
+fn added() {}
diff --git a/old.rs b/old.rs
--- a/old.rs
+++ /dev/null
@@ -1 +0,0 @@
-fn old() {}
";
let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
assert_eq!(preflight.touched_files, vec!["new.rs", "old.rs"]);
assert_eq!(preflight.files_total, 2);
assert_eq!(preflight.hunks_total, 2);
assert_eq!(preflight.creates, vec!["new.rs"]);
assert_eq!(preflight.deletes, vec!["old.rs"]);
}
#[test]
fn test_preflight_apply_patch_timestamp_headers_strip_metadata() {
let patch = "diff --git a/src/lib.rs b/src/lib.rs\n\
--- a/src/lib.rs\t2026-06-26 10:00:00 +0000\n\
+++ b/src/lib.rs\t2026-06-26 10:01:00 +0000\n\
@@ -1,1 +1,1 @@\n\
-old\n\
+new\n";
let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
assert_eq!(preflight.files_total, 1);
assert_eq!(preflight.hunks_total, 1);
}
#[test]
fn test_preflight_apply_patch_ignores_forged_headers_inside_hunk_shape() {
let patch = r"--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@
line1
--- a/forged.rs
+++ b/forged.rs
line3
";
let preflight = preflight_apply_patch(&json!({
"path": "src/lib.rs",
"patch": patch
}))
.expect("preflight");
assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
assert_eq!(preflight.header_path_mismatch, None);
}
#[test]
fn test_preflight_apply_patch_replace_list() {
let canonical = preflight_apply_patch(&json!({
"replace": [
{ "path": "one.txt", "content": "one" },
{ "path": "two.txt", "content": "two" }
]
}))
.expect("preflight");
let legacy = preflight_apply_patch(&json!({
"changes": [
{ "path": "one.txt", "content": "one" },
{ "path": "two.txt", "content": "two" }
]
}))
.expect("legacy preflight");
assert_eq!(canonical.touched_files, vec!["one.txt", "two.txt"]);
assert_eq!(canonical.files_total, 2);
assert_eq!(canonical.hunks_total, 0);
assert_eq!(legacy, canonical);
}
#[test]
fn test_preflight_replace_files_total_counts_entries() {
let preflight = preflight_apply_patch(&json!({
"replace": [
{ "path": "same.txt", "content": "one" },
{ "path": "same.txt", "content": "two" }
]
}))
.expect("preflight");
assert_eq!(preflight.touched_files, vec!["same.txt"]);
assert_eq!(preflight.files_total, 2);
}
#[test]
fn test_preflight_patch_files_total_counts_sections() {
let patch = r"diff --git a/same.txt b/same.txt
--- a/same.txt
+++ b/same.txt
@@ -1,1 +1,1 @@
-one
+two
diff --git a/same.txt b/same.txt
--- a/same.txt
+++ b/same.txt
@@ -2,1 +2,1 @@
-three
+four
";
let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
assert_eq!(preflight.touched_files, vec!["same.txt"]);
assert_eq!(preflight.files_total, 2);
assert_eq!(preflight.hunks_total, 2);
}
#[test]
fn test_apply_hunk_simple() {
let mut lines = vec![
"line1".to_string(),
"line2".to_string(),
"line3".to_string(),
];
let hunk = Hunk {
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 3,
lines: vec![
HunkLine::Context("line1".to_string()),
HunkLine::Remove("line2".to_string()),
HunkLine::Add("modified".to_string()),
HunkLine::Context("line3".to_string()),
],
};
let mut offset: isize = 0;
let outcome = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap();
assert_eq!(outcome.fuzz_used, 0);
assert!(!outcome.relocated);
assert_eq!(lines, vec!["line1", "modified", "line3"]);
}
#[test]
fn test_apply_hunk_with_fuzz() {
let mut lines = vec![
"line0".to_string(),
"line1".to_string(),
"line2".to_string(),
"line3".to_string(),
];
let hunk = Hunk {
old_start: 1, old_count: 2,
new_start: 1,
new_count: 2,
lines: vec![
HunkLine::Remove("line1".to_string()),
HunkLine::Add("modified".to_string()),
HunkLine::Context("line2".to_string()),
],
};
let mut offset: isize = 0;
let outcome = apply_hunk(&mut lines, &hunk, 3, &mut offset).unwrap();
assert!(outcome.fuzz_used > 0);
assert!(!outcome.relocated);
assert_eq!(lines, vec!["line0", "modified", "line2", "line3"]);
}
#[test]
fn test_apply_hunk_no_match_returns_error() {
let mut lines = vec!["line1".to_string(), "line2".to_string()];
let hunk = Hunk {
old_start: 5,
old_count: 1,
new_start: 5,
new_count: 1,
lines: vec![
HunkLine::Context("missing".to_string()),
HunkLine::Add("new".to_string()),
],
};
let mut offset: isize = 0;
let err = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap_err();
assert!(matches!(
err,
ApplyHunkError::NoMatch {
expected_line: 5,
..
}
));
}
#[tokio::test]
async fn test_apply_patch_tool() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("test.txt"), "line1\nline2\nline3\n").expect("write");
let patch = r"--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
line1
-line2
+modified
line3
";
let tool = ApplyPatchTool;
let result = tool
.execute(json!({"path": "test.txt", "patch": patch}), &ctx)
.await
.expect("execute");
assert!(result.success);
assert_eq!(
result.metadata.as_ref().unwrap()["event"],
"apply_patch.preflight"
);
assert_eq!(
result.metadata.as_ref().unwrap()["touched_files"],
json!(["test.txt"])
);
assert!(
result
.metadata
.as_ref()
.unwrap()
.get("header_path_mismatch")
.is_none()
);
assert!(
result
.metadata
.as_ref()
.unwrap()
.get("path_override")
.is_some()
);
let mutation = &result.metadata.as_ref().unwrap()["mutation"];
assert_eq!(
mutation["files"],
json!([{ "path": "test.txt", "outcome": "updated" }])
);
assert!(
mutation["diff"]
.as_str()
.is_some_and(|diff| diff.contains("-line2") && diff.contains("+modified")),
"{mutation}"
);
let patch_result = parse_patch_result(result);
assert_eq!(patch_result.touched_files, vec!["test.txt"]);
assert_eq!(patch_result.hunks_applied, 1);
let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
assert!(content.contains("modified"));
assert!(!content.contains("line2"));
assert!(content.ends_with('\n'), "trailing newline was dropped");
}
#[test]
fn reassemble_preserving_newlines_keeps_style() {
let lines = vec!["a".to_string(), "b".to_string()];
assert_eq!(reassemble_preserving_newlines(&lines, "x\ny\n"), "a\nb\n");
assert_eq!(reassemble_preserving_newlines(&lines, "x\ny"), "a\nb");
assert_eq!(
reassemble_preserving_newlines(&lines, "x\r\ny\r\n"),
"a\r\nb\r\n"
);
assert_eq!(reassemble_preserving_newlines(&lines, ""), "a\nb\n");
assert_eq!(reassemble_preserving_newlines(&[], "x\n"), "");
}
#[tokio::test]
async fn apply_patch_preserves_crlf_line_endings() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("crlf.txt"), "line1\r\nline2\r\nline3\r\n").expect("write");
let patch =
"--- a/crlf.txt\n+++ b/crlf.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+modified\n line3\n";
let result = ApplyPatchTool
.execute(json!({"path": "crlf.txt", "patch": patch}), &ctx)
.await
.expect("execute");
assert!(result.success);
let content = fs::read_to_string(tmp.path().join("crlf.txt")).expect("read");
assert!(content.contains("modified"));
assert!(
content.contains("\r\n"),
"CRLF was flipped to LF: {content:?}"
);
assert!(!content.contains("\n\n"), "spurious bare LF introduced");
assert!(content.ends_with("\r\n"), "trailing CRLF dropped");
}
#[tokio::test]
async fn test_apply_patch_add_lines() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("test.txt"), "line1\nline3\n").expect("write");
let patch = r"@@ -1,2 +1,3 @@
line1
+line2
line3
";
let tool = ApplyPatchTool;
let result = tool
.execute(json!({"path": "test.txt", "patch": patch}), &ctx)
.await
.expect("execute");
assert!(result.success);
let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
assert_eq!(
mutation["files"],
json!([{ "path": "test.txt", "outcome": "updated" }])
);
assert!(
mutation["diff"]
.as_str()
.is_some_and(|diff| diff.contains("+line2")),
"{mutation}"
);
let patch_result = parse_patch_result(result);
assert_eq!(patch_result.touched_files, vec!["test.txt"]);
let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
assert!(content.contains("line2"));
}
#[tokio::test]
async fn test_apply_patch_create_new_file() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let patch = r"@@ -0,0 +1,3 @@
+line1
+line2
+line3
";
let tool = ApplyPatchTool;
let result = tool
.execute(
json!({"path": "new_file.txt", "patch": patch, "create_if_missing": true}),
&ctx,
)
.await
.expect("execute");
assert!(result.success);
let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
assert_eq!(
mutation["files"],
json!([{ "path": "new_file.txt", "outcome": "created" }])
);
assert!(
mutation["diff"]
.as_str()
.is_some_and(|diff| diff.contains("+line1")),
"{mutation}"
);
let patch_result = parse_patch_result(result);
assert_eq!(patch_result.touched_files, vec!["new_file.txt"]);
assert!(patch_result.file_summaries.first().unwrap().created);
assert!(tmp.path().join("new_file.txt").exists());
}
#[tokio::test]
async fn test_apply_patch_replace_list() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("one.txt"), "old\n").expect("write");
let tool = ApplyPatchTool;
let result = tool
.execute(
json!({
"replace": [
{ "path": "one.txt", "content": "new\n" },
{ "path": "two.txt", "content": "second\n" }
]
}),
&ctx,
)
.await
.expect("execute");
assert!(result.success);
let metadata = result.metadata.as_ref().expect("metadata");
assert_eq!(metadata["event"], "apply_patch.preflight");
assert_eq!(metadata["touched_files"], json!(["one.txt", "two.txt"]));
assert_eq!(metadata["files_total"], 2);
assert_eq!(metadata["hunks_total"], 0);
assert!(metadata.get("path_override").is_none());
assert_eq!(
metadata["mutation"]["files"],
json!([
{ "path": "one.txt", "outcome": "updated" },
{ "path": "two.txt", "outcome": "created" }
])
);
let mutation_diff = metadata["mutation"]["diff"]
.as_str()
.expect("mutation diff");
assert!(mutation_diff.contains("diff --git a/one.txt b/one.txt"));
assert!(mutation_diff.contains("diff --git a/two.txt b/two.txt"));
assert!(mutation_diff.contains("--- a/one.txt"), "{mutation_diff}");
assert!(mutation_diff.contains("+++ b/two.txt"), "{mutation_diff}");
let patch_result = parse_patch_result(result);
let mut touched = patch_result.touched_files.clone();
touched.sort();
assert_eq!(touched, vec!["one.txt", "two.txt"]);
assert_eq!(patch_result.hunks_total, 0);
assert_eq!(
fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
"new\n"
);
assert_eq!(
fs::read_to_string(tmp.path().join("two.txt")).unwrap(),
"second\n"
);
}
#[tokio::test]
async fn test_apply_patch_legacy_changes_list() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("legacy.txt"), "old\n").expect("write");
let result = ApplyPatchTool
.execute(
json!({
"changes": [
{ "path": "legacy.txt", "content": "new\n" }
]
}),
&ctx,
)
.await
.expect("legacy changes alias should execute");
assert!(result.success);
assert_eq!(
fs::read_to_string(tmp.path().join("legacy.txt")).unwrap(),
"new\n"
);
}
#[tokio::test]
async fn apply_patch_rejects_every_mixed_mode_before_writing() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("guard.txt"), "old\n").expect("write");
let patch = "--- a/guard.txt\n+++ b/guard.txt\n@@ -1 +1 @@\n-old\n+patched\n";
let replacement = json!([{
"path": "guard.txt",
"content": "replaced\n"
}]);
let cases = [
(
["patch", "replace"],
json!({"patch": patch, "replace": replacement.clone()}),
),
(
["patch", "changes"],
json!({"patch": patch, "changes": replacement.clone()}),
),
(
["replace", "changes"],
json!({
"replace": replacement.clone(),
"changes": replacement.clone()
}),
),
];
for (fields, input) in cases {
let err = ApplyPatchTool
.execute(input, &ctx)
.await
.expect_err("mixed modes must be rejected");
let ToolError::InvalidInput { message } = err else {
panic!("mixed modes should be invalid input, got: {err}");
};
assert!(message.contains("simultaneously"), "{message}");
for field in fields {
assert!(message.contains(field), "{message}");
}
assert_eq!(
fs::read_to_string(tmp.path().join("guard.txt")).unwrap(),
"old\n",
"mixed modes must be rejected before the first write"
);
}
}
#[tokio::test]
async fn test_apply_patch_replace_list_rolls_back_on_write_failure() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("one.txt"), "old\n").expect("write");
fs::write(tmp.path().join("blocked"), "not a dir\n").expect("write blocker");
let tool = ApplyPatchTool;
let err = tool
.execute(
json!({
"replace": [
{ "path": "one.txt", "content": "new\n" },
{ "path": "blocked/two.txt", "content": "second\n" }
]
}),
&ctx,
)
.await
.expect_err("second write should fail");
let message = err.to_string();
assert!(message.contains("blocked"), "{message}");
assert_eq!(
fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
"old\n"
);
assert!(!tmp.path().join("blocked").join("two.txt").exists());
}
#[tokio::test]
async fn test_apply_patch_multi_file_diff() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("a.txt"), "line1\nline2\n").expect("write");
fs::write(tmp.path().join("b.txt"), "alpha\nbeta\n").expect("write");
let patch = r"diff --git a/a.txt b/a.txt
--- a/a.txt
+++ b/a.txt
@@ -1,2 +1,2 @@
line1
-line2
+line2-mod
diff --git a/b.txt b/b.txt
--- a/b.txt
+++ b/b.txt
@@ -1,2 +1,3 @@
alpha
+beta2
beta
";
let tool = ApplyPatchTool;
let result = tool
.execute(json!({"patch": patch}), &ctx)
.await
.expect("execute");
assert!(result.success);
let metadata = result.metadata.as_ref().expect("metadata");
assert_eq!(metadata["event"], "apply_patch.preflight");
assert_eq!(metadata["touched_files"], json!(["a.txt", "b.txt"]));
assert_eq!(metadata["files_total"], 2);
assert_eq!(metadata["hunks_total"], 2);
assert!(metadata.get("path_override").is_none());
let patch_result = parse_patch_result(result);
let mut touched = patch_result.touched_files.clone();
touched.sort();
assert_eq!(touched, vec!["a.txt", "b.txt"]);
assert_eq!(patch_result.files_applied, 2);
let a = fs::read_to_string(tmp.path().join("a.txt")).unwrap();
let b = fs::read_to_string(tmp.path().join("b.txt")).unwrap();
assert!(a.contains("line2-mod"));
assert!(b.contains("beta2"));
}
#[tokio::test]
async fn mutation_receipt_covers_delete_rename_and_multifile_outcomes() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("old.txt"), "same\n").expect("old");
fs::write(tmp.path().join("update.txt"), "before\n").expect("update");
fs::write(tmp.path().join("delete.txt"), "gone\n").expect("delete");
let patch = r"diff --git a/old.txt b/old.txt
--- a/old.txt
+++ /dev/null
@@ -1 +0,0 @@
-same
diff --git a/new.txt b/new.txt
--- /dev/null
+++ b/new.txt
@@ -0,0 +1 @@
+same
diff --git a/update.txt b/update.txt
--- a/update.txt
+++ b/update.txt
@@ -1 +1 @@
-before
+after
diff --git a/create.txt b/create.txt
--- /dev/null
+++ b/create.txt
@@ -0,0 +1 @@
+fresh
diff --git a/delete.txt b/delete.txt
--- a/delete.txt
+++ /dev/null
@@ -1 +0,0 @@
-gone
";
let result = ApplyPatchTool
.execute(json!({"patch": patch}), &ctx)
.await
.expect("execute");
let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
assert_eq!(
mutation["files"],
json!([
{ "path": "update.txt", "outcome": "updated" },
{ "path": "create.txt", "outcome": "created" },
{ "path": "delete.txt", "outcome": "deleted" }
])
);
assert_eq!(
mutation["renames"],
json!([{ "from": "old.txt", "to": "new.txt" }])
);
let exact = mutation["diff"].as_str().expect("exact mutation diff");
assert!(exact.contains("rename from old.txt"), "{exact}");
assert!(exact.contains("rename to new.txt"), "{exact}");
assert!(exact.contains("--- a/update.txt"), "{exact}");
assert!(exact.contains("+++ b/create.txt"), "{exact}");
assert!(exact.contains("--- a/delete.txt"), "{exact}");
assert!(!tmp.path().join("old.txt").exists());
assert_eq!(
fs::read_to_string(tmp.path().join("new.txt")).expect("renamed target"),
"same\n"
);
assert_eq!(
fs::read_to_string(tmp.path().join("update.txt")).expect("updated"),
"after\n"
);
assert!(tmp.path().join("create.txt").exists());
assert!(!tmp.path().join("delete.txt").exists());
}
#[tokio::test]
async fn test_apply_patch_requires_headers_without_path() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = ApplyPatchTool;
let patch = r"@@ -1,1 +1,1 @@
-old
+new
";
let err = tool
.execute(json!({"patch": patch}), &ctx)
.await
.unwrap_err();
match err {
ToolError::InvalidInput { message } => {
assert!(message.contains("no file headers"));
assert!(message.contains("Provide `path`"));
}
other => panic!("expected invalid input, got: {other}"),
}
}
#[tokio::test]
async fn test_path_override_rejects_multi_file_diff() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = ApplyPatchTool;
let patch = r"diff --git a/a.txt b/a.txt
--- a/a.txt
+++ b/a.txt
@@ -1,1 +1,1 @@
-one
+one-mod
diff --git a/b.txt b/b.txt
--- a/b.txt
+++ b/b.txt
@@ -1,1 +1,1 @@
-two
+two-mod
";
let err = tool
.execute(json!({"path": "a.txt", "patch": patch}), &ctx)
.await
.unwrap_err();
match err {
ToolError::InvalidInput { message } => {
assert!(message.contains("multiple files"));
assert!(message.contains("a.txt"));
assert!(message.contains("b.txt"));
}
other => panic!("expected invalid input, got: {other}"),
}
}
#[tokio::test]
async fn test_apply_patch_summary_reports_fuzz() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = ApplyPatchTool;
fs::write(tmp.path().join("test.txt"), "line0\nline1\nline2\nline3\n").expect("write");
let patch = r"@@ -1,2 +1,2 @@
-line1
+modified
line2
";
let result = tool
.execute(json!({"path": "test.txt", "patch": patch, "fuzz": 3}), &ctx)
.await
.expect("execute");
assert!(result.success);
let patch_result = parse_patch_result(result);
assert_eq!(patch_result.hunks_with_fuzz, 1);
assert!(patch_result.fuzz_used > 0);
assert!(patch_result.message.contains("Fuzz used"));
let summary = patch_result.file_summaries.first().unwrap();
assert_eq!(summary.hunks_with_fuzz, 1);
}
#[tokio::test]
async fn test_path_override_header_mismatch_note() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = ApplyPatchTool;
fs::write(tmp.path().join("override.txt"), "old\n").expect("write");
let patch = r"--- a/other.txt
+++ b/other.txt
@@ -1,1 +1,1 @@
-old
+new
";
let result = tool
.execute(json!({"path": "override.txt", "patch": patch}), &ctx)
.await
.expect("execute");
let metadata = result.metadata.as_ref().expect("metadata");
assert!(
metadata["header_path_mismatch"]
.as_str()
.unwrap()
.contains("headers reference `other.txt`")
);
let patch_result = parse_patch_result(result);
assert!(
patch_result
.message
.contains("headers reference `other.txt`")
);
assert!(
patch_result
.message
.contains("path` overrides to `override.txt`")
);
}
#[test]
fn test_apply_patch_tool_properties() {
let tool = ApplyPatchTool;
assert_eq!(tool.name(), "apply_patch");
assert!(!tool.is_read_only());
assert!(tool.is_sandboxable());
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
}
#[test]
fn test_multi_hunk_offset_tracking() {
let mut lines: Vec<String> = vec![
"line1".to_string(),
"line2".to_string(),
"line3".to_string(),
"line4".to_string(),
"line5".to_string(),
"line6".to_string(),
];
let hunk1 = Hunk {
old_start: 1,
old_count: 2,
new_start: 1,
new_count: 4,
lines: vec![
HunkLine::Context("line1".to_string()),
HunkLine::Add("new_a".to_string()),
HunkLine::Add("new_b".to_string()),
HunkLine::Context("line2".to_string()),
],
};
let hunk2 = Hunk {
old_start: 5, old_count: 1,
new_start: 7,
new_count: 1,
lines: vec![
HunkLine::Remove("line5".to_string()),
HunkLine::Add("modified5".to_string()),
],
};
let mut offset: isize = 0;
let outcome1 = apply_hunk(&mut lines, &hunk1, 3, &mut offset).unwrap();
assert_eq!(outcome1.fuzz_used, 0);
assert!(!outcome1.relocated);
assert_eq!(offset, 2); assert_eq!(
lines,
vec![
"line1", "new_a", "new_b", "line2", "line3", "line4", "line5", "line6"
]
);
let outcome2 = apply_hunk(&mut lines, &hunk2, 3, &mut offset).unwrap();
assert_eq!(outcome2.fuzz_used, 0);
assert!(!outcome2.relocated);
assert!(lines.contains(&"modified5".to_string()));
assert!(!lines.contains(&"line5".to_string()));
}
#[test]
fn test_apply_hunk_relocates_to_unique_context_match() {
let mut lines: Vec<String> = (0..25).map(|i| format!("line{i}")).collect();
let hunk = Hunk {
old_start: 1, old_count: 5,
new_start: 1,
new_count: 5,
lines: vec![
HunkLine::Context("line19".to_string()),
HunkLine::Context("line20".to_string()),
HunkLine::Remove("line21".to_string()),
HunkLine::Add("line21-modified".to_string()),
HunkLine::Context("line22".to_string()),
HunkLine::Context("line23".to_string()),
],
};
let mut offset: isize = 0;
let outcome = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap();
assert!(
outcome.relocated,
"expected relocation for stale line numbers"
);
assert_eq!(outcome.fuzz_used, 0);
assert_eq!(lines[21], "line21-modified");
assert!(!lines.contains(&"line21".to_string()));
}
#[test]
fn test_apply_hunk_ambiguous_context_reports_candidates() {
let mut lines: Vec<String> = [
"header0", "header1", "header2", "block-a", "block-b", "block-c", "block-d", "middle",
"block-a", "block-b", "block-c", "block-d", "footer",
]
.iter()
.map(|s| s.to_string())
.collect();
let hunk = Hunk {
old_start: 1,
old_count: 4,
new_start: 1,
new_count: 4,
lines: vec![
HunkLine::Remove("block-a".to_string()),
HunkLine::Remove("block-b".to_string()),
HunkLine::Remove("block-c".to_string()),
HunkLine::Remove("block-d".to_string()),
HunkLine::Add("block-a-modified".to_string()),
HunkLine::Add("block-b".to_string()),
HunkLine::Add("block-c".to_string()),
HunkLine::Add("block-d".to_string()),
],
};
let mut offset: isize = 0;
let err = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap_err();
match err {
ApplyHunkError::ContextAmbiguous {
expected_line,
candidate_lines,
} => {
assert_eq!(expected_line, 1);
assert_eq!(candidate_lines, vec![4, 9]);
}
other => panic!("expected ContextAmbiguous, got: {other:?}"),
}
}
#[test]
fn test_apply_hunk_short_anchor_not_relocated() {
let mut lines: Vec<String> = ["zero", "one", "two", "three", "four"]
.iter()
.map(|s| s.to_string())
.collect();
let hunk = Hunk {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
lines: vec![
HunkLine::Remove("four".to_string()),
HunkLine::Add("four-modified".to_string()),
],
};
let mut offset: isize = 0;
let err = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap_err();
assert!(
matches!(err, ApplyHunkError::NoMatch { .. }),
"short anchors must not be relocated"
);
}
#[tokio::test]
async fn test_apply_patch_relocates_stale_line_numbers() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = ApplyPatchTool;
let content = (0..30)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
fs::write(tmp.path().join("stale.txt"), &content).expect("write");
let patch = r"@@ -1,5 +1,5 @@
line19
line20
-line21
+line21-modified
line22
line23
";
let result = tool
.execute(
json!({"path": "stale.txt", "patch": patch, "fuzz": 1}),
&ctx,
)
.await
.expect("execute");
assert!(result.success);
let patch_result = parse_patch_result(result);
assert_eq!(patch_result.hunks_relocated, 1);
assert!(
patch_result.message.contains("stale line numbers"),
"message: {}",
patch_result.message
);
let summary = patch_result.file_summaries.first().unwrap();
assert_eq!(summary.hunks_relocated, 1);
let edited = fs::read_to_string(tmp.path().join("stale.txt")).expect("read");
assert!(edited.contains("line21-modified"));
assert!(!edited.contains("\nline21\n"));
}
#[tokio::test]
async fn test_apply_patch_ambiguous_context_reports_candidates() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = ApplyPatchTool;
let content = "header0\nheader1\nheader2\nblock-a\nblock-b\nblock-c\nblock-d\nmiddle\nblock-a\nblock-b\nblock-c\nblock-d\nfooter\n";
fs::write(tmp.path().join("dup.txt"), content).expect("write");
let patch = r"@@ -1,4 +1,4 @@
-block-a
-block-b
-block-c
-block-d
+block-a-modified
+block-b
+block-c
+block-d
";
let err = tool
.execute(json!({"path": "dup.txt", "patch": patch, "fuzz": 1}), &ctx)
.await
.unwrap_err();
let message = err.to_string();
assert!(
message.contains("multiple locations"),
"expected ambiguity error, got: {message}"
);
assert!(
message.contains("lines 4, 9"),
"expected candidate lines, got: {message}"
);
let unchanged = fs::read_to_string(tmp.path().join("dup.txt")).expect("read");
assert_eq!(
unchanged, content,
"ambiguous hunk must not modify the file"
);
}
#[tokio::test]
async fn test_apply_patch_relocates_after_crlf_chinese_edit_shift() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = ApplyPatchTool;
let mut lines = vec!["/* 扭矩控制模块 */".to_string()];
for i in 0..60 {
lines.push(format!("int cfg_{i} = {i}; // 配置项 {i}"));
}
let content = lines.join("\r\n") + "\r\n";
fs::write(tmp.path().join("app_foc.c"), &content).expect("write");
let patch1 = r"@@ -11,1 +11,9 @@
int cfg_9 = 9; // 配置项 9
+int new_a = 100; // 新增配置 A
+int new_b = 101; // 新增配置 B
+int new_c = 102; // 新增配置 C
+int new_d = 103; // 新增配置 D
+int new_e = 104; // 新增配置 E
+int new_f = 105; // 新增配置 F
+int new_g = 106; // 新增配置 G
+int new_h = 107; // 新增配置 H
";
let r1 = tool
.execute(
json!({"path": "app_foc.c", "patch": patch1, "fuzz": 0}),
&ctx,
)
.await
.expect("first patch");
assert!(r1.success, "first patch should apply: {}", r1.content);
let patch2 = r"@@ -31,5 +31,5 @@
int cfg_30 = 30; // 配置项 30
int cfg_31 = 31; // 配置项 31
-int cfg_32 = 32; // 配置项 32
+int cfg_32 = 3200; // 配置项 32 已修改
int cfg_33 = 33; // 配置项 33
int cfg_34 = 34; // 配置项 34
";
let r2 = tool
.execute(
json!({"path": "app_foc.c", "patch": patch2, "fuzz": 1}),
&ctx,
)
.await
.expect("second patch");
assert!(r2.success, "second patch should relocate: {}", r2.content);
let pr2 = parse_patch_result(r2);
assert_eq!(pr2.hunks_relocated, 1, "second patch must be relocated");
let edited = fs::read_to_string(tmp.path().join("app_foc.c")).expect("read");
assert!(
edited.contains("int cfg_32 = 3200;"),
"replacement must land"
);
assert!(
edited.contains("int cfg_33 = 33;"),
"context after the edit must stay intact"
);
assert!(
edited.contains("int new_h = 107;"),
"first edit must survive"
);
}
const GUARD_PATCH: &str = "--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
line1
-line2
+modified
line3
";
#[tokio::test]
async fn patch_with_matching_expected_hash_proceeds() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let body = "line1\nline2\nline3\n";
fs::write(tmp.path().join("test.txt"), body).expect("write");
ApplyPatchTool
.execute(
json!({
"path": "test.txt",
"patch": GUARD_PATCH,
"expected_hash": content_hash(body.as_bytes()),
}),
&ctx,
)
.await
.expect("matching hash must not block the patch");
let updated = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
assert!(updated.contains("modified"), "{updated}");
}
#[tokio::test]
async fn patch_with_stale_expected_hash_rejects_without_writing() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let body = "line1\nline2\nline3\n";
fs::write(tmp.path().join("test.txt"), body).expect("write");
let err = ApplyPatchTool
.execute(
json!({
"path": "test.txt",
"patch": GUARD_PATCH,
"expected_hash": content_hash(b"a different file entirely\n"),
}),
&ctx,
)
.await
.expect_err("stale hash must reject");
let message = err.to_string();
assert!(message.contains("changed since it was read"), "{message}");
assert!(message.contains("nothing was written"), "{message}");
assert_eq!(
fs::read_to_string(tmp.path().join("test.txt")).expect("read"),
body,
"a rejected patch must not modify the file"
);
}
#[tokio::test]
async fn patch_without_expected_hash_is_unchanged() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("test.txt"), "line1\nline2\nline3\n").expect("write");
ApplyPatchTool
.execute(json!({ "path": "test.txt", "patch": GUARD_PATCH }), &ctx)
.await
.expect("absent expected_hash keeps the pre-#3979 behavior");
let updated = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
assert!(updated.contains("modified"), "{updated}");
}
#[tokio::test]
async fn multi_file_patch_guards_the_first_file_and_writes_nothing_on_mismatch() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::write(tmp.path().join("one.txt"), "one\n").expect("write");
fs::write(tmp.path().join("two.txt"), "two\n").expect("write");
let patch = "diff --git a/one.txt b/one.txt
--- a/one.txt
+++ b/one.txt
@@ -1 +1 @@
-one
+ONE
diff --git a/two.txt b/two.txt
--- a/two.txt
+++ b/two.txt
@@ -1 +1 @@
-two
+TWO
";
let err = ApplyPatchTool
.execute(
json!({
"patch": patch,
"expected_hash": content_hash(b"stale\n"),
}),
&ctx,
)
.await
.expect_err("stale hash on the first file must reject the whole patch");
assert!(err.to_string().contains("one.txt"), "{err}");
assert_eq!(
fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
"one\n"
);
assert_eq!(
fs::read_to_string(tmp.path().join("two.txt")).unwrap(),
"two\n"
);
}
#[tokio::test]
async fn patch_expected_hash_on_a_missing_target_fails_closed() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let err = ApplyPatchTool
.execute(
json!({
"path": "absent.txt",
"patch": GUARD_PATCH,
"create_if_missing": true,
"expected_hash": content_hash(b"anything"),
}),
&ctx,
)
.await
.expect_err("guarded patch of a missing file must fail closed");
assert!(err.to_string().contains("does not exist"), "{err}");
assert!(!tmp.path().join("absent.txt").exists());
}
}