Skip to main content

zeph_tui/widgets/
diff.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use ratatui::style::Style;
7use ratatui::text::{Line, Span};
8use similar::ChangeTag;
9
10use crate::highlight::SYNTAX_HIGHLIGHTER;
11use crate::theme::{SyntaxTheme, Theme};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DiffLineKind {
15    Added,
16    Removed,
17    Context,
18}
19
20#[derive(Debug, Clone)]
21pub struct DiffLine {
22    pub kind: DiffLineKind,
23    pub content: String,
24}
25
26#[must_use]
27pub fn compute_diff(old: &str, new: &str) -> Vec<DiffLine> {
28    let diff = similar::TextDiff::from_lines(old, new);
29    diff.iter_all_changes()
30        .map(|change| {
31            let kind = match change.tag() {
32                ChangeTag::Delete => DiffLineKind::Removed,
33                ChangeTag::Insert => DiffLineKind::Added,
34                ChangeTag::Equal => DiffLineKind::Context,
35            };
36            DiffLine {
37                kind,
38                content: change.value().to_owned(),
39            }
40        })
41        .collect()
42}
43
44#[must_use]
45pub fn render_diff_lines(lines: &[DiffLine], file_path: &str, theme: &Theme) -> Vec<Line<'static>> {
46    let lang = lang_from_path(file_path);
47    let syntax_theme = SyntaxTheme::default();
48    let mut result = Vec::new();
49
50    // Header
51    let added = lines
52        .iter()
53        .filter(|l| l.kind == DiffLineKind::Added)
54        .count();
55    let removed = lines
56        .iter()
57        .filter(|l| l.kind == DiffLineKind::Removed)
58        .count();
59    result.push(Line::from(Span::styled(
60        format!("{file_path}: +{added} -{removed}"),
61        theme.diff_header,
62    )));
63
64    for dl in lines {
65        let (gutter, gutter_style, bg) = match dl.kind {
66            DiffLineKind::Added => ("+", theme.diff_gutter_add, Some(theme.diff_added_bg)),
67            DiffLineKind::Removed => ("-", theme.diff_gutter_remove, Some(theme.diff_removed_bg)),
68            DiffLineKind::Context => (" ", Style::default(), None),
69        };
70
71        let content = dl.content.trim_end_matches('\n');
72        let mut line_spans = vec![Span::styled(format!("{gutter} "), gutter_style)];
73
74        let highlighted =
75            lang.and_then(|l| SYNTAX_HIGHLIGHTER.highlight(l, content, &syntax_theme));
76
77        if let Some(spans) = highlighted {
78            for span in spans {
79                let mut style = span.style;
80                if let Some(bg_color) = bg {
81                    style = style.bg(bg_color);
82                }
83                line_spans.push(Span::styled(span.content.to_string(), style));
84            }
85        } else {
86            let mut style = Style::default();
87            if let Some(bg_color) = bg {
88                style = style.bg(bg_color);
89            }
90            line_spans.push(Span::styled(content.to_string(), style));
91        }
92
93        result.push(Line::from(line_spans));
94    }
95
96    result
97}
98
99/// Render a compact one-line diff summary.
100#[must_use]
101pub fn render_diff_compact(file_path: &str, lines: &[DiffLine], theme: &Theme) -> Line<'static> {
102    let added = lines
103        .iter()
104        .filter(|l| l.kind == DiffLineKind::Added)
105        .count();
106    let removed = lines
107        .iter()
108        .filter(|l| l.kind == DiffLineKind::Removed)
109        .count();
110    Line::from(Span::styled(
111        format!("  {file_path}: +{added} -{removed} lines"),
112        theme.diff_header,
113    ))
114}
115
116fn lang_from_path(path: &str) -> Option<&'static str> {
117    match Path::new(path).extension()?.to_str()? {
118        "rs" => Some("rust"),
119        "py" => Some("python"),
120        "js" => Some("javascript"),
121        "json" => Some("json"),
122        "toml" => Some("toml"),
123        "sh" | "bash" => Some("bash"),
124        _ => None,
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn compute_diff_empty_to_content() {
134        let lines = compute_diff("", "hello\nworld\n");
135        assert_eq!(lines.len(), 2);
136        assert!(lines.iter().all(|l| l.kind == DiffLineKind::Added));
137    }
138
139    #[test]
140    fn compute_diff_identical() {
141        let lines = compute_diff("same\n", "same\n");
142        assert_eq!(lines.len(), 1);
143        assert_eq!(lines[0].kind, DiffLineKind::Context);
144    }
145
146    #[test]
147    fn compute_diff_edit() {
148        let lines = compute_diff("foo\nbar\nbaz\n", "foo\nqux\nbaz\n");
149        let removed: Vec<_> = lines
150            .iter()
151            .filter(|l| l.kind == DiffLineKind::Removed)
152            .collect();
153        let added: Vec<_> = lines
154            .iter()
155            .filter(|l| l.kind == DiffLineKind::Added)
156            .collect();
157        assert_eq!(removed.len(), 1);
158        assert!(removed[0].content.contains("bar"));
159        assert_eq!(added.len(), 1);
160        assert!(added[0].content.contains("qux"));
161    }
162
163    #[test]
164    fn compute_diff_content_to_empty() {
165        let lines = compute_diff("hello\n", "");
166        assert_eq!(lines.len(), 1);
167        assert_eq!(lines[0].kind, DiffLineKind::Removed);
168    }
169
170    #[test]
171    fn render_diff_lines_has_header() {
172        let diff_lines = compute_diff("", "line\n");
173        let theme = Theme::default();
174        let rendered = render_diff_lines(&diff_lines, "test.rs", &theme);
175        assert!(!rendered.is_empty());
176        let header: String = rendered[0]
177            .spans
178            .iter()
179            .map(|s| s.content.as_ref())
180            .collect();
181        assert!(header.contains("test.rs"));
182        assert!(header.contains("+1"));
183    }
184
185    #[test]
186    fn render_diff_compact_format() {
187        let diff_lines = compute_diff("old\n", "new\n");
188        let theme = Theme::default();
189        let line = render_diff_compact("file.rs", &diff_lines, &theme);
190        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
191        assert!(text.contains("file.rs"));
192        assert!(text.contains("+1"));
193        assert!(text.contains("-1"));
194    }
195
196    #[test]
197    fn lang_from_path_known() {
198        assert_eq!(lang_from_path("foo.rs"), Some("rust"));
199        assert_eq!(lang_from_path("bar.py"), Some("python"));
200        assert_eq!(lang_from_path("baz.js"), Some("javascript"));
201        assert_eq!(lang_from_path("q.toml"), Some("toml"));
202        assert_eq!(lang_from_path("s.sh"), Some("bash"));
203    }
204
205    #[test]
206    fn lang_from_path_unknown() {
207        assert_eq!(lang_from_path("file.xyz"), None);
208        assert_eq!(lang_from_path("noext"), None);
209    }
210}