1use 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
57fn 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
968pub 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}