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 if dest.abs == source.abs {
174 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
974pub 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}