use crate::apply::apply_edits;
use crate::diff_preview::build_compact_diff_preview;
use crate::format::compute_file_hash;
use crate::mismatch::{HashlineError, MismatchDetails, MismatchError};
use crate::normalize::{self, LineEnding};
use crate::parser::{Patch, PatchSection};
use crate::recovery::{Recovery, RecoveryArgs, RecoveryFailure};
use crate::snapshots::SnapshotStore;
use crate::types::{CompactDiffOptions, Edit};
use std::collections::HashSet;
use std::sync::Arc;
#[async_trait::async_trait]
pub trait HashlineFs: Send + Sync {
async fn read_text(&self, path: &str) -> Result<String, HashlineError>;
async fn write_text(&self, path: &str, text: &str) -> Result<String, HashlineError>;
async fn preflight_write(&self, _path: &str) -> Result<(), HashlineError> {
Ok(())
}
fn canonical_path(&self, path: &str) -> String;
fn is_not_found(&self, err: &HashlineError) -> bool {
matches!(err, HashlineError::NotFound { .. })
}
}
#[derive(Debug, Clone)]
pub struct PatcherApplyResult {
pub sections: Vec<PatchSectionResult>,
}
#[derive(Debug, Clone)]
pub struct PatchSectionResult {
pub path: String,
pub diff: String,
pub first_changed_line: Option<u32>,
pub warnings: Vec<String>,
pub new_hash: String,
}
struct PreparedSection {
path: String,
before_text: String,
result_text: String,
line_ending: LineEnding,
had_bom: bool,
first_changed_line: Option<u32>,
warnings: Vec<String>,
}
pub struct Patcher {
fs: Arc<dyn HashlineFs>,
snapshots: Arc<dyn SnapshotStore>,
}
impl Patcher {
pub fn new(fs: Arc<dyn HashlineFs>, snapshots: Arc<dyn SnapshotStore>) -> Self {
Self { fs, snapshots }
}
pub async fn apply(&self, patch: &Patch) -> Result<PatcherApplyResult, HashlineError> {
let prepared = self.prepare_all(&patch.sections).await?;
let mut results = Vec::with_capacity(prepared.len());
for section in &prepared {
let result = self.commit(section).await?;
results.push(result);
}
Ok(PatcherApplyResult { sections: results })
}
pub async fn preflight(&self, patch: &Patch) -> Result<(), HashlineError> {
self.prepare_all(&patch.sections).await?;
Ok(())
}
async fn prepare_all(
&self,
sections: &[PatchSection],
) -> Result<Vec<PreparedSection>, HashlineError> {
let mut seen_paths: HashSet<String> = HashSet::new();
for section in sections {
let canonical = self.fs.canonical_path(§ion.file_path);
if !seen_paths.insert(canonical.clone()) {
return Err(HashlineError::DuplicateCanonicalPath { path: canonical });
}
}
let mut prepared = Vec::with_capacity(sections.len());
for section in sections {
prepared.push(self.prepare_section(section).await?);
}
Ok(prepared)
}
async fn prepare_section(
&self,
section: &PatchSection,
) -> Result<PreparedSection, HashlineError> {
let canonical = self.fs.canonical_path(§ion.file_path);
let mut warnings = section.warnings.clone();
let raw = self.fs.read_text(§ion.file_path).await?;
let bom = normalize::strip_bom(&raw);
let had_bom = !bom.bom.is_empty();
let line_ending = normalize::detect_line_ending(bom.text);
let normalized = normalize::normalize_to_lf(bom.text);
let (text_to_edit, tag_warnings) = self
.resolve_tag(&canonical, §ion.file_hash, &normalized, §ion.edits)
.await?;
warnings.extend(tag_warnings);
self.check_seen_lines(&canonical, §ion.file_hash, §ion.edits)?;
let apply_result = apply_edits(&text_to_edit, §ion.edits)?;
warnings.extend(apply_result.warnings);
if apply_result.text == text_to_edit {
return Err(HashlineError::NoOp {
path: section.file_path.clone(),
});
}
Ok(PreparedSection {
path: section.file_path.clone(),
before_text: text_to_edit,
result_text: apply_result.text,
line_ending,
had_bom,
first_changed_line: apply_result.first_changed_line,
warnings,
})
}
async fn resolve_tag(
&self,
canonical: &str,
file_hash: &str,
live_text: &str,
edits: &[Edit],
) -> Result<(String, Vec<String>), HashlineError> {
let live_hash = compute_file_hash(live_text);
if file_hash.is_empty() {
return Ok((live_text.to_string(), Vec::new()));
}
if live_hash == file_hash {
return Ok((live_text.to_string(), Vec::new()));
}
if edits.iter().all(is_position_independent) {
return Ok((
live_text.to_string(),
vec![crate::messages::HEADTAIL_DRIFT_WARNING.to_string()],
));
}
let recovery = Recovery::new(self.snapshots.as_ref());
match recovery.try_recover(RecoveryArgs {
path: canonical,
file_hash,
current_text: live_text,
edits,
}) {
Ok(recovered) => Ok((recovered.text, recovered.warnings)),
Err(RecoveryFailure::NoSnapshot) => {
Err(mismatch_error(
canonical, file_hash, &live_hash, live_text, edits,
false, ))
}
Err(RecoveryFailure::ExternalModification { .. }) => {
Err(mismatch_error(
canonical, file_hash, &live_hash, live_text, edits,
true, ))
}
Err(RecoveryFailure::ChainMismatch) => {
Err(mismatch_error(
canonical, file_hash, &live_hash, live_text, edits, true,
))
}
}
}
fn check_seen_lines(
&self,
canonical: &str,
file_hash: &str,
edits: &[Edit],
) -> Result<(), HashlineError> {
if file_hash.is_empty() {
return Ok(());
}
let snapshot = match self.snapshots.by_hash(canonical, file_hash) {
Some(s) => s,
None => return Ok(()), };
let seen = match &snapshot.seen_lines {
Some(s) => s,
None => return Ok(()), };
let mut unseen: Vec<u32> = Vec::new();
for edit in edits {
let anchor_line = edit.anchor_line();
if anchor_line == 0 || anchor_line == u32::MAX {
continue; }
if !seen.contains(&anchor_line) {
unseen.push(anchor_line);
}
}
if unseen.is_empty() {
return Ok(());
}
let msg = format_unseen_lines(&unseen);
Err(HashlineError::UnseenLines(msg))
}
async fn commit(&self, section: &PreparedSection) -> Result<PatchSectionResult, HashlineError> {
let canonical = self.fs.canonical_path(§ion.path);
let mut output = normalize::restore_line_endings(§ion.result_text, section.line_ending);
if section.had_bom {
output = format!("\u{feff}{output}");
}
self.fs.preflight_write(§ion.path).await?;
self.fs.write_text(§ion.path, &output).await?;
let new_hash = compute_file_hash(§ion.result_text);
let total_lines = section.result_text.split('\n').count() as u32;
let all_lines: Vec<u32> = (1..=total_lines).collect();
self.snapshots
.record(&canonical, §ion.result_text, Some(&all_lines));
let preview = build_compact_diff_preview(
§ion.before_text,
§ion.result_text,
&CompactDiffOptions::default(),
);
let diff = preview.lines.join("\n");
Ok(PatchSectionResult {
path: section.path.clone(),
diff,
first_changed_line: section.first_changed_line,
warnings: section.warnings.clone(),
new_hash,
})
}
}
fn is_position_independent(edit: &Edit) -> bool {
matches!(
edit,
Edit::Insert {
cursor: crate::types::Cursor::Bof | crate::types::Cursor::Eof,
..
}
)
}
fn mismatch_error(
path: &str,
expected: &str,
actual: &str,
live_text: &str,
edits: &[Edit],
hash_recognized: bool,
) -> HashlineError {
let file_lines: Vec<String> = live_text.split('\n').map(String::from).collect();
let anchor_lines: Vec<u32> = edits.iter().map(|e| e.anchor_line()).collect();
let details = MismatchDetails {
path: Some(path.to_string()),
expected_file_hash: expected.to_string(),
actual_file_hash: actual.to_string(),
file_lines,
anchor_lines,
hash_recognized,
};
let err = MismatchError::new(details);
HashlineError::Mismatch {
detail: err.message,
expected: expected.to_string(),
actual: actual.to_string(),
}
}
fn format_unseen_lines(lines: &[u32]) -> String {
let listed: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
format!(
"Edit rejected: lines {} were not shown in your last read. \
Re-read those exact lines before editing them.",
listed.join(", ")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::snapshots::InMemorySnapshotStore;
struct MemFs {
root: parking_lot::RwLock<std::collections::HashMap<String, String>>,
}
impl MemFs {
fn new() -> Self {
Self {
root: parking_lot::RwLock::new(std::collections::HashMap::new()),
}
}
fn put(&self, path: &str, text: &str) {
self.root.write().insert(path.to_string(), text.to_string());
}
}
#[async_trait::async_trait]
impl HashlineFs for MemFs {
async fn read_text(&self, path: &str) -> Result<String, HashlineError> {
self.root
.read()
.get(path)
.cloned()
.ok_or_else(|| HashlineError::NotFound {
path: path.to_string(),
})
}
async fn write_text(&self, path: &str, text: &str) -> Result<String, HashlineError> {
self.root.write().insert(path.to_string(), text.to_string());
Ok(path.to_string())
}
fn canonical_path(&self, path: &str) -> String {
path.strip_prefix("./").unwrap_or(path).to_string()
}
}
fn make_patcher() -> (Patcher, Arc<MemFs>, Arc<InMemorySnapshotStore>) {
let fs = Arc::new(MemFs::new());
let store = Arc::new(InMemorySnapshotStore::new());
let patcher = Patcher::new(fs.clone(), store.clone());
(patcher, fs, store)
}
#[tokio::test]
async fn apply_simple_swap() {
let (patcher, fs, store) = make_patcher();
let content = "fn main() {\n todo!()\n}\n";
fs.put("main.rs", content);
let tag = store.record("main.rs", content, Some(&[1, 2, 3]));
let patch_text = format!(
"*** Begin Patch\n[main.rs#{tag}]\nSWAP 2.=2:\n+ println!(\"hi\")\n*** End Patch"
);
let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
let result = patcher.apply(&patch).await.unwrap();
assert_eq!(result.sections.len(), 1);
let new_content = fs.read_text("main.rs").await.unwrap();
assert!(new_content.contains("println!"));
assert!(!new_content.contains("todo!"));
}
#[tokio::test]
async fn apply_rejects_stale_tag_with_no_snapshot() {
let (patcher, fs, _store) = make_patcher();
fs.put("f.rs", "a\nb\n");
let patch_text = "*** Begin Patch\n[f.rs#FFFF]\nSWAP 1.=1:\n+x\n*** End Patch";
let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
let result = patcher.apply(&patch).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, HashlineError::Mismatch { .. }));
}
#[tokio::test]
async fn apply_head_tail_drift_allowed() {
let (patcher, fs, _store) = make_patcher();
fs.put("f.rs", "a\nb\n");
let patch_text = "*** Begin Patch\n[f.rs#FFFF]\nINS.HEAD:\n+prefix\n*** End Patch";
let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
let _result = patcher.apply(&patch).await.unwrap();
let new_content = fs.read_text("f.rs").await.unwrap();
assert!(new_content.starts_with("prefix"));
}
#[tokio::test]
async fn apply_no_tag_applies_without_validation() {
let (patcher, fs, _store) = make_patcher();
fs.put("f.rs", "a\nb\n");
let patch_text = "*** Begin Patch\n[f.rs]\nSWAP 1.=1:\n+x\n*** End Patch";
let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
let _result = patcher.apply(&patch).await.unwrap();
let new_content = fs.read_text("f.rs").await.unwrap();
assert!(new_content.starts_with("x\n"));
}
#[tokio::test]
async fn apply_records_new_snapshot() {
let (patcher, fs, store) = make_patcher();
let content = "a\nb\n";
fs.put("f.rs", content);
let tag = store.record("f.rs", content, Some(&[1, 2]));
let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+x\n*** End Patch");
let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
let result = patcher.apply(&patch).await.unwrap();
let new_hash = &result.sections[0].new_hash;
assert!(!new_hash.is_empty());
let snap = store.by_hash("f.rs", new_hash);
assert!(snap.is_some());
}
#[tokio::test]
async fn apply_rejects_duplicate_canonical_paths() {
let (patcher, fs, _store) = make_patcher();
fs.put("f.rs", "a\nb\n");
let patch_text =
"*** Begin Patch\n[f.rs]\nSWAP 1.=1:\n+x\n[./f.rs]\nSWAP 2.=2:\n+y\n*** End Patch";
let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
let result = patcher.apply(&patch).await;
assert!(matches!(
result,
Err(HashlineError::DuplicateCanonicalPath { .. })
));
}
#[tokio::test]
async fn apply_noop_is_error() {
let (patcher, fs, store) = make_patcher();
let content = "a\nb\n";
fs.put("f.rs", content);
let tag = store.record("f.rs", content, Some(&[1, 2]));
let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+a\n*** End Patch");
let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
let result = patcher.apply(&patch).await;
assert!(matches!(result, Err(HashlineError::NoOp { .. })));
}
#[tokio::test]
async fn preflight_does_not_write() {
let (patcher, fs, store) = make_patcher();
let content = "a\nb\n";
fs.put("f.rs", content);
let tag = store.record("f.rs", content, Some(&[1, 2]));
let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+x\n*** End Patch");
let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
patcher.preflight(&patch).await.unwrap();
let content_after = fs.read_text("f.rs").await.unwrap();
assert_eq!(content_after, content);
}
}