Skip to main content

lago_core/
hashline.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// 4 hex chars from FNV-1a 16-bit hash of line content.
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
6pub struct LineHash(String);
7
8impl LineHash {
9    /// Create a `LineHash` from raw bytes by computing FNV-1a 16-bit.
10    pub fn from_content(content: &str) -> Self {
11        let h = fnv1a_16(content.as_bytes());
12        Self(format!("{h:04x}"))
13    }
14
15    /// Create a `LineHash` from an existing hex string (e.g. parsed from hashline format).
16    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/// A single annotated line.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct HashLine {
34    pub line_num: u32,
35    pub hash: LineHash,
36    pub content: String,
37}
38
39/// A complete file with line hashes.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct HashLineFile {
42    pub lines: Vec<HashLine>,
43}
44
45impl HashLineFile {
46    /// Build a `HashLineFile` from raw file content.
47    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    /// Render to the hashline text format: `N:HHHH|content`
66    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    /// Find all lines matching a given hash.
83    pub fn find_by_hash(&self, hash: &LineHash) -> Vec<&HashLine> {
84        self.lines.iter().filter(|l| l.hash == *hash).collect()
85    }
86
87    /// Reconstruct file content from lines.
88    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    /// Apply a set of edits, validating hashes and returning the new content.
97    ///
98    /// Algorithm: validate all hashes → sort edits by line (reverse) → apply bottom-up.
99    pub fn apply_edits(&self, edits: &[HashLineEdit]) -> Result<String, HashLineError> {
100        if edits.is_empty() {
101            return Ok(self.to_content());
102        }
103
104        // Validate all edits first
105        for edit in edits {
106            self.validate_edit(edit)?;
107        }
108
109        // Check for overlapping edits
110        self.check_overlaps(edits)?;
111
112        let mut lines: Vec<String> = self.lines.iter().map(|l| l.content.clone()).collect();
113
114        // Sort edits by primary line number, descending (apply bottom-up)
115        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; // insert after this line
134                    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                    // Remove the range
167                    lines.drain(start_idx..=end_idx);
168                    // Insert replacement
169                    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/// Edit operations referencing lines by hash.
293#[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/// Hashline-specific errors.
325#[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
347/// Get the primary line number from an edit (for sorting).
348fn 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
358/// FNV-1a 16-bit hash (pure Rust, no dependencies).
359fn fnv1a_16(data: &[u8]) -> u16 {
360    // FNV-1a 32-bit then fold to 16 bits via xor-folding
361    let mut hash: u32 = 0x811c_9dc5; // FNV offset basis
362    for &byte in data {
363        hash ^= byte as u32;
364        hash = hash.wrapping_mul(0x0100_0193); // FNV prime
365    }
366    // Xor-fold to 16 bits
367    ((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        // Empty string — FNV-1a 32-bit of "" is 0x811c9dc5, xor-folded to 16 bits
377        let h = fnv1a_16(b"");
378        assert_eq!(format!("{h:04x}"), "1cd9");
379
380        // Known content produces consistent hashes
381        let h1 = fnv1a_16(b"fn main() {");
382        let h2 = fnv1a_16(b"fn main() {");
383        assert_eq!(h1, h2);
384
385        // Different content produces different hashes (probabilistically)
386        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        // Consistent
396        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        // Each line matches N:HHHH|content
441        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); // 4 hex chars
448        }
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        // Same content on two lines produces same hash
471        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}