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