1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4#[serde(rename_all = "snake_case")]
5pub enum DiffLineKind {
6 Add,
7 Delete,
8 Context,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct DiffLine {
13 pub kind: DiffLineKind,
14 pub content: String,
15 pub new_line_number: Option<u32>,
16 pub old_line_number: Option<u32>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct DiffHunk {
21 pub old_start: u32,
22 pub old_count: u32,
23 pub new_start: u32,
24 pub new_count: u32,
25 pub lines: Vec<DiffLine>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct FileDiff {
30 pub path: String,
31 pub old_path: Option<String>,
32 pub hunks: Vec<DiffHunk>,
33}
34
35pub fn parse_unified_diff(raw: &str) -> Vec<FileDiff> {
37 let mut files: Vec<FileDiff> = Vec::new();
38 let lines: Vec<&str> = raw.lines().collect();
39 let mut i = 0;
40
41 while i < lines.len() {
42 if !lines[i].starts_with("diff --git ") {
43 i += 1;
44 continue;
45 }
46
47 let diff_line = lines[i];
48 let (a_path, b_path) = parse_diff_header(diff_line);
49 i += 1;
50
51 let mut old_path: Option<String> = None;
52 let mut new_path = b_path.clone();
53
54 while i < lines.len()
55 && !lines[i].starts_with("diff --git ")
56 && !lines[i].starts_with("@@ ")
57 {
58 if let Some(stripped) = lines[i].strip_prefix("rename from ") {
59 old_path = Some(stripped.to_string());
60 }
61 if let Some(stripped) = lines[i].strip_prefix("rename to ") {
62 new_path = stripped.to_string();
63 }
64 i += 1;
65 }
66
67 if old_path.is_none() && a_path != b_path {
68 old_path = Some(a_path);
69 }
70
71 let mut hunks: Vec<DiffHunk> = Vec::new();
72
73 while i < lines.len() && !lines[i].starts_with("diff --git ") {
74 if lines[i].starts_with("@@ ") {
75 if let Some(hunk) = parse_hunk(&lines, &mut i) {
76 hunks.push(hunk);
77 } else {
78 i += 1;
79 }
80 } else {
81 i += 1;
82 }
83 }
84
85 files.push(FileDiff {
86 path: new_path,
87 old_path,
88 hunks,
89 });
90 }
91
92 files
93}
94
95fn parse_diff_header(line: &str) -> (String, String) {
96 let rest = line.strip_prefix("diff --git ").unwrap_or("");
97 if let Some(pos) = rest.rfind(" b/") {
98 let a = rest[..pos].strip_prefix("a/").unwrap_or(&rest[..pos]);
99 let b = rest[pos + 1..]
100 .strip_prefix("b/")
101 .unwrap_or(&rest[pos + 1..]);
102 (a.to_string(), b.to_string())
103 } else {
104 let parts: Vec<&str> = rest.splitn(2, ' ').collect();
105 let a = parts
106 .first()
107 .unwrap_or(&"")
108 .strip_prefix("a/")
109 .unwrap_or(parts.first().unwrap_or(&""));
110 let b = parts
111 .get(1)
112 .unwrap_or(&"")
113 .strip_prefix("b/")
114 .unwrap_or(parts.get(1).unwrap_or(&""));
115 (a.to_string(), b.to_string())
116 }
117}
118
119fn parse_hunk(lines: &[&str], i: &mut usize) -> Option<DiffHunk> {
120 let header = lines[*i];
121 let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
122 *i += 1;
123
124 let mut hunk_lines: Vec<DiffLine> = Vec::new();
125 let mut old_line = old_start;
126 let mut new_line = new_start;
127
128 while *i < lines.len() {
129 let line = lines[*i];
130 if line.starts_with("diff --git ") || line.starts_with("@@ ") {
131 break;
132 }
133
134 if let Some(content) = line.strip_prefix('+') {
135 hunk_lines.push(DiffLine {
136 kind: DiffLineKind::Add,
137 content: content.to_string(),
138 new_line_number: Some(new_line),
139 old_line_number: None,
140 });
141 new_line += 1;
142 } else if let Some(content) = line.strip_prefix('-') {
143 hunk_lines.push(DiffLine {
144 kind: DiffLineKind::Delete,
145 content: content.to_string(),
146 new_line_number: None,
147 old_line_number: Some(old_line),
148 });
149 old_line += 1;
150 } else if let Some(content) = line.strip_prefix(' ') {
151 hunk_lines.push(DiffLine {
152 kind: DiffLineKind::Context,
153 content: content.to_string(),
154 new_line_number: Some(new_line),
155 old_line_number: Some(old_line),
156 });
157 old_line += 1;
158 new_line += 1;
159 } else if line == "\\ No newline at end of file" {
160 *i += 1;
161 continue;
162 } else {
163 hunk_lines.push(DiffLine {
164 kind: DiffLineKind::Context,
165 content: line.to_string(),
166 new_line_number: Some(new_line),
167 old_line_number: Some(old_line),
168 });
169 old_line += 1;
170 new_line += 1;
171 }
172
173 *i += 1;
174 }
175
176 Some(DiffHunk {
177 old_start,
178 old_count,
179 new_start,
180 new_count,
181 lines: hunk_lines,
182 })
183}
184
185fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32, u32)> {
186 let inner = header.strip_prefix("@@ ")?;
187 let end = inner.find(" @@")?;
188 let range_str = &inner[..end];
189
190 let parts: Vec<&str> = range_str.split_whitespace().collect();
191 if parts.len() != 2 {
192 return None;
193 }
194
195 let old = parts[0].strip_prefix('-')?;
196 let new = parts[1].strip_prefix('+')?;
197
198 let (old_start, old_count) = parse_range(old);
199 let (new_start, new_count) = parse_range(new);
200
201 Some((old_start, old_count, new_start, new_count))
202}
203
204fn parse_range(s: &str) -> (u32, u32) {
205 if let Some((start, count)) = s.split_once(',') {
206 (start.parse().unwrap_or(0), count.parse().unwrap_or(0))
207 } else {
208 (s.parse().unwrap_or(0), 1)
209 }
210}