1use std::path::{Path, PathBuf};
12
13use async_trait::async_trait;
14
15use crate::constants::MAX_PATCH_FILE_BYTES;
16use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
17use crate::render::diff::{DisplayDiff, MAX_DISPLAY_DIFF_LINES, generate_display_diff};
18use crate::runtime::apply_patch::{Hunk, UpdateFileChunk, derive_new_contents, parse_patch};
21
22use super::super::ctx::ExecContext;
23use super::ToolExecutor;
24use super::filesystem::{after_file_mutation, diff_summary, mutation_policy_outcome};
25use super::path_safety::{AllowedRoots, ResolvedInRoot, resolve_in_roots};
26
27const APPLY_PATCH_DESCRIPTION: &str = "Edit files with a patch. Pass `patch` as one string in this exact envelope:\n*** Begin Patch\n*** Update File: <path>\n@@ <optional anchor line, e.g. a function signature>\n <unchanged context line>\n-<line to remove>\n+<line to add>\n*** End Patch\nUse '*** Add File: <path>' then '+'-prefixed lines to create a file; '*** Delete File: <path>' to remove one; '*** Move to: <path>' immediately after an Update File line to rename. Include a few unchanged context lines (prefixed with a space) around each change so the edit can be located; matching tolerates whitespace/quote drift. Paths must resolve inside the project directory or the session scratchpad.";
28
29pub struct ApplyPatchTool;
31
32#[async_trait]
33impl ToolExecutor for ApplyPatchTool {
34 fn name(&self) -> &'static str {
35 "apply_patch"
36 }
37
38 fn schema(&self) -> ToolDefinition {
39 ToolDefinition {
40 name: "apply_patch".to_string(),
41 description: APPLY_PATCH_DESCRIPTION.to_string(),
42 input_schema: serde_json::json!({
43 "type": "object",
44 "properties": {
45 "patch": {
46 "type": "string",
47 "description": "The full patch envelope, from '*** Begin Patch' to '*** End Patch'."
48 }
49 },
50 "required": ["patch"]
51 }),
52 }
53 }
54
55 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
56 let start = std::time::Instant::now();
57 let Some(patch) = args.get("patch").and_then(|v| v.as_str()) else {
58 return ToolOutcome::error("apply_patch requires 'patch' (string)", 0.0);
59 };
60 let hunks = match parse_patch(patch) {
61 Ok(h) => h,
62 Err(e) => return ToolOutcome::error(format!("apply_patch: {e}"), 0.0),
63 };
64 let (ops, paths) = match plan_ops(&ctx, &hunks) {
65 Ok(v) => v,
66 Err(e) => return ToolOutcome::error(format!("apply_patch: {e}"), 0.0),
67 };
68
69 let summary_path = ops
70 .first()
71 .map(PlannedOp::display)
72 .unwrap_or_default()
73 .to_string();
74 let pending_action = serde_json::json!({
75 "tool": "apply_patch",
76 "args": { "patch": patch },
77 "workdir": ctx.workdir.display().to_string(),
78 "turn_id": ctx.turn.0,
79 "call_id": ctx.call_id.0,
80 "task_id": ctx.task_id.clone(),
81 });
82 if let Some(outcome) = mutation_policy_outcome(
85 &ctx,
86 "apply_patch",
87 &summary_path,
88 &paths.project,
89 pending_action,
90 paths.all_scratch,
91 )
92 .await
93 {
94 return outcome;
95 }
96
97 let _guards = tokio::select! {
100 biased;
101 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
102 g = super::path_lock::lock_paths(&paths.all) => g,
103 };
104 if ctx.config.safety.checkpoint_on_mutation
107 && !paths.project.is_empty()
108 && let Err(e) = crate::runtime::create_checkpoint_for_task(
109 &ctx.workdir,
110 &paths.project,
111 Some(serde_json::json!({ "tool": "apply_patch" })),
112 ctx.checkpoint_origin(),
113 )
114 {
115 return ToolOutcome::error(format!("apply_patch checkpoint failed: {e}"), 0.0);
116 }
117
118 let report = tokio::select! {
119 biased;
120 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
121 result = tokio::task::spawn_blocking(move || apply_all_blocking(&ops)) => {
122 match result {
123 Ok(Ok(report)) => report,
124 Ok(Err(e)) => {
125 return ToolOutcome::error(format!("apply_patch: {e}"), start.elapsed().as_secs_f64());
126 },
127 Err(e) => {
128 return ToolOutcome::error(format!("apply_patch join error: {e}"), start.elapsed().as_secs_f64());
129 },
130 }
131 }
132 };
133 after_file_mutation(&ctx, "apply_patch", &summary_path);
134 build_outcome(report, start.elapsed().as_secs_f64())
135 }
136}
137
138enum PlannedOp {
142 Add {
143 root: PathBuf,
144 rel: PathBuf,
145 display: String,
146 contents: String,
147 },
148 Delete {
149 root: PathBuf,
150 rel: PathBuf,
151 display: String,
152 },
153 Update {
154 src_root: PathBuf,
155 src_rel: PathBuf,
156 dst_root: PathBuf,
157 dst_rel: PathBuf,
158 src_display: String,
159 dst_display: String,
160 chunks: Vec<UpdateFileChunk>,
161 },
162}
163
164impl PlannedOp {
165 fn display(&self) -> &str {
166 match self {
167 PlannedOp::Add { display, .. } | PlannedOp::Delete { display, .. } => display,
168 PlannedOp::Update { dst_display, .. } => dst_display,
169 }
170 }
171}
172
173struct PatchPaths {
178 all: Vec<PathBuf>,
179 project: Vec<PathBuf>,
180 all_scratch: bool,
181}
182
183fn plan_ops(ctx: &ExecContext, hunks: &[Hunk]) -> Result<(Vec<PlannedOp>, PatchPaths), String> {
187 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
188 let mut ops = Vec::new();
189 let mut all: Vec<PathBuf> = Vec::new();
190 let mut project: Vec<PathBuf> = Vec::new();
191 fn remember(r: &ResolvedInRoot, all: &mut Vec<PathBuf>, project: &mut Vec<PathBuf>) {
192 if !all.contains(&r.abs) {
193 all.push(r.abs.clone());
194 }
195 if !r.in_scratchpad && !project.contains(&r.abs) {
196 project.push(r.abs.clone());
197 }
198 }
199 for hunk in hunks {
200 match hunk {
201 Hunk::AddFile { path, contents } => {
202 let raw = path.to_string_lossy().to_string();
203 let resolved = resolve_in_roots(&roots, &raw)?;
204 remember(&resolved, &mut all, &mut project);
205 ops.push(PlannedOp::Add {
206 root: resolved.root,
207 rel: resolved.rel,
208 display: raw,
209 contents: contents.clone(),
210 });
211 },
212 Hunk::DeleteFile { path } => {
213 let raw = path.to_string_lossy().to_string();
214 let resolved = resolve_in_roots(&roots, &raw)?;
215 remember(&resolved, &mut all, &mut project);
216 ops.push(PlannedOp::Delete {
217 root: resolved.root,
218 rel: resolved.rel,
219 display: raw,
220 });
221 },
222 Hunk::UpdateFile {
223 path,
224 move_path,
225 chunks,
226 } => {
227 let raw = path.to_string_lossy().to_string();
228 let src = resolve_in_roots(&roots, &raw)?;
229 remember(&src, &mut all, &mut project);
230 let (dst_root, dst_rel, dst_display) = match move_path {
231 Some(mv) => {
232 let mv_raw = mv.to_string_lossy().to_string();
233 let dst = resolve_in_roots(&roots, &mv_raw)?;
234 remember(&dst, &mut all, &mut project);
235 (dst.root, dst.rel, mv_raw)
236 },
237 None => (src.root.clone(), src.rel.clone(), raw.clone()),
238 };
239 ops.push(PlannedOp::Update {
240 src_root: src.root,
241 src_rel: src.rel,
242 dst_root,
243 dst_rel,
244 src_display: raw,
245 dst_display,
246 chunks: chunks.clone(),
247 });
248 },
249 }
250 }
251 all.sort();
252 project.sort();
253 let all_scratch = !all.is_empty() && project.is_empty();
254 Ok((
255 ops,
256 PatchPaths {
257 all,
258 project,
259 all_scratch,
260 },
261 ))
262}
263
264fn apply_all_blocking(ops: &[PlannedOp]) -> Result<ApplyReport, String> {
267 let mut report = ApplyReport::default();
268 for op in ops {
269 match op {
270 PlannedOp::Add {
271 root,
272 rel,
273 display,
274 contents,
275 } => {
276 let body = if contents.is_empty() || contents.ends_with('\n') {
279 contents.clone()
280 } else {
281 format!("{contents}\n")
282 };
283 ensure_parent(root, rel)?;
284 crate::runtime::write_atomic_beneath(root, rel, body.as_bytes())
285 .map_err(|e| format!("{display}: {e}"))?;
286 report.added.push(display.clone());
287 report.push_diff(&format!("A {display}"), &generate_display_diff("", &body));
288 },
289 PlannedOp::Delete { root, rel, display } => {
290 crate::runtime::remove_file_beneath(root, rel)
291 .map_err(|e| format!("{display}: {e}"))?;
292 report.deleted.push(display.clone());
293 report.push_line(format!("=== D {display} ==="));
294 },
295 PlannedOp::Update {
296 src_root,
297 src_rel,
298 dst_root,
299 dst_rel,
300 src_display,
301 dst_display,
302 chunks,
303 } => {
304 let original = read_capped_beneath(src_root, src_rel, MAX_PATCH_FILE_BYTES)
305 .map_err(|e| format!("{src_display}: {e}"))?
306 .ok_or_else(|| {
307 format!(
308 "{src_display}: file too large to patch safely (> {MAX_PATCH_FILE_BYTES} bytes)"
309 )
310 })?;
311 let applied = derive_new_contents(&original, chunks)
312 .map_err(|e| format!("{dst_display}: {e}"))?;
313 report.fuzzy |= applied.fuzzy;
314 ensure_parent(dst_root, dst_rel)?;
315 crate::runtime::write_atomic_beneath(
316 dst_root,
317 dst_rel,
318 applied.new_contents.as_bytes(),
319 )
320 .map_err(|e| format!("{dst_display}: {e}"))?;
321 let diff = generate_display_diff(&original, &applied.new_contents);
322 if src_root == dst_root && src_rel == dst_rel {
323 report.modified.push(dst_display.clone());
324 report.push_diff(&format!("M {dst_display}"), &diff);
325 } else {
326 crate::runtime::remove_file_beneath(src_root, src_rel)
327 .map_err(|e| format!("{src_display}: {e}"))?;
328 report
329 .renamed
330 .push((src_display.clone(), dst_display.clone()));
331 report.push_diff(&format!("R {src_display} -> {dst_display}"), &diff);
332 }
333 },
334 }
335 }
336 Ok(report)
337}
338
339fn ensure_parent(root: &Path, rel: &Path) -> Result<(), String> {
340 if let Some(parent) = rel.parent()
341 && !parent.as_os_str().is_empty()
342 {
343 crate::runtime::create_dir_all_beneath(root, parent)
344 .map_err(|e| format!("{}: {e}", rel.display()))?;
345 }
346 Ok(())
347}
348
349fn read_capped_beneath(root: &Path, rel: &Path, cap: usize) -> std::io::Result<Option<String>> {
353 use std::io::Read;
354 let file = crate::runtime::open_beneath(root, rel, crate::runtime::OpenIntent::Read)?;
355 let mut buf = Vec::new();
356 file.take(cap as u64 + 1).read_to_end(&mut buf)?;
357 if buf.len() > cap {
358 return Ok(None);
359 }
360 Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
361}
362
363#[derive(Default)]
366struct ApplyReport {
367 added: Vec<String>,
368 modified: Vec<String>,
369 deleted: Vec<String>,
370 renamed: Vec<(String, String)>,
371 fuzzy: bool,
372 added_lines: usize,
373 removed_lines: usize,
374 diff_lines: Vec<String>,
375 diff_truncated: bool,
376}
377
378impl ApplyReport {
379 fn push_line(&mut self, line: String) {
380 if self.diff_lines.len() < MAX_DISPLAY_DIFF_LINES {
381 self.diff_lines.push(line);
382 } else {
383 self.diff_truncated = true;
384 }
385 }
386
387 fn push_diff(&mut self, header: &str, diff: &DisplayDiff) {
388 self.added_lines += diff.added;
389 self.removed_lines += diff.removed;
390 self.push_line(format!("=== {header} ==="));
391 for line in diff.display_diff.lines() {
392 self.push_line(line.to_string());
393 }
394 self.diff_truncated |= diff.truncated;
395 }
396}
397
398fn build_outcome(report: ApplyReport, duration_secs: f64) -> ToolOutcome {
399 let total =
400 report.added.len() + report.modified.len() + report.deleted.len() + report.renamed.len();
401 let mut lines = vec![format!("Applied patch: {total} file(s)")];
402 lines.extend(report.added.iter().map(|p| format!("A {p}")));
403 lines.extend(report.modified.iter().map(|p| format!("M {p}")));
404 lines.extend(report.renamed.iter().map(|(a, b)| format!("R {a} -> {b}")));
405 lines.extend(report.deleted.iter().map(|p| format!("D {p}")));
406 if report.fuzzy {
407 lines.push(
408 "note: one or more hunks matched with fuzzy (whitespace/Unicode) context; verify the result."
409 .to_string(),
410 );
411 }
412 let model_content = lines.join("\n");
413 ToolOutcome::success(
414 model_content,
415 diff_summary(report.added_lines, report.removed_lines, duration_secs),
416 duration_secs,
417 )
418 .with_metadata(ToolRunMetadata {
419 detail: ToolMetadata::ApplyPatch {
420 added: report.added,
421 modified: report.modified,
422 deleted: report.deleted,
423 renamed: report.renamed,
424 fuzzy: report.fuzzy,
425 },
426 display_diff: Some(report.diff_lines.join("\n")),
427 diff_truncated: report.diff_truncated,
428 lines_added: report.added_lines,
429 lines_removed: report.removed_lines,
430 ..ToolRunMetadata::default()
431 })
432}
433
434#[cfg(test)]
435mod tests;