Skip to main content

typed_patch/
lib.rs

1//! # typed-patch
2//!
3//! Structured patch schema plus validation and apply helpers.
4//!
5//! This crate owns the typed patch representation, anchor/range edit model, and
6//! deterministic validation/apply surface used by `forge-engine`. It does not
7//! own execution backends or archive/promotion policy.
8
9//! # typed-patch
10//!
11//! Structured patch model plus validation and application helpers.
12//!
13//! This Tier 3 crate turns edits into typed operations, validates them against
14//! forge-policy constraints, and applies them through a workspace-safe
15//! filesystem abstraction.
16
17use 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}