Skip to main content

mermaid_cli/render/
diff.rs

1//! Diff rendering shared between the file-mutating tools (`write_file`,
2//! `apply_patch`) that PRODUCE diff output and the chat renderer that COLORS
3//! added/removed lines. Both the marker conventions and the producer
4//! ([`generate_display_diff`]) live here in the render layer, so every tool that
5//! needs a display diff shares one implementation and one format.
6
7/// Marker for a REMOVED line. Formatted as `{num:>4}{marker}{content}`
8/// so the three-byte width stays aligned across line numbers up to
9/// 9,999. Lines without a matching prefix fall through to `Context`.
10pub const DIFF_REMOVED_MARKER: &str = " - ";
11
12/// Marker for an ADDED line.
13pub const DIFF_ADDED_MARKER: &str = " + ";
14
15/// Classification of a diff line. The chat renderer matches on this
16/// to choose a background color.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DiffLineKind {
19    Context,
20    Removed,
21    Added,
22}
23
24/// Parse one line of diff output. Lines that don't follow the
25/// `{number}{marker}{content}` shape — including malformed or
26/// truncated input — fall through to `Context` so the renderer's
27/// match stays exhaustive without panicking.
28pub fn parse_diff_line(line: &str) -> DiffLineKind {
29    let trimmed = line.trim_start();
30    let after_num = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
31    if after_num.starts_with(DIFF_REMOVED_MARKER) {
32        DiffLineKind::Removed
33    } else if after_num.starts_with(DIFF_ADDED_MARKER) {
34        DiffLineKind::Added
35    } else {
36        DiffLineKind::Context
37    }
38}
39
40/// A rendered display diff plus its change counts. Produced by
41/// [`generate_display_diff`] and carried on tool metadata for the renderer.
42#[derive(Debug, Clone)]
43pub(crate) struct DisplayDiff {
44    pub display_diff: String,
45    pub added: usize,
46    pub removed: usize,
47    pub truncated: bool,
48}
49
50/// Lines of unchanged context shown around a change.
51const DIFF_CONTEXT_LINES: usize = 3;
52/// Hard cap on rendered diff lines so a huge edit can't flood the transcript.
53pub(crate) const MAX_DISPLAY_DIFF_LINES: usize = 220;
54
55/// Render a line-numbered display diff of `old` → `new`. Emits only changed
56/// lines plus a few lines of surrounding context, each as
57/// `{num:>4}{marker}{content}` using the shared markers so [`parse_diff_line`]
58/// colors it. No unified-diff header lines. Shared by `write_file` and
59/// `apply_patch`.
60pub(crate) fn generate_display_diff(old: &str, new: &str) -> DisplayDiff {
61    let old_lines: Vec<&str> = old.lines().collect();
62    let new_lines: Vec<&str> = new.lines().collect();
63    let mut prefix = 0usize;
64    let min_len = old_lines.len().min(new_lines.len());
65    while prefix < min_len && old_lines[prefix] == new_lines[prefix] {
66        prefix += 1;
67    }
68
69    let mut suffix = 0usize;
70    while suffix < min_len.saturating_sub(prefix)
71        && old_lines[old_lines.len() - 1 - suffix] == new_lines[new_lines.len() - 1 - suffix]
72    {
73        suffix += 1;
74    }
75
76    let old_changed_end = old_lines.len().saturating_sub(suffix);
77    let new_changed_end = new_lines.len().saturating_sub(suffix);
78    let old_changed = &old_lines[prefix..old_changed_end];
79    let new_changed = &new_lines[prefix..new_changed_end];
80    let added = new_changed.len();
81    let removed = old_changed.len();
82
83    let context_start = prefix.saturating_sub(DIFF_CONTEXT_LINES);
84    let context_end_old = (old_changed_end + DIFF_CONTEXT_LINES).min(old_lines.len());
85    let mut lines = Vec::new();
86
87    let mut truncated = false;
88    let push_line = |line: String, lines: &mut Vec<String>, truncated: &mut bool| {
89        if lines.len() < MAX_DISPLAY_DIFF_LINES {
90            lines.push(line);
91        } else {
92            *truncated = true;
93        }
94    };
95
96    for (idx, line) in old_lines[context_start..prefix].iter().enumerate() {
97        push_line(
98            format!("{:>4}   {}", context_start + idx + 1, line),
99            &mut lines,
100            &mut truncated,
101        );
102    }
103    for (idx, line) in old_changed.iter().enumerate() {
104        push_line(
105            format!("{:>4}{}{}", prefix + idx + 1, DIFF_REMOVED_MARKER, line),
106            &mut lines,
107            &mut truncated,
108        );
109    }
110    for (idx, line) in new_changed.iter().enumerate() {
111        push_line(
112            format!("{:>4}{}{}", prefix + idx + 1, DIFF_ADDED_MARKER, line),
113            &mut lines,
114            &mut truncated,
115        );
116    }
117    for (idx, line) in old_lines[old_changed_end..context_end_old]
118        .iter()
119        .enumerate()
120    {
121        push_line(
122            format!("{:>4}   {}", old_changed_end + idx + 1, line),
123            &mut lines,
124            &mut truncated,
125        );
126    }
127    if truncated {
128        lines.push(format!(
129            "... diff truncated after {MAX_DISPLAY_DIFF_LINES} display lines"
130        ));
131    }
132
133    DisplayDiff {
134        display_diff: lines.join("\n"),
135        added,
136        removed,
137        truncated,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn parses_added_line() {
147        let line = format!("   5{}fn main() {{", DIFF_ADDED_MARKER);
148        assert_eq!(parse_diff_line(&line), DiffLineKind::Added);
149    }
150
151    #[test]
152    fn parses_removed_line() {
153        let line = format!("  12{}old = true;", DIFF_REMOVED_MARKER);
154        assert_eq!(parse_diff_line(&line), DiffLineKind::Removed);
155    }
156
157    #[test]
158    fn parses_context_line() {
159        let line = "   7   existing line";
160        assert_eq!(parse_diff_line(line), DiffLineKind::Context);
161    }
162
163    #[test]
164    fn malformed_falls_through_to_context() {
165        assert_eq!(parse_diff_line("no number at start"), DiffLineKind::Context);
166        assert_eq!(parse_diff_line(""), DiffLineKind::Context);
167    }
168}