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
61pub 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 if !line.starts_with("diff --git ") {
72 i += 1;
73 continue;
74 }
75
76 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 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 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 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 } else if l.is_empty() {
143 hunk_lines.push(HunkLine::Context(String::new()));
145 } else {
146 }
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 let final_path = if status == DiffStatus::Renamed {
166 new_path
167 } else {
168 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
187fn parse_diff_header(line: &str) -> Result<(String, String), crate::error::GitError> {
190 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 if let Some(a_rest) = rest.strip_prefix("a/") {
201 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 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
223fn parse_hunk_header(line: &str) -> Result<(u32, u32, u32, u32), crate::error::GitError> {
226 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]; 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
248fn 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}