Skip to main content

aft/commands/
apply_patch.rs

1//! Rust implementation of the `apply_patch` command.
2
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use lsp_types::FileChangeType;
8use serde_json::{json, Value};
9
10use crate::context::AppContext;
11use crate::edit;
12use crate::patch::apply::apply_update_chunks;
13use crate::patch::parser::{parse_patch, Hunk};
14use crate::protocol::{RawRequest, Response};
15
16#[derive(Clone)]
17struct ResolvedHunk {
18    hunk: Hunk,
19    source: ResolvedPath,
20    move_dest: Option<ResolvedPath>,
21}
22
23#[derive(Clone)]
24struct ResolvedPath {
25    abs: PathBuf,
26    rel: String,
27}
28
29struct AppliedHunkResult {
30    index: usize,
31    kind: &'static str,
32    file_path: PathBuf,
33    display_path: PathBuf,
34    move_path: Option<PathBuf>,
35    before: String,
36    after: String,
37    additions: usize,
38    deletions: usize,
39}
40
41struct DiffEntry {
42    file_path: PathBuf,
43    display_path: PathBuf,
44    move_path: Option<PathBuf>,
45    last_kind: &'static str,
46    before: String,
47    after: String,
48    additions: usize,
49    deletions: usize,
50    hunk_count: usize,
51}
52
53fn path_string(path: &Path) -> String {
54    path.to_string_lossy().into_owned()
55}
56
57/// Agent-facing DISPLAY paths (relativePath, movePath, diff headers) are
58/// forward-slash normalized so they are stable across platforms — matching the
59/// rest of AFT's tools (e.g. outline's `path_to_slash`). Without this, Windows
60/// emits `nested\dest.txt` and breaks the cross-platform display contract.
61fn display_slash(path: &Path) -> String {
62    path.to_string_lossy().replace('\\', "/")
63}
64
65fn command_params(req: &RawRequest) -> &Value {
66    req.params
67        .get("params")
68        .filter(|params| params.is_object())
69        .unwrap_or(&req.params)
70}
71
72fn project_root(ctx: &AppContext) -> Option<PathBuf> {
73    ctx.config().project_root.clone()
74}
75
76fn project_root_for_relative_paths(ctx: &AppContext) -> Option<PathBuf> {
77    project_root(ctx)
78}
79
80fn resolve_patch_input(ctx: &AppContext, path: &str) -> PathBuf {
81    let raw = Path::new(path);
82    if raw.is_absolute() {
83        raw.to_path_buf()
84    } else if let Some(root) = project_root(ctx) {
85        root.join(raw)
86    } else {
87        std::env::current_dir()
88            .unwrap_or_else(|_| PathBuf::from("."))
89            .join(raw)
90    }
91}
92
93fn normalize_resolved_path(path: PathBuf) -> PathBuf {
94    crate::inspect::job::canonicalize_normalized(&path)
95}
96
97fn relative_path(abs: &Path, root: Option<&Path>) -> String {
98    let abs = crate::inspect::job::canonicalize_normalized(abs);
99    if let Some(root) = root {
100        // Bare canonical paths are verbatim (`\\?\`) on Windows, while a
101        // configured root can already be non-verbatim. Normalize both sides
102        // before `strip_prefix` so patch responses stay project-relative.
103        let root = crate::inspect::job::canonicalize_normalized(root);
104        if let Ok(rel) = abs.strip_prefix(root) {
105            return display_slash(rel);
106        }
107    }
108    display_slash(&abs)
109}
110
111fn resolve_path(req: &RawRequest, ctx: &AppContext, path: &str) -> Result<ResolvedPath, Response> {
112    let input = resolve_patch_input(ctx, path);
113    let abs = normalize_resolved_path(ctx.validate_path(&req.id, &input)?);
114    let root = project_root_for_relative_paths(ctx);
115    let rel = relative_path(&abs, root.as_deref());
116    Ok(ResolvedPath { abs, rel })
117}
118
119fn remember_path(
120    abs: &Path,
121    rel: &str,
122    affected_abs: &mut Vec<String>,
123    affected_rel: &mut Vec<String>,
124) {
125    let abs_s = path_string(abs);
126    if !affected_abs.iter().any(|existing| existing == &abs_s) {
127        affected_abs.push(abs_s);
128    }
129    if !affected_rel.iter().any(|existing| existing == rel) {
130        affected_rel.push(rel.to_string());
131    }
132}
133
134fn resolve_hunks(
135    req: &RawRequest,
136    ctx: &AppContext,
137    hunks: Vec<Hunk>,
138) -> Result<(Vec<ResolvedHunk>, Vec<String>, Vec<String>), Response> {
139    let mut resolved = Vec::with_capacity(hunks.len());
140    let mut affected_abs = Vec::new();
141    let mut affected_rel = Vec::new();
142
143    for hunk in hunks {
144        let (source_path, move_path) = match &hunk {
145            Hunk::Add { path, .. } | Hunk::Delete { path } => (path.as_str(), None),
146            Hunk::Update {
147                path, move_path, ..
148            } => (path.as_str(), move_path.as_deref()),
149        };
150        let source = resolve_path(req, ctx, source_path)?;
151        remember_path(
152            &source.abs,
153            &source.rel,
154            &mut affected_abs,
155            &mut affected_rel,
156        );
157        let move_dest = if let Some(move_path) = move_path {
158            let dest = resolve_path(req, ctx, move_path)?;
159            if dest.abs == source.abs {
160                // Models can spell an in-place edit as a move back to the same file.
161                // Drop the move so apply and preview both use the normal update path.
162                None
163            } else {
164                remember_path(&dest.abs, &dest.rel, &mut affected_abs, &mut affected_rel);
165                Some(dest)
166            }
167        } else {
168            None
169        };
170        resolved.push(ResolvedHunk {
171            hunk,
172            source,
173            move_dest,
174        });
175    }
176
177    Ok((resolved, affected_abs, affected_rel))
178}
179
180fn line_count(content: &str) -> usize {
181    if content.is_empty() {
182        return 0;
183    }
184    let mut parts = content.split('\n').collect::<Vec<_>>();
185    if parts.last() == Some(&"") {
186        parts.pop();
187    }
188    parts.len()
189}
190
191fn diff_counts(before: &str, after: &str) -> (usize, usize) {
192    use similar::ChangeTag;
193
194    let diff = similar::TextDiff::from_lines(before, after);
195    let mut additions = 0usize;
196    let mut deletions = 0usize;
197    for change in diff.iter_all_changes() {
198        match change.tag() {
199            ChangeTag::Insert => additions += 1,
200            ChangeTag::Delete => deletions += 1,
201            ChangeTag::Equal => {}
202        }
203    }
204    (additions, deletions)
205}
206
207fn ensure_parent_dirs(path: &Path) -> Result<(), String> {
208    if let Some(parent) = path.parent() {
209        if !parent.as_os_str().is_empty() && !parent.exists() {
210            fs::create_dir_all(parent)
211                .map_err(|error| format!("failed to create directories: {error}"))?;
212        }
213    }
214    Ok(())
215}
216
217fn discard_latest_backup(ctx: &AppContext, req: &RawRequest, op_id: &str, path: &Path) {
218    ctx.backup()
219        .lock()
220        .discard_latest_operation_entry_for_path(req.session(), op_id, path);
221}
222
223fn snapshot_for_write_once(
224    req: &RawRequest,
225    ctx: &AppContext,
226    path: &Path,
227    op_id: &str,
228    existed: bool,
229    description: &str,
230    backed_paths: &mut HashSet<PathBuf>,
231) -> Result<bool, String> {
232    if backed_paths.contains(path) {
233        return Ok(false);
234    }
235
236    if existed {
237        edit::auto_backup(ctx, req.session(), path, description, Some(op_id))
238            .map(|_| ())
239            .map_err(|error| error.to_string())
240    } else {
241        ctx.backup()
242            .lock()
243            .snapshot_op_tombstone(req.session(), op_id, path, description)
244            .map(|_| ())
245            .map_err(|error| error.to_string())
246    }?;
247    backed_paths.insert(path.to_path_buf());
248    Ok(true)
249}
250
251fn restore_pre_write_state(path: &Path, existed: bool, original: Option<&str>) {
252    if existed {
253        if let Some(original) = original {
254            let _ = fs::write(path, original);
255        }
256    } else if path.exists() {
257        let _ = fs::remove_file(path);
258    }
259}
260
261fn write_patched_file(
262    req: &RawRequest,
263    ctx: &AppContext,
264    path: &Path,
265    content: &str,
266    op_id: &str,
267    description: &str,
268    backed_paths: &mut HashSet<PathBuf>,
269) -> Result<(String, bool), String> {
270    let existed = path.exists();
271    let original = if existed {
272        Some(
273            fs::read_to_string(path)
274                .map_err(|error| format!("failed to read pre-write content: {error}"))?,
275        )
276    } else {
277        None
278    };
279
280    let snapshot_taken =
281        snapshot_for_write_once(req, ctx, path, op_id, existed, description, backed_paths)?;
282    if let Err(error) = ensure_parent_dirs(path) {
283        if snapshot_taken {
284            discard_latest_backup(ctx, req, op_id, path);
285            backed_paths.remove(path);
286        }
287        return Err(error);
288    }
289
290    let params = command_params(req);
291    let mut write_result = match edit::write_format_validate(path, content, &ctx.config(), params) {
292        Ok(result) => result,
293        Err(error) => {
294            restore_pre_write_state(path, existed, original.as_deref());
295            if snapshot_taken {
296                discard_latest_backup(ctx, req, op_id, path);
297                backed_paths.remove(path);
298            }
299            return Err(error.to_string());
300        }
301    };
302
303    if write_result.rolled_back {
304        if snapshot_taken {
305            discard_latest_backup(ctx, req, op_id, path);
306            backed_paths.remove(path);
307        }
308        return Err("produced invalid syntax (rolled back)".to_string());
309    }
310
311    let final_content = fs::read_to_string(path).unwrap_or_else(|_| content.to_string());
312    let change_type = if existed {
313        FileChangeType::CHANGED
314    } else {
315        FileChangeType::CREATED
316    };
317    ctx.lsp_notify_watched_config_file(path, change_type);
318    write_result.lsp_outcome = ctx.lsp_post_write(path, &final_content, params);
319    Ok((final_content, snapshot_taken))
320}
321
322fn delete_file_with_backup(
323    req: &RawRequest,
324    ctx: &AppContext,
325    path: &Path,
326    op_id: &str,
327    backed_paths: &mut HashSet<PathBuf>,
328) -> Result<bool, String> {
329    let snapshot_taken = if backed_paths.contains(path) {
330        false
331    } else {
332        edit::auto_backup(
333            ctx,
334            req.session(),
335            path,
336            "apply_patch: pre-delete backup",
337            Some(op_id),
338        )
339        .map_err(|error| error.to_string())?;
340        backed_paths.insert(path.to_path_buf());
341        true
342    };
343
344    if let Err(error) = fs::remove_file(path) {
345        if snapshot_taken {
346            discard_latest_backup(ctx, req, op_id, path);
347            backed_paths.remove(path);
348        }
349        return Err(format!("failed to delete: {error}"));
350    }
351    ctx.lsp_notify_watched_config_file(path, FileChangeType::DELETED);
352    Ok(snapshot_taken)
353}
354
355fn read_required(path: &Path, action: &str, patch_path: &str) -> Result<String, String> {
356    fs::read_to_string(path).map_err(|error| format!("Failed to {action} {patch_path}: {error}"))
357}
358
359fn preview_virtual_content(
360    virtual_files: &HashMap<PathBuf, Option<String>>,
361    path: &Path,
362) -> Option<Option<String>> {
363    virtual_files.get(path).cloned()
364}
365
366fn read_preview_content(
367    virtual_files: &HashMap<PathBuf, Option<String>>,
368    path: &Path,
369    action: &str,
370    patch_path: &str,
371) -> Result<String, String> {
372    if let Some(content) = preview_virtual_content(virtual_files, path) {
373        return content.ok_or_else(|| {
374            format!(
375                "Failed to {action} {patch_path}: file not found: {}",
376                path_string(path)
377            )
378        });
379    }
380    read_required(path, action, patch_path)
381}
382
383fn build_preview_response(
384    req: &RawRequest,
385    resolved: &[ResolvedHunk],
386    affected_abs: Vec<String>,
387    affected_rel: Vec<String>,
388) -> Response {
389    let mut virtual_files: HashMap<PathBuf, Option<String>> = HashMap::new();
390    let mut patches = Vec::new();
391    let filepath = affected_rel
392        .first()
393        .cloned()
394        .unwrap_or_else(|| ".".to_string());
395
396    for resolved_hunk in resolved {
397        match &resolved_hunk.hunk {
398            Hunk::Add { path, contents } => {
399                let virtual_content =
400                    preview_virtual_content(&virtual_files, &resolved_hunk.source.abs);
401                let exists = virtual_content
402                    .map(|content| content.is_some())
403                    .unwrap_or_else(|| resolved_hunk.source.abs.exists());
404                if exists {
405                    return Response::error(
406                        &req.id,
407                        "invalid_request",
408                        format!(
409                            "Failed to create {path}: file already exists. Use *** Update File: to modify, or *** Delete File: first if you want to replace it entirely."
410                        ),
411                    );
412                }
413                let after = ensure_trailing_newline(contents);
414                patches.push(edit::build_unified_diff(
415                    &display_slash(&resolved_hunk.source.abs),
416                    "",
417                    &after,
418                ));
419                virtual_files.insert(resolved_hunk.source.abs.clone(), Some(after));
420            }
421            Hunk::Delete { path } => {
422                let before = match read_preview_content(
423                    &virtual_files,
424                    &resolved_hunk.source.abs,
425                    "delete",
426                    path,
427                ) {
428                    Ok(content) => content,
429                    Err(error) => return Response::error(&req.id, "invalid_request", error),
430                };
431                patches.push(edit::build_unified_diff(
432                    &display_slash(&resolved_hunk.source.abs),
433                    &before,
434                    "",
435                ));
436                virtual_files.insert(resolved_hunk.source.abs.clone(), None);
437            }
438            Hunk::Update {
439                path,
440                chunks,
441                move_path: _,
442            } => {
443                let before = match read_preview_content(
444                    &virtual_files,
445                    &resolved_hunk.source.abs,
446                    "update",
447                    path,
448                ) {
449                    Ok(content) => content,
450                    Err(error) => return Response::error(&req.id, "invalid_request", error),
451                };
452                let after = match apply_update_chunks(
453                    &before,
454                    &path_string(&resolved_hunk.source.abs),
455                    chunks,
456                ) {
457                    Ok(content) => content,
458                    Err(error) => {
459                        return Response::error(
460                            &req.id,
461                            "invalid_request",
462                            format!("Failed to update {path}: {error}"),
463                        );
464                    }
465                };
466                let target = resolved_hunk
467                    .move_dest
468                    .as_ref()
469                    .unwrap_or(&resolved_hunk.source);
470                patches.push(edit::build_unified_diff(
471                    &display_slash(&target.abs),
472                    &before,
473                    &after,
474                ));
475                if resolved_hunk.move_dest.is_some() {
476                    virtual_files.insert(resolved_hunk.source.abs.clone(), None);
477                }
478                virtual_files.insert(target.abs.clone(), Some(after));
479            }
480        }
481    }
482
483    Response::success(
484        &req.id,
485        json!({
486            "preview": true,
487            "preview_diff": patches.join("\n"),
488            "affected_paths": affected_abs,
489            "affected_rel_paths": affected_rel,
490            "filepath": filepath,
491        }),
492    )
493}
494
495fn ensure_trailing_newline(content: &str) -> String {
496    if content.ends_with('\n') {
497        content.to_string()
498    } else {
499        format!("{content}\n")
500    }
501}
502
503fn add_failure(failures: &mut Vec<Value>, path: &str, error: String) {
504    failures.push(json!({ "path": path, "error": error }));
505}
506
507fn failure_paths(failures: &[Value]) -> String {
508    failures
509        .iter()
510        .filter_map(|failure| failure.get("path").and_then(Value::as_str))
511        .collect::<Vec<_>>()
512        .join(", ")
513}
514
515fn apply_add(
516    req: &RawRequest,
517    ctx: &AppContext,
518    resolved: &ResolvedHunk,
519    _path: &str,
520    contents: &str,
521    op_id: &str,
522    backed_paths: &mut HashSet<PathBuf>,
523) -> Result<AppliedHunkResult, String> {
524    if resolved.source.abs.exists() {
525        return Err(
526            "file already exists. Use *** Update File: to modify, or *** Delete File: first if you want to replace it entirely."
527                .to_string(),
528        );
529    }
530
531    let after = ensure_trailing_newline(contents);
532    let (final_content, _) = write_patched_file(
533        req,
534        ctx,
535        &resolved.source.abs,
536        &after,
537        op_id,
538        "apply_patch: file created by add hunk",
539        backed_paths,
540    )?;
541    let (additions, deletions) = diff_counts("", &final_content);
542    Ok(AppliedHunkResult {
543        index: 0,
544        kind: "add",
545        file_path: resolved.source.abs.clone(),
546        display_path: resolved.source.abs.clone(),
547        move_path: None,
548        before: String::new(),
549        after: final_content,
550        additions,
551        deletions,
552    })
553}
554
555fn apply_delete(
556    req: &RawRequest,
557    ctx: &AppContext,
558    resolved: &ResolvedHunk,
559    _path: &str,
560    op_id: &str,
561    backed_paths: &mut HashSet<PathBuf>,
562) -> Result<AppliedHunkResult, String> {
563    if !resolved.source.abs.exists() {
564        return Err("file not found".to_string());
565    }
566    if !resolved.source.abs.is_file() {
567        return Err("not a regular file".to_string());
568    }
569
570    let before = fs::read_to_string(&resolved.source.abs)
571        .map_err(|error| format!("failed to read before delete: {error}"))?;
572    let deletions = line_count(&before);
573    delete_file_with_backup(req, ctx, &resolved.source.abs, op_id, backed_paths)?;
574    Ok(AppliedHunkResult {
575        index: 0,
576        kind: "delete",
577        file_path: resolved.source.abs.clone(),
578        display_path: resolved.source.abs.clone(),
579        move_path: None,
580        before,
581        after: String::new(),
582        additions: 0,
583        deletions,
584    })
585}
586
587fn apply_update(
588    req: &RawRequest,
589    ctx: &AppContext,
590    resolved: &ResolvedHunk,
591    chunks: &[crate::patch::parser::UpdateFileChunk],
592    op_id: &str,
593    backed_paths: &mut HashSet<PathBuf>,
594) -> Result<AppliedHunkResult, String> {
595    let original = fs::read_to_string(&resolved.source.abs)
596        .map_err(|error| format!("failed to read file: {error}"))?;
597    let new_content = apply_update_chunks(&original, &path_string(&resolved.source.abs), chunks)?;
598
599    if let Some(dest) = &resolved.move_dest {
600        apply_move_update(
601            req,
602            ctx,
603            resolved,
604            dest,
605            original,
606            new_content,
607            op_id,
608            backed_paths,
609        )
610    } else {
611        let (final_content, _) = write_patched_file(
612            req,
613            ctx,
614            &resolved.source.abs,
615            &new_content,
616            op_id,
617            "apply_patch: pre-update backup",
618            backed_paths,
619        )?;
620        let (additions, deletions) = diff_counts(&original, &final_content);
621        Ok(AppliedHunkResult {
622            index: 0,
623            kind: "update",
624            file_path: resolved.source.abs.clone(),
625            display_path: resolved.source.abs.clone(),
626            move_path: None,
627            before: original,
628            after: final_content,
629            additions,
630            deletions,
631        })
632    }
633}
634
635fn apply_move_update(
636    req: &RawRequest,
637    ctx: &AppContext,
638    resolved: &ResolvedHunk,
639    dest: &ResolvedPath,
640    original: String,
641    new_content: String,
642    op_id: &str,
643    backed_paths: &mut HashSet<PathBuf>,
644) -> Result<AppliedHunkResult, String> {
645    let dest_existed = dest.abs.exists();
646    let dest_snapshot = if dest_existed {
647        Some(
648            fs::read_to_string(&dest.abs)
649                .map_err(|error| format!("move: failed to read destination snapshot: {error}"))?,
650        )
651    } else {
652        None
653    };
654
655    let (final_content, dest_snapshot_taken) = match write_patched_file(
656        req,
657        ctx,
658        &dest.abs,
659        &new_content,
660        op_id,
661        "apply_patch: move destination backup",
662        backed_paths,
663    ) {
664        Ok(outcome) => outcome,
665        Err(error) => {
666            if !dest_existed && dest.abs.exists() {
667                let _ = fs::remove_file(&dest.abs);
668            }
669            return Err(error);
670        }
671    };
672
673    let source_snapshot_taken = if backed_paths.contains(&resolved.source.abs) {
674        false
675    } else {
676        edit::auto_backup(
677            ctx,
678            req.session(),
679            &resolved.source.abs,
680            "apply_patch: move source backup",
681            Some(op_id),
682        )
683        .map_err(|error| error.to_string())?;
684        backed_paths.insert(resolved.source.abs.clone());
685        true
686    };
687
688    if let Err(error) = fs::remove_file(&resolved.source.abs) {
689        if source_snapshot_taken {
690            discard_latest_backup(ctx, req, op_id, &resolved.source.abs);
691            backed_paths.remove(&resolved.source.abs);
692        }
693        rollback_move_destination(
694            req,
695            ctx,
696            op_id,
697            &dest.abs,
698            dest_existed,
699            dest_snapshot.as_deref(),
700            dest_snapshot_taken,
701            backed_paths,
702        );
703        return Err(format!(
704            "move: failed to remove source after writing destination: {error}"
705        ));
706    }
707    ctx.lsp_notify_watched_config_file(&resolved.source.abs, FileChangeType::DELETED);
708
709    let (additions, deletions) = diff_counts(&original, &final_content);
710    Ok(AppliedHunkResult {
711        index: 0,
712        kind: "update",
713        file_path: resolved.source.abs.clone(),
714        display_path: dest.abs.clone(),
715        move_path: Some(dest.abs.clone()),
716        before: original,
717        after: final_content,
718        additions,
719        deletions,
720    })
721}
722
723fn rollback_move_destination(
724    req: &RawRequest,
725    ctx: &AppContext,
726    op_id: &str,
727    dest: &Path,
728    dest_existed: bool,
729    dest_snapshot: Option<&str>,
730    dest_snapshot_taken: bool,
731    backed_paths: &mut HashSet<PathBuf>,
732) {
733    if dest_snapshot_taken {
734        discard_latest_backup(ctx, req, op_id, dest);
735        backed_paths.remove(dest);
736    }
737    if dest_existed {
738        if let Some(snapshot) = dest_snapshot {
739            let _ = fs::write(dest, snapshot);
740        }
741    } else if dest.exists() {
742        let _ = fs::remove_file(dest);
743    }
744}
745
746fn report_key(applied: &AppliedHunkResult) -> String {
747    if let Some(move_path) = &applied.move_path {
748        format!(
749            "{}\0{}",
750            path_string(&applied.file_path),
751            path_string(move_path)
752        )
753    } else {
754        path_string(&applied.file_path)
755    }
756}
757
758fn metadata_files(applied: &[AppliedHunkResult], root: Option<&Path>) -> (String, Vec<Value>) {
759    let mut entries: Vec<(String, DiffEntry)> = Vec::new();
760
761    for applied_hunk in applied {
762        let key = report_key(applied_hunk);
763        if let Some((_, entry)) = entries.iter_mut().find(|(existing, _)| existing == &key) {
764            entry.display_path = applied_hunk.display_path.clone();
765            if applied_hunk.move_path.is_some() {
766                entry.move_path = applied_hunk.move_path.clone();
767            }
768            entry.last_kind = applied_hunk.kind;
769            entry.after = applied_hunk.after.clone();
770            entry.hunk_count += 1;
771            let (additions, deletions) = diff_counts(&entry.before, &entry.after);
772            entry.additions = additions;
773            entry.deletions = deletions;
774        } else {
775            entries.push((
776                key,
777                DiffEntry {
778                    file_path: applied_hunk.file_path.clone(),
779                    display_path: applied_hunk.display_path.clone(),
780                    move_path: applied_hunk.move_path.clone(),
781                    last_kind: applied_hunk.kind,
782                    before: applied_hunk.before.clone(),
783                    after: applied_hunk.after.clone(),
784                    additions: applied_hunk.additions,
785                    deletions: applied_hunk.deletions,
786                    hunk_count: 1,
787                },
788            ));
789        }
790    }
791
792    let files = entries
793        .into_iter()
794        .map(|(_, entry)| {
795            let patch = edit::build_unified_diff(
796                &display_slash(&entry.display_path),
797                &entry.before,
798                &entry.after,
799            );
800            let entry_type = if entry.move_path.is_some() {
801                "move"
802            } else if entry.hunk_count == 1 {
803                entry.last_kind
804            } else if entry.before.is_empty() && !entry.after.is_empty() {
805                "add"
806            } else if !entry.before.is_empty() && entry.after.is_empty() {
807                "delete"
808            } else {
809                "update"
810            };
811            let mut value = json!({
812                "filePath": path_string(&entry.file_path),
813                "relativePath": relative_path(&entry.display_path, root),
814                "type": entry_type,
815                "patch": patch,
816                "additions": entry.additions,
817                "deletions": entry.deletions,
818            });
819            if let Some(move_path) = entry.move_path {
820                value["movePath"] = json!(display_slash(&move_path));
821            }
822            value
823        })
824        .collect::<Vec<_>>();
825
826    let diff = files
827        .iter()
828        .filter_map(|file| file.get("patch").and_then(Value::as_str))
829        .filter(|patch| !patch.is_empty())
830        .collect::<Vec<_>>()
831        .join("\n");
832
833    (diff, files)
834}
835
836fn apply_patch(req: &RawRequest, ctx: &AppContext, resolved: &[ResolvedHunk]) -> Response {
837    let op_id = crate::backup::new_op_id();
838    let mut backed_paths = HashSet::new();
839    let mut output_lines = Vec::new();
840    let mut failures = Vec::new();
841    let mut applied = Vec::new();
842
843    for (index, resolved_hunk) in resolved.iter().enumerate() {
844        match &resolved_hunk.hunk {
845            Hunk::Add { path, contents } => {
846                match apply_add(
847                    req,
848                    ctx,
849                    resolved_hunk,
850                    path,
851                    contents,
852                    &op_id,
853                    &mut backed_paths,
854                ) {
855                    Ok(mut result) => {
856                        result.index = index;
857                        output_lines.push(format!("Created {path}"));
858                        applied.push(result);
859                    }
860                    Err(error) => {
861                        output_lines.push(format!("Failed to create {path}: {error}"));
862                        add_failure(&mut failures, path, error);
863                    }
864                }
865            }
866            Hunk::Delete { path } => {
867                match apply_delete(req, ctx, resolved_hunk, path, &op_id, &mut backed_paths) {
868                    Ok(mut result) => {
869                        result.index = index;
870                        output_lines.push(format!("Deleted {path}"));
871                        applied.push(result);
872                    }
873                    Err(error) => {
874                        output_lines.push(format!("Failed to delete {path}: {error}"));
875                        add_failure(&mut failures, path, error);
876                    }
877                }
878            }
879            Hunk::Update {
880                path,
881                move_path,
882                chunks,
883            } => match apply_update(req, ctx, resolved_hunk, chunks, &op_id, &mut backed_paths) {
884                Ok(mut result) => {
885                    result.index = index;
886                    if let (Some(_), Some(move_path)) = (&resolved_hunk.move_dest, move_path) {
887                        output_lines.push(format!("Updated and moved {path} → {move_path}"));
888                    } else {
889                        output_lines.push(format!("Updated {path}"));
890                    }
891                    applied.push(result);
892                }
893                Err(error) => {
894                    output_lines.push(format!("Failed to update {path}: {error}"));
895                    add_failure(&mut failures, path, error);
896                }
897            },
898        }
899    }
900
901    if !failures.is_empty() {
902        let partial = failures.len() < resolved.len();
903        let failed_list = failure_paths(&failures);
904        let summary = if partial {
905            format!(
906                "Patch partially applied — {} of {} hunk(s) succeeded. Failed: {failed_list}. Successful changes are kept; use `aft_safety` to revert if you want to abort.",
907                resolved.len() - failures.len(),
908                resolved.len()
909            )
910        } else {
911            format!(
912                "Patch failed — none of the {} hunk(s) applied: {failed_list}.",
913                resolved.len()
914            )
915        };
916        output_lines.push(summary);
917    }
918
919    let root = project_root_for_relative_paths(ctx);
920    let (diff, files) = metadata_files(&applied, root.as_deref());
921    let output = output_lines.join("\n");
922
923    if applied.is_empty() && !failures.is_empty() {
924        return Response::error_with_data(
925            req.id.clone(),
926            "apply_patch_failed",
927            output.clone(),
928            json!({
929                "output": output,
930                "complete": false,
931                "all_failed": true,
932                "partial": false,
933                "failures": failures,
934                "metadata": { "diff": "", "files": [] },
935            }),
936        );
937    }
938
939    let complete = failures.is_empty();
940    let title = if complete {
941        format!("Applied {} hunks", resolved.len())
942    } else {
943        format!("Applied {} of {} hunks", applied.len(), resolved.len())
944    };
945
946    Response::success(
947        &req.id,
948        json!({
949            "output": output,
950            "title": title,
951            "complete": complete,
952            "partial": !complete,
953            "all_failed": false,
954            "failures": failures,
955            "metadata": { "diff": diff, "files": files },
956        }),
957    )
958}
959
960#[cfg(test)]
961mod tests {
962    #[cfg(windows)]
963    use super::*;
964
965    #[cfg(windows)]
966    fn verbatim_path(path: &Path) -> PathBuf {
967        PathBuf::from(format!(r"\\?\{}", path.display()))
968    }
969
970    #[cfg(windows)]
971    #[test]
972    fn normalize_resolved_path_strips_windows_verbatim_prefix() {
973        let temp = tempfile::tempdir().expect("create temporary project");
974        let file = temp.path().join("src/main.rs");
975        fs::create_dir_all(file.parent().expect("source directory"))
976            .expect("create source directory");
977        fs::write(&file, "fn main() {}\n").expect("write source file");
978
979        let normalized = crate::inspect::job::canonicalize_normalized(&file);
980        let verbatim = verbatim_path(&normalized);
981
982        assert_eq!(normalize_resolved_path(verbatim), normalized);
983    }
984
985    #[cfg(windows)]
986    #[test]
987    fn relative_path_matches_a_verbatim_root_to_a_normalized_file() {
988        let temp = tempfile::tempdir().expect("create temporary project");
989        let file = temp.path().join("src/main.rs");
990        fs::create_dir_all(file.parent().expect("source directory"))
991            .expect("create source directory");
992        fs::write(&file, "fn main() {}\n").expect("write source file");
993
994        let normalized_root = crate::inspect::job::canonicalize_normalized(temp.path());
995        let normalized_file = crate::inspect::job::canonicalize_normalized(&file);
996
997        assert_eq!(
998            relative_path(&normalized_file, Some(&verbatim_path(&normalized_root))),
999            "src/main.rs"
1000        );
1001    }
1002}
1003
1004/// Handle a raw `apply_patch` request.
1005pub fn handle_apply_patch(req: &RawRequest, ctx: &AppContext) -> Response {
1006    let params = command_params(req);
1007    let patch_text = match params.get("patch_text").and_then(Value::as_str) {
1008        Some(patch_text) => patch_text,
1009        None => {
1010            return Response::error(
1011                &req.id,
1012                "invalid_request",
1013                "apply_patch: missing required param 'patch_text'",
1014            );
1015        }
1016    };
1017
1018    let hunks = match parse_patch(patch_text) {
1019        Ok(hunks) => hunks,
1020        Err(error) => return Response::error(&req.id, "invalid_request", error),
1021    };
1022    if hunks.is_empty() {
1023        return Response::error(
1024            &req.id,
1025            "invalid_request",
1026            "Empty patch: no file operations found",
1027        );
1028    }
1029
1030    let (resolved, affected_abs, affected_rel) = match resolve_hunks(req, ctx, hunks) {
1031        Ok(resolved) => resolved,
1032        Err(response) => return response,
1033    };
1034
1035    if edit::wants_preview(params) {
1036        return build_preview_response(req, &resolved, affected_abs, affected_rel);
1037    }
1038
1039    apply_patch(req, ctx, &resolved)
1040}