Skip to main content

sparrow/tui/formatters/
diff.rs

1// ─── Diff formatting ──────────────────────────────────────────────────────────
2// Parses unified diff format and emits ANSI-coloured terminal output.
3// + lines are green, - lines are red, @@ headers are cyan, file headers are bold.
4
5/// A single line in a diff hunk with its kind.
6#[derive(Debug, Clone)]
7pub enum DiffLineKind {
8    Context,
9    Plus,
10    Minus,
11    Hunk,
12    FileHeader,
13}
14
15/// One line of a diff.
16#[derive(Debug, Clone)]
17pub struct DiffLine {
18    pub kind: DiffLineKind,
19    pub text: String,
20}
21
22/// A parsed diff with optional file header and hunks.
23#[derive(Debug, Clone)]
24pub struct ParsedDiff {
25    pub file_header: Option<String>,
26    pub lines: Vec<DiffLine>,
27    pub plus_count: u32,
28    pub minus_count: u32,
29}
30
31/// ANSI colour constants for diff rendering.
32pub struct DiffColors {
33    pub plus: &'static str,
34    pub minus: &'static str,
35    pub hunk: &'static str,
36    pub file: &'static str,
37    pub context: &'static str,
38    pub reset: &'static str,
39}
40
41impl Default for DiffColors {
42    fn default() -> Self {
43        Self {
44            plus: "\x1b[38;2;116;194;88m",   // green
45            minus: "\x1b[38;2;217;106;99m",  // red
46            hunk: "\x1b[38;2;86;182;194m",   // cyan
47            file: "\x1b[1;38;2;242;169;60m", // bold amber
48            context: "\x1b[38;2;137;125;108m", // dim
49            reset: "\x1b[0m",
50        }
51    }
52}
53
54/// Parse a unified diff string into structured lines.
55///
56/// Handles the standard unified diff format:
57/// ```
58/// diff --git a/file b/file
59/// --- a/file
60/// +++ b/file
61/// @@ -1,3 +1,4 @@
62///  context
63/// -removed
64/// +added
65/// ```
66pub fn parse(diff: &str) -> ParsedDiff {
67    let mut lines = Vec::new();
68    let mut file_header: Option<String> = None;
69    let mut plus_count: u32 = 0;
70    let mut minus_count: u32 = 0;
71
72    for raw in diff.lines() {
73        let line = raw.to_string();
74
75        if raw.starts_with("diff --git") || raw.starts_with("--- ") || raw.starts_with("+++ ") {
76            if file_header.is_none() && (raw.starts_with("diff --git") || raw.starts_with("--- ")) {
77                // Extract filename
78                let fname = if raw.starts_with("diff --git") {
79                    raw.strip_prefix("diff --git a/")
80                        .and_then(|s| s.split_whitespace().next())
81                        .unwrap_or(raw)
82                } else if raw.starts_with("--- ") {
83                    raw.strip_prefix("--- a/")
84                        .or_else(|| raw.strip_prefix("--- "))
85                        .unwrap_or(raw)
86                } else {
87                    raw
88                };
89                file_header = Some(fname.to_string());
90            }
91            lines.push(DiffLine {
92                kind: DiffLineKind::FileHeader,
93                text: line,
94            });
95        } else if raw.starts_with("@@") {
96            lines.push(DiffLine {
97                kind: DiffLineKind::Hunk,
98                text: line,
99            });
100        } else if raw.starts_with('+') && !raw.starts_with("+++") {
101            plus_count += 1;
102            lines.push(DiffLine {
103                kind: DiffLineKind::Plus,
104                text: line,
105            });
106        } else if raw.starts_with('-') && !raw.starts_with("---") {
107            minus_count += 1;
108            lines.push(DiffLine {
109                kind: DiffLineKind::Minus,
110                text: line,
111            });
112        } else {
113            lines.push(DiffLine {
114                kind: DiffLineKind::Context,
115                text: line,
116            });
117        }
118    }
119
120    ParsedDiff {
121        file_header,
122        lines,
123        plus_count,
124        minus_count,
125    }
126}
127
128/// Render a parsed diff to ANSI-coloured terminal text.
129pub fn render(parsed: &ParsedDiff, colors: &DiffColors) -> String {
130    let mut out = String::new();
131
132    // Summary line
133    if let Some(ref file) = parsed.file_header {
134        out.push_str(&format!(
135            "{file}📄 {file}{reset}  ",
136            file = colors.file,
137            reset = colors.reset,
138        ));
139    }
140    out.push_str(&format!(
141        "{plus}+{}{reset}  {minus}-{}{reset}\n",
142        parsed.plus_count,
143        parsed.minus_count,
144        plus = colors.plus,
145        minus = colors.minus,
146        reset = colors.reset
147    ));
148
149    // Divider
150    out.push_str(&format!(
151        "{dim}─────────────────────────────────────────────{reset}\n",
152        dim = colors.context,
153        reset = colors.reset
154    ));
155
156    for entry in &parsed.lines {
157        let (prefix, reset) = match entry.kind {
158            DiffLineKind::FileHeader => (colors.file, colors.reset),
159            DiffLineKind::Hunk => (colors.hunk, colors.reset),
160            DiffLineKind::Plus => (colors.plus, colors.reset),
161            DiffLineKind::Minus => (colors.minus, colors.reset),
162            DiffLineKind::Context => (colors.context, colors.reset),
163        };
164        out.push_str(&format!("{prefix}{text}{reset}\n", text = entry.text));
165    }
166
167    out
168}
169
170/// One-shot: parse a unified diff string and render it with colours.
171pub fn format_diff(diff: &str) -> String {
172    let colors = DiffColors::default();
173    let parsed = parse(diff);
174    render(&parsed, &colors)
175}