Skip to main content

loom_diff/
render.rs

1//! git-style unified diff rendering. Takes a [`FileDiff`] and produces
2//! the canonical `--- a/path` / `+++ b/path` / `@@ -.. +.. @@` output
3//! everyone (and every agent's prior) recognizes.
4
5use crate::structured::{DiffLineKind, FileDiff, FileDiffStatus};
6
7/// Options for [`unified_diff_string`].
8#[derive(Debug, Clone)]
9pub struct UnifiedDiffOptions {
10    /// `a/` prefix used in the `--- a/` header (git convention).
11    pub a_prefix: String,
12    /// `b/` prefix used in the `+++ b/` header.
13    pub b_prefix: String,
14    /// Whether to colorize with ANSI escape codes (TTY mode).
15    pub color: bool,
16}
17
18impl Default for UnifiedDiffOptions {
19    fn default() -> Self {
20        Self {
21            a_prefix: "a/".into(),
22            b_prefix: "b/".into(),
23            color: false,
24        }
25    }
26}
27
28const ANSI_RESET: &str = "\x1b[0m";
29const ANSI_BOLD: &str = "\x1b[1m";
30const ANSI_RED: &str = "\x1b[31m";
31const ANSI_GREEN: &str = "\x1b[32m";
32const ANSI_CYAN: &str = "\x1b[36m";
33
34/// Render a [`FileDiff`] as a unified-diff string. Identical layout to
35/// `git diff` so agents and humans both find this reading exactly as
36/// they expect. Empty string for unchanged files.
37pub fn unified_diff_string(diff: &FileDiff, opts: &UnifiedDiffOptions) -> String {
38    if matches!(diff.status, FileDiffStatus::Unchanged) {
39        return String::new();
40    }
41
42    let mut s = String::new();
43    let a_path = diff.a_path.as_deref().unwrap_or("/dev/null");
44    let b_path = diff.b_path.as_deref().unwrap_or("/dev/null");
45
46    let header_color = if opts.color { ANSI_BOLD } else { "" };
47    let hunk_color = if opts.color { ANSI_CYAN } else { "" };
48    let add_color = if opts.color { ANSI_GREEN } else { "" };
49    let del_color = if opts.color { ANSI_RED } else { "" };
50    let reset = if opts.color { ANSI_RESET } else { "" };
51
52    s.push_str(&format!(
53        "{}diff --loom {}{}{}{}\n",
54        header_color,
55        opts.a_prefix,
56        a_path,
57        opts.b_prefix.is_empty().then(|| "").unwrap_or(""),
58        reset,
59    ));
60
61    // Emit canonical --- / +++ headers, with /dev/null on the absent side.
62    match diff.status {
63        FileDiffStatus::Added => {
64            s.push_str(&format!("{}--- /dev/null{}\n", header_color, reset));
65            s.push_str(&format!(
66                "{}+++ {}{}{}\n",
67                header_color, opts.b_prefix, b_path, reset
68            ));
69        }
70        FileDiffStatus::Deleted => {
71            s.push_str(&format!(
72                "{}--- {}{}{}\n",
73                header_color, opts.a_prefix, a_path, reset
74            ));
75            s.push_str(&format!("{}+++ /dev/null{}\n", header_color, reset));
76        }
77        FileDiffStatus::Modified => {
78            s.push_str(&format!(
79                "{}--- {}{}{}\n",
80                header_color, opts.a_prefix, a_path, reset
81            ));
82            s.push_str(&format!(
83                "{}+++ {}{}{}\n",
84                header_color, opts.b_prefix, b_path, reset
85            ));
86        }
87        FileDiffStatus::Binary { ref reason } => {
88            s.push_str(&format!("Binary files differ ({:?})\n", reason));
89            return s;
90        }
91        FileDiffStatus::Unchanged => unreachable!(),
92    }
93
94    for hunk in &diff.hunks {
95        s.push_str(&format!(
96            "{}@@ -{},{} +{},{} @@{}\n",
97            hunk_color, hunk.a_start, hunk.a_count, hunk.b_start, hunk.b_count, reset,
98        ));
99        for line in &hunk.lines {
100            let (sigil, color) = match line.kind {
101                DiffLineKind::Delete => ('-', del_color),
102                DiffLineKind::Insert => ('+', add_color),
103                DiffLineKind::Equal => (' ', ""),
104            };
105            s.push_str(color);
106            s.push(sigil);
107            s.push_str(&line.content);
108            s.push_str(reset);
109            s.push('\n');
110        }
111    }
112
113    s
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::structured::file_diff;
120
121    #[test]
122    fn modified_renders_unified_with_hunk_header() {
123        let a = "alpha\nbeta\ngamma\n";
124        let b = "alpha\nBETA\ngamma\n";
125        let d = file_diff(Some(a), Some(b), Some("g.txt"), Some("g.txt"), 1);
126        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
127        assert!(s.contains("--- a/g.txt"));
128        assert!(s.contains("+++ b/g.txt"));
129        assert!(s.contains("@@ -"));
130        assert!(s.contains("-beta"));
131        assert!(s.contains("+BETA"));
132    }
133
134    #[test]
135    fn added_renders_dev_null_on_a_side() {
136        let d = file_diff(None, Some("hi\n"), None, Some("new.txt"), 3);
137        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
138        assert!(s.contains("--- /dev/null"));
139        assert!(s.contains("+++ b/new.txt"));
140        assert!(s.contains("+hi"));
141    }
142
143    #[test]
144    fn deleted_renders_dev_null_on_b_side() {
145        let d = file_diff(Some("bye\n"), None, Some("gone.txt"), None, 3);
146        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
147        assert!(s.contains("--- a/gone.txt"));
148        assert!(s.contains("+++ /dev/null"));
149        assert!(s.contains("-bye"));
150    }
151
152    #[test]
153    fn unchanged_renders_empty() {
154        let d = file_diff(Some("same\n"), Some("same\n"), Some("p"), Some("p"), 3);
155        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
156        assert!(s.is_empty());
157    }
158}