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