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