Skip to main content

tracevault_core/
diff.rs

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
35/// Parse `git diff` output into structured file diffs.
36pub 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}