Skip to main content

tokmd_format/diff/
render.rs

1//! Markdown rendering for diff receipts.
2//!
3//! This module owns human diff table rendering and color formatting. The parent
4//! diff module owns row/totals computation and JSON receipt construction.
5
6use std::fmt::Write as FmtWrite;
7
8use tokmd_types::{DiffRow, DiffTotals};
9
10fn format_delta(delta: i64) -> String {
11    if delta > 0 {
12        format!("+{}", delta)
13    } else {
14        delta.to_string()
15    }
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum DiffColorMode {
20    Off,
21    Ansi,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct DiffRenderOptions {
26    pub compact: bool,
27    pub color: DiffColorMode,
28}
29
30impl Default for DiffRenderOptions {
31    fn default() -> Self {
32        Self {
33            compact: false,
34            color: DiffColorMode::Off,
35        }
36    }
37}
38
39fn format_delta_colored(delta: i64, mode: DiffColorMode) -> String {
40    let raw = format_delta(delta);
41    if mode == DiffColorMode::Off {
42        return raw;
43    }
44    if delta > 0 {
45        format!("\x1b[32m{}\x1b[0m", raw)
46    } else if delta < 0 {
47        format!("\x1b[31m{}\x1b[0m", raw)
48    } else {
49        format!("\x1b[33m{}\x1b[0m", raw)
50    }
51}
52
53fn format_pct_delta_colored(delta_pct: f64, mode: DiffColorMode) -> String {
54    let raw = format!("{:+.1}%", delta_pct);
55    if mode == DiffColorMode::Off {
56        return raw;
57    }
58    if delta_pct > 0.0 {
59        format!("\x1b[32m{}\x1b[0m", raw)
60    } else if delta_pct < 0.0 {
61        format!("\x1b[31m{}\x1b[0m", raw)
62    } else {
63        format!("\x1b[33m{}\x1b[0m", raw)
64    }
65}
66
67fn percent_change(old: usize, new: usize) -> f64 {
68    if old > 0 {
69        ((new as f64 - old as f64) / old as f64) * 100.0
70    } else if new > 0 {
71        100.0
72    } else {
73        0.0
74    }
75}
76
77/// Render diff as Markdown table with optional compact/color behavior.
78pub fn render_diff_md_with_options(
79    from_source: &str,
80    to_source: &str,
81    rows: &[DiffRow],
82    totals: &DiffTotals,
83    options: DiffRenderOptions,
84) -> String {
85    // Heuristic: (rows + 20) * 80 chars per row
86    let mut s = String::with_capacity((rows.len() + 20) * 80);
87
88    let _ = writeln!(s, "## Diff: {} → {}", from_source, to_source);
89    s.push('\n');
90
91    let languages_added = rows
92        .iter()
93        .filter(|r| r.old_code == 0 && r.new_code > 0)
94        .count();
95    let languages_removed = rows
96        .iter()
97        .filter(|r| r.old_code > 0 && r.new_code == 0)
98        .count();
99    let languages_modified = rows
100        .len()
101        .saturating_sub(languages_added + languages_removed);
102
103    if options.compact {
104        s.push_str("### Summary\n\n");
105        s.push_str("|Metric|Value|\n");
106        s.push_str("|---|---:|\n");
107        let _ = writeln!(s, "|From LOC|{}|", totals.old_code);
108        let _ = writeln!(s, "|To LOC|{}|", totals.new_code);
109        let _ = writeln!(
110            s,
111            "|Delta LOC|{}|",
112            format_delta_colored(totals.delta_code, options.color)
113        );
114        let _ = writeln!(
115            s,
116            "|LOC Change|{}|",
117            format_pct_delta_colored(
118                percent_change(totals.old_code, totals.new_code),
119                options.color
120            )
121        );
122        let _ = writeln!(
123            s,
124            "|Delta Lines|{}|",
125            format_delta_colored(totals.delta_lines, options.color)
126        );
127        let _ = writeln!(
128            s,
129            "|Delta Files|{}|",
130            format_delta_colored(totals.delta_files, options.color)
131        );
132        let _ = writeln!(
133            s,
134            "|Delta Bytes|{}|",
135            format_delta_colored(totals.delta_bytes, options.color)
136        );
137        let _ = writeln!(
138            s,
139            "|Delta Tokens|{}|",
140            format_delta_colored(totals.delta_tokens, options.color)
141        );
142        let _ = writeln!(s, "|Languages changed|{}|", rows.len());
143        let _ = writeln!(s, "|Languages added|{}|", languages_added);
144        let _ = writeln!(s, "|Languages removed|{}|", languages_removed);
145        let _ = writeln!(s, "|Languages modified|{}|", languages_modified);
146        return s;
147    }
148
149    // Summary comparison table
150    s.push_str("### Summary\n\n");
151    s.push_str("|Metric|From|To|Delta|Change|\n");
152    s.push_str("|---|---:|---:|---:|---:|\n");
153
154    let _ = writeln!(
155        s,
156        "|LOC|{}|{}|{}|{}|",
157        totals.old_code,
158        totals.new_code,
159        format_delta_colored(totals.delta_code, options.color),
160        format_pct_delta_colored(
161            percent_change(totals.old_code, totals.new_code),
162            options.color
163        )
164    );
165    let _ = writeln!(
166        s,
167        "|Lines|{}|{}|{}|{}|",
168        totals.old_lines,
169        totals.new_lines,
170        format_delta_colored(totals.delta_lines, options.color),
171        format_pct_delta_colored(
172            percent_change(totals.old_lines, totals.new_lines),
173            options.color
174        )
175    );
176    let _ = writeln!(
177        s,
178        "|Files|{}|{}|{}|{}|",
179        totals.old_files,
180        totals.new_files,
181        format_delta_colored(totals.delta_files, options.color),
182        format_pct_delta_colored(
183            percent_change(totals.old_files, totals.new_files),
184            options.color
185        )
186    );
187    let _ = writeln!(
188        s,
189        "|Bytes|{}|{}|{}|{}|",
190        totals.old_bytes,
191        totals.new_bytes,
192        format_delta_colored(totals.delta_bytes, options.color),
193        format_pct_delta_colored(
194            percent_change(totals.old_bytes, totals.new_bytes),
195            options.color
196        )
197    );
198    let _ = writeln!(
199        s,
200        "|Tokens|{}|{}|{}|{}|",
201        totals.old_tokens,
202        totals.new_tokens,
203        format_delta_colored(totals.delta_tokens, options.color),
204        format_pct_delta_colored(
205            percent_change(totals.old_tokens, totals.new_tokens),
206            options.color
207        )
208    );
209    s.push('\n');
210
211    s.push_str("### Language Movement\n\n");
212    s.push_str("|Type|Count|\n");
213    s.push_str("|---|---:|\n");
214    let _ = writeln!(s, "|Changed|{}|", rows.len());
215    let _ = writeln!(s, "|Added|{}|", languages_added);
216    let _ = writeln!(s, "|Removed|{}|", languages_removed);
217    let _ = writeln!(s, "|Modified|{}|", languages_modified);
218    s.push('\n');
219
220    // Detailed language breakdown
221    s.push_str("### Language Breakdown\n\n");
222    s.push_str("|Language|Old LOC|New LOC|Delta|\n");
223    s.push_str("|---|---:|---:|---:|\n");
224
225    for row in rows {
226        let _ = writeln!(
227            s,
228            "|{}|{}|{}|{}|",
229            row.lang,
230            row.old_code,
231            row.new_code,
232            format_delta_colored(row.delta_code, options.color)
233        );
234    }
235
236    let _ = writeln!(
237        s,
238        "|**Total**|{}|{}|{}|",
239        totals.old_code,
240        totals.new_code,
241        format_delta_colored(totals.delta_code, options.color)
242    );
243
244    s
245}
246
247/// Render diff as Markdown table.
248pub fn render_diff_md(
249    from_source: &str,
250    to_source: &str,
251    rows: &[DiffRow],
252    totals: &DiffTotals,
253) -> String {
254    render_diff_md_with_options(
255        from_source,
256        to_source,
257        rows,
258        totals,
259        DiffRenderOptions::default(),
260    )
261}
262
263#[cfg(test)]
264mod tests {
265    use super::format_delta;
266
267    #[test]
268    fn test_format_delta() {
269        // Kills mutants in format_delta function
270        assert_eq!(format_delta(5), "+5");
271        assert_eq!(format_delta(0), "0");
272        assert_eq!(format_delta(-3), "-3");
273    }
274}