1use std::collections::{BTreeMap, BTreeSet};
18use std::path::{Path, PathBuf};
19
20use forge_policy::{validate_forbidden_paths, validate_patch_caps, Violation, ViolationKind};
21use sandbox_workspace::PatchFs;
22
23#[derive(Debug, thiserror::Error)]
24pub enum PatchError {
25 #[error("patch validation failed")]
26 Validation { violations: Vec<Violation> },
27
28 #[error("anchor resolution failed: {0}")]
29 AnchorResolution(String),
30
31 #[error("patch apply failed: {0}")]
32 Apply(String),
33
34 #[error("io error: {0}")]
35 Io(#[from] std::io::Error),
36
37 #[error("workspace error: {0}")]
38 Workspace(#[from] sandbox_workspace::WorkspaceError),
39
40 #[error("policy error: {0}")]
41 Policy(#[from] forge_policy::PolicyError),
42
43 #[error("{0}")]
44 Other(String),
45}
46
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct StructuredPatch {
49 pub patch_id: uuid::Uuid,
50 pub summary: String,
51 pub edits: Vec<FileEdit>,
52 pub notes: Vec<String>,
53}
54
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct FileEdit {
57 pub path: PathBuf,
58 pub ops: Vec<EditOp>,
59 pub mode: Option<FileMode>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub enum FileMode {
64 Create,
65 Delete,
66 Modify,
67}
68
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
70pub enum EditOp {
71 Insert {
72 anchor: Anchor,
73 lines: Vec<String>,
74 },
75 Delete {
76 range: LineRange,
77 },
78 Replace {
79 range: LineRange,
80 lines: Vec<String>,
81 },
82}
83
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub enum Anchor {
86 AfterLine {
87 line: u32,
88 context_before: Vec<String>,
89 context_after: Vec<String>,
90 },
91 BeforeLine {
92 line: u32,
93 context_before: Vec<String>,
94 context_after: Vec<String>,
95 },
96 AfterMatch {
97 needle: String,
98 occurrence: u32,
99 },
100 BeforeMatch {
101 needle: String,
102 occurrence: u32,
103 },
104}
105
106#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
107pub struct LineRange {
108 pub start: u32,
109 pub end_exclusive: u32,
110}
111
112#[derive(Debug, Clone)]
113pub struct ValidationResult {
114 pub ok: bool,
115 pub violations: Vec<Violation>,
116}
117
118#[derive(Debug, Clone)]
119pub struct PatchPolicy {
120 pub forbidden_paths: Vec<String>,
121 pub allow_test_modifications: bool,
122 pub max_files_changed: usize,
123 pub max_total_lines_changed: usize,
124 pub max_lines_changed_per_file: usize,
125}
126
127#[derive(Debug, Clone, Default)]
128pub struct LineAttributionMap {
129 pub mappings: BTreeMap<String, Vec<(u32, u32)>>,
130 pub resolved_anchors: BTreeMap<(String, usize), u32>,
131}
132
133impl EditOp {
134 pub fn lines_added(&self) -> usize {
135 match self {
136 Self::Insert { lines, .. } => lines.len(),
137 Self::Delete { .. } => 0,
138 Self::Replace { lines, .. } => lines.len(),
139 }
140 }
141
142 pub fn lines_removed(&self) -> usize {
143 match self {
144 Self::Insert { .. } => 0,
145 Self::Delete { range } | Self::Replace { range, .. } => {
146 range.end_exclusive.saturating_sub(range.start) as usize
147 }
148 }
149 }
150
151 pub fn lines_changed(&self) -> usize {
152 self.lines_added() + self.lines_removed()
153 }
154
155 pub fn kind(&self) -> &str {
156 match self {
157 Self::Insert { .. } => "Insert",
158 Self::Delete { .. } => "Delete",
159 Self::Replace { .. } => "Replace",
160 }
161 }
162
163 pub fn anchor_kind(&self) -> Option<&str> {
164 match self {
165 Self::Insert {
166 anchor: Anchor::AfterLine { .. },
167 ..
168 } => Some("AfterLine"),
169 Self::Insert {
170 anchor: Anchor::BeforeLine { .. },
171 ..
172 } => Some("BeforeLine"),
173 Self::Insert {
174 anchor: Anchor::AfterMatch { .. },
175 ..
176 } => Some("AfterMatch"),
177 Self::Insert {
178 anchor: Anchor::BeforeMatch { .. },
179 ..
180 } => Some("BeforeMatch"),
181 _ => None,
182 }
183 }
184
185 pub fn context_lines(&self) -> Vec<String> {
186 match self {
187 Self::Insert {
188 anchor:
189 Anchor::AfterLine {
190 context_before,
191 context_after,
192 ..
193 }
194 | Anchor::BeforeLine {
195 context_before,
196 context_after,
197 ..
198 },
199 ..
200 } => {
201 let mut context = context_before.clone();
202 context.extend(context_after.iter().cloned());
203 context
204 }
205 _ => Vec::new(),
206 }
207 }
208}
209
210impl FileEdit {
211 pub fn total_lines_changed(&self) -> usize {
212 self.ops.iter().map(EditOp::lines_changed).sum()
213 }
214}
215
216impl StructuredPatch {
217 pub fn files_changed(&self) -> usize {
218 self.edits.len()
219 }
220
221 pub fn total_lines_changed(&self) -> usize {
222 self.edits.iter().map(FileEdit::total_lines_changed).sum()
223 }
224
225 pub fn per_file_lines_changed(&self) -> Vec<usize> {
226 self.edits
227 .iter()
228 .map(FileEdit::total_lines_changed)
229 .collect()
230 }
231
232 pub fn file_paths(&self) -> Vec<&str> {
233 self.edits
234 .iter()
235 .filter_map(|edit| edit.path.to_str())
236 .collect()
237 }
238}
239
240pub fn validate_patch(patch: &StructuredPatch, policy: &PatchPolicy) -> ValidationResult {
241 let mut violations = Vec::new();
242
243 if patch.edits.is_empty() {
244 violations.push(Violation {
245 kind: ViolationKind::EmptyPatch,
246 message: "patch has no file edits".to_string(),
247 });
248 }
249
250 for edit in &patch.edits {
251 if edit.path.is_absolute() {
252 violations.push(Violation {
253 kind: ViolationKind::ForbiddenPath,
254 message: format!(
255 "path '{}' is absolute — must be relative to workspace",
256 edit.path.display()
257 ),
258 });
259 }
260
261 for component in edit.path.components() {
262 if component == std::path::Component::ParentDir {
263 violations.push(Violation {
264 kind: ViolationKind::ForbiddenPath,
265 message: format!(
266 "path '{}' contains '..' — workspace escape not allowed",
267 edit.path.display()
268 ),
269 });
270 break;
271 }
272 }
273 }
274
275 let mut seen = BTreeSet::new();
276 for edit in &patch.edits {
277 let key = edit.path.to_string_lossy().to_string();
278 if !seen.insert(key.clone()) {
279 violations.push(Violation {
280 kind: ViolationKind::DuplicatePath,
281 message: format!("duplicate file path in patch: '{key}'"),
282 });
283 }
284 }
285
286 let path_strings: Vec<String> = patch
287 .file_paths()
288 .into_iter()
289 .map(ToString::to_string)
290 .collect();
291 violations.extend(validate_forbidden_paths(
292 &path_strings,
293 &policy.forbidden_paths,
294 policy.allow_test_modifications,
295 ));
296 violations.extend(validate_patch_caps(
297 patch.files_changed(),
298 patch.total_lines_changed(),
299 &patch.per_file_lines_changed(),
300 policy.max_files_changed,
301 policy.max_total_lines_changed,
302 policy.max_lines_changed_per_file,
303 ));
304
305 for edit in &patch.edits {
306 if edit.ops.is_empty() && edit.mode != Some(FileMode::Delete) {
307 violations.push(Violation {
308 kind: ViolationKind::UselessEdit,
309 message: format!(
310 "file '{}' has no ops and is not a deletion",
311 edit.path.display()
312 ),
313 });
314 }
315
316 for op in &edit.ops {
317 match op {
318 EditOp::Delete { range } | EditOp::Replace { range, .. } => {
319 if range.start < 1 {
320 violations.push(Violation {
321 kind: ViolationKind::DegenerateRange,
322 message: format!(
323 "file '{}': range start must be >= 1, got {}",
324 edit.path.display(),
325 range.start
326 ),
327 });
328 }
329 if range.end_exclusive <= range.start {
330 violations.push(Violation {
331 kind: ViolationKind::DegenerateRange,
332 message: format!(
333 "file '{}': degenerate range [{}, {})",
334 edit.path.display(),
335 range.start,
336 range.end_exclusive
337 ),
338 });
339 }
340 }
341 EditOp::Insert { anchor, .. } => match anchor {
342 Anchor::AfterMatch { occurrence, .. }
343 | Anchor::BeforeMatch { occurrence, .. }
344 if *occurrence == 0 =>
345 {
346 violations.push(Violation {
347 kind: ViolationKind::InvalidOccurrence,
348 message: format!("occurrence must be > 0 for {:?}, got 0", anchor),
349 });
350 }
351 _ => {}
352 },
353 }
354 }
355 }
356
357 ValidationResult {
358 ok: violations.is_empty(),
359 violations,
360 }
361}
362
363pub fn apply_patch<F: PatchFs>(
364 patch: &StructuredPatch,
365 fs: &F,
366) -> Result<LineAttributionMap, PatchError> {
367 let mut snapshots: BTreeMap<String, Option<Vec<String>>> = BTreeMap::new();
368 let mut attribution_map = LineAttributionMap::default();
369
370 for edit in &patch.edits {
371 let path_key = edit.path.to_string_lossy().to_string();
372 snapshots.insert(path_key, fs.snapshot_lines(&edit.path)?);
373 }
374
375 for edit in &patch.edits {
376 let path_key = edit.path.to_string_lossy().to_string();
377 match apply_file_edit(edit, &edit.path, &path_key, &mut attribution_map, fs) {
378 Ok(line_map) => {
379 attribution_map.mappings.insert(path_key, line_map);
380 }
381 Err(error) => {
382 for (path_key, snapshot) in &snapshots {
383 let relative = Path::new(path_key.as_str());
384 match snapshot {
385 Some(lines) => {
386 fs.write_lines(relative, lines)?;
387 }
388 None => {
389 fs.remove_file(relative)?;
390 }
391 }
392 }
393 return Err(error);
394 }
395 }
396 }
397
398 Ok(attribution_map)
399}
400
401pub async fn render_diff(original_dir: &Path, patched_dir: &Path) -> Result<String, PatchError> {
402 let original = original_dir.to_path_buf();
403 let patched = patched_dir.to_path_buf();
404
405 tokio::task::spawn_blocking(move || {
406 if let Ok(diff) = render_diff_git(&original, &patched) {
407 return Ok(diff);
408 }
409 render_diff_internal(&original, &patched)
410 })
411 .await
412 .map_err(|error| PatchError::Other(format!("render_diff join error: {error}")))?
413}
414
415fn apply_file_edit<F: PatchFs>(
416 edit: &FileEdit,
417 path: &Path,
418 file_key: &str,
419 attribution_map: &mut LineAttributionMap,
420 fs: &F,
421) -> Result<Vec<(u32, u32)>, PatchError> {
422 let mode = edit.mode.as_ref().unwrap_or(&FileMode::Modify);
423
424 match mode {
425 FileMode::Delete => {
426 fs.remove_file(path)?;
427 return Ok(Vec::new());
428 }
429 FileMode::Create => {
430 if fs.exists(path)? {
431 return Err(PatchError::Apply(format!(
432 "cannot create {}: file already exists",
433 path.display()
434 )));
435 }
436 fs.create_parent_dirs(path)?;
437 }
438 FileMode::Modify => {
439 if !fs.exists(path)? {
440 return Err(PatchError::Apply(format!(
441 "cannot modify {}: file does not exist",
442 path.display()
443 )));
444 }
445 }
446 }
447
448 let mut content = if *mode == FileMode::Create {
449 Vec::new()
450 } else {
451 fs.read_lines(path)?
452 };
453
454 let original_len = content.len();
455 let mut offset: i64 = 0;
456 let mut line_mappings = Vec::new();
457 for index in 0..original_len {
458 line_mappings.push((index as u32 + 1, index as u32 + 1));
459 }
460
461 for (op_index, op) in edit.ops.iter().enumerate() {
462 match op {
463 EditOp::Insert { anchor, lines } => {
464 let insert_index =
465 resolve_anchor(anchor, &content).map_err(PatchError::AnchorResolution)?;
466
467 if matches!(
468 anchor,
469 Anchor::AfterMatch { .. } | Anchor::BeforeMatch { .. }
470 ) {
471 attribution_map
472 .resolved_anchors
473 .insert((file_key.to_string(), op_index), (insert_index + 1) as u32);
474 }
475
476 for (line_offset, line) in lines.iter().enumerate() {
477 content.insert(insert_index + line_offset, line.clone());
478 }
479 offset += lines.len() as i64;
480
481 for mapping in &mut line_mappings {
482 if mapping.1 as usize > insert_index {
483 mapping.1 += lines.len() as u32;
484 }
485 }
486 }
487 EditOp::Delete { range } => {
488 let start = adjusted_index(range.start, offset, content.len())?;
489 let end = adjusted_index(range.end_exclusive, offset, content.len())?;
490 if start >= content.len() || end > content.len() || start >= end {
491 return Err(PatchError::Apply(format!(
492 "delete range [{}, {}) out of bounds (content has {} lines, offset={offset})",
493 range.start, range.end_exclusive, content.len()
494 )));
495 }
496
497 content.drain(start..end);
498 let removed = (end - start) as i64;
499 offset -= removed;
500
501 for mapping in &mut line_mappings {
502 let mapped = mapping.1 as usize;
503 if mapped > end {
504 mapping.1 -= removed as u32;
505 } else if mapped > start {
506 mapping.1 = start as u32 + 1;
507 }
508 }
509 }
510 EditOp::Replace { range, lines } => {
511 let start = adjusted_index(range.start, offset, content.len())?;
512 let end = adjusted_index(range.end_exclusive, offset, content.len())?;
513 if start >= content.len() || end > content.len() || start >= end {
514 return Err(PatchError::Apply(format!(
515 "replace range [{}, {}) out of bounds (content has {} lines, offset={offset})",
516 range.start, range.end_exclusive, content.len()
517 )));
518 }
519
520 let removed = (end - start) as i64;
521 let added = lines.len() as i64;
522
523 content.drain(start..end);
524 for (line_offset, line) in lines.iter().enumerate() {
525 content.insert(start + line_offset, line.clone());
526 }
527
528 let net = added - removed;
529 offset += net;
530
531 for mapping in &mut line_mappings {
532 let mapped = mapping.1 as usize;
533 if mapped > end {
534 mapping.1 = (mapped as i64 + net) as u32;
535 } else if mapped > start && mapped <= end {
536 mapping.1 = start as u32 + 1;
537 }
538 }
539 }
540 }
541 }
542
543 fs.write_lines(path, &content)?;
544 Ok(line_mappings)
545}
546
547fn adjusted_index(line: u32, offset: i64, content_len: usize) -> Result<usize, PatchError> {
548 let adjusted = line as i64 - 1 + offset;
549 if adjusted < 0 {
550 return Err(PatchError::Apply(format!(
551 "line offset underflow: line={line}, cumulative offset={offset}"
552 )));
553 }
554 Ok((adjusted as usize).min(content_len))
555}
556
557fn resolve_anchor(anchor: &Anchor, content: &[String]) -> Result<usize, String> {
558 match anchor {
559 Anchor::AfterLine {
560 line,
561 context_before,
562 context_after,
563 } => {
564 let index = *line as usize;
565 if index > content.len() {
566 return Err(format!(
567 "AfterLine {line} is beyond content length {}",
568 content.len()
569 ));
570 }
571 if !verify_context(content, index, context_before, context_after) {
572 return Err(format!("AfterLine {line}: context mismatch"));
573 }
574 Ok(index)
575 }
576 Anchor::BeforeLine {
577 line,
578 context_before,
579 context_after,
580 } => {
581 let index = (*line as usize).saturating_sub(1);
582 if index > content.len() {
583 return Err(format!(
584 "BeforeLine {line} is beyond content length {}",
585 content.len()
586 ));
587 }
588 if !verify_context(content, index, context_before, context_after) {
589 return Err(format!("BeforeLine {line}: context mismatch"));
590 }
591 Ok(index)
592 }
593 Anchor::AfterMatch { needle, occurrence } => {
594 let positions = find_all_matches(content, needle);
595 let occurrence = *occurrence as usize;
596 if occurrence == 0 {
597 return Err("occurrence must be >= 1".to_string());
598 }
599 if positions.len() < occurrence {
600 return Err(format!(
601 "AfterMatch: only {} matches for '{}', need occurrence {}",
602 positions.len(),
603 needle,
604 occurrence
605 ));
606 }
607 Ok(positions[occurrence - 1] + 1)
608 }
609 Anchor::BeforeMatch { needle, occurrence } => {
610 let positions = find_all_matches(content, needle);
611 let occurrence = *occurrence as usize;
612 if occurrence == 0 {
613 return Err("occurrence must be >= 1".to_string());
614 }
615 if positions.len() < occurrence {
616 return Err(format!(
617 "BeforeMatch: only {} matches for '{}', need occurrence {}",
618 positions.len(),
619 needle,
620 occurrence
621 ));
622 }
623 Ok(positions[occurrence - 1])
624 }
625 }
626}
627
628fn verify_context(
629 content: &[String],
630 index: usize,
631 context_before: &[String],
632 context_after: &[String],
633) -> bool {
634 if context_before.is_empty() && context_after.is_empty() {
635 return true;
636 }
637
638 for (context_index, line) in context_before.iter().enumerate() {
639 let check = index as i64 - context_before.len() as i64 + context_index as i64;
640 if check < 0 || check as usize >= content.len() {
641 return false;
642 }
643 if content[check as usize].trim() != line.trim() {
644 return false;
645 }
646 }
647
648 for (context_index, line) in context_after.iter().enumerate() {
649 let check = index + context_index;
650 if check >= content.len() {
651 return false;
652 }
653 if content[check].trim() != line.trim() {
654 return false;
655 }
656 }
657
658 true
659}
660
661fn find_all_matches(content: &[String], needle: &str) -> Vec<usize> {
662 content
663 .iter()
664 .enumerate()
665 .filter(|(_, line)| line.contains(needle))
666 .map(|(index, _)| index)
667 .collect()
668}
669
670fn render_diff_git(original_dir: &Path, patched_dir: &Path) -> Result<String, PatchError> {
671 let output = std::process::Command::new("git")
672 .args(["diff", "--no-index", "--no-color", "--"])
673 .arg(original_dir)
674 .arg(patched_dir)
675 .output()
676 .map_err(|error| PatchError::Other(format!("git not available: {error}")))?;
677
678 if output.status.code() == Some(0) {
679 return Ok(String::new());
680 }
681
682 let diff = String::from_utf8_lossy(&output.stdout).to_string();
683 if diff.is_empty() && !output.stderr.is_empty() {
684 return Err(PatchError::Other(format!(
685 "git diff failed: {}",
686 String::from_utf8_lossy(&output.stderr)
687 )));
688 }
689
690 Ok(diff)
691}
692
693fn render_diff_internal(original_dir: &Path, patched_dir: &Path) -> Result<String, PatchError> {
694 let mut result = String::new();
695 let mut files = BTreeSet::new();
696
697 if original_dir.exists() {
698 collect_files(original_dir, original_dir, &mut files)?;
699 }
700 if patched_dir.exists() {
701 collect_files(patched_dir, patched_dir, &mut files)?;
702 }
703
704 for file in &files {
705 let original_path = original_dir.join(file);
706 let patched_path = patched_dir.join(file);
707
708 let original_content = if original_path.exists() {
709 std::fs::read_to_string(&original_path)?
710 } else {
711 String::new()
712 };
713 let patched_content = if patched_path.exists() {
714 std::fs::read_to_string(&patched_path)?
715 } else {
716 String::new()
717 };
718
719 if original_content == patched_content {
720 continue;
721 }
722
723 let diff = similar::TextDiff::from_lines(&original_content, &patched_content);
724 let unified = diff
725 .unified_diff()
726 .context_radius(3)
727 .header(&format!("a/{file}"), &format!("b/{file}"))
728 .to_string();
729 result.push_str(&unified);
730 }
731
732 Ok(result)
733}
734
735fn collect_files(base: &Path, dir: &Path, files: &mut BTreeSet<String>) -> Result<(), PatchError> {
736 for entry in walkdir::WalkDir::new(dir)
737 .sort_by_file_name()
738 .into_iter()
739 .filter_map(|entry| entry.ok())
740 {
741 if entry.file_type().is_file() {
742 if let Ok(relative) = entry.path().strip_prefix(base) {
743 files.insert(relative.to_string_lossy().to_string());
744 }
745 }
746 }
747 Ok(())
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use proptest::prelude::*;
754 use sandbox_workspace::LocalPatchFs;
755 use tempfile::tempdir;
756
757 #[test]
758 fn strategy_helpers_count_lines() {
759 let op = EditOp::Replace {
760 range: LineRange {
761 start: 1,
762 end_exclusive: 3,
763 },
764 lines: vec!["x".to_string()],
765 };
766 assert_eq!(op.lines_added(), 1);
767 assert_eq!(op.lines_removed(), 2);
768 }
769
770 fn permissive_policy() -> PatchPolicy {
771 PatchPolicy {
772 forbidden_paths: vec!["target".into()],
773 allow_test_modifications: true,
774 max_files_changed: 10,
775 max_total_lines_changed: 100,
776 max_lines_changed_per_file: 100,
777 }
778 }
779
780 #[test]
781 fn validate_patch_rejects_workspace_escape_and_invalid_occurrence() {
782 let patch = StructuredPatch {
783 patch_id: uuid::Uuid::new_v4(),
784 summary: "bad patch".into(),
785 edits: vec![FileEdit {
786 path: PathBuf::from("../escape.rs"),
787 mode: Some(FileMode::Modify),
788 ops: vec![EditOp::Insert {
789 anchor: Anchor::AfterMatch {
790 needle: "fn main".into(),
791 occurrence: 0,
792 },
793 lines: vec!["println!(\"oops\");".into()],
794 }],
795 }],
796 notes: vec![],
797 };
798
799 let validation = validate_patch(&patch, &permissive_policy());
800 assert!(!validation.ok);
801 assert!(validation
802 .violations
803 .iter()
804 .any(|violation| violation.kind == ViolationKind::ForbiddenPath));
805 assert!(validation
806 .violations
807 .iter()
808 .any(|violation| violation.kind == ViolationKind::InvalidOccurrence));
809 }
810
811 #[test]
812 fn apply_patch_rolls_back_when_a_later_edit_fails() {
813 let root = tempdir().unwrap();
814 let fs = LocalPatchFs::new(root.path());
815 let existing = Path::new("src/lib.rs");
816 fs.write_lines(existing, &["fn main() {}".into()]).unwrap();
817
818 let patch = StructuredPatch {
819 patch_id: uuid::Uuid::new_v4(),
820 summary: "mixed patch".into(),
821 edits: vec![
822 FileEdit {
823 path: existing.into(),
824 mode: Some(FileMode::Modify),
825 ops: vec![EditOp::Replace {
826 range: LineRange {
827 start: 1,
828 end_exclusive: 2,
829 },
830 lines: vec!["fn main() { println!(\"changed\"); }".into()],
831 }],
832 },
833 FileEdit {
834 path: PathBuf::from("missing/file.rs"),
835 mode: Some(FileMode::Modify),
836 ops: vec![EditOp::Insert {
837 anchor: Anchor::AfterLine {
838 line: 1,
839 context_before: vec![],
840 context_after: vec![],
841 },
842 lines: vec!["let x = 1;".into()],
843 }],
844 },
845 ],
846 notes: vec![],
847 };
848
849 let error = apply_patch(&patch, &fs).unwrap_err();
850 assert!(matches!(error, PatchError::Apply(_)));
851 assert_eq!(
852 fs.read_lines(existing).unwrap(),
853 vec!["fn main() {}".to_string()]
854 );
855 }
856
857 #[test]
858 fn idempotent_replace_patch_can_be_applied_twice() {
859 let root = tempdir().unwrap();
860 let fs = LocalPatchFs::new(root.path());
861 let existing = Path::new("src/lib.rs");
862 fs.write_lines(existing, &["fn main() {}".into()]).unwrap();
863
864 let patch = StructuredPatch {
865 patch_id: uuid::Uuid::new_v4(),
866 summary: "idempotent replace".into(),
867 edits: vec![FileEdit {
868 path: existing.into(),
869 mode: Some(FileMode::Modify),
870 ops: vec![EditOp::Replace {
871 range: LineRange {
872 start: 1,
873 end_exclusive: 2,
874 },
875 lines: vec!["fn main() {}".into()],
876 }],
877 }],
878 notes: vec![],
879 };
880
881 apply_patch(&patch, &fs).unwrap();
882 let once = fs.read_lines(existing).unwrap();
883 apply_patch(&patch, &fs).unwrap();
884 let twice = fs.read_lines(existing).unwrap();
885
886 assert_eq!(once, twice);
887 assert_eq!(twice, vec!["fn main() {}".to_string()]);
888 }
889
890 proptest! {
891 #[test]
892 fn parent_dir_paths_are_always_rejected(path_tail in "[A-Za-z0-9_./-]{1,24}") {
893 let patch = StructuredPatch {
894 patch_id: uuid::Uuid::new_v4(),
895 summary: "escape".into(),
896 edits: vec![FileEdit {
897 path: PathBuf::from(format!("../{path_tail}")),
898 mode: Some(FileMode::Modify),
899 ops: vec![EditOp::Delete {
900 range: LineRange { start: 1, end_exclusive: 2 },
901 }],
902 }],
903 notes: vec![],
904 };
905
906 let validation = validate_patch(&patch, &permissive_policy());
907 prop_assert!(!validation.ok);
908 prop_assert!(validation
909 .violations
910 .iter()
911 .any(|violation| violation.kind == ViolationKind::ForbiddenPath));
912 }
913
914 #[test]
915 fn idempotent_replace_patch_remains_stable_for_arbitrary_line(line in "[A-Za-z0-9_ (){};]{1,32}") {
916 let root = tempdir().unwrap();
917 let fs = LocalPatchFs::new(root.path());
918 let existing = Path::new("src/lib.rs");
919 fs.write_lines(existing, &["fn main() {}".into()]).unwrap();
920
921 let patch = StructuredPatch {
922 patch_id: uuid::Uuid::new_v4(),
923 summary: "idempotent replace".into(),
924 edits: vec![FileEdit {
925 path: existing.into(),
926 mode: Some(FileMode::Modify),
927 ops: vec![EditOp::Replace {
928 range: LineRange {
929 start: 1,
930 end_exclusive: 2,
931 },
932 lines: vec![line.clone()],
933 }],
934 }],
935 notes: vec![],
936 };
937
938 apply_patch(&patch, &fs).unwrap();
939 let once = fs.read_lines(existing).unwrap();
940 apply_patch(&patch, &fs).unwrap();
941 let twice = fs.read_lines(existing).unwrap();
942
943 prop_assert_eq!(once, twice.clone());
944 prop_assert_eq!(twice, vec![line]);
945 }
946 }
947}