1use 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 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#[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}