Skip to main content

lit/core/
diff.rs

1/// Diff Engine — Myers diff algorithm with structured output
2///
3/// Provides line-level diffing between blobs and tree-level diffing
4/// between commits/branches. All output is structured for agent consumption.
5use serde::{Deserialize, Serialize};
6
7/// A single diff hunk representing a contiguous region of changes
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct DiffHunk {
10    pub old_start: usize,
11    pub old_count: usize,
12    pub new_start: usize,
13    pub new_count: usize,
14    pub lines: Vec<DiffLine>,
15}
16
17/// A single line in a diff
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DiffLine {
20    pub kind: DiffLineKind,
21    pub content: String,
22}
23
24/// The type of a diff line
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum DiffLineKind {
28    Context,
29    Add,
30    Remove,
31}
32
33/// A file-level diff result
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FileDiff {
36    pub path: String,
37    pub status: FileStatus,
38    pub hunks: Vec<DiffHunk>,
39    pub old_hash: Option<String>,
40    pub new_hash: Option<String>,
41    pub is_binary: bool,
42    pub additions: usize,
43    pub deletions: usize,
44}
45
46/// File status in a diff
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum FileStatus {
50    Added,
51    Modified,
52    Deleted,
53}
54
55/// Stat summary for a file
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct DiffStat {
58    pub path: String,
59    pub additions: usize,
60    pub deletions: usize,
61    pub status: FileStatus,
62}
63
64/// Context lines around hunks (default: 3)
65const CONTEXT_LINES: usize = 3;
66
67/// Myers diff algorithm — computes the shortest edit script between two sequences
68///
69/// Returns a list of edit operations (keep, insert, delete) that transform `old` into `new`.
70pub fn myers_diff<'a>(old: &'a [&str], new: &'a [&str]) -> Vec<DiffOp<'a>> {
71    let n = old.len();
72    let m = new.len();
73
74    if n == 0 && m == 0 {
75        return vec![];
76    }
77    if n == 0 {
78        return new.iter().map(|l| DiffOp::Insert(l)).collect();
79    }
80    if m == 0 {
81        return old.iter().map(|l| DiffOp::Delete(l)).collect();
82    }
83
84    let max = n + m;
85    // V array indexed from -max to +max; offset by max
86    let size = 2 * max + 1;
87    let mut v = vec![0usize; size];
88    let mut trace: Vec<Vec<usize>> = Vec::new();
89
90    'outer: for d in 0..=(max as isize) {
91        trace.push(v.clone());
92        let mut new_v = v.clone();
93
94        let k_min = -d;
95        let k_max = d;
96        let mut k = k_min;
97        while k <= k_max {
98            let idx = (k + max as isize) as usize;
99            let mut x = if k == -d
100                || (k != d
101                    && v[((k - 1) + max as isize) as usize] < v[((k + 1) + max as isize) as usize])
102            {
103                v[((k + 1) + max as isize) as usize]
104            } else {
105                v[((k - 1) + max as isize) as usize] + 1
106            };
107
108            let mut y = (x as isize - k) as usize;
109
110            // Follow diagonal (matching lines)
111            while x < n && y < m && old[x] == new[y] {
112                x += 1;
113                y += 1;
114            }
115
116            new_v[idx] = x;
117
118            if x >= n && y >= m {
119                v = new_v;
120                trace.push(v.clone());
121                break 'outer;
122            }
123
124            k += 2;
125        }
126        v = new_v;
127    }
128
129    // Backtrack to find the actual edit path
130    backtrack(&trace, n, m, max, old, new)
131}
132
133/// Edit operation from Myers diff
134#[derive(Debug, Clone)]
135pub enum DiffOp<'a> {
136    Equal(&'a str),
137    Insert(&'a str),
138    Delete(&'a str),
139}
140
141fn backtrack<'a>(
142    trace: &[Vec<usize>],
143    n: usize,
144    m: usize,
145    max: usize,
146    old: &'a [&str],
147    new: &'a [&str],
148) -> Vec<DiffOp<'a>> {
149    let mut ops = Vec::new();
150    let mut x = n;
151    let mut y = m;
152
153    for d in (0..trace.len().saturating_sub(1)).rev() {
154        let v = &trace[d];
155        let k = x as isize - y as isize;
156
157        let (prev_x, prev_y) = if d == 0 {
158            // At d=0, the starting position is always (0, 0)
159            (0usize, 0usize)
160        } else {
161            let prev_k = if k == -(d as isize)
162                || (k != d as isize
163                    && v[((k - 1) + max as isize) as usize] < v[((k + 1) + max as isize) as usize])
164            {
165                k + 1
166            } else {
167                k - 1
168            };
169
170            let px = v[(prev_k + max as isize) as usize];
171            let py = (px as isize - prev_k) as usize;
172            (px, py)
173        };
174
175        // Diagonal moves (equal lines)
176        while x > prev_x && y > prev_y {
177            x -= 1;
178            y -= 1;
179            ops.push(DiffOp::Equal(old[x]));
180        }
181
182        if d > 0 {
183            let prev_k = if k == -(d as isize)
184                || (k != d as isize
185                    && v[((k - 1) + max as isize) as usize] < v[((k + 1) + max as isize) as usize])
186            {
187                k + 1
188            } else {
189                k - 1
190            };
191            let prev_x = v[(prev_k + max as isize) as usize];
192
193            if x == prev_x {
194                // Insert
195                y -= 1;
196                ops.push(DiffOp::Insert(new[y]));
197            } else {
198                // Delete
199                x -= 1;
200                ops.push(DiffOp::Delete(old[x]));
201            }
202        }
203    }
204
205    ops.reverse();
206    ops
207}
208
209/// Convert diff operations into hunks with context lines
210pub fn ops_to_hunks(ops: &[DiffOp], context: usize) -> Vec<DiffHunk> {
211    if ops.is_empty() {
212        return vec![];
213    }
214
215    // First, convert ops to annotated lines with positions
216    let mut lines: Vec<(usize, usize, DiffLineKind, String)> = Vec::new();
217    let mut old_line = 0usize;
218    let mut new_line = 0usize;
219
220    for op in ops {
221        match op {
222            DiffOp::Equal(s) => {
223                lines.push((old_line, new_line, DiffLineKind::Context, s.to_string()));
224                old_line += 1;
225                new_line += 1;
226            }
227            DiffOp::Delete(s) => {
228                lines.push((old_line, new_line, DiffLineKind::Remove, s.to_string()));
229                old_line += 1;
230            }
231            DiffOp::Insert(s) => {
232                lines.push((old_line, new_line, DiffLineKind::Add, s.to_string()));
233                new_line += 1;
234            }
235        }
236    }
237
238    // Find change regions and group into hunks with context
239    let change_indices: Vec<usize> = lines
240        .iter()
241        .enumerate()
242        .filter(|(_, (_, _, kind, _))| *kind != DiffLineKind::Context)
243        .map(|(i, _)| i)
244        .collect();
245
246    if change_indices.is_empty() {
247        return vec![];
248    }
249
250    // Group changes that are within `context * 2` lines of each other
251    let mut groups: Vec<(usize, usize)> = Vec::new();
252    let mut group_start = change_indices[0];
253    let mut group_end = change_indices[0];
254
255    for &idx in &change_indices[1..] {
256        if idx <= group_end + context * 2 + 1 {
257            group_end = idx;
258        } else {
259            groups.push((group_start, group_end));
260            group_start = idx;
261            group_end = idx;
262        }
263    }
264    groups.push((group_start, group_end));
265
266    // Build hunks from groups
267    let mut hunks = Vec::new();
268    for (start, end) in groups {
269        let hunk_start = start.saturating_sub(context);
270        let hunk_end = (end + context + 1).min(lines.len());
271
272        let mut hunk_lines = Vec::new();
273        let mut old_start = 0;
274        let mut new_start = 0;
275        let mut old_count = 0;
276        let mut new_count = 0;
277        let mut first = true;
278
279        for line in lines.iter().take(hunk_end).skip(hunk_start) {
280            let (ol, nl, kind, content) = line;
281            if first {
282                old_start = *ol;
283                new_start = *nl;
284                first = false;
285            }
286            match kind {
287                DiffLineKind::Context => {
288                    old_count += 1;
289                    new_count += 1;
290                }
291                DiffLineKind::Add => {
292                    new_count += 1;
293                }
294                DiffLineKind::Remove => {
295                    old_count += 1;
296                }
297            }
298            hunk_lines.push(DiffLine {
299                kind: *kind,
300                content: content.clone(),
301            });
302        }
303
304        hunks.push(DiffHunk {
305            old_start: old_start + 1, // 1-indexed
306            old_count,
307            new_start: new_start + 1, // 1-indexed
308            new_count,
309            lines: hunk_lines,
310        });
311    }
312
313    hunks
314}
315
316/// Diff two text strings, returning structured hunks
317pub fn diff_text(old: &str, new: &str) -> Vec<DiffHunk> {
318    let old_lines: Vec<&str> = old.lines().collect();
319    let new_lines: Vec<&str> = new.lines().collect();
320    let ops = myers_diff(&old_lines, &new_lines);
321    ops_to_hunks(&ops, CONTEXT_LINES)
322}
323
324/// Diff two blobs, returning a FileDiff
325pub fn diff_blobs(
326    path: &str,
327    old_content: Option<&[u8]>,
328    new_content: Option<&[u8]>,
329    old_hash: Option<String>,
330    new_hash: Option<String>,
331) -> FileDiff {
332    let status = match (old_content, new_content) {
333        (None, Some(_)) => FileStatus::Added,
334        (Some(_), None) => FileStatus::Deleted,
335        _ => FileStatus::Modified,
336    };
337
338    // Check if either side is binary
339    let old_text = old_content.and_then(|c| std::str::from_utf8(c).ok());
340    let new_text = new_content.and_then(|c| std::str::from_utf8(c).ok());
341
342    let is_binary = (old_content.is_some() && old_text.is_none())
343        || (new_content.is_some() && new_text.is_none());
344
345    if is_binary {
346        return FileDiff {
347            path: path.to_string(),
348            status,
349            hunks: vec![],
350            old_hash,
351            new_hash,
352            is_binary: true,
353            additions: 0,
354            deletions: 0,
355        };
356    }
357
358    let old_str = old_text.unwrap_or("");
359    let new_str = new_text.unwrap_or("");
360
361    let hunks = diff_text(old_str, new_str);
362
363    let mut additions = 0;
364    let mut deletions = 0;
365    for hunk in &hunks {
366        for line in &hunk.lines {
367            match line.kind {
368                DiffLineKind::Add => additions += 1,
369                DiffLineKind::Remove => deletions += 1,
370                DiffLineKind::Context => {}
371            }
372        }
373    }
374
375    FileDiff {
376        path: path.to_string(),
377        status,
378        hunks,
379        old_hash,
380        new_hash,
381        is_binary: false,
382        additions,
383        deletions,
384    }
385}
386
387/// Collect all file paths from a tree recursively
388pub fn collect_tree_files(
389    tree: &crate::core::Tree,
390    store: &crate::storage::ObjectStore,
391    prefix: &str,
392) -> Result<Vec<(String, crate::core::ObjectHash)>, String> {
393    let mut files = Vec::new();
394
395    for entry in &tree.entries {
396        let path = if prefix.is_empty() {
397            entry.name.clone()
398        } else {
399            format!("{}/{}", prefix, entry.name)
400        };
401
402        match entry.object_type.as_str() {
403            "blob" => {
404                files.push((path, entry.hash.clone()));
405            }
406            "tree" => {
407                let subtree = match store.read(&entry.hash)? {
408                    crate::core::Object::Tree(t) => t,
409                    _ => return Err(format!("Expected tree at {}", path)),
410                };
411                files.extend(collect_tree_files(&subtree, store, &path)?);
412            }
413            _ => {}
414        }
415    }
416
417    Ok(files)
418}
419
420/// Diff two trees by their commit hashes, returning per-file diffs
421pub fn diff_trees(
422    old_tree: &crate::core::Tree,
423    new_tree: &crate::core::Tree,
424    store: &crate::storage::ObjectStore,
425) -> Result<Vec<FileDiff>, String> {
426    let old_files = collect_tree_files(old_tree, store, "")?;
427    let new_files = collect_tree_files(new_tree, store, "")?;
428
429    let mut old_map: std::collections::HashMap<String, crate::core::ObjectHash> =
430        old_files.into_iter().collect();
431    let new_map: std::collections::HashMap<String, crate::core::ObjectHash> =
432        new_files.into_iter().collect();
433
434    let mut diffs = Vec::new();
435
436    // Files in new tree
437    for (path, new_hash) in &new_map {
438        if let Some(old_hash) = old_map.remove(path) {
439            // File exists in both — check if modified
440            if old_hash != *new_hash {
441                let old_blob = read_blob(store, &old_hash)?;
442                let new_blob = read_blob(store, new_hash)?;
443                diffs.push(diff_blobs(
444                    path,
445                    Some(&old_blob),
446                    Some(&new_blob),
447                    Some(old_hash.to_string()),
448                    Some(new_hash.to_string()),
449                ));
450            }
451        } else {
452            // File only in new tree — added
453            let new_blob = read_blob(store, new_hash)?;
454            diffs.push(diff_blobs(
455                path,
456                None,
457                Some(&new_blob),
458                None,
459                Some(new_hash.to_string()),
460            ));
461        }
462    }
463
464    // Files only in old tree — deleted
465    for (path, old_hash) in &old_map {
466        let old_blob = read_blob(store, old_hash)?;
467        diffs.push(diff_blobs(
468            path,
469            Some(&old_blob),
470            None,
471            Some(old_hash.to_string()),
472            None,
473        ));
474    }
475
476    // Sort by path for deterministic output
477    diffs.sort_by(|a, b| a.path.cmp(&b.path));
478
479    Ok(diffs)
480}
481
482fn read_blob(
483    store: &crate::storage::ObjectStore,
484    hash: &crate::core::ObjectHash,
485) -> Result<Vec<u8>, String> {
486    match store.read(hash)? {
487        crate::core::Object::Blob(b) => Ok(b.content),
488        _ => Err(format!("Expected blob object for hash {}", hash)),
489    }
490}
491
492/// A segment of a word-level diff within a single line
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct WordDiffSegment {
495    pub kind: DiffLineKind,
496    pub text: String,
497}
498
499/// Tokenize a line into words and whitespace, preserving all characters
500fn tokenize_words(line: &str) -> Vec<&str> {
501    let mut tokens = Vec::new();
502    let mut chars = line.char_indices().peekable();
503    while let Some(&(start, ch)) = chars.peek() {
504        if ch.is_alphanumeric() || ch == '_' {
505            // Word token
506            let mut end = start;
507            while let Some(&(i, c)) = chars.peek() {
508                if c.is_alphanumeric() || c == '_' {
509                    end = i + c.len_utf8();
510                    chars.next();
511                } else {
512                    break;
513                }
514            }
515            tokens.push(&line[start..end]);
516        } else {
517            // Non-word token (punctuation, whitespace, etc.) — each char is its own token
518            tokens.push(&line[start..start + ch.len_utf8()]);
519            chars.next();
520        }
521    }
522    tokens
523}
524
525/// Compute word-level diff between two lines that were changed
526///
527/// Returns a sequence of segments marking equal, added, and removed words.
528pub fn word_diff_line(old_line: &str, new_line: &str) -> Vec<WordDiffSegment> {
529    let old_tokens = tokenize_words(old_line);
530    let new_tokens = tokenize_words(new_line);
531
532    let ops = myers_diff(&old_tokens, &new_tokens);
533    let mut segments: Vec<WordDiffSegment> = Vec::new();
534
535    for op in &ops {
536        let (kind, text) = match op {
537            DiffOp::Equal(s) => (DiffLineKind::Context, *s),
538            DiffOp::Delete(s) => (DiffLineKind::Remove, *s),
539            DiffOp::Insert(s) => (DiffLineKind::Add, *s),
540        };
541        // Merge adjacent segments of the same kind
542        if let Some(last) = segments.last_mut() {
543            if last.kind == kind {
544                last.text.push_str(text);
545                continue;
546            }
547        }
548        segments.push(WordDiffSegment {
549            kind,
550            text: text.to_string(),
551        });
552    }
553    segments
554}
555
556/// Produce word-level diff segments for paired Remove/Add lines in a hunk
557///
558/// Returns a new list of DiffLines where consecutive Remove/Add pairs
559/// are annotated with `word_segments`.
560pub fn annotate_hunk_word_diff(hunk: &DiffHunk) -> Vec<(DiffLine, Option<Vec<WordDiffSegment>>)> {
561    let mut result: Vec<(DiffLine, Option<Vec<WordDiffSegment>>)> = Vec::new();
562    let lines = &hunk.lines;
563    let mut i = 0;
564
565    while i < lines.len() {
566        if lines[i].kind == DiffLineKind::Remove {
567            // Collect consecutive removes
568            let remove_start = i;
569            while i < lines.len() && lines[i].kind == DiffLineKind::Remove {
570                i += 1;
571            }
572            let remove_end = i;
573            // Collect consecutive adds
574            let add_start = i;
575            while i < lines.len() && lines[i].kind == DiffLineKind::Add {
576                i += 1;
577            }
578            let add_end = i;
579
580            let removes = &lines[remove_start..remove_end];
581            let adds = &lines[add_start..add_end];
582            let pairs = removes.len().min(adds.len());
583
584            // Pair up removes and adds for word-level diff
585            for j in 0..pairs {
586                let segs = word_diff_line(&removes[j].content, &adds[j].content);
587                result.push((removes[j].clone(), Some(segs.clone())));
588                result.push((adds[j].clone(), Some(segs)));
589            }
590            // Remaining unpaired removes
591            for remove in removes.iter().skip(pairs) {
592                result.push((remove.clone(), None));
593            }
594            // Remaining unpaired adds
595            for add in adds.iter().skip(pairs) {
596                result.push((add.clone(), None));
597            }
598        } else {
599            result.push((lines[i].clone(), None));
600            i += 1;
601        }
602    }
603    result
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn test_empty_diff() {
612        let hunks = diff_text("", "");
613        assert!(hunks.is_empty());
614    }
615
616    #[test]
617    fn test_add_lines() {
618        let hunks = diff_text("", "hello\nworld\n");
619        assert_eq!(hunks.len(), 1);
620        assert_eq!(hunks[0].lines.len(), 2);
621        assert!(hunks[0].lines.iter().all(|l| l.kind == DiffLineKind::Add));
622    }
623
624    #[test]
625    fn test_delete_lines() {
626        let hunks = diff_text("hello\nworld\n", "");
627        assert_eq!(hunks.len(), 1);
628        assert!(hunks[0]
629            .lines
630            .iter()
631            .all(|l| l.kind == DiffLineKind::Remove));
632    }
633
634    #[test]
635    fn test_modify_line() {
636        let hunks = diff_text("hello\nworld\n", "hello\nearth\n");
637        assert_eq!(hunks.len(), 1);
638        let changes: Vec<_> = hunks[0]
639            .lines
640            .iter()
641            .filter(|l| l.kind != DiffLineKind::Context)
642            .collect();
643        assert_eq!(changes.len(), 2); // one remove, one add
644    }
645
646    #[test]
647    fn test_identical_text() {
648        let hunks = diff_text("hello\nworld\n", "hello\nworld\n");
649        assert!(hunks.is_empty());
650    }
651
652    #[test]
653    fn test_additions_count() {
654        let diff = diff_blobs("test.txt", Some(b"a\nb\n"), Some(b"a\nb\nc\n"), None, None);
655        assert_eq!(diff.additions, 1);
656        assert_eq!(diff.deletions, 0);
657        assert_eq!(diff.status, FileStatus::Modified);
658    }
659
660    #[test]
661    fn test_new_file_status() {
662        let diff = diff_blobs("test.txt", None, Some(b"hello\n"), None, None);
663        assert_eq!(diff.status, FileStatus::Added);
664    }
665
666    #[test]
667    fn test_deleted_file_status() {
668        let diff = diff_blobs("test.txt", Some(b"hello\n"), None, None, None);
669        assert_eq!(diff.status, FileStatus::Deleted);
670    }
671
672    #[test]
673    fn test_binary_detection() {
674        let diff = diff_blobs("img.png", Some(b"\x89PNG\r\n\x1a\n\x00"), None, None, None);
675        assert!(diff.is_binary);
676    }
677
678    #[test]
679    fn test_multi_hunk() {
680        let old = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n";
681        let new = "1\n2\n3\n4\n5\n6\n7\nEIGHT\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nTWENTY\n";
682        let hunks = diff_text(old, new);
683        // Should produce 2 separate hunks since changes are far apart
684        assert!(hunks.len() >= 2);
685    }
686
687    #[test]
688    fn test_tokenize_words() {
689        let tokens = tokenize_words("hello world_foo + bar");
690        assert_eq!(
691            tokens,
692            vec!["hello", " ", "world_foo", " ", "+", " ", "bar"]
693        );
694    }
695
696    #[test]
697    fn test_word_diff_simple() {
698        let segs = word_diff_line("the quick brown fox", "the slow brown fox");
699        // "the " = equal, "quick" = remove, "slow" = insert, " brown fox" = equal
700        let kinds: Vec<_> = segs.iter().map(|s| s.kind).collect();
701        assert!(kinds.contains(&DiffLineKind::Remove));
702        assert!(kinds.contains(&DiffLineKind::Add));
703        assert!(kinds.contains(&DiffLineKind::Context));
704    }
705
706    #[test]
707    fn test_word_diff_identical() {
708        let segs = word_diff_line("no change", "no change");
709        assert!(segs.iter().all(|s| s.kind == DiffLineKind::Context));
710    }
711}