Skip to main content

oxicode_hashline/
patcher.rs

1//! Filesystem-backed patch orchestrator.
2//!
3//! The [`Patcher`] is the bridge between a parsed [`Patch`] (pure data) and the
4//! real filesystem. It owns the two-phase commit:
5//!
6//! 1. **Prepare** every section: read the file, normalize (BOM/CRLF), validate
7//!    the snapshot tag (with drift recovery), check seen-lines, and apply edits
8//!    in memory. No writes happen during prepare.
9//! 2. **Commit**: restore line endings/BOM, write through [`HashlineFs`], and
10//!    record the post-edit snapshot so the new tag is immediately anchorable.
11//!
12//! All-or-nothing: if *any* section fails to prepare, nothing is written. If
13//! prepare succeeds for all sections but a write fails mid-batch, the result
14//! reports which sections committed and which did not.
15//!
16//! Ported from omp `packages/hashline/src/patcher.ts`.
17use crate::apply::apply_edits;
18use crate::diff_preview::build_compact_diff_preview;
19use crate::format::compute_file_hash;
20use crate::mismatch::{HashlineError, MismatchDetails, MismatchError};
21use crate::normalize::{self, LineEnding};
22use crate::parser::{Patch, PatchSection};
23use crate::recovery::{Recovery, RecoveryArgs, RecoveryFailure};
24use crate::snapshots::SnapshotStore;
25use crate::types::{CompactDiffOptions, Edit};
26use std::collections::HashSet;
27use std::sync::Arc;
28
29// ── Filesystem seam ──────────────────────────────────────────────────────
30
31/// Filesystem abstraction. The host (oxicode-agent) injects a concrete impl that
32/// wires in `PathGuard` security and `file_mutation_queue` serialization.
33///
34/// All paths are relative to the host's root directory.
35#[async_trait::async_trait]
36pub trait HashlineFs: Send + Sync {
37    /// Read the full raw text of `path`. Returns [`HashlineError::NotFound`]
38    /// when the file does not exist.
39    async fn read_text(&self, path: &str) -> Result<String, HashlineError>;
40
41    /// Atomically write `text` to `path`, returning the written path.
42    async fn write_text(&self, path: &str, text: &str) -> Result<String, HashlineError>;
43
44    /// Check write permission / parent existence without writing. Default is a
45    /// no-op (always ok).
46    async fn preflight_write(&self, _path: &str) -> Result<(), HashlineError> {
47        Ok(())
48    }
49
50    /// Canonicalize `path` for duplicate-section detection. Two sections whose
51    /// paths canonicalize to the same string are treated as the same file.
52    fn canonical_path(&self, path: &str) -> String;
53
54    /// Whether `err` represents a "file not found" condition.
55    fn is_not_found(&self, err: &HashlineError) -> bool {
56        matches!(err, HashlineError::NotFound { .. })
57    }
58}
59
60// ── Result types ─────────────────────────────────────────────────────────
61
62/// Result of applying an entire patch.
63#[derive(Debug, Clone)]
64pub struct PatcherApplyResult {
65    /// Per-section results, in the same order as the input patch sections.
66    pub sections: Vec<PatchSectionResult>,
67}
68
69/// Result of applying one section.
70#[derive(Debug, Clone)]
71pub struct PatchSectionResult {
72    /// Canonical file path that was edited.
73    pub path: String,
74    /// Compact post-edit diff preview (omp-style renumbered preview).
75    pub diff: String,
76    /// 1-indexed first changed line, if any.
77    pub first_changed_line: Option<u32>,
78    /// Warnings (parse warnings, recovery notices, boundary repair, etc.).
79    pub warnings: Vec<String>,
80    /// Content hash of the post-edit file — the tag for the next `[path#TAG]`.
81    pub new_hash: String,
82}
83
84/// Internal: a section that passed prepare and is ready to commit.
85struct PreparedSection {
86    path: String,
87    /// Pre-edit normalized (LF, no BOM) text — for diff preview.
88    before_text: String,
89    /// Post-edit normalized (LF, no BOM) text.
90    result_text: String,
91    /// Original line ending for restore-on-write.
92    line_ending: LineEnding,
93    /// Whether the original file had a BOM.
94    had_bom: bool,
95    first_changed_line: Option<u32>,
96    warnings: Vec<String>,
97}
98
99// ── Patcher ──────────────────────────────────────────────────────────────
100
101/// Orchestrates hashline patch application against a real filesystem.
102pub struct Patcher {
103    fs: Arc<dyn HashlineFs>,
104    snapshots: Arc<dyn SnapshotStore>,
105}
106
107impl Patcher {
108    /// Create a patcher with the given filesystem and snapshot store.
109    pub fn new(fs: Arc<dyn HashlineFs>, snapshots: Arc<dyn SnapshotStore>) -> Self {
110        Self { fs, snapshots }
111    }
112
113    /// Apply a patch: prepare all sections, then commit all (all-or-nothing).
114    pub async fn apply(&self, patch: &Patch) -> Result<PatcherApplyResult, HashlineError> {
115        let prepared = self.prepare_all(&patch.sections).await?;
116
117        // Commit phase: write every prepared section.
118        let mut results = Vec::with_capacity(prepared.len());
119        for section in &prepared {
120            let result = self.commit(section).await?;
121            results.push(result);
122        }
123
124        Ok(PatcherApplyResult { sections: results })
125    }
126
127    /// Preflight (dry-run): prepare all sections without writing.
128    pub async fn preflight(&self, patch: &Patch) -> Result<(), HashlineError> {
129        self.prepare_all(&patch.sections).await?;
130        Ok(())
131    }
132
133    // ── Prepare ──────────────────────────────────────────────────────────
134
135    /// Prepare every section. Returns `Err` on the first failure, leaving
136    /// nothing written.
137    async fn prepare_all(
138        &self,
139        sections: &[PatchSection],
140    ) -> Result<Vec<PreparedSection>, HashlineError> {
141        // Duplicate canonical-path detection.
142        let mut seen_paths: HashSet<String> = HashSet::new();
143        for section in sections {
144            let canonical = self.fs.canonical_path(&section.file_path);
145            if !seen_paths.insert(canonical.clone()) {
146                return Err(HashlineError::DuplicateCanonicalPath { path: canonical });
147            }
148        }
149
150        let mut prepared = Vec::with_capacity(sections.len());
151        for section in sections {
152            prepared.push(self.prepare_section(section).await?);
153        }
154        Ok(prepared)
155    }
156
157    /// Prepare a single section: read → normalize → validate tag → check seen
158    /// lines → apply edits in memory.
159    async fn prepare_section(
160        &self,
161        section: &PatchSection,
162    ) -> Result<PreparedSection, HashlineError> {
163        let canonical = self.fs.canonical_path(&section.file_path);
164
165        // Collect parse warnings up front.
166        let mut warnings = section.warnings.clone();
167
168        // Read.
169        let raw = self.fs.read_text(&section.file_path).await?;
170
171        // Normalize: detect line ending + BOM, strip for processing.
172        let bom = normalize::strip_bom(&raw);
173        let had_bom = !bom.bom.is_empty();
174        let line_ending = normalize::detect_line_ending(bom.text);
175        let normalized = normalize::normalize_to_lf(bom.text);
176
177        // Validate the snapshot tag and decide which text to edit.
178        let (text_to_edit, tag_warnings) = self
179            .resolve_tag(&canonical, &section.file_hash, &normalized, &section.edits)
180            .await?;
181        warnings.extend(tag_warnings);
182
183        // Check seen lines.
184        self.check_seen_lines(&canonical, &section.file_hash, &section.edits)?;
185
186        // Apply edits.
187        let apply_result = apply_edits(&text_to_edit, &section.edits)?;
188        warnings.extend(apply_result.warnings);
189
190        if apply_result.text == text_to_edit {
191            return Err(HashlineError::NoOp {
192                path: section.file_path.clone(),
193            });
194        }
195
196        Ok(PreparedSection {
197            path: section.file_path.clone(),
198            before_text: text_to_edit,
199            result_text: apply_result.text,
200            line_ending,
201            had_bom,
202            first_changed_line: apply_result.first_changed_line,
203            warnings,
204        })
205    }
206
207    // ── Tag resolution (the decision tree) ───────────────────────────────
208
209    /// Decide which text to apply edits to, based on the section's tag vs the
210    /// live file hash.
211    ///
212    /// Returns `(text_to_edit, warnings)`.
213    async fn resolve_tag(
214        &self,
215        canonical: &str,
216        file_hash: &str,
217        live_text: &str,
218        edits: &[Edit],
219    ) -> Result<(String, Vec<String>), HashlineError> {
220        let live_hash = compute_file_hash(live_text);
221
222        // No tag in the header → apply without validation (lenient path).
223        if file_hash.is_empty() {
224            return Ok((live_text.to_string(), Vec::new()));
225        }
226
227        // Tag matches live → normal path.
228        if live_hash == file_hash {
229            return Ok((live_text.to_string(), Vec::new()));
230        }
231
232        // Head/tail-only edits are position-independent → apply despite drift.
233        if edits.iter().all(is_position_independent) {
234            return Ok((
235                live_text.to_string(),
236                vec![crate::messages::HEADTAIL_DRIFT_WARNING.to_string()],
237            ));
238        }
239
240        // Drift on anchored edits → try recovery.
241        let recovery = Recovery::new(self.snapshots.as_ref());
242        match recovery.try_recover(RecoveryArgs {
243            path: canonical,
244            file_hash,
245            current_text: live_text,
246            edits,
247        }) {
248            Ok(recovered) => Ok((recovered.text, recovered.warnings)),
249            Err(RecoveryFailure::NoSnapshot) => {
250                // Tag not recognized — likely fabricated or from a prior session.
251                Err(mismatch_error(
252                    canonical, file_hash, &live_hash, live_text, edits,
253                    false, // hash not recognized
254                ))
255            }
256            Err(RecoveryFailure::ExternalModification { .. }) => {
257                // Tag IS the head but live differs — external write.
258                Err(mismatch_error(
259                    canonical, file_hash, &live_hash, live_text, edits,
260                    true, // hash recognized (it's the head)
261                ))
262            }
263            Err(RecoveryFailure::ChainMismatch) => {
264                // Session-chain guards failed.
265                Err(mismatch_error(
266                    canonical, file_hash, &live_hash, live_text, edits, true,
267                ))
268            }
269        }
270    }
271
272    // ── Seen-lines check ─────────────────────────────────────────────────
273
274    /// Verify every edit anchor was displayed to the model in a prior read.
275    fn check_seen_lines(
276        &self,
277        canonical: &str,
278        file_hash: &str,
279        edits: &[Edit],
280    ) -> Result<(), HashlineError> {
281        if file_hash.is_empty() {
282            return Ok(());
283        }
284        let snapshot = match self.snapshots.by_hash(canonical, file_hash) {
285            Some(s) => s,
286            None => return Ok(()), // No provenance — skip the check.
287        };
288        let seen = match &snapshot.seen_lines {
289            Some(s) => s,
290            None => return Ok(()), // No seen-line tracking — skip.
291        };
292
293        let mut unseen: Vec<u32> = Vec::new();
294        for edit in edits {
295            let anchor_line = edit.anchor_line();
296            if anchor_line == 0 || anchor_line == u32::MAX {
297                continue; // Bof / Eof — position-based.
298            }
299            if !seen.contains(&anchor_line) {
300                unseen.push(anchor_line);
301            }
302        }
303
304        if unseen.is_empty() {
305            return Ok(());
306        }
307
308        let msg = format_unseen_lines(&unseen);
309        Err(HashlineError::UnseenLines(msg))
310    }
311
312    // ── Commit ───────────────────────────────────────────────────────────
313
314    /// Write a prepared section and record its new snapshot.
315    async fn commit(&self, section: &PreparedSection) -> Result<PatchSectionResult, HashlineError> {
316        let canonical = self.fs.canonical_path(&section.path);
317
318        // Restore line endings + BOM.
319        let mut output = normalize::restore_line_endings(&section.result_text, section.line_ending);
320        if section.had_bom {
321            output = format!("\u{feff}{output}");
322        }
323
324        // Preflight + write.
325        self.fs.preflight_write(&section.path).await?;
326        self.fs.write_text(&section.path, &output).await?;
327
328        // Compute new hash for the normalized result text.
329        let new_hash = compute_file_hash(&section.result_text);
330
331        // Record the post-edit snapshot. The anchor lines become "seen" so a
332        // follow-up edit anchored at any line of the new state validates.
333        let total_lines = section.result_text.split('\n').count() as u32;
334        let all_lines: Vec<u32> = (1..=total_lines).collect();
335        self.snapshots
336            .record(&canonical, &section.result_text, Some(&all_lines));
337
338        // Build compact diff preview (omp-style renumbered post-edit preview).
339        let preview = build_compact_diff_preview(
340            &section.before_text,
341            &section.result_text,
342            &CompactDiffOptions::default(),
343        );
344        let diff = preview.lines.join("\n");
345
346        Ok(PatchSectionResult {
347            path: section.path.clone(),
348            diff,
349            first_changed_line: section.first_changed_line,
350            warnings: section.warnings.clone(),
351            new_hash,
352        })
353    }
354}
355
356// ── Helpers ──────────────────────────────────────────────────────────────
357
358/// True for `INS.HEAD` / `INS.TAIL` edits whose landing position does not
359/// depend on content (only file boundaries).
360fn is_position_independent(edit: &Edit) -> bool {
361    matches!(
362        edit,
363        Edit::Insert {
364            cursor: crate::types::Cursor::Bof | crate::types::Cursor::Eof,
365            ..
366        }
367    )
368}
369
370/// Build a [`HashlineError::Mismatch`] from the diagnostic context.
371fn mismatch_error(
372    path: &str,
373    expected: &str,
374    actual: &str,
375    live_text: &str,
376    edits: &[Edit],
377    hash_recognized: bool,
378) -> HashlineError {
379    let file_lines: Vec<String> = live_text.split('\n').map(String::from).collect();
380    let anchor_lines: Vec<u32> = edits.iter().map(|e| e.anchor_line()).collect();
381    let details = MismatchDetails {
382        path: Some(path.to_string()),
383        expected_file_hash: expected.to_string(),
384        actual_file_hash: actual.to_string(),
385        file_lines,
386        anchor_lines,
387        hash_recognized,
388    };
389    let err = MismatchError::new(details);
390    HashlineError::Mismatch {
391        detail: err.message,
392        expected: expected.to_string(),
393        actual: actual.to_string(),
394    }
395}
396
397/// Format the "unseen lines" error message.
398fn format_unseen_lines(lines: &[u32]) -> String {
399    let listed: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
400    format!(
401        "Edit rejected: lines {} were not shown in your last read. \
402         Re-read those exact lines before editing them.",
403        listed.join(", ")
404    )
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::snapshots::InMemorySnapshotStore;
411
412    /// In-memory HashlineFs for testing.
413    struct MemFs {
414        root: parking_lot::RwLock<std::collections::HashMap<String, String>>,
415    }
416
417    impl MemFs {
418        fn new() -> Self {
419            Self {
420                root: parking_lot::RwLock::new(std::collections::HashMap::new()),
421            }
422        }
423
424        fn put(&self, path: &str, text: &str) {
425            self.root.write().insert(path.to_string(), text.to_string());
426        }
427    }
428
429    #[async_trait::async_trait]
430    impl HashlineFs for MemFs {
431        async fn read_text(&self, path: &str) -> Result<String, HashlineError> {
432            self.root
433                .read()
434                .get(path)
435                .cloned()
436                .ok_or_else(|| HashlineError::NotFound {
437                    path: path.to_string(),
438                })
439        }
440
441        async fn write_text(&self, path: &str, text: &str) -> Result<String, HashlineError> {
442            self.root.write().insert(path.to_string(), text.to_string());
443            Ok(path.to_string())
444        }
445        fn canonical_path(&self, path: &str) -> String {
446            // Normalize: strip leading "./" for duplicate detection.
447            path.strip_prefix("./").unwrap_or(path).to_string()
448        }
449    }
450
451    fn make_patcher() -> (Patcher, Arc<MemFs>, Arc<InMemorySnapshotStore>) {
452        let fs = Arc::new(MemFs::new());
453        let store = Arc::new(InMemorySnapshotStore::new());
454        let patcher = Patcher::new(fs.clone(), store.clone());
455        (patcher, fs, store)
456    }
457
458    #[tokio::test]
459    async fn apply_simple_swap() {
460        let (patcher, fs, store) = make_patcher();
461        let content = "fn main() {\n    todo!()\n}\n";
462        fs.put("main.rs", content);
463        let tag = store.record("main.rs", content, Some(&[1, 2, 3]));
464
465        let patch_text = format!(
466            "*** Begin Patch\n[main.rs#{tag}]\nSWAP 2.=2:\n+    println!(\"hi\")\n*** End Patch"
467        );
468        let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
469        let result = patcher.apply(&patch).await.unwrap();
470
471        assert_eq!(result.sections.len(), 1);
472        let new_content = fs.read_text("main.rs").await.unwrap();
473        assert!(new_content.contains("println!"));
474        assert!(!new_content.contains("todo!"));
475    }
476
477    #[tokio::test]
478    async fn apply_rejects_stale_tag_with_no_snapshot() {
479        let (patcher, fs, _store) = make_patcher();
480        fs.put("f.rs", "a\nb\n");
481
482        let patch_text = "*** Begin Patch\n[f.rs#FFFF]\nSWAP 1.=1:\n+x\n*** End Patch";
483        let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
484        let result = patcher.apply(&patch).await;
485
486        assert!(result.is_err());
487        let err = result.unwrap_err();
488        assert!(matches!(err, HashlineError::Mismatch { .. }));
489    }
490
491    #[tokio::test]
492    async fn apply_head_tail_drift_allowed() {
493        let (patcher, fs, _store) = make_patcher();
494        fs.put("f.rs", "a\nb\n");
495
496        // Tag doesn't match, but it's a HEAD insert (position-independent).
497        let patch_text = "*** Begin Patch\n[f.rs#FFFF]\nINS.HEAD:\n+prefix\n*** End Patch";
498        let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
499        let _result = patcher.apply(&patch).await.unwrap();
500
501        let new_content = fs.read_text("f.rs").await.unwrap();
502        assert!(new_content.starts_with("prefix"));
503    }
504
505    #[tokio::test]
506    async fn apply_no_tag_applies_without_validation() {
507        let (patcher, fs, _store) = make_patcher();
508        fs.put("f.rs", "a\nb\n");
509
510        // No #TAG in header.
511        let patch_text = "*** Begin Patch\n[f.rs]\nSWAP 1.=1:\n+x\n*** End Patch";
512        let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
513        let _result = patcher.apply(&patch).await.unwrap();
514
515        let new_content = fs.read_text("f.rs").await.unwrap();
516        assert!(new_content.starts_with("x\n"));
517    }
518
519    #[tokio::test]
520    async fn apply_records_new_snapshot() {
521        let (patcher, fs, store) = make_patcher();
522        let content = "a\nb\n";
523        fs.put("f.rs", content);
524        let tag = store.record("f.rs", content, Some(&[1, 2]));
525
526        let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+x\n*** End Patch");
527        let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
528        let result = patcher.apply(&patch).await.unwrap();
529
530        // The new hash should resolve to a snapshot.
531        let new_hash = &result.sections[0].new_hash;
532        assert!(!new_hash.is_empty());
533        let snap = store.by_hash("f.rs", new_hash);
534        assert!(snap.is_some());
535    }
536
537    #[tokio::test]
538    async fn apply_rejects_duplicate_canonical_paths() {
539        let (patcher, fs, _store) = make_patcher();
540        fs.put("f.rs", "a\nb\n");
541
542        // Two sections with DIFFERENT path strings that canonicalize to the
543        // same file ("./f.rs" → "f.rs"). The parser won't merge these because
544        // the raw strings differ; the patcher's canonical-path check catches it.
545        let patch_text =
546            "*** Begin Patch\n[f.rs]\nSWAP 1.=1:\n+x\n[./f.rs]\nSWAP 2.=2:\n+y\n*** End Patch";
547        let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
548        let result = patcher.apply(&patch).await;
549
550        assert!(matches!(
551            result,
552            Err(HashlineError::DuplicateCanonicalPath { .. })
553        ));
554    }
555
556    #[tokio::test]
557    async fn apply_noop_is_error() {
558        let (patcher, fs, store) = make_patcher();
559        let content = "a\nb\n";
560        fs.put("f.rs", content);
561        let tag = store.record("f.rs", content, Some(&[1, 2]));
562
563        // SWAP 1.=1 with body that's identical to line 1 → no-op.
564        let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+a\n*** End Patch");
565        let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
566        let result = patcher.apply(&patch).await;
567
568        assert!(matches!(result, Err(HashlineError::NoOp { .. })));
569    }
570
571    #[tokio::test]
572    async fn preflight_does_not_write() {
573        let (patcher, fs, store) = make_patcher();
574        let content = "a\nb\n";
575        fs.put("f.rs", content);
576        let tag = store.record("f.rs", content, Some(&[1, 2]));
577
578        let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+x\n*** End Patch");
579        let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
580        patcher.preflight(&patch).await.unwrap();
581
582        // File should be unchanged.
583        let content_after = fs.read_text("f.rs").await.unwrap();
584        assert_eq!(content_after, content);
585    }
586}