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..].strip_prefix("b/").unwrap_or(&rest[pos + 1..]);
100 (a.to_string(), b.to_string())
101 } else {
102 let parts: Vec<&str> = rest.splitn(2, ' ').collect();
103 let a = parts
104 .first()
105 .unwrap_or(&"")
106 .strip_prefix("a/")
107 .unwrap_or(parts.first().unwrap_or(&""));
108 let b = parts
109 .get(1)
110 .unwrap_or(&"")
111 .strip_prefix("b/")
112 .unwrap_or(parts.get(1).unwrap_or(&""));
113 (a.to_string(), b.to_string())
114 }
115}
116
117fn parse_hunk(lines: &[&str], i: &mut usize) -> Option<DiffHunk> {
118 let header = lines[*i];
119 let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
120 *i += 1;
121
122 let mut hunk_lines: Vec<DiffLine> = Vec::new();
123 let mut old_line = old_start;
124 let mut new_line = new_start;
125
126 while *i < lines.len() {
127 let line = lines[*i];
128 if line.starts_with("diff --git ") || line.starts_with("@@ ") {
129 break;
130 }
131
132 if let Some(content) = line.strip_prefix('+') {
133 hunk_lines.push(DiffLine {
134 kind: DiffLineKind::Add,
135 content: content.to_string(),
136 new_line_number: Some(new_line),
137 old_line_number: None,
138 });
139 new_line += 1;
140 } else if let Some(content) = line.strip_prefix('-') {
141 hunk_lines.push(DiffLine {
142 kind: DiffLineKind::Delete,
143 content: content.to_string(),
144 new_line_number: None,
145 old_line_number: Some(old_line),
146 });
147 old_line += 1;
148 } else if let Some(content) = line.strip_prefix(' ') {
149 hunk_lines.push(DiffLine {
150 kind: DiffLineKind::Context,
151 content: content.to_string(),
152 new_line_number: Some(new_line),
153 old_line_number: Some(old_line),
154 });
155 old_line += 1;
156 new_line += 1;
157 } else if line == "\\ No newline at end of file" {
158 *i += 1;
159 continue;
160 } else {
161 hunk_lines.push(DiffLine {
162 kind: DiffLineKind::Context,
163 content: line.to_string(),
164 new_line_number: Some(new_line),
165 old_line_number: Some(old_line),
166 });
167 old_line += 1;
168 new_line += 1;
169 }
170
171 *i += 1;
172 }
173
174 Some(DiffHunk {
175 old_start,
176 old_count,
177 new_start,
178 new_count,
179 lines: hunk_lines,
180 })
181}
182
183fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32, u32)> {
184 let inner = header.strip_prefix("@@ ")?;
185 let end = inner.find(" @@")?;
186 let range_str = &inner[..end];
187
188 let parts: Vec<&str> = range_str.split_whitespace().collect();
189 if parts.len() != 2 {
190 return None;
191 }
192
193 let old = parts[0].strip_prefix('-')?;
194 let new = parts[1].strip_prefix('+')?;
195
196 let (old_start, old_count) = parse_range(old);
197 let (new_start, new_count) = parse_range(new);
198
199 Some((old_start, old_count, new_start, new_count))
200}
201
202fn parse_range(s: &str) -> (u32, u32) {
203 if let Some((start, count)) = s.split_once(',') {
204 (
205 start.parse().unwrap_or(0),
206 count.parse().unwrap_or(0),
207 )
208 } else {
209 (s.parse().unwrap_or(0), 1)
210 }
211}