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