Skip to main content

ane/commands/
diff.rs

1use similar::{ChangeTag, TextDiff};
2
3pub fn unified_diff(path: &str, original: &str, modified: &str) -> String {
4    if original == modified {
5        return String::new();
6    }
7
8    let diff = TextDiff::from_lines(original, modified);
9    let mut output = String::new();
10
11    let clean = path.strip_prefix('/').unwrap_or(path);
12    output.push_str(&format!("--- a/{clean}\n"));
13    output.push_str(&format!("+++ b/{clean}\n"));
14
15    for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
16        output.push_str(&format!("{hunk}"));
17    }
18
19    output
20}
21
22pub fn colored_unified_diff(path: &str, original: &str, modified: &str) -> String {
23    if original == modified {
24        return String::new();
25    }
26
27    const BOLD: &str = "\x1b[1m";
28    const CYAN: &str = "\x1b[36m";
29    const RED_BG: &str = "\x1b[41;97m";
30    const GREEN_BG: &str = "\x1b[42;30m";
31    const RESET: &str = "\x1b[0m";
32    const DIM: &str = "\x1b[2m";
33
34    let diff = TextDiff::from_lines(original, modified);
35    let mut output = String::new();
36
37    let clean = path.strip_prefix('/').unwrap_or(path);
38    output.push_str(&format!("{BOLD}--- a/{clean}{RESET}\n"));
39    output.push_str(&format!("{BOLD}+++ b/{clean}{RESET}\n"));
40
41    for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
42        output.push_str(&format!("{CYAN}{}{RESET}\n", hunk.header()));
43        for change in hunk.iter_changes() {
44            let line = change.value();
45            let line_no_newline = line.strip_suffix('\n').unwrap_or(line);
46            match change.tag() {
47                ChangeTag::Delete => {
48                    output.push_str(&format!("{RED_BG}-{line_no_newline}{RESET}\n"));
49                }
50                ChangeTag::Insert => {
51                    output.push_str(&format!("{GREEN_BG}+{line_no_newline}{RESET}\n"));
52                }
53                ChangeTag::Equal => {
54                    output.push_str(&format!("{DIM} {line_no_newline}{RESET}\n"));
55                }
56            }
57        }
58    }
59
60    output
61}
62
63pub fn collect_diffs(changes: Vec<(String, String, String)>) -> String {
64    let mut output = String::new();
65    for (path, original, modified) in changes {
66        let diff = unified_diff(&path, &original, &modified);
67        if !diff.is_empty() {
68            output.push_str(&diff);
69        }
70    }
71    output
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn no_diff_when_identical() {
80        let result = unified_diff("test.rs", "hello\n", "hello\n");
81        assert!(result.is_empty());
82    }
83
84    #[test]
85    fn produces_unified_diff() {
86        let result = unified_diff("test.rs", "aaa\nbbb\nccc\n", "aaa\nxxx\nccc\n");
87        assert!(result.contains("--- a/test.rs"));
88        assert!(result.contains("+++ b/test.rs"));
89        assert!(result.contains("-bbb"));
90        assert!(result.contains("+xxx"));
91    }
92
93    #[test]
94    fn absolute_path_does_not_double_slash() {
95        let result = unified_diff("/tmp/foo.rs", "old\n", "new\n");
96        assert!(result.contains("--- a/tmp/foo.rs"), "got:\n{result}");
97        assert!(!result.contains("a//tmp/foo.rs"), "got:\n{result}");
98    }
99
100    #[test]
101    fn collect_multiple_diffs() {
102        let changes = vec![
103            ("a.rs".into(), "old\n".into(), "new\n".into()),
104            ("b.rs".into(), "same\n".into(), "same\n".into()),
105        ];
106        let result = collect_diffs(changes);
107        assert!(result.contains("a.rs"));
108        assert!(!result.contains("b.rs"));
109    }
110
111    #[test]
112    fn colored_diff_empty_when_identical() {
113        let result = colored_unified_diff("test.rs", "hello\n", "hello\n");
114        assert!(result.is_empty());
115    }
116
117    #[test]
118    fn colored_diff_has_ansi_codes() {
119        let result = colored_unified_diff("test.rs", "aaa\nbbb\nccc\n", "aaa\nxxx\nccc\n");
120        assert!(
121            result.contains("\x1b[1m--- a/test.rs"),
122            "missing bold header"
123        );
124        assert!(result.contains("\x1b[36m@@ "), "missing cyan hunk header");
125        assert!(
126            result.contains("\x1b[41;97m-bbb"),
127            "missing red deleted line"
128        );
129        assert!(
130            result.contains("\x1b[42;30m+xxx"),
131            "missing green added line"
132        );
133        assert!(result.contains("\x1b[2m aaa"), "missing dim context line");
134    }
135}