1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
6pub struct LineHash(String);
7
8impl LineHash {
9 pub fn from_content(content: &str) -> Self {
11 let h = fnv1a_16(content.as_bytes());
12 Self(format!("{h:04x}"))
13 }
14
15 pub fn from_hex(hex: impl Into<String>) -> Self {
17 Self(hex.into())
18 }
19
20 pub fn as_str(&self) -> &str {
21 &self.0
22 }
23}
24
25impl fmt::Display for LineHash {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 f.write_str(&self.0)
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct HashLine {
34 pub line_num: u32,
35 pub hash: LineHash,
36 pub content: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct HashLineFile {
42 pub lines: Vec<HashLine>,
43}
44
45impl HashLineFile {
46 pub fn from_content(content: &str) -> Self {
48 if content.is_empty() {
49 return Self { lines: Vec::new() };
50 }
51
52 let lines = content
53 .split('\n')
54 .enumerate()
55 .map(|(i, line)| HashLine {
56 line_num: (i + 1) as u32,
57 hash: LineHash::from_content(line),
58 content: line.to_string(),
59 })
60 .collect();
61
62 Self { lines }
63 }
64
65 pub fn to_hashline_text(&self) -> String {
67 let mut out = String::new();
68 for (i, line) in self.lines.iter().enumerate() {
69 if i > 0 {
70 out.push('\n');
71 }
72 out.push_str(&format!(
73 "{}:{}|{}",
74 line.line_num,
75 line.hash.as_str(),
76 line.content
77 ));
78 }
79 out
80 }
81
82 pub fn find_by_hash(&self, hash: &LineHash) -> Vec<&HashLine> {
84 self.lines.iter().filter(|l| l.hash == *hash).collect()
85 }
86
87 pub fn to_content(&self) -> String {
89 self.lines
90 .iter()
91 .map(|l| l.content.as_str())
92 .collect::<Vec<_>>()
93 .join("\n")
94 }
95
96 pub fn apply_edits(&self, edits: &[HashLineEdit]) -> Result<String, HashLineError> {
100 if edits.is_empty() {
101 return Ok(self.to_content());
102 }
103
104 for edit in edits {
106 self.validate_edit(edit)?;
107 }
108
109 self.check_overlaps(edits)?;
111
112 let mut lines: Vec<String> = self.lines.iter().map(|l| l.content.clone()).collect();
113
114 let mut sorted: Vec<&HashLineEdit> = edits.iter().collect();
116 sorted.sort_by_key(|e| std::cmp::Reverse(primary_line(e)));
117
118 for edit in sorted {
119 match edit {
120 HashLineEdit::Replace {
121 line_num,
122 new_content,
123 ..
124 } => {
125 let idx = (*line_num as usize) - 1;
126 lines[idx] = new_content.clone();
127 }
128 HashLineEdit::InsertAfter {
129 line_num,
130 new_content,
131 ..
132 } => {
133 let idx = *line_num as usize; let new_lines: Vec<String> =
135 new_content.split('\n').map(|s| s.to_string()).collect();
136 for (i, nl) in new_lines.into_iter().enumerate() {
137 lines.insert(idx + i, nl);
138 }
139 }
140 HashLineEdit::InsertBefore {
141 line_num,
142 new_content,
143 ..
144 } => {
145 let idx = (*line_num as usize) - 1;
146 let new_lines: Vec<String> =
147 new_content.split('\n').map(|s| s.to_string()).collect();
148 for (i, nl) in new_lines.into_iter().enumerate() {
149 lines.insert(idx + i, nl);
150 }
151 }
152 HashLineEdit::Delete { line_num, .. } => {
153 let idx = (*line_num as usize) - 1;
154 lines.remove(idx);
155 }
156 HashLineEdit::ReplaceRange {
157 start_line,
158 end_line,
159 new_content,
160 ..
161 } => {
162 let start_idx = (*start_line as usize) - 1;
163 let end_idx = (*end_line as usize) - 1;
164 let new_lines: Vec<String> =
165 new_content.split('\n').map(|s| s.to_string()).collect();
166 lines.drain(start_idx..=end_idx);
168 for (i, nl) in new_lines.into_iter().enumerate() {
170 lines.insert(start_idx + i, nl);
171 }
172 }
173 }
174 }
175
176 Ok(lines.join("\n"))
177 }
178
179 fn validate_edit(&self, edit: &HashLineEdit) -> Result<(), HashLineError> {
180 let total = self.lines.len() as u32;
181
182 match edit {
183 HashLineEdit::Replace {
184 anchor_hash,
185 line_num,
186 ..
187 }
188 | HashLineEdit::InsertAfter {
189 anchor_hash,
190 line_num,
191 ..
192 }
193 | HashLineEdit::InsertBefore {
194 anchor_hash,
195 line_num,
196 ..
197 }
198 | HashLineEdit::Delete {
199 anchor_hash,
200 line_num,
201 } => {
202 if *line_num == 0 || *line_num > total {
203 return Err(HashLineError::LineOutOfBounds {
204 line_num: *line_num,
205 total_lines: total,
206 });
207 }
208 let idx = (*line_num as usize) - 1;
209 let actual = &self.lines[idx].hash;
210 if actual != anchor_hash {
211 return Err(HashLineError::HashMismatch {
212 line_num: *line_num,
213 expected: anchor_hash.clone(),
214 actual: actual.clone(),
215 });
216 }
217 }
218 HashLineEdit::ReplaceRange {
219 start_hash,
220 start_line,
221 end_hash,
222 end_line,
223 ..
224 } => {
225 if *start_line == 0 || *start_line > total {
226 return Err(HashLineError::LineOutOfBounds {
227 line_num: *start_line,
228 total_lines: total,
229 });
230 }
231 if *end_line == 0 || *end_line > total {
232 return Err(HashLineError::LineOutOfBounds {
233 line_num: *end_line,
234 total_lines: total,
235 });
236 }
237 let start_actual = &self.lines[(*start_line as usize) - 1].hash;
238 if start_actual != start_hash {
239 return Err(HashLineError::HashMismatch {
240 line_num: *start_line,
241 expected: start_hash.clone(),
242 actual: start_actual.clone(),
243 });
244 }
245 let end_actual = &self.lines[(*end_line as usize) - 1].hash;
246 if end_actual != end_hash {
247 return Err(HashLineError::HashMismatch {
248 line_num: *end_line,
249 expected: end_hash.clone(),
250 actual: end_actual.clone(),
251 });
252 }
253 }
254 }
255
256 Ok(())
257 }
258
259 fn check_overlaps(&self, edits: &[HashLineEdit]) -> Result<(), HashLineError> {
260 let mut touched: Vec<u32> = Vec::new();
261 for edit in edits {
262 match edit {
263 HashLineEdit::Replace { line_num, .. }
264 | HashLineEdit::InsertAfter { line_num, .. }
265 | HashLineEdit::InsertBefore { line_num, .. }
266 | HashLineEdit::Delete { line_num, .. } => {
267 if touched.contains(line_num) {
268 return Err(HashLineError::OverlappingEdits {
269 line_num: *line_num,
270 });
271 }
272 touched.push(*line_num);
273 }
274 HashLineEdit::ReplaceRange {
275 start_line,
276 end_line,
277 ..
278 } => {
279 for ln in *start_line..=*end_line {
280 if touched.contains(&ln) {
281 return Err(HashLineError::OverlappingEdits { line_num: ln });
282 }
283 touched.push(ln);
284 }
285 }
286 }
287 }
288 Ok(())
289 }
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(tag = "op")]
295pub enum HashLineEdit {
296 Replace {
297 anchor_hash: LineHash,
298 line_num: u32,
299 new_content: String,
300 },
301 InsertAfter {
302 anchor_hash: LineHash,
303 line_num: u32,
304 new_content: String,
305 },
306 InsertBefore {
307 anchor_hash: LineHash,
308 line_num: u32,
309 new_content: String,
310 },
311 Delete {
312 anchor_hash: LineHash,
313 line_num: u32,
314 },
315 ReplaceRange {
316 start_hash: LineHash,
317 start_line: u32,
318 end_hash: LineHash,
319 end_line: u32,
320 new_content: String,
321 },
322}
323
324#[derive(Debug, thiserror::Error)]
326pub enum HashLineError {
327 #[error("hash mismatch at line {line_num}: expected {expected}, actual {actual}")]
328 HashMismatch {
329 line_num: u32,
330 expected: LineHash,
331 actual: LineHash,
332 },
333
334 #[error("line {line_num} out of bounds (file has {total_lines} lines)")]
335 LineOutOfBounds { line_num: u32, total_lines: u32 },
336
337 #[error("ambiguous hash {hash}: matches lines {matching_lines:?}")]
338 AmbiguousHash {
339 hash: LineHash,
340 matching_lines: Vec<u32>,
341 },
342
343 #[error("overlapping edits at line {line_num}")]
344 OverlappingEdits { line_num: u32 },
345}
346
347fn primary_line(edit: &HashLineEdit) -> u32 {
349 match edit {
350 HashLineEdit::Replace { line_num, .. }
351 | HashLineEdit::InsertAfter { line_num, .. }
352 | HashLineEdit::InsertBefore { line_num, .. }
353 | HashLineEdit::Delete { line_num, .. } => *line_num,
354 HashLineEdit::ReplaceRange { start_line, .. } => *start_line,
355 }
356}
357
358fn fnv1a_16(data: &[u8]) -> u16 {
360 let mut hash: u32 = 0x811c_9dc5; for &byte in data {
363 hash ^= byte as u32;
364 hash = hash.wrapping_mul(0x0100_0193); }
366 ((hash >> 16) ^ (hash & 0xFFFF)) as u16
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn fnv1a_known_hashes() {
376 let h = fnv1a_16(b"");
378 assert_eq!(format!("{h:04x}"), "1cd9");
379
380 let h1 = fnv1a_16(b"fn main() {");
382 let h2 = fnv1a_16(b"fn main() {");
383 assert_eq!(h1, h2);
384
385 let h3 = fnv1a_16(b"fn main() {");
387 let h4 = fnv1a_16(b"fn other() {");
388 assert_ne!(h3, h4);
389 }
390
391 #[test]
392 fn line_hash_from_content() {
393 let h = LineHash::from_content("fn main() {");
394 assert_eq!(h.as_str().len(), 4);
395 assert_eq!(h, LineHash::from_content("fn main() {"));
397 }
398
399 #[test]
400 fn line_hash_display() {
401 let h = LineHash::from_hex("a3f1");
402 assert_eq!(format!("{h}"), "a3f1");
403 }
404
405 #[test]
406 fn from_content_multiline() {
407 let content = "fn main() {\n println!(\"hello\");\n}";
408 let file = HashLineFile::from_content(content);
409 assert_eq!(file.lines.len(), 3);
410 assert_eq!(file.lines[0].line_num, 1);
411 assert_eq!(file.lines[0].content, "fn main() {");
412 assert_eq!(file.lines[1].line_num, 2);
413 assert_eq!(file.lines[1].content, " println!(\"hello\");");
414 assert_eq!(file.lines[2].line_num, 3);
415 assert_eq!(file.lines[2].content, "}");
416 }
417
418 #[test]
419 fn from_content_empty() {
420 let file = HashLineFile::from_content("");
421 assert!(file.lines.is_empty());
422 }
423
424 #[test]
425 fn from_content_single_line() {
426 let file = HashLineFile::from_content("hello");
427 assert_eq!(file.lines.len(), 1);
428 assert_eq!(file.lines[0].line_num, 1);
429 assert_eq!(file.lines[0].content, "hello");
430 }
431
432 #[test]
433 fn to_hashline_text_format() {
434 let content = "fn main() {\n println!(\"hello\");\n}";
435 let file = HashLineFile::from_content(content);
436 let text = file.to_hashline_text();
437 let lines: Vec<&str> = text.split('\n').collect();
438 assert_eq!(lines.len(), 3);
439
440 for line in &lines {
442 let parts: Vec<&str> = line.splitn(2, '|').collect();
443 assert_eq!(parts.len(), 2);
444 let prefix = parts[0];
445 assert!(prefix.contains(':'));
446 let hash_part: Vec<&str> = prefix.splitn(2, ':').collect();
447 assert_eq!(hash_part[1].len(), 4); }
449 }
450
451 #[test]
452 fn to_hashline_text_roundtrip() {
453 let content = "line one\nline two\nline three";
454 let file = HashLineFile::from_content(content);
455 let reconstructed = file.to_content();
456 assert_eq!(reconstructed, content);
457 }
458
459 #[test]
460 fn find_by_hash_found() {
461 let content = "aaa\nbbb\nccc";
462 let file = HashLineFile::from_content(content);
463 let hash = &file.lines[1].hash;
464 let found = file.find_by_hash(hash);
465 assert!(found.iter().any(|l| l.content == "bbb"));
466 }
467
468 #[test]
469 fn find_by_hash_duplicates() {
470 let content = "dup\nother\ndup";
472 let file = HashLineFile::from_content(content);
473 let hash = &file.lines[0].hash;
474 let found = file.find_by_hash(hash);
475 assert_eq!(found.len(), 2);
476 assert_eq!(found[0].line_num, 1);
477 assert_eq!(found[1].line_num, 3);
478 }
479
480 #[test]
481 fn apply_edit_replace() {
482 let content = "aaa\nbbb\nccc";
483 let file = HashLineFile::from_content(content);
484 let hash = file.lines[1].hash.clone();
485 let edits = vec![HashLineEdit::Replace {
486 anchor_hash: hash,
487 line_num: 2,
488 new_content: "BBB".to_string(),
489 }];
490 let result = file.apply_edits(&edits).unwrap();
491 assert_eq!(result, "aaa\nBBB\nccc");
492 }
493
494 #[test]
495 fn apply_edit_insert_after() {
496 let content = "aaa\nccc";
497 let file = HashLineFile::from_content(content);
498 let hash = file.lines[0].hash.clone();
499 let edits = vec![HashLineEdit::InsertAfter {
500 anchor_hash: hash,
501 line_num: 1,
502 new_content: "bbb".to_string(),
503 }];
504 let result = file.apply_edits(&edits).unwrap();
505 assert_eq!(result, "aaa\nbbb\nccc");
506 }
507
508 #[test]
509 fn apply_edit_insert_before() {
510 let content = "bbb\nccc";
511 let file = HashLineFile::from_content(content);
512 let hash = file.lines[0].hash.clone();
513 let edits = vec![HashLineEdit::InsertBefore {
514 anchor_hash: hash,
515 line_num: 1,
516 new_content: "aaa".to_string(),
517 }];
518 let result = file.apply_edits(&edits).unwrap();
519 assert_eq!(result, "aaa\nbbb\nccc");
520 }
521
522 #[test]
523 fn apply_edit_delete() {
524 let content = "aaa\nbbb\nccc";
525 let file = HashLineFile::from_content(content);
526 let hash = file.lines[1].hash.clone();
527 let edits = vec![HashLineEdit::Delete {
528 anchor_hash: hash,
529 line_num: 2,
530 }];
531 let result = file.apply_edits(&edits).unwrap();
532 assert_eq!(result, "aaa\nccc");
533 }
534
535 #[test]
536 fn apply_edit_replace_range() {
537 let content = "aaa\nbbb\nccc\nddd";
538 let file = HashLineFile::from_content(content);
539 let start_hash = file.lines[1].hash.clone();
540 let end_hash = file.lines[2].hash.clone();
541 let edits = vec![HashLineEdit::ReplaceRange {
542 start_hash,
543 start_line: 2,
544 end_hash,
545 end_line: 3,
546 new_content: "xxx\nyyy".to_string(),
547 }];
548 let result = file.apply_edits(&edits).unwrap();
549 assert_eq!(result, "aaa\nxxx\nyyy\nddd");
550 }
551
552 #[test]
553 fn apply_edit_error_hash_mismatch() {
554 let content = "aaa\nbbb";
555 let file = HashLineFile::from_content(content);
556 let edits = vec![HashLineEdit::Replace {
557 anchor_hash: LineHash::from_hex("0000"),
558 line_num: 1,
559 new_content: "xxx".to_string(),
560 }];
561 let err = file.apply_edits(&edits).unwrap_err();
562 assert!(matches!(err, HashLineError::HashMismatch { .. }));
563 }
564
565 #[test]
566 fn apply_edit_error_line_out_of_bounds() {
567 let content = "aaa\nbbb";
568 let file = HashLineFile::from_content(content);
569 let edits = vec![HashLineEdit::Delete {
570 anchor_hash: LineHash::from_hex("0000"),
571 line_num: 5,
572 }];
573 let err = file.apply_edits(&edits).unwrap_err();
574 assert!(matches!(err, HashLineError::LineOutOfBounds { .. }));
575 }
576
577 #[test]
578 fn apply_edit_error_overlapping() {
579 let content = "aaa\nbbb\nccc";
580 let file = HashLineFile::from_content(content);
581 let hash = file.lines[1].hash.clone();
582 let edits = vec![
583 HashLineEdit::Replace {
584 anchor_hash: hash.clone(),
585 line_num: 2,
586 new_content: "xxx".to_string(),
587 },
588 HashLineEdit::Delete {
589 anchor_hash: hash,
590 line_num: 2,
591 },
592 ];
593 let err = file.apply_edits(&edits).unwrap_err();
594 assert!(matches!(err, HashLineError::OverlappingEdits { .. }));
595 }
596
597 #[test]
598 fn apply_multiple_non_overlapping_edits() {
599 let content = "aaa\nbbb\nccc\nddd";
600 let file = HashLineFile::from_content(content);
601 let hash1 = file.lines[0].hash.clone();
602 let hash3 = file.lines[2].hash.clone();
603 let edits = vec![
604 HashLineEdit::Replace {
605 anchor_hash: hash1,
606 line_num: 1,
607 new_content: "AAA".to_string(),
608 },
609 HashLineEdit::Replace {
610 anchor_hash: hash3,
611 line_num: 3,
612 new_content: "CCC".to_string(),
613 },
614 ];
615 let result = file.apply_edits(&edits).unwrap();
616 assert_eq!(result, "AAA\nbbb\nCCC\nddd");
617 }
618
619 #[test]
620 fn apply_empty_edits() {
621 let content = "aaa\nbbb";
622 let file = HashLineFile::from_content(content);
623 let result = file.apply_edits(&[]).unwrap();
624 assert_eq!(result, content);
625 }
626
627 #[test]
628 fn serde_roundtrip_hash_line_edit_replace() {
629 let edit = HashLineEdit::Replace {
630 anchor_hash: LineHash::from_hex("a3f1"),
631 line_num: 5,
632 new_content: "new line".to_string(),
633 };
634 let json = serde_json::to_string(&edit).unwrap();
635 assert!(json.contains("\"op\":\"Replace\""));
636 let back: HashLineEdit = serde_json::from_str(&json).unwrap();
637 if let HashLineEdit::Replace {
638 anchor_hash,
639 line_num,
640 new_content,
641 } = back
642 {
643 assert_eq!(anchor_hash.as_str(), "a3f1");
644 assert_eq!(line_num, 5);
645 assert_eq!(new_content, "new line");
646 } else {
647 panic!("wrong variant");
648 }
649 }
650
651 #[test]
652 fn serde_roundtrip_hash_line_edit_delete() {
653 let edit = HashLineEdit::Delete {
654 anchor_hash: LineHash::from_hex("beef"),
655 line_num: 3,
656 };
657 let json = serde_json::to_string(&edit).unwrap();
658 let back: HashLineEdit = serde_json::from_str(&json).unwrap();
659 if let HashLineEdit::Delete {
660 anchor_hash,
661 line_num,
662 } = back
663 {
664 assert_eq!(anchor_hash.as_str(), "beef");
665 assert_eq!(line_num, 3);
666 } else {
667 panic!("wrong variant");
668 }
669 }
670
671 #[test]
672 fn serde_roundtrip_hash_line_edit_replace_range() {
673 let edit = HashLineEdit::ReplaceRange {
674 start_hash: LineHash::from_hex("aaaa"),
675 start_line: 2,
676 end_hash: LineHash::from_hex("bbbb"),
677 end_line: 5,
678 new_content: "x\ny".to_string(),
679 };
680 let json = serde_json::to_string(&edit).unwrap();
681 let back: HashLineEdit = serde_json::from_str(&json).unwrap();
682 if let HashLineEdit::ReplaceRange {
683 start_hash,
684 start_line,
685 end_hash,
686 end_line,
687 new_content,
688 } = back
689 {
690 assert_eq!(start_hash.as_str(), "aaaa");
691 assert_eq!(start_line, 2);
692 assert_eq!(end_hash.as_str(), "bbbb");
693 assert_eq!(end_line, 5);
694 assert_eq!(new_content, "x\ny");
695 } else {
696 panic!("wrong variant");
697 }
698 }
699
700 #[test]
701 fn hashline_error_display() {
702 let err = HashLineError::HashMismatch {
703 line_num: 3,
704 expected: LineHash::from_hex("aaaa"),
705 actual: LineHash::from_hex("bbbb"),
706 };
707 assert!(err.to_string().contains("hash mismatch at line 3"));
708
709 let err = HashLineError::LineOutOfBounds {
710 line_num: 10,
711 total_lines: 5,
712 };
713 assert!(err.to_string().contains("line 10 out of bounds"));
714
715 let err = HashLineError::OverlappingEdits { line_num: 2 };
716 assert!(err.to_string().contains("overlapping edits at line 2"));
717 }
718
719 #[test]
720 fn insert_multiline_after() {
721 let content = "aaa\nccc";
722 let file = HashLineFile::from_content(content);
723 let hash = file.lines[0].hash.clone();
724 let edits = vec![HashLineEdit::InsertAfter {
725 anchor_hash: hash,
726 line_num: 1,
727 new_content: "b1\nb2".to_string(),
728 }];
729 let result = file.apply_edits(&edits).unwrap();
730 assert_eq!(result, "aaa\nb1\nb2\nccc");
731 }
732}