Skip to main content

oxicode_hashline/
recovery.rs

1//! Snapshot-tag drift recovery.
2//!
3//! When a section's `[path#TAG]` header names a tag that no longer matches the
4//! live file's content hash, the patcher delegates here. M1 implements only
5//! **session-chain replay** (Phase 1): if the tag names an older in-session
6//! snapshot — meaning a prior edit in the *same* session advanced the hash —
7//! and the intermediate edit left the anchor lines (and line count) untouched,
8//! the new edits are replayed directly onto the live content.
9//!
10//! Phase 2 (3-way merge via `similar`, for *external* modifications where the
11//! tag IS the head but the live file differs) is deferred to M1.5 behind the
12//! `three-way-merge` feature. Without it, an external modification cleanly
13//! falls back to a `MismatchError` — the same behaviour as the legacy
14//! str_replace path.
15//!
16//! Ported from omp `packages/hashline/src/recovery.ts`.
17
18use crate::apply::apply_edits;
19use crate::messages::{RECOVERY_SESSION_CHAIN_WARNING, RECOVERY_SESSION_REPLAY_WARNING};
20use crate::snapshots::{Snapshot, SnapshotStore};
21use crate::types::Edit;
22
23// ── Input / output ───────────────────────────────────────────────────────
24
25/// Arguments for [`Recovery::try_recover`].
26pub struct RecoveryArgs<'a> {
27    /// Canonical file path.
28    pub path: &'a str,
29    /// The stale tag from the section header.
30    pub file_hash: &'a str,
31    /// Current (live) file text, already normalized to LF / BOM-stripped.
32    pub current_text: &'a str,
33    /// The edits the section wants to apply.
34    pub edits: &'a [Edit],
35}
36
37/// A successfully recovered edit application.
38#[derive(Debug, Clone)]
39pub struct RecoveryResult {
40    /// Post-edit text (normalized).
41    pub text: String,
42    /// 1-indexed first changed line, if any.
43    pub first_changed_line: Option<u32>,
44    /// Warnings (always includes the session-chain notice).
45    pub warnings: Vec<String>,
46}
47
48/// Why recovery could not proceed.
49#[derive(Debug)]
50pub enum RecoveryFailure {
51    /// No snapshot was ever recorded for `path` + `file_hash`.
52    NoSnapshot,
53    /// The tag names the latest (head) snapshot, but the live file differs — an
54    /// external modification. M1 rejects; Phase 2 (3-way merge) would handle
55    /// this.
56    ExternalModification {
57        /// The head snapshot the tag resolved to.
58        snapshot: Box<Snapshot>,
59    },
60    /// The session-chain guards failed (line count or anchor content changed
61    /// between the tagged snapshot and live).
62    ChainMismatch,
63}
64
65// ── Engine ───────────────────────────────────────────────────────────────
66
67/// Borrowed recovery engine. Cheap to construct; borrows the snapshot store
68/// for the duration of a single `try_recover` call.
69pub struct Recovery<'a> {
70    store: &'a dyn SnapshotStore,
71}
72
73impl<'a> Recovery<'a> {
74    /// Create a recovery engine backed by `store`.
75    pub fn new(store: &'a dyn SnapshotStore) -> Self {
76        Self { store }
77    }
78
79    /// Attempt to recover a stale-tag edit.
80    ///
81    /// Decision tree (M1):
82    /// 1. Look up the snapshot for `(path, file_hash)`. Missing → `NoSnapshot`.
83    /// 2. If that snapshot IS the current head, the live file was modified
84    ///    externally → `ExternalModification` (M1.5 would 3-way-merge).
85    /// 3. Otherwise the tag is an older in-session version. Try session-chain
86    ///    replay: if line count matches and every anchor line is byte-identical
87    ///    between snapshot and live, apply the edits directly to live.
88    pub fn try_recover(&self, args: RecoveryArgs<'a>) -> Result<RecoveryResult, RecoveryFailure> {
89        let snapshot = self
90            .store
91            .by_hash(args.path, args.file_hash)
92            .ok_or(RecoveryFailure::NoSnapshot)?;
93
94        let is_head = self.store.head(args.path).as_ref() == Some(&snapshot);
95        if is_head {
96            return Err(RecoveryFailure::ExternalModification {
97                snapshot: Box::new(snapshot),
98            });
99        }
100
101        replay_session_chain(&snapshot, args.current_text, args.edits)
102            .ok_or(RecoveryFailure::ChainMismatch)
103    }
104}
105
106// ── Session-chain replay ─────────────────────────────────────────────────
107
108/// Replay `edits` onto `live` using `snapshot` as the drift guard.
109///
110/// Returns `None` when the guards fail (line count mismatch or an anchor line
111/// differs between snapshot and live), signalling the caller to fall back to a
112/// `MismatchError`.
113fn replay_session_chain(snapshot: &Snapshot, live: &str, edits: &[Edit]) -> Option<RecoveryResult> {
114    let snap_lines: Vec<&str> = snapshot.text.split('\n').collect();
115    let live_lines: Vec<&str> = live.split('\n').collect();
116
117    // Guard 1: line count must match. An intermediate in-session edit that
118    // inserted or deleted lines would have shifted every anchor below it,
119    // making replay unsafe.
120    if snap_lines.len() != live_lines.len() {
121        return None;
122    }
123
124    // Guard 2: every edit's anchor line must be byte-identical between the
125    // tagged snapshot and the live file. If the intermediate edit touched an
126    // anchor line, we cannot safely replay.
127    for edit in edits {
128        let anchor_line = edit.anchor_line();
129        // Bof (0) and Eof (u32::MAX) are position-based, not content-based.
130        if anchor_line == 0 || anchor_line == u32::MAX {
131            continue;
132        }
133        let idx = (anchor_line as usize).saturating_sub(1);
134        if idx >= snap_lines.len() {
135            return None;
136        }
137        if snap_lines[idx] != live_lines[idx] {
138            return None;
139        }
140    }
141
142    // Guards passed — apply edits directly onto the live content.
143    let result = apply_edits(live, edits).ok()?;
144    if result.text == live {
145        return None; // no-op
146    }
147
148    let mut warnings = result.warnings;
149    warnings.insert(0, RECOVERY_SESSION_CHAIN_WARNING.to_string());
150    // The replay fast-path verify hedge is secondary guidance.
151    warnings.push(RECOVERY_SESSION_REPLAY_WARNING.to_string());
152
153    Some(RecoveryResult {
154        text: result.text,
155        first_changed_line: result.first_changed_line,
156        warnings,
157    })
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::snapshots::InMemorySnapshotStore;
164    use crate::types::{Anchor, Cursor, Edit};
165    use std::sync::Arc;
166
167    fn insert_edit(line: u32, text: &str) -> Edit {
168        Edit::Insert {
169            cursor: Cursor::AfterAnchor(Anchor { line }),
170            text: text.to_string(),
171            line_num: 1,
172            index: 0,
173            mode: None,
174        }
175    }
176
177    fn make_store() -> Arc<InMemorySnapshotStore> {
178        Arc::new(InMemorySnapshotStore::new())
179    }
180
181    #[test]
182    fn no_snapshot_for_tag_returns_no_snapshot() {
183        let store = make_store();
184        let recovery = Recovery::new(store.as_ref());
185        let edits = vec![insert_edit(1, "x")];
186        let result = recovery.try_recover(RecoveryArgs {
187            path: "f.rs",
188            file_hash: "AAAA",
189            current_text: "a\nb",
190            edits: &edits,
191        });
192        assert!(matches!(result, Err(RecoveryFailure::NoSnapshot)));
193    }
194
195    #[test]
196    fn head_tag_with_drift_is_external_modification() {
197        let store = make_store();
198        // Record live content → becomes head.
199        let tag = store.record("f.rs", "a\nb\n", Some(&[1, 2]));
200        let recovery = Recovery::new(store.as_ref());
201        let edits = vec![insert_edit(1, "x")];
202        // Live text differs from what was recorded → external mod.
203        let result = recovery.try_recover(RecoveryArgs {
204            path: "f.rs",
205            file_hash: &tag,
206            current_text: "a\nCHANGED\n",
207            edits: &edits,
208        });
209        assert!(matches!(
210            result,
211            Err(RecoveryFailure::ExternalModification { .. })
212        ));
213    }
214
215    #[test]
216    fn session_chain_replay_succeeds_when_anchors_untouched() {
217        let store = make_store();
218        // Step 1: read records snapshot with tag H1.
219        let h1 = store.record("f.rs", "line1\nline2\nline3\n", Some(&[1, 2, 3]));
220        // Step 2: an in-session edit advances the hash. Record the new state.
221        store.record("f.rs", "line1\nline2\nCHANGED3\n", None);
222        // Now head != H1, so recovery enters the session-chain path.
223        let recovery = Recovery::new(store.as_ref());
224        // Edit anchored at line 1 (unchanged between H1 and live).
225        let edits = vec![insert_edit(1, "inserted")];
226        let result = recovery.try_recover(RecoveryArgs {
227            path: "f.rs",
228            file_hash: &h1,
229            current_text: "line1\nline2\nCHANGED3\n",
230            edits: &edits,
231        });
232        assert!(result.is_ok());
233        let recovered = result.unwrap();
234        // The inserted text should appear in the result.
235        assert!(recovered.text.contains("inserted"));
236        assert!(recovered.warnings.iter().any(|w| w.contains("session")));
237    }
238
239    #[test]
240    fn session_chain_replay_fails_when_anchor_line_changed() {
241        let store = make_store();
242        let h1 = store.record("f.rs", "line1\nline2\n", Some(&[1, 2]));
243        // Intermediate edit changed line 1.
244        store.record("f.rs", "CHANGED1\nline2\n", None);
245        let recovery = Recovery::new(store.as_ref());
246        // Edit anchored at line 1 — but line 1 changed between H1 and live.
247        let edits = vec![insert_edit(1, "x")];
248        let result = recovery.try_recover(RecoveryArgs {
249            path: "f.rs",
250            file_hash: &h1,
251            current_text: "CHANGED1\nline2\n",
252            edits: &edits,
253        });
254        assert!(matches!(result, Err(RecoveryFailure::ChainMismatch)));
255    }
256
257    #[test]
258    fn session_chain_replay_fails_on_line_count_change() {
259        let store = make_store();
260        let h1 = store.record("f.rs", "a\nb\n", Some(&[1, 2]));
261        // Intermediate edit added a line → line count differs.
262        store.record("f.rs", "a\nb\nc\n", None);
263        let recovery = Recovery::new(store.as_ref());
264        let edits = vec![insert_edit(1, "x")];
265        let result = recovery.try_recover(RecoveryArgs {
266            path: "f.rs",
267            file_hash: &h1,
268            current_text: "a\nb\nc\n",
269            edits: &edits,
270        });
271        assert!(matches!(result, Err(RecoveryFailure::ChainMismatch)));
272    }
273
274    #[test]
275    fn bof_eof_anchors_skip_content_check() {
276        let store = make_store();
277        let h1 = store.record("f.rs", "a\nb\n", Some(&[1, 2]));
278        store.record("f.rs", "X\nb\n", None);
279        let recovery = Recovery::new(store.as_ref());
280        // HEAD insert — anchor is Bof, content check is skipped.
281        let edits = vec![Edit::Insert {
282            cursor: Cursor::Bof,
283            text: "prefix".to_string(),
284            line_num: 1,
285            index: 0,
286            mode: None,
287        }];
288        let result = recovery.try_recover(RecoveryArgs {
289            path: "f.rs",
290            file_hash: &h1,
291            current_text: "X\nb\n",
292            edits: &edits,
293        });
294        assert!(result.is_ok());
295    }
296}