use super::{
apply::apply_edits,
block::resolve_block_edits,
format::{compute_file_hash, format_numbered_lines},
model::{Anchor, ApplyResult, Cursor, Edit, ParseWarning},
snapshots::HashlineSnapshotStore,
tokenizer::split_hashline_lines,
};
use similar::{DiffTag, TextDiff};
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
pub(crate) const RECOVERY_EXTERNAL_WARNING: &str = "file changed since read; recovered edit by exact 3-way merge against retained hashline snapshot";
pub(crate) const RECOVERY_LINE_REMAP_WARNING: &str =
"file changed since read; recovered edit by remapping anchors through unchanged lines";
pub(crate) const RECOVERY_SESSION_CHAIN_WARNING: &str =
"file changed after the tagged snapshot; recovered through retained in-session snapshot chain";
pub(crate) const RECOVERY_SESSION_REPLAY_WARNING: &str = "file changed after the tagged snapshot; replayed stale anchors because line count and anchor content still match";
pub(crate) const HEAD_TAIL_STALE_WARNING: &str = "file changed since read; applied INS.HEAD/INS.TAIL on current file because no line anchor is required";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RecoveryResult {
pub(crate) text: String,
pub(crate) first_changed_line: Option<usize>,
pub(crate) warnings: Vec<ParseWarning>,
pub(crate) block_resolutions: Vec<super::model::BlockResolution>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PathRecovery {
pub(crate) path: PathBuf,
pub(crate) hash: String,
}
pub(crate) fn apply_with_staleness_recovery(
store: &mut HashlineSnapshotStore,
path: &Path,
current_text: &str,
expected_hash: &str,
edits: &[Edit],
) -> Result<RecoveryResult, String> {
let live_hash = compute_file_hash(current_text);
if live_hash == expected_hash {
enforce_seen_line_guard(store, path, expected_hash, current_text, edits)?;
let lowered = resolve_block_edits(edits, current_text, path)?;
let mut applied = apply_edits(current_text, &lowered.edits)?;
applied.warnings.extend(
lowered
.warnings
.into_iter()
.map(|message| ParseWarning::ApplyRepair { message }),
);
applied.block_resolutions = lowered.block_resolutions;
return Ok(applied.into());
}
if only_head_tail_inserts(edits) {
let mut applied = apply_edits(current_text, edits)?;
applied.warnings.insert(
0,
ParseWarning::ApplyRepair {
message: HEAD_TAIL_STALE_WARNING.to_string(),
},
);
return Ok(applied.into());
}
let snapshot = store
.find_by_hash(path, expected_hash)
.cloned()
.ok_or_else(|| {
mismatch_message(path, expected_hash, &live_hash, current_text, false, edits)
})?;
enforce_seen_line_guard_for_snapshot(
store,
path,
expected_hash,
&snapshot.text,
&snapshot.seen_lines,
edits,
)?;
let head = store.head(path).cloned();
let is_head = head
.as_ref()
.is_some_and(|head| head.hash == snapshot.hash && head.text == snapshot.text);
let lowered = resolve_block_edits(edits, &snapshot.text, path)?;
enforce_seen_line_guard_for_snapshot(
store,
path,
expected_hash,
&snapshot.text,
&snapshot.seen_lines,
&lowered.edits,
)?;
let warning = if is_head {
RECOVERY_EXTERNAL_WARNING
} else {
RECOVERY_SESSION_CHAIN_WARNING
};
if let Some(result) =
apply_edits_to_snapshot(&snapshot.text, current_text, &lowered.edits, warning)
{
return Ok(with_block_resolution(result, &lowered));
}
if let Some(result) =
replay_remapped_anchors_on_current(&snapshot.text, current_text, &lowered.edits)
{
return Ok(with_block_resolution(result, &lowered));
}
if !is_head
&& let Some(result) =
replay_session_chain_on_current(&snapshot.text, current_text, &lowered.edits)
{
return Ok(with_block_resolution(result, &lowered));
}
Err(mismatch_message(
path,
expected_hash,
&live_hash,
current_text,
true,
edits,
))
}
pub(crate) fn recover_path_by_tag(
store: &HashlineSnapshotStore,
authored_path: &Path,
expected_hash: &str,
) -> Result<Option<PathRecovery>, String> {
let Some(basename) = authored_path.file_name() else {
return Ok(None);
};
let matches = store
.by_hash(expected_hash)
.into_iter()
.filter(|snapshot| snapshot.path.file_name() == Some(basename))
.collect::<Vec<_>>();
if matches.is_empty() {
return Ok(None);
}
let unique_paths = matches
.iter()
.map(|snapshot| snapshot.path.clone())
.collect::<HashSet<_>>();
if unique_paths.len() != 1 {
return Err(format!(
"cannot recover missing path {} from hash #{expected_hash}: basename/tag match is not unique",
authored_path.display()
));
}
Ok(Some(PathRecovery {
path: matches[0].path.clone(),
hash: matches[0].hash.clone(),
}))
}
fn with_block_resolution(
mut result: RecoveryResult,
lowered: &super::block::BlockEditResolution,
) -> RecoveryResult {
result.warnings.extend(
lowered
.warnings
.iter()
.cloned()
.map(|message| ParseWarning::ApplyRepair { message }),
);
result.block_resolutions = lowered.block_resolutions.clone();
result
}
fn enforce_seen_line_guard(
store: &mut HashlineSnapshotStore,
path: &Path,
hash: &str,
current_text: &str,
edits: &[Edit],
) -> Result<(), String> {
let Some(snapshot) = store.by_content(path, current_text).cloned() else {
return Err(format!(
"Edit rejected for {}: hash #{hash} is current but was not recorded by read in this session.",
path.display()
));
};
enforce_seen_line_guard_for_snapshot(
store,
path,
hash,
current_text,
&snapshot.seen_lines,
edits,
)
}
fn enforce_seen_line_guard_for_snapshot(
store: &mut HashlineSnapshotStore,
path: &Path,
hash: &str,
snapshot_text: &str,
seen_lines: &HashSet<usize>,
edits: &[Edit],
) -> Result<(), String> {
let anchors = collect_anchor_lines(edits);
if anchors.is_empty() {
return Ok(());
}
let missing = anchors
.into_iter()
.filter(|line| !seen_lines.contains(line))
.collect::<HashSet<_>>();
if missing.is_empty() {
return Ok(());
}
let mut revealed = missing.iter().copied().collect::<Vec<_>>();
revealed.sort_unstable();
let complete = revealed.len() <= 40;
let reveal_lines = revealed.iter().copied().take(40).collect::<Vec<_>>();
if complete {
store.record_seen_lines(path, hash, reveal_lines.iter().copied());
}
let lines = split_hashline_lines(snapshot_text);
let rows = reveal_lines
.iter()
.filter_map(|line| {
lines
.get(line.saturating_sub(1))
.map(|text| (*line, text.clone()))
})
.collect::<Vec<_>>();
let mut message = format!(
"Edit rejected for {}: anchor line(s) were not displayed by read under #{hash}: {}.",
path.display(),
revealed
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", ")
);
if !rows.is_empty() {
message.push_str("\nVisible lines for retry:\n");
message.push_str(&format_numbered_lines(&rows).join("\n"));
}
if !complete {
message.push_str(
"\nOnly first 40 unseen anchors were revealed; re-read target range before retrying.",
);
}
Err(message)
}
fn only_head_tail_inserts(edits: &[Edit]) -> bool {
!edits.is_empty()
&& edits.iter().all(|edit| {
matches!(
edit,
Edit::Insert {
cursor: Cursor::Bof | Cursor::Eof,
..
}
)
})
}
fn apply_edits_to_snapshot(
previous_text: &str,
current_text: &str,
edits: &[Edit],
warning: &str,
) -> Option<RecoveryResult> {
let applied = apply_edits(previous_text, edits).ok()?;
if applied.text == previous_text {
return None;
}
let merged = apply_exact_line_patch(previous_text, &applied.text, current_text)?;
if merged == current_text {
return None;
}
let first_changed_line =
find_first_changed_line(current_text, &merged).or(applied.first_changed_line);
let mut warnings = Vec::new();
if first_changed_line.is_some() {
warnings.push(ParseWarning::ApplyRepair {
message: warning.to_string(),
});
}
warnings.extend(applied.warnings);
Some(RecoveryResult {
text: merged,
first_changed_line,
warnings,
block_resolutions: Vec::new(),
})
}
fn apply_exact_line_patch(
previous_text: &str,
applied_text: &str,
current_text: &str,
) -> Option<String> {
let old_lines = split_hashline_lines(previous_text);
let new_lines = split_hashline_lines(applied_text);
let mut current_lines = split_hashline_lines(current_text);
let diff = TextDiff::from_lines(previous_text, applied_text);
let mut delta: isize = 0;
for group in diff.grouped_ops(3) {
let old_start = group.iter().map(|op| op.old_range().start).min()?;
let old_end = group.iter().map(|op| op.old_range().end).max()?;
let new_start = group.iter().map(|op| op.new_range().start).min()?;
let new_end = group.iter().map(|op| op.new_range().end).max()?;
if old_start == old_end && new_start == new_end {
continue;
}
let start = old_start as isize + delta;
if start < 0 {
return None;
}
let start = start as usize;
let old_hunk = &old_lines[old_start..old_end];
let new_hunk = &new_lines[new_start..new_end];
if current_lines.get(start..start + old_hunk.len()) != Some(old_hunk) {
return None;
}
current_lines.splice(start..start + old_hunk.len(), new_hunk.iter().cloned());
delta += new_hunk.len() as isize - old_hunk.len() as isize;
}
Some(join_hashline_lines(
¤t_lines,
current_text.ends_with('\n') || applied_text.ends_with('\n'),
))
}
fn build_line_map(previous_text: &str, current_text: &str) -> HashMap<usize, usize> {
let diff = TextDiff::from_lines(previous_text, current_text);
let mut map = HashMap::new();
for op in diff.ops() {
if op.tag() != DiffTag::Equal {
continue;
}
let old = op.old_range();
let new = op.new_range();
for offset in 0..old.end.saturating_sub(old.start) {
map.insert(old.start + offset + 1, new.start + offset + 1);
}
}
map
}
fn replay_remapped_anchors_on_current(
previous_text: &str,
current_text: &str,
edits: &[Edit],
) -> Option<RecoveryResult> {
let line_map = build_line_map(previous_text, current_text);
let remapped = remap_edits(&line_map, edits)?;
let applied = apply_edits(current_text, &remapped).ok()?;
if applied.text == current_text {
return None;
}
let mut warnings = vec![ParseWarning::ApplyRepair {
message: RECOVERY_LINE_REMAP_WARNING.to_string(),
}];
warnings.extend(applied.warnings);
Some(RecoveryResult {
text: applied.text,
first_changed_line: applied.first_changed_line,
warnings,
block_resolutions: Vec::new(),
})
}
fn remap_edits(line_map: &HashMap<usize, usize>, edits: &[Edit]) -> Option<Vec<Edit>> {
let mut offsets = Vec::new();
let mut map_line = |line: usize| -> Option<usize> {
let mapped = *line_map.get(&line)?;
offsets.push(mapped as isize - line as isize);
Some(mapped)
};
let mut remapped = Vec::with_capacity(edits.len());
for edit in edits {
remapped.push(match edit {
Edit::Delete {
anchor,
line_num,
index,
old_assertion,
} => Edit::Delete {
anchor: Anchor {
line: map_line(anchor.line)?,
},
line_num: *line_num,
index: *index,
old_assertion: old_assertion.clone(),
},
Edit::Block {
anchor,
payloads,
mode,
line_num,
index,
} => Edit::Block {
anchor: Anchor {
line: map_line(anchor.line)?,
},
payloads: payloads.clone(),
mode: *mode,
line_num: *line_num,
index: *index,
},
Edit::Insert {
cursor,
text,
line_num,
index,
mode,
block_start,
} => {
let cursor = match cursor {
Cursor::Bof => Cursor::Bof,
Cursor::Eof => Cursor::Eof,
Cursor::BeforeAnchor { anchor } => Cursor::BeforeAnchor {
anchor: Anchor {
line: map_line(anchor.line)?,
},
},
Cursor::AfterAnchor { anchor } => Cursor::AfterAnchor {
anchor: Anchor {
line: map_line(anchor.line)?,
},
},
};
let block_start = match block_start {
Some(line) => Some(map_line(*line)?),
None => None,
};
Edit::Insert {
cursor,
text: text.clone(),
line_num: *line_num,
index: *index,
mode: *mode,
block_start,
}
}
});
}
if offsets.is_empty() {
return None;
}
let first = offsets[0];
if first == 0 || !offsets.iter().all(|offset| *offset == first) {
return None;
}
Some(remapped)
}
fn replay_session_chain_on_current(
previous_text: &str,
current_text: &str,
edits: &[Edit],
) -> Option<RecoveryResult> {
if split_hashline_lines(previous_text).len() != split_hashline_lines(current_text).len() {
return None;
}
if !verify_anchor_content(previous_text, current_text, edits) {
return None;
}
let applied = apply_edits(current_text, edits).ok()?;
if applied.text == current_text {
return None;
}
let mut warnings = vec![ParseWarning::ApplyRepair {
message: RECOVERY_SESSION_REPLAY_WARNING.to_string(),
}];
warnings.extend(applied.warnings);
Some(RecoveryResult {
text: applied.text,
first_changed_line: applied.first_changed_line,
warnings,
block_resolutions: Vec::new(),
})
}
fn verify_anchor_content(previous_text: &str, current_text: &str, edits: &[Edit]) -> bool {
let previous_lines = split_hashline_lines(previous_text);
let current_lines = split_hashline_lines(current_text);
collect_anchor_lines(edits).into_iter().all(|line| {
line > 0
&& previous_lines.get(line - 1).is_some_and(|previous| {
current_lines
.get(line - 1)
.is_some_and(|current| current == previous)
})
})
}
fn collect_anchor_lines(edits: &[Edit]) -> Vec<usize> {
let mut lines = Vec::new();
for edit in edits {
match edit {
Edit::Delete { anchor, .. } | Edit::Block { anchor, .. } => lines.push(anchor.line),
Edit::Insert {
cursor,
block_start,
..
} => {
match cursor {
Cursor::BeforeAnchor { anchor } | Cursor::AfterAnchor { anchor } => {
lines.push(anchor.line)
}
Cursor::Bof | Cursor::Eof => {}
}
if let Some(line) = block_start {
lines.push(*line);
}
}
}
}
lines
}
fn find_first_changed_line(a: &str, b: &str) -> Option<usize> {
if a == b {
return None;
}
let a_lines = split_hashline_lines(a);
let b_lines = split_hashline_lines(b);
for index in 0..a_lines.len().max(b_lines.len()) {
if a_lines.get(index) != b_lines.get(index) {
return Some(index + 1);
}
}
None
}
fn mismatch_message(
path: &Path,
expected_hash: &str,
actual_hash: &str,
current_text: &str,
hash_recognized: bool,
edits: &[Edit],
) -> String {
let mut out = if hash_recognized {
format!(
"Edit rejected for {}: file changed between read and edit. Section is bound to #{expected_hash}, but current file hashes to #{actual_hash}.",
path.display()
)
} else {
format!(
"Edit rejected for {}: hash #{expected_hash} is not from this session. Current file hashes to #{actual_hash}.",
path.display()
)
};
let anchor_lines = collect_anchor_lines(edits);
if !anchor_lines.is_empty() {
let lines = split_hashline_lines(current_text);
let mut rows = Vec::new();
for line in anchor_lines.into_iter().collect::<HashSet<_>>() {
let start = line.saturating_sub(2).max(1);
let end = (line + 2).min(lines.len());
for row in start..=end {
if let Some(text) = lines.get(row - 1) {
rows.push((row, text.clone()));
}
}
}
rows.sort_by_key(|(line, _)| *line);
rows.dedup_by_key(|(line, _)| *line);
if !rows.is_empty() {
out.push_str("\n\nCurrent context:\n");
out.push_str(&format_numbered_lines(&rows).join("\n"));
}
}
out
}
fn join_hashline_lines(lines: &[String], trailing_newline: bool) -> String {
if lines.is_empty() {
return String::new();
}
let mut text = lines.join("\n");
if trailing_newline && !text.ends_with('\n') {
text.push('\n');
}
text
}
impl From<ApplyResult> for RecoveryResult {
fn from(value: ApplyResult) -> Self {
Self {
text: value.text,
first_changed_line: value.first_changed_line,
warnings: value.warnings,
block_resolutions: value.block_resolutions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::hash_edit::parser::parse_patch;
fn warning_messages(result: &RecoveryResult) -> Vec<&str> {
result
.warnings
.iter()
.filter_map(|warning| match warning {
ParseWarning::ApplyRepair { message } => Some(message.as_str()),
_ => None,
})
.collect()
}
#[test]
fn direct_match_applies_when_anchor_was_seen() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let hash = store.record(&path, "one\ntwo\nthree", [2]);
let parsed = parse_patch("SWAP 2.=2:\n+TWO").unwrap();
let result = apply_with_staleness_recovery(
&mut store,
&path,
"one\ntwo\nthree",
&hash,
&parsed.edits,
)
.unwrap();
assert_eq!(result.text, "one\nTWO\nthree");
assert_eq!(result.first_changed_line, Some(2));
}
#[test]
fn unknown_stale_tag_fails_with_expected_and_current_tags() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let parsed = parse_patch("DEL 1").unwrap();
let err = apply_with_staleness_recovery(&mut store, &path, "one", "ABCD", &parsed.edits)
.unwrap_err();
assert!(err.contains("#ABCD"), "{err}");
assert!(
err.contains(&format!("#{}", compute_file_hash("one"))),
"{err}"
);
assert!(err.contains("not from this session"), "{err}");
}
#[test]
fn external_drift_recovers_with_exact_three_way_merge() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let old = "one\ntwo\nthree\nfour\nfive\nsix\nseven";
let hash = store.record(&path, old, [2]);
let current = "one\ntwo\nthree\nfour\nfive\nSIX\nseven";
let parsed = parse_patch("SWAP 2.=2:\n+TWO").unwrap();
let result =
apply_with_staleness_recovery(&mut store, &path, current, &hash, &parsed.edits)
.unwrap();
assert_eq!(result.text, "one\nTWO\nthree\nfour\nfive\nSIX\nseven");
assert!(
warning_messages(&result)
.iter()
.any(|msg| msg.contains("3-way"))
);
}
#[test]
fn external_insert_before_target_recovers_by_anchor_remap() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let old = "one\ntwo\nthree";
let hash = store.record(&path, old, [2]);
let current = "zero\none\ntwo\nthree";
let parsed = parse_patch("SWAP 2.=2:\n+TWO").unwrap();
let result =
apply_with_staleness_recovery(&mut store, &path, current, &hash, &parsed.edits)
.unwrap();
assert_eq!(result.text, "zero\none\nTWO\nthree");
assert!(
warning_messages(&result)
.iter()
.any(|msg| msg.contains("remapping"))
);
}
#[test]
fn session_chain_replay_requires_equal_line_count_and_anchor_content() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let old = "one\ntwo\nthree";
let hash = store.record(&path, old, [2]);
store.record(&path, "ONE\ntwo\nthree", [2]);
let parsed = parse_patch("SWAP 2.=2:\n+TWO").unwrap();
let result = apply_with_staleness_recovery(
&mut store,
&path,
"ONE\ntwo\nthree",
&hash,
&parsed.edits,
)
.unwrap();
assert_eq!(result.text, "ONE\nTWO\nthree");
assert!(
warning_messages(&result)
.iter()
.any(|msg| msg.contains("replayed stale anchors"))
);
}
#[test]
fn seen_line_rejection_reveals_and_retry_merges_revealed_lines() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let text = "one\ntwo\nthree";
let hash = store.record(&path, text, [1]);
let parsed = parse_patch("SWAP 2.=2:\n+TWO").unwrap();
let err = apply_with_staleness_recovery(&mut store, &path, text, &hash, &parsed.edits)
.unwrap_err();
assert!(err.contains("anchor line(s) were not displayed"), "{err}");
assert!(err.contains("2:two"), "{err}");
let result =
apply_with_staleness_recovery(&mut store, &path, text, &hash, &parsed.edits).unwrap();
assert_eq!(result.text, "one\nTWO\nthree");
}
#[test]
fn stale_recovery_rejects_unseen_anchor_and_reveals_retry_line() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let old = "one\ntwo\nthree\nfour\nfive\nsix\nseven";
let hash = store.record(&path, old, [1]);
let current = "one\ntwo\nthree\nfour\nfive\nSIX\nseven";
let parsed = parse_patch("SWAP 2.=2:\n+TWO").unwrap();
let err = apply_with_staleness_recovery(&mut store, &path, current, &hash, &parsed.edits)
.unwrap_err();
assert!(err.contains("anchor line(s) were not displayed"), "{err}");
assert!(err.contains("2:two"), "{err}");
assert!(!err.contains("TWO"), "{err}");
let result =
apply_with_staleness_recovery(&mut store, &path, current, &hash, &parsed.edits)
.unwrap();
assert_eq!(result.text, "one\nTWO\nthree\nfour\nfive\nSIX\nseven");
}
#[test]
fn stale_block_recovery_rejects_unseen_resolved_block_lines() {
let path = PathBuf::from("main.rs");
let mut store = HashlineSnapshotStore::default();
let old = "fn main() {\n println!(\"hi\");\n}\n\nfn spacer() {\n println!(\"space\");\n}\n\nfn other() {}\n";
let hash = store.record(&path, old, [1]);
let current = "fn main() {\n println!(\"hi\");\n}\n\nfn spacer() {\n println!(\"space\");\n}\n\nfn other() { println!(\"drift\"); }\n";
let parsed =
parse_patch("SWAP.BLK 1:\n+fn main() {\n+ println!(\"changed\");\n+}").unwrap();
let err = apply_with_staleness_recovery(&mut store, &path, current, &hash, &parsed.edits)
.unwrap_err();
assert!(err.contains("anchor line(s) were not displayed"), "{err}");
assert!(err.contains("2: println!(\"hi\");"), "{err}");
assert!(err.contains("3:}"), "{err}");
let result =
apply_with_staleness_recovery(&mut store, &path, current, &hash, &parsed.edits)
.unwrap();
assert!(result.text.contains("println!(\"changed\");"));
assert!(result.text.contains("println!(\"drift\")"));
}
#[test]
fn stale_head_tail_insert_applies_with_warning_without_snapshot() {
let path = PathBuf::from("a.txt");
let mut store = HashlineSnapshotStore::default();
let parsed = parse_patch("INS.HEAD:\n+zero").unwrap();
let result =
apply_with_staleness_recovery(&mut store, &path, "one", "ABCD", &parsed.edits).unwrap();
assert_eq!(result.text, "zero\none");
assert!(
warning_messages(&result)
.iter()
.any(|msg| msg.contains("INS.HEAD/INS.TAIL"))
);
}
#[test]
fn tag_based_path_recovery_requires_unique_basename_and_tag() {
let mut store = HashlineSnapshotStore::default();
let hash = store.record("/tmp/one/a.txt", "one", [1]);
assert_eq!(
recover_path_by_tag(&store, Path::new("missing/a.txt"), &hash)
.unwrap()
.unwrap()
.path,
PathBuf::from("/tmp/one/a.txt")
);
store.record("/tmp/two/a.txt", "one", [1]);
let err = recover_path_by_tag(&store, Path::new("missing/a.txt"), &hash).unwrap_err();
assert!(err.contains("not unique"));
}
}