gitent_core/
diff.rs

1use crate::error::Result;
2use crate::models::Change;
3use similar::{ChangeTag, TextDiff};
4
5#[derive(Debug, Clone)]
6pub struct FileDiff {
7    pub path: String,
8    pub old_content: Option<String>,
9    pub new_content: Option<String>,
10    pub diff_lines: Vec<DiffLine>,
11}
12
13#[derive(Debug, Clone)]
14pub struct DiffLine {
15    pub line_type: DiffLineType,
16    pub content: String,
17    pub old_line_number: Option<usize>,
18    pub new_line_number: Option<usize>,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DiffLineType {
23    Context,
24    Addition,
25    Deletion,
26}
27
28impl FileDiff {
29    pub fn from_change(change: &Change) -> Result<Self> {
30        let old_content = change
31            .content_before
32            .as_ref()
33            .and_then(|bytes| String::from_utf8(bytes.clone()).ok());
34
35        let new_content = change
36            .content_after
37            .as_ref()
38            .and_then(|bytes| String::from_utf8(bytes.clone()).ok());
39
40        let diff_lines = if let (Some(old), Some(new)) = (&old_content, &new_content) {
41            Self::compute_diff(old, new)
42        } else {
43            Vec::new()
44        };
45
46        Ok(FileDiff {
47            path: change.path.to_string_lossy().to_string(),
48            old_content,
49            new_content,
50            diff_lines,
51        })
52    }
53
54    fn compute_diff(old_text: &str, new_text: &str) -> Vec<DiffLine> {
55        let diff = TextDiff::from_lines(old_text, new_text);
56        let mut lines = Vec::new();
57        let mut old_line_num = 1;
58        let mut new_line_num = 1;
59
60        for change in diff.iter_all_changes() {
61            let (line_type, old_num, new_num) = match change.tag() {
62                ChangeTag::Delete => {
63                    let num = old_line_num;
64                    old_line_num += 1;
65                    (DiffLineType::Deletion, Some(num), None)
66                }
67                ChangeTag::Insert => {
68                    let num = new_line_num;
69                    new_line_num += 1;
70                    (DiffLineType::Addition, None, Some(num))
71                }
72                ChangeTag::Equal => {
73                    let old_num = old_line_num;
74                    let new_num = new_line_num;
75                    old_line_num += 1;
76                    new_line_num += 1;
77                    (DiffLineType::Context, Some(old_num), Some(new_num))
78                }
79            };
80
81            lines.push(DiffLine {
82                line_type,
83                content: change.to_string(),
84                old_line_number: old_num,
85                new_line_number: new_num,
86            });
87        }
88
89        lines
90    }
91
92    pub fn format_unified(&self, context_lines: usize) -> String {
93        let mut output = String::new();
94
95        output.push_str(&format!("--- {}\n", self.path));
96        output.push_str(&format!("+++ {}\n", self.path));
97
98        let mut in_hunk = false;
99        let mut hunk_start = 0;
100        let mut hunk_lines = Vec::new();
101
102        for (i, line) in self.diff_lines.iter().enumerate() {
103            if line.line_type != DiffLineType::Context || in_hunk {
104                if !in_hunk {
105                    in_hunk = true;
106                    hunk_start = i.saturating_sub(context_lines);
107                }
108
109                let prefix = match line.line_type {
110                    DiffLineType::Addition => "+",
111                    DiffLineType::Deletion => "-",
112                    DiffLineType::Context => " ",
113                };
114
115                hunk_lines.push(format!("{}{}", prefix, line.content));
116
117                // Check if we should close the hunk
118                if i + context_lines >= self.diff_lines.len() - 1 {
119                    if !hunk_lines.is_empty() {
120                        output.push_str(&format!(
121                            "@@ -{},{} +{},{} @@\n",
122                            self.diff_lines[hunk_start].old_line_number.unwrap_or(0),
123                            hunk_lines.len(),
124                            self.diff_lines[hunk_start].new_line_number.unwrap_or(0),
125                            hunk_lines.len()
126                        ));
127                        output.push_str(&hunk_lines.join(""));
128                        hunk_lines.clear();
129                    }
130                    in_hunk = false;
131                }
132            }
133        }
134
135        output
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::models::ChangeType;
143    use std::path::PathBuf;
144    use uuid::Uuid;
145
146    #[test]
147    fn test_diff_computation() {
148        let old_text = "line 1\nline 2\nline 3\n";
149        let new_text = "line 1\nline 2 modified\nline 3\nline 4\n";
150
151        let diff_lines = FileDiff::compute_diff(old_text, new_text);
152
153        assert!(!diff_lines.is_empty());
154        assert!(diff_lines
155            .iter()
156            .any(|l| l.line_type == DiffLineType::Addition));
157        assert!(diff_lines
158            .iter()
159            .any(|l| l.line_type == DiffLineType::Deletion));
160    }
161
162    #[test]
163    fn test_file_diff_from_change() {
164        let session_id = Uuid::new_v4();
165        let change = Change::new(ChangeType::Modify, PathBuf::from("test.txt"), session_id)
166            .with_content_before(b"Hello\nWorld\n".to_vec())
167            .with_content_after(b"Hello\nRust\nWorld\n".to_vec());
168
169        let file_diff = FileDiff::from_change(&change).unwrap();
170
171        assert_eq!(file_diff.path, "test.txt");
172        assert!(file_diff.old_content.is_some());
173        assert!(file_diff.new_content.is_some());
174        assert!(!file_diff.diff_lines.is_empty());
175    }
176}