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..].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}