Skip to main content

chronicle/git/
diff.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::git_error::DiffParseSnafu;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "snake_case")]
7pub enum DiffStatus {
8    Added,
9    Modified,
10    Deleted,
11    Renamed,
12}
13
14#[derive(Debug, Clone)]
15pub struct FileDiff {
16    pub path: String,
17    pub old_path: Option<String>,
18    pub status: DiffStatus,
19    pub hunks: Vec<Hunk>,
20}
21
22#[derive(Debug, Clone)]
23pub struct Hunk {
24    pub old_start: u32,
25    pub old_count: u32,
26    pub new_start: u32,
27    pub new_count: u32,
28    pub header: String,
29    pub lines: Vec<HunkLine>,
30}
31
32#[derive(Debug, Clone)]
33pub enum HunkLine {
34    Context(String),
35    Added(String),
36    Removed(String),
37}
38
39impl FileDiff {
40    pub fn added_line_count(&self) -> usize {
41        self.hunks
42            .iter()
43            .flat_map(|h| &h.lines)
44            .filter(|l| matches!(l, HunkLine::Added(_)))
45            .count()
46    }
47
48    pub fn removed_line_count(&self) -> usize {
49        self.hunks
50            .iter()
51            .flat_map(|h| &h.lines)
52            .filter(|l| matches!(l, HunkLine::Removed(_)))
53            .count()
54    }
55
56    pub fn changed_line_count(&self) -> usize {
57        self.added_line_count() + self.removed_line_count()
58    }
59}
60
61/// Parse unified diff output into structured FileDiff objects.
62pub fn parse_diff(diff_output: &str) -> Result<Vec<FileDiff>, crate::error::GitError> {
63    let mut files: Vec<FileDiff> = Vec::new();
64    let lines: Vec<&str> = diff_output.lines().collect();
65    let mut i = 0;
66
67    while i < lines.len() {
68        let line = lines[i];
69
70        // Look for "diff --git a/... b/..."
71        if !line.starts_with("diff --git ") {
72            i += 1;
73            continue;
74        }
75
76        // Parse the file paths from the diff header
77        let (a_path, b_path) = parse_diff_header(line)?;
78
79        let mut status = DiffStatus::Modified;
80        let mut old_path: Option<String> = None;
81        let mut new_path = b_path.clone();
82        i += 1;
83
84        // Parse extended headers (new file, deleted file, rename, etc.)
85        while i < lines.len()
86            && !lines[i].starts_with("diff --git ")
87            && !lines[i].starts_with("@@")
88            && !lines[i].starts_with("--- ")
89        {
90            let hdr = lines[i];
91            if hdr.starts_with("new file mode") {
92                status = DiffStatus::Added;
93            } else if hdr.starts_with("deleted file mode") {
94                status = DiffStatus::Deleted;
95            } else if hdr.starts_with("rename from ") {
96                old_path = Some(hdr.trim_start_matches("rename from ").to_string());
97                status = DiffStatus::Renamed;
98            } else if hdr.starts_with("rename to ") {
99                new_path = hdr.trim_start_matches("rename to ").to_string();
100            }
101            i += 1;
102        }
103
104        // Parse --- and +++ lines
105        if i < lines.len() && lines[i].starts_with("--- ") {
106            let minus_path = &lines[i][4..];
107            if minus_path == "/dev/null" {
108                status = DiffStatus::Added;
109            }
110            i += 1;
111        }
112        if i < lines.len() && lines[i].starts_with("+++ ") {
113            let plus_path = &lines[i][4..];
114            if plus_path == "/dev/null" {
115                status = DiffStatus::Deleted;
116            }
117            i += 1;
118        }
119
120        // Parse hunks
121        let mut hunks: Vec<Hunk> = Vec::new();
122        while i < lines.len() && !lines[i].starts_with("diff --git ") {
123            if lines[i].starts_with("@@") {
124                let hunk = parse_hunk_header(lines[i])?;
125                let header = lines[i].to_string();
126                let mut hunk_lines: Vec<HunkLine> = Vec::new();
127                i += 1;
128
129                while i < lines.len()
130                    && !lines[i].starts_with("@@")
131                    && !lines[i].starts_with("diff --git ")
132                {
133                    let l = lines[i];
134                    if let Some(content) = l.strip_prefix('+') {
135                        hunk_lines.push(HunkLine::Added(content.to_string()));
136                    } else if let Some(content) = l.strip_prefix('-') {
137                        hunk_lines.push(HunkLine::Removed(content.to_string()));
138                    } else if let Some(content) = l.strip_prefix(' ') {
139                        hunk_lines.push(HunkLine::Context(content.to_string()));
140                    } else if l == "\\ No newline at end of file" {
141                        // skip
142                    } else if l.is_empty() {
143                        // empty context line (git sometimes omits the leading space)
144                        hunk_lines.push(HunkLine::Context(String::new()));
145                    } else {
146                        // unknown line, skip
147                    }
148                    i += 1;
149                }
150
151                hunks.push(Hunk {
152                    old_start: hunk.0,
153                    old_count: hunk.1,
154                    new_start: hunk.2,
155                    new_count: hunk.3,
156                    header,
157                    lines: hunk_lines,
158                });
159            } else {
160                i += 1;
161            }
162        }
163
164        // Use the appropriate path
165        let final_path = if status == DiffStatus::Renamed {
166            new_path
167        } else {
168            // For non-rename, prefer b_path, but fall back to a_path for deletions
169            if status == DiffStatus::Deleted {
170                a_path
171            } else {
172                b_path
173            }
174        };
175
176        files.push(FileDiff {
177            path: final_path,
178            old_path,
179            status,
180            hunks,
181        });
182    }
183
184    Ok(files)
185}
186
187/// Parse "diff --git a/path b/path" header.
188/// Returns (a_path, b_path) with the a/ and b/ prefixes stripped.
189fn parse_diff_header(line: &str) -> Result<(String, String), crate::error::GitError> {
190    // Format: "diff --git a/<path> b/<path>"
191    // Tricky: paths can contain spaces. We rely on "a/" and "b/" prefixes.
192    let rest = line.strip_prefix("diff --git ").ok_or_else(|| {
193        DiffParseSnafu {
194            message: format!("invalid diff header: {line}"),
195        }
196        .build()
197    })?;
198
199    // Find " b/" to split - search from the right side of a/ prefix
200    if let Some(a_rest) = rest.strip_prefix("a/") {
201        // Find " b/" separator
202        if let Some(sep_pos) = a_rest.find(" b/") {
203            let a_path = &a_rest[..sep_pos];
204            let b_path = &a_rest[sep_pos + 3..];
205            return Ok((a_path.to_string(), b_path.to_string()));
206        }
207    }
208
209    // Fallback for unusual formats (e.g., no prefix)
210    let parts: Vec<&str> = rest.splitn(2, ' ').collect();
211    if parts.len() == 2 {
212        let a = parts[0].strip_prefix("a/").unwrap_or(parts[0]);
213        let b = parts[1].strip_prefix("b/").unwrap_or(parts[1]);
214        Ok((a.to_string(), b.to_string()))
215    } else {
216        Err(DiffParseSnafu {
217            message: format!("cannot parse diff header: {line}"),
218        }
219        .build())
220    }
221}
222
223/// Parse "@@ -old_start,old_count +new_start,new_count @@" header.
224/// Returns (old_start, old_count, new_start, new_count).
225fn parse_hunk_header(line: &str) -> Result<(u32, u32, u32, u32), crate::error::GitError> {
226    // Format: "@@ -1,5 +1,7 @@ optional header text"
227    let at_end = line.find(" @@").ok_or_else(|| {
228        DiffParseSnafu {
229            message: format!("invalid hunk header: {line}"),
230        }
231        .build()
232    })?;
233    let range_part = &line[3..at_end]; // skip "@@ "
234    let parts: Vec<&str> = range_part.split(' ').collect();
235    if parts.len() < 2 {
236        return Err(DiffParseSnafu {
237            message: format!("invalid hunk header ranges: {line}"),
238        }
239        .build());
240    }
241
242    let (old_start, old_count) = parse_range(parts[0].trim_start_matches('-'))?;
243    let (new_start, new_count) = parse_range(parts[1].trim_start_matches('+'))?;
244
245    Ok((old_start, old_count, new_start, new_count))
246}
247
248/// Parse "start,count" or just "start" (count defaults to 1).
249fn parse_range(s: &str) -> Result<(u32, u32), crate::error::GitError> {
250    if let Some((start_s, count_s)) = s.split_once(',') {
251        let start: u32 = start_s.parse().map_err(|_| {
252            DiffParseSnafu {
253                message: format!("invalid range number: {s}"),
254            }
255            .build()
256        })?;
257        let count: u32 = count_s.parse().map_err(|_| {
258            DiffParseSnafu {
259                message: format!("invalid range number: {s}"),
260            }
261            .build()
262        })?;
263        Ok((start, count))
264    } else {
265        let start: u32 = s.parse().map_err(|_| {
266            DiffParseSnafu {
267                message: format!("invalid range number: {s}"),
268            }
269            .build()
270        })?;
271        Ok((start, 1))
272    }
273}