Skip to main content

whogitit/capture/
diff.rs

1use sha2::{Digest, Sha256};
2use similar::{ChangeTag, TextDiff};
3
4use crate::utils::{hex, DIFF_HASH_BYTES};
5
6/// A hunk of changed lines in a diff
7#[derive(Debug, Clone)]
8pub struct DiffHunk {
9    /// Starting line in the new file (1-indexed)
10    pub new_start: u32,
11    /// Number of lines in the new file
12    pub new_count: u32,
13    /// The actual new lines content
14    pub content: Vec<String>,
15}
16
17impl DiffHunk {
18    /// Compute SHA-256 hash of the content
19    pub fn content_hash(&self) -> String {
20        let mut hasher = Sha256::new();
21        for line in &self.content {
22            hasher.update(line.as_bytes());
23            hasher.update(b"\n");
24        }
25        let result = hasher.finalize();
26        hex::encode(&result[..DIFF_HASH_BYTES])
27    }
28}
29
30/// Result of computing a diff
31#[derive(Debug)]
32pub struct DiffResult {
33    /// Added/modified hunks
34    pub hunks: Vec<DiffHunk>,
35    /// Total lines added
36    pub lines_added: u32,
37    /// Total lines removed
38    pub lines_removed: u32,
39}
40
41/// Compute line-level diff between old and new content
42pub fn compute_diff(old_content: &str, new_content: &str) -> DiffResult {
43    let diff = TextDiff::from_lines(old_content, new_content);
44
45    let mut hunks = Vec::new();
46    let mut lines_added = 0u32;
47    let mut lines_removed = 0u32;
48
49    let mut current_hunk: Option<DiffHunk> = None;
50    let mut new_line_num = 0u32;
51
52    for change in diff.iter_all_changes() {
53        match change.tag() {
54            ChangeTag::Equal => {
55                // Flush current hunk if exists
56                if let Some(hunk) = current_hunk.take() {
57                    if !hunk.content.is_empty() {
58                        hunks.push(hunk);
59                    }
60                }
61                new_line_num += 1;
62            }
63            ChangeTag::Insert => {
64                new_line_num += 1;
65                lines_added += 1;
66
67                let line = change.value().trim_end_matches('\n').to_string();
68
69                if let Some(ref mut hunk) = current_hunk {
70                    // Extend existing hunk if contiguous
71                    if hunk.new_start + hunk.new_count == new_line_num {
72                        hunk.new_count += 1;
73                        hunk.content.push(line);
74                    } else {
75                        // Start new hunk
76                        let old_hunk = current_hunk.take().unwrap();
77                        if !old_hunk.content.is_empty() {
78                            hunks.push(old_hunk);
79                        }
80                        current_hunk = Some(DiffHunk {
81                            new_start: new_line_num,
82                            new_count: 1,
83                            content: vec![line],
84                        });
85                    }
86                } else {
87                    // Start new hunk
88                    current_hunk = Some(DiffHunk {
89                        new_start: new_line_num,
90                        new_count: 1,
91                        content: vec![line],
92                    });
93                }
94            }
95            ChangeTag::Delete => {
96                lines_removed += 1;
97                // Deletions don't advance new line number
98            }
99        }
100    }
101
102    // Flush final hunk
103    if let Some(hunk) = current_hunk {
104        if !hunk.content.is_empty() {
105            hunks.push(hunk);
106        }
107    }
108
109    DiffResult {
110        hunks,
111        lines_added,
112        lines_removed,
113    }
114}
115
116/// Compute diff for a newly created file (all lines are additions)
117pub fn compute_create_diff(content: &str) -> DiffResult {
118    compute_diff("", content)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_simple_addition() {
127        let old = "line1\nline2\n";
128        let new = "line1\nline2\nline3\n";
129
130        let result = compute_diff(old, new);
131
132        assert_eq!(result.lines_added, 1);
133        assert_eq!(result.lines_removed, 0);
134        assert_eq!(result.hunks.len(), 1);
135        assert_eq!(result.hunks[0].new_start, 3);
136        assert_eq!(result.hunks[0].new_count, 1);
137        assert_eq!(result.hunks[0].content, vec!["line3"]);
138    }
139
140    #[test]
141    fn test_insertion_in_middle() {
142        let old = "line1\nline3\n";
143        let new = "line1\nline2\nline3\n";
144
145        let result = compute_diff(old, new);
146
147        assert_eq!(result.lines_added, 1);
148        assert_eq!(result.hunks.len(), 1);
149        assert_eq!(result.hunks[0].new_start, 2);
150    }
151
152    #[test]
153    fn test_multiple_hunks() {
154        let old = "a\nb\nc\nd\ne\n";
155        let new = "a\nX\nb\nc\nY\nd\ne\n";
156
157        let result = compute_diff(old, new);
158
159        assert_eq!(result.lines_added, 2);
160        assert_eq!(result.hunks.len(), 2);
161        assert_eq!(result.hunks[0].new_start, 2);
162        assert_eq!(result.hunks[0].content, vec!["X"]);
163        assert_eq!(result.hunks[1].new_start, 5);
164        assert_eq!(result.hunks[1].content, vec!["Y"]);
165    }
166
167    #[test]
168    fn test_create_diff() {
169        let content = "line1\nline2\nline3\n";
170        let result = compute_create_diff(content);
171
172        assert_eq!(result.lines_added, 3);
173        assert_eq!(result.hunks.len(), 1);
174        assert_eq!(result.hunks[0].new_start, 1);
175        assert_eq!(result.hunks[0].new_count, 3);
176    }
177
178    #[test]
179    fn test_content_hash() {
180        let hunk = DiffHunk {
181            new_start: 1,
182            new_count: 2,
183            content: vec!["hello".to_string(), "world".to_string()],
184        };
185
186        let hash = hunk.content_hash();
187        assert_eq!(hash.len(), 16); // 8 bytes = 16 hex chars
188
189        // Same content should produce same hash
190        let hunk2 = DiffHunk {
191            new_start: 100,
192            new_count: 2,
193            content: vec!["hello".to_string(), "world".to_string()],
194        };
195        assert_eq!(hunk.content_hash(), hunk2.content_hash());
196    }
197
198    #[test]
199    fn test_replacement() {
200        let old = "old line\n";
201        let new = "new line\n";
202
203        let result = compute_diff(old, new);
204
205        assert_eq!(result.lines_added, 1);
206        assert_eq!(result.lines_removed, 1);
207        assert_eq!(result.hunks.len(), 1);
208        assert_eq!(result.hunks[0].content, vec!["new line"]);
209    }
210}