1use crate::apply::apply_edits;
18use crate::diff_preview::build_compact_diff_preview;
19use crate::format::compute_file_hash;
20use crate::mismatch::{HashlineError, MismatchDetails, MismatchError};
21use crate::normalize::{self, LineEnding};
22use crate::parser::{Patch, PatchSection};
23use crate::recovery::{Recovery, RecoveryArgs, RecoveryFailure};
24use crate::snapshots::SnapshotStore;
25use crate::types::{CompactDiffOptions, Edit};
26use std::collections::HashSet;
27use std::sync::Arc;
28
29#[async_trait::async_trait]
36pub trait HashlineFs: Send + Sync {
37 async fn read_text(&self, path: &str) -> Result<String, HashlineError>;
40
41 async fn write_text(&self, path: &str, text: &str) -> Result<String, HashlineError>;
43
44 async fn preflight_write(&self, _path: &str) -> Result<(), HashlineError> {
47 Ok(())
48 }
49
50 fn canonical_path(&self, path: &str) -> String;
53
54 fn is_not_found(&self, err: &HashlineError) -> bool {
56 matches!(err, HashlineError::NotFound { .. })
57 }
58}
59
60#[derive(Debug, Clone)]
64pub struct PatcherApplyResult {
65 pub sections: Vec<PatchSectionResult>,
67}
68
69#[derive(Debug, Clone)]
71pub struct PatchSectionResult {
72 pub path: String,
74 pub diff: String,
76 pub first_changed_line: Option<u32>,
78 pub warnings: Vec<String>,
80 pub new_hash: String,
82}
83
84struct PreparedSection {
86 path: String,
87 before_text: String,
89 result_text: String,
91 line_ending: LineEnding,
93 had_bom: bool,
95 first_changed_line: Option<u32>,
96 warnings: Vec<String>,
97}
98
99pub struct Patcher {
103 fs: Arc<dyn HashlineFs>,
104 snapshots: Arc<dyn SnapshotStore>,
105}
106
107impl Patcher {
108 pub fn new(fs: Arc<dyn HashlineFs>, snapshots: Arc<dyn SnapshotStore>) -> Self {
110 Self { fs, snapshots }
111 }
112
113 pub async fn apply(&self, patch: &Patch) -> Result<PatcherApplyResult, HashlineError> {
115 let prepared = self.prepare_all(&patch.sections).await?;
116
117 let mut results = Vec::with_capacity(prepared.len());
119 for section in &prepared {
120 let result = self.commit(section).await?;
121 results.push(result);
122 }
123
124 Ok(PatcherApplyResult { sections: results })
125 }
126
127 pub async fn preflight(&self, patch: &Patch) -> Result<(), HashlineError> {
129 self.prepare_all(&patch.sections).await?;
130 Ok(())
131 }
132
133 async fn prepare_all(
138 &self,
139 sections: &[PatchSection],
140 ) -> Result<Vec<PreparedSection>, HashlineError> {
141 let mut seen_paths: HashSet<String> = HashSet::new();
143 for section in sections {
144 let canonical = self.fs.canonical_path(§ion.file_path);
145 if !seen_paths.insert(canonical.clone()) {
146 return Err(HashlineError::DuplicateCanonicalPath { path: canonical });
147 }
148 }
149
150 let mut prepared = Vec::with_capacity(sections.len());
151 for section in sections {
152 prepared.push(self.prepare_section(section).await?);
153 }
154 Ok(prepared)
155 }
156
157 async fn prepare_section(
160 &self,
161 section: &PatchSection,
162 ) -> Result<PreparedSection, HashlineError> {
163 let canonical = self.fs.canonical_path(§ion.file_path);
164
165 let mut warnings = section.warnings.clone();
167
168 let raw = self.fs.read_text(§ion.file_path).await?;
170
171 let bom = normalize::strip_bom(&raw);
173 let had_bom = !bom.bom.is_empty();
174 let line_ending = normalize::detect_line_ending(bom.text);
175 let normalized = normalize::normalize_to_lf(bom.text);
176
177 let (text_to_edit, tag_warnings) = self
179 .resolve_tag(&canonical, §ion.file_hash, &normalized, §ion.edits)
180 .await?;
181 warnings.extend(tag_warnings);
182
183 self.check_seen_lines(&canonical, §ion.file_hash, §ion.edits)?;
185
186 let apply_result = apply_edits(&text_to_edit, §ion.edits)?;
188 warnings.extend(apply_result.warnings);
189
190 if apply_result.text == text_to_edit {
191 return Err(HashlineError::NoOp {
192 path: section.file_path.clone(),
193 });
194 }
195
196 Ok(PreparedSection {
197 path: section.file_path.clone(),
198 before_text: text_to_edit,
199 result_text: apply_result.text,
200 line_ending,
201 had_bom,
202 first_changed_line: apply_result.first_changed_line,
203 warnings,
204 })
205 }
206
207 async fn resolve_tag(
214 &self,
215 canonical: &str,
216 file_hash: &str,
217 live_text: &str,
218 edits: &[Edit],
219 ) -> Result<(String, Vec<String>), HashlineError> {
220 let live_hash = compute_file_hash(live_text);
221
222 if file_hash.is_empty() {
224 return Ok((live_text.to_string(), Vec::new()));
225 }
226
227 if live_hash == file_hash {
229 return Ok((live_text.to_string(), Vec::new()));
230 }
231
232 if edits.iter().all(is_position_independent) {
234 return Ok((
235 live_text.to_string(),
236 vec![crate::messages::HEADTAIL_DRIFT_WARNING.to_string()],
237 ));
238 }
239
240 let recovery = Recovery::new(self.snapshots.as_ref());
242 match recovery.try_recover(RecoveryArgs {
243 path: canonical,
244 file_hash,
245 current_text: live_text,
246 edits,
247 }) {
248 Ok(recovered) => Ok((recovered.text, recovered.warnings)),
249 Err(RecoveryFailure::NoSnapshot) => {
250 Err(mismatch_error(
252 canonical, file_hash, &live_hash, live_text, edits,
253 false, ))
255 }
256 Err(RecoveryFailure::ExternalModification { .. }) => {
257 Err(mismatch_error(
259 canonical, file_hash, &live_hash, live_text, edits,
260 true, ))
262 }
263 Err(RecoveryFailure::ChainMismatch) => {
264 Err(mismatch_error(
266 canonical, file_hash, &live_hash, live_text, edits, true,
267 ))
268 }
269 }
270 }
271
272 fn check_seen_lines(
276 &self,
277 canonical: &str,
278 file_hash: &str,
279 edits: &[Edit],
280 ) -> Result<(), HashlineError> {
281 if file_hash.is_empty() {
282 return Ok(());
283 }
284 let snapshot = match self.snapshots.by_hash(canonical, file_hash) {
285 Some(s) => s,
286 None => return Ok(()), };
288 let seen = match &snapshot.seen_lines {
289 Some(s) => s,
290 None => return Ok(()), };
292
293 let mut unseen: Vec<u32> = Vec::new();
294 for edit in edits {
295 let anchor_line = edit.anchor_line();
296 if anchor_line == 0 || anchor_line == u32::MAX {
297 continue; }
299 if !seen.contains(&anchor_line) {
300 unseen.push(anchor_line);
301 }
302 }
303
304 if unseen.is_empty() {
305 return Ok(());
306 }
307
308 let msg = format_unseen_lines(&unseen);
309 Err(HashlineError::UnseenLines(msg))
310 }
311
312 async fn commit(&self, section: &PreparedSection) -> Result<PatchSectionResult, HashlineError> {
316 let canonical = self.fs.canonical_path(§ion.path);
317
318 let mut output = normalize::restore_line_endings(§ion.result_text, section.line_ending);
320 if section.had_bom {
321 output = format!("\u{feff}{output}");
322 }
323
324 self.fs.preflight_write(§ion.path).await?;
326 self.fs.write_text(§ion.path, &output).await?;
327
328 let new_hash = compute_file_hash(§ion.result_text);
330
331 let total_lines = section.result_text.split('\n').count() as u32;
334 let all_lines: Vec<u32> = (1..=total_lines).collect();
335 self.snapshots
336 .record(&canonical, §ion.result_text, Some(&all_lines));
337
338 let preview = build_compact_diff_preview(
340 §ion.before_text,
341 §ion.result_text,
342 &CompactDiffOptions::default(),
343 );
344 let diff = preview.lines.join("\n");
345
346 Ok(PatchSectionResult {
347 path: section.path.clone(),
348 diff,
349 first_changed_line: section.first_changed_line,
350 warnings: section.warnings.clone(),
351 new_hash,
352 })
353 }
354}
355
356fn is_position_independent(edit: &Edit) -> bool {
361 matches!(
362 edit,
363 Edit::Insert {
364 cursor: crate::types::Cursor::Bof | crate::types::Cursor::Eof,
365 ..
366 }
367 )
368}
369
370fn mismatch_error(
372 path: &str,
373 expected: &str,
374 actual: &str,
375 live_text: &str,
376 edits: &[Edit],
377 hash_recognized: bool,
378) -> HashlineError {
379 let file_lines: Vec<String> = live_text.split('\n').map(String::from).collect();
380 let anchor_lines: Vec<u32> = edits.iter().map(|e| e.anchor_line()).collect();
381 let details = MismatchDetails {
382 path: Some(path.to_string()),
383 expected_file_hash: expected.to_string(),
384 actual_file_hash: actual.to_string(),
385 file_lines,
386 anchor_lines,
387 hash_recognized,
388 };
389 let err = MismatchError::new(details);
390 HashlineError::Mismatch {
391 detail: err.message,
392 expected: expected.to_string(),
393 actual: actual.to_string(),
394 }
395}
396
397fn format_unseen_lines(lines: &[u32]) -> String {
399 let listed: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
400 format!(
401 "Edit rejected: lines {} were not shown in your last read. \
402 Re-read those exact lines before editing them.",
403 listed.join(", ")
404 )
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::snapshots::InMemorySnapshotStore;
411
412 struct MemFs {
414 root: parking_lot::RwLock<std::collections::HashMap<String, String>>,
415 }
416
417 impl MemFs {
418 fn new() -> Self {
419 Self {
420 root: parking_lot::RwLock::new(std::collections::HashMap::new()),
421 }
422 }
423
424 fn put(&self, path: &str, text: &str) {
425 self.root.write().insert(path.to_string(), text.to_string());
426 }
427 }
428
429 #[async_trait::async_trait]
430 impl HashlineFs for MemFs {
431 async fn read_text(&self, path: &str) -> Result<String, HashlineError> {
432 self.root
433 .read()
434 .get(path)
435 .cloned()
436 .ok_or_else(|| HashlineError::NotFound {
437 path: path.to_string(),
438 })
439 }
440
441 async fn write_text(&self, path: &str, text: &str) -> Result<String, HashlineError> {
442 self.root.write().insert(path.to_string(), text.to_string());
443 Ok(path.to_string())
444 }
445 fn canonical_path(&self, path: &str) -> String {
446 path.strip_prefix("./").unwrap_or(path).to_string()
448 }
449 }
450
451 fn make_patcher() -> (Patcher, Arc<MemFs>, Arc<InMemorySnapshotStore>) {
452 let fs = Arc::new(MemFs::new());
453 let store = Arc::new(InMemorySnapshotStore::new());
454 let patcher = Patcher::new(fs.clone(), store.clone());
455 (patcher, fs, store)
456 }
457
458 #[tokio::test]
459 async fn apply_simple_swap() {
460 let (patcher, fs, store) = make_patcher();
461 let content = "fn main() {\n todo!()\n}\n";
462 fs.put("main.rs", content);
463 let tag = store.record("main.rs", content, Some(&[1, 2, 3]));
464
465 let patch_text = format!(
466 "*** Begin Patch\n[main.rs#{tag}]\nSWAP 2.=2:\n+ println!(\"hi\")\n*** End Patch"
467 );
468 let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
469 let result = patcher.apply(&patch).await.unwrap();
470
471 assert_eq!(result.sections.len(), 1);
472 let new_content = fs.read_text("main.rs").await.unwrap();
473 assert!(new_content.contains("println!"));
474 assert!(!new_content.contains("todo!"));
475 }
476
477 #[tokio::test]
478 async fn apply_rejects_stale_tag_with_no_snapshot() {
479 let (patcher, fs, _store) = make_patcher();
480 fs.put("f.rs", "a\nb\n");
481
482 let patch_text = "*** Begin Patch\n[f.rs#FFFF]\nSWAP 1.=1:\n+x\n*** End Patch";
483 let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
484 let result = patcher.apply(&patch).await;
485
486 assert!(result.is_err());
487 let err = result.unwrap_err();
488 assert!(matches!(err, HashlineError::Mismatch { .. }));
489 }
490
491 #[tokio::test]
492 async fn apply_head_tail_drift_allowed() {
493 let (patcher, fs, _store) = make_patcher();
494 fs.put("f.rs", "a\nb\n");
495
496 let patch_text = "*** Begin Patch\n[f.rs#FFFF]\nINS.HEAD:\n+prefix\n*** End Patch";
498 let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
499 let _result = patcher.apply(&patch).await.unwrap();
500
501 let new_content = fs.read_text("f.rs").await.unwrap();
502 assert!(new_content.starts_with("prefix"));
503 }
504
505 #[tokio::test]
506 async fn apply_no_tag_applies_without_validation() {
507 let (patcher, fs, _store) = make_patcher();
508 fs.put("f.rs", "a\nb\n");
509
510 let patch_text = "*** Begin Patch\n[f.rs]\nSWAP 1.=1:\n+x\n*** End Patch";
512 let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
513 let _result = patcher.apply(&patch).await.unwrap();
514
515 let new_content = fs.read_text("f.rs").await.unwrap();
516 assert!(new_content.starts_with("x\n"));
517 }
518
519 #[tokio::test]
520 async fn apply_records_new_snapshot() {
521 let (patcher, fs, store) = make_patcher();
522 let content = "a\nb\n";
523 fs.put("f.rs", content);
524 let tag = store.record("f.rs", content, Some(&[1, 2]));
525
526 let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+x\n*** End Patch");
527 let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
528 let result = patcher.apply(&patch).await.unwrap();
529
530 let new_hash = &result.sections[0].new_hash;
532 assert!(!new_hash.is_empty());
533 let snap = store.by_hash("f.rs", new_hash);
534 assert!(snap.is_some());
535 }
536
537 #[tokio::test]
538 async fn apply_rejects_duplicate_canonical_paths() {
539 let (patcher, fs, _store) = make_patcher();
540 fs.put("f.rs", "a\nb\n");
541
542 let patch_text =
546 "*** Begin Patch\n[f.rs]\nSWAP 1.=1:\n+x\n[./f.rs]\nSWAP 2.=2:\n+y\n*** End Patch";
547 let patch = crate::parser::split_patch_input(patch_text, None).unwrap();
548 let result = patcher.apply(&patch).await;
549
550 assert!(matches!(
551 result,
552 Err(HashlineError::DuplicateCanonicalPath { .. })
553 ));
554 }
555
556 #[tokio::test]
557 async fn apply_noop_is_error() {
558 let (patcher, fs, store) = make_patcher();
559 let content = "a\nb\n";
560 fs.put("f.rs", content);
561 let tag = store.record("f.rs", content, Some(&[1, 2]));
562
563 let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+a\n*** End Patch");
565 let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
566 let result = patcher.apply(&patch).await;
567
568 assert!(matches!(result, Err(HashlineError::NoOp { .. })));
569 }
570
571 #[tokio::test]
572 async fn preflight_does_not_write() {
573 let (patcher, fs, store) = make_patcher();
574 let content = "a\nb\n";
575 fs.put("f.rs", content);
576 let tag = store.record("f.rs", content, Some(&[1, 2]));
577
578 let patch_text = format!("*** Begin Patch\n[f.rs#{tag}]\nSWAP 1.=1:\n+x\n*** End Patch");
579 let patch = crate::parser::split_patch_input(&patch_text, None).unwrap();
580 patcher.preflight(&patch).await.unwrap();
581
582 let content_after = fs.read_text("f.rs").await.unwrap();
584 assert_eq!(content_after, content);
585 }
586}