use crate::apply::apply_edits;
use crate::messages::{RECOVERY_SESSION_CHAIN_WARNING, RECOVERY_SESSION_REPLAY_WARNING};
use crate::snapshots::{Snapshot, SnapshotStore};
use crate::types::Edit;
pub struct RecoveryArgs<'a> {
pub path: &'a str,
pub file_hash: &'a str,
pub current_text: &'a str,
pub edits: &'a [Edit],
}
#[derive(Debug, Clone)]
pub struct RecoveryResult {
pub text: String,
pub first_changed_line: Option<u32>,
pub warnings: Vec<String>,
}
#[derive(Debug)]
pub enum RecoveryFailure {
NoSnapshot,
ExternalModification {
snapshot: Box<Snapshot>,
},
ChainMismatch,
}
pub struct Recovery<'a> {
store: &'a dyn SnapshotStore,
}
impl<'a> Recovery<'a> {
pub fn new(store: &'a dyn SnapshotStore) -> Self {
Self { store }
}
pub fn try_recover(&self, args: RecoveryArgs<'a>) -> Result<RecoveryResult, RecoveryFailure> {
let snapshot = self
.store
.by_hash(args.path, args.file_hash)
.ok_or(RecoveryFailure::NoSnapshot)?;
let is_head = self.store.head(args.path).as_ref() == Some(&snapshot);
if is_head {
return Err(RecoveryFailure::ExternalModification {
snapshot: Box::new(snapshot),
});
}
replay_session_chain(&snapshot, args.current_text, args.edits)
.ok_or(RecoveryFailure::ChainMismatch)
}
}
fn replay_session_chain(snapshot: &Snapshot, live: &str, edits: &[Edit]) -> Option<RecoveryResult> {
let snap_lines: Vec<&str> = snapshot.text.split('\n').collect();
let live_lines: Vec<&str> = live.split('\n').collect();
if snap_lines.len() != live_lines.len() {
return None;
}
for edit in edits {
let anchor_line = edit.anchor_line();
if anchor_line == 0 || anchor_line == u32::MAX {
continue;
}
let idx = (anchor_line as usize).saturating_sub(1);
if idx >= snap_lines.len() {
return None;
}
if snap_lines[idx] != live_lines[idx] {
return None;
}
}
let result = apply_edits(live, edits).ok()?;
if result.text == live {
return None; }
let mut warnings = result.warnings;
warnings.insert(0, RECOVERY_SESSION_CHAIN_WARNING.to_string());
warnings.push(RECOVERY_SESSION_REPLAY_WARNING.to_string());
Some(RecoveryResult {
text: result.text,
first_changed_line: result.first_changed_line,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::snapshots::InMemorySnapshotStore;
use crate::types::{Anchor, Cursor, Edit};
use std::sync::Arc;
fn insert_edit(line: u32, text: &str) -> Edit {
Edit::Insert {
cursor: Cursor::AfterAnchor(Anchor { line }),
text: text.to_string(),
line_num: 1,
index: 0,
mode: None,
}
}
fn make_store() -> Arc<InMemorySnapshotStore> {
Arc::new(InMemorySnapshotStore::new())
}
#[test]
fn no_snapshot_for_tag_returns_no_snapshot() {
let store = make_store();
let recovery = Recovery::new(store.as_ref());
let edits = vec![insert_edit(1, "x")];
let result = recovery.try_recover(RecoveryArgs {
path: "f.rs",
file_hash: "AAAA",
current_text: "a\nb",
edits: &edits,
});
assert!(matches!(result, Err(RecoveryFailure::NoSnapshot)));
}
#[test]
fn head_tag_with_drift_is_external_modification() {
let store = make_store();
let tag = store.record("f.rs", "a\nb\n", Some(&[1, 2]));
let recovery = Recovery::new(store.as_ref());
let edits = vec![insert_edit(1, "x")];
let result = recovery.try_recover(RecoveryArgs {
path: "f.rs",
file_hash: &tag,
current_text: "a\nCHANGED\n",
edits: &edits,
});
assert!(matches!(
result,
Err(RecoveryFailure::ExternalModification { .. })
));
}
#[test]
fn session_chain_replay_succeeds_when_anchors_untouched() {
let store = make_store();
let h1 = store.record("f.rs", "line1\nline2\nline3\n", Some(&[1, 2, 3]));
store.record("f.rs", "line1\nline2\nCHANGED3\n", None);
let recovery = Recovery::new(store.as_ref());
let edits = vec![insert_edit(1, "inserted")];
let result = recovery.try_recover(RecoveryArgs {
path: "f.rs",
file_hash: &h1,
current_text: "line1\nline2\nCHANGED3\n",
edits: &edits,
});
assert!(result.is_ok());
let recovered = result.unwrap();
assert!(recovered.text.contains("inserted"));
assert!(recovered.warnings.iter().any(|w| w.contains("session")));
}
#[test]
fn session_chain_replay_fails_when_anchor_line_changed() {
let store = make_store();
let h1 = store.record("f.rs", "line1\nline2\n", Some(&[1, 2]));
store.record("f.rs", "CHANGED1\nline2\n", None);
let recovery = Recovery::new(store.as_ref());
let edits = vec![insert_edit(1, "x")];
let result = recovery.try_recover(RecoveryArgs {
path: "f.rs",
file_hash: &h1,
current_text: "CHANGED1\nline2\n",
edits: &edits,
});
assert!(matches!(result, Err(RecoveryFailure::ChainMismatch)));
}
#[test]
fn session_chain_replay_fails_on_line_count_change() {
let store = make_store();
let h1 = store.record("f.rs", "a\nb\n", Some(&[1, 2]));
store.record("f.rs", "a\nb\nc\n", None);
let recovery = Recovery::new(store.as_ref());
let edits = vec![insert_edit(1, "x")];
let result = recovery.try_recover(RecoveryArgs {
path: "f.rs",
file_hash: &h1,
current_text: "a\nb\nc\n",
edits: &edits,
});
assert!(matches!(result, Err(RecoveryFailure::ChainMismatch)));
}
#[test]
fn bof_eof_anchors_skip_content_check() {
let store = make_store();
let h1 = store.record("f.rs", "a\nb\n", Some(&[1, 2]));
store.record("f.rs", "X\nb\n", None);
let recovery = Recovery::new(store.as_ref());
let edits = vec![Edit::Insert {
cursor: Cursor::Bof,
text: "prefix".to_string(),
line_num: 1,
index: 0,
mode: None,
}];
let result = recovery.try_recover(RecoveryArgs {
path: "f.rs",
file_hash: &h1,
current_text: "X\nb\n",
edits: &edits,
});
assert!(result.is_ok());
}
}