1use 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
23pub struct RecoveryArgs<'a> {
27 pub path: &'a str,
29 pub file_hash: &'a str,
31 pub current_text: &'a str,
33 pub edits: &'a [Edit],
35}
36
37#[derive(Debug, Clone)]
39pub struct RecoveryResult {
40 pub text: String,
42 pub first_changed_line: Option<u32>,
44 pub warnings: Vec<String>,
46}
47
48#[derive(Debug)]
50pub enum RecoveryFailure {
51 NoSnapshot,
53 ExternalModification {
57 snapshot: Box<Snapshot>,
59 },
60 ChainMismatch,
63}
64
65pub struct Recovery<'a> {
70 store: &'a dyn SnapshotStore,
71}
72
73impl<'a> Recovery<'a> {
74 pub fn new(store: &'a dyn SnapshotStore) -> Self {
76 Self { store }
77 }
78
79 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
106fn 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 if snap_lines.len() != live_lines.len() {
121 return None;
122 }
123
124 for edit in edits {
128 let anchor_line = edit.anchor_line();
129 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 let result = apply_edits(live, edits).ok()?;
144 if result.text == live {
145 return None; }
147
148 let mut warnings = result.warnings;
149 warnings.insert(0, RECOVERY_SESSION_CHAIN_WARNING.to_string());
150 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 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 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 let h1 = store.record("f.rs", "line1\nline2\nline3\n", Some(&[1, 2, 3]));
220 store.record("f.rs", "line1\nline2\nCHANGED3\n", None);
222 let recovery = Recovery::new(store.as_ref());
224 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 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 store.record("f.rs", "CHANGED1\nline2\n", None);
245 let recovery = Recovery::new(store.as_ref());
246 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 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 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}