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