1use crate::agent::ui::theme::ThemeKey;
2use crate::tui::Theme;
3
4struct ParsedDiffLine<'a> {
7 prefix: char,
8 line_num: &'a str,
9 content: &'a str,
10}
11
12fn parse_diff_line(line: &str) -> Option<ParsedDiffLine<'_>> {
19 let bytes = line.as_bytes();
20 if bytes.is_empty() {
21 return None;
22 }
23 let prefix = bytes[0] as char;
24 if prefix != '+' && prefix != '-' && prefix != ' ' {
25 return None;
26 }
27 let rest = &line[1..];
28 let mut idx = 0;
31 let content_start = 'b: {
32 while idx < rest.len() {
34 let c = rest.as_bytes()[idx] as char;
35 if c == ' ' || c.is_ascii_digit() {
36 idx += 1;
37 } else {
38 break;
39 }
40 }
41 if idx < rest.len() && rest.as_bytes()[idx] == b' ' {
43 break 'b idx + 1;
44 }
45 if idx == 0 {
49 break 'b 0;
50 }
51 break 'b idx;
53 };
54
55 let line_num_part = &rest[..idx].trim();
56 let content = &rest[content_start.min(rest.len())..];
57
58 Some(ParsedDiffLine {
59 prefix,
60 line_num: if line_num_part.is_empty() {
61 ""
62 } else {
63 line_num_part
64 },
65 content,
66 })
67}
68
69pub fn render_diff(diff_text: &str, theme: &dyn Theme) -> Vec<String> {
88 let mut lines: Vec<String> = Vec::new();
89 let diff_lines: Vec<&str> = diff_text.lines().collect();
90 let mut i = 0;
91
92 while i < diff_lines.len() {
93 let raw = diff_lines[i];
94
95 if raw.is_empty() {
96 i += 1;
97 continue;
98 }
99
100 if raw.starts_with("---") || raw.starts_with("+++") || raw.starts_with("@@") {
102 i += 1;
103 continue;
104 }
105
106 let parsed = parse_diff_line(raw);
107 if parsed.is_none() {
108 i += 1;
110 continue;
111 }
112 let parsed = parsed.unwrap();
113
114 if parsed.prefix == '-' {
115 let mut removed: Vec<ParsedDiffLine> = Vec::new();
117 while i < diff_lines.len() {
118 let l = diff_lines[i];
119 if let Some(p) = parse_diff_line(l)
120 && p.prefix == '-'
121 {
122 removed.push(p);
123 i += 1;
124 } else {
125 break;
126 }
127 }
128
129 let mut added: Vec<ParsedDiffLine> = Vec::new();
131 while i < diff_lines.len() {
132 let l = diff_lines[i];
133 if let Some(p) = parse_diff_line(l)
134 && p.prefix == '+'
135 {
136 added.push(p);
137 i += 1;
138 } else {
139 break;
140 }
141 }
142
143 if removed.len() == 1 && added.len() == 1 {
145 render_intra_line_diff(
146 &replace_tabs(removed[0].content),
147 &replace_tabs(added[0].content),
148 removed[0].line_num,
149 added[0].line_num,
150 &mut lines,
151 theme,
152 );
153 } else {
154 for r in &removed {
156 let content = replace_tabs(r.content);
157 let line_num = r.line_num;
158 if line_num.is_empty() {
159 lines.push(
160 theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", content)),
161 );
162 } else {
163 lines.push(theme.fg_key(
164 ThemeKey::ToolDiffRemoved,
165 &format!("-{} {}", line_num, content),
166 ));
167 }
168 }
169 for a in &added {
170 let content = replace_tabs(a.content);
171 let line_num = a.line_num;
172 if line_num.is_empty() {
173 lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
174 } else {
175 lines.push(theme.fg_key(
176 ThemeKey::ToolDiffAdded,
177 &format!("+{} {}", line_num, content),
178 ));
179 }
180 }
181 }
182 } else if parsed.prefix == '+' {
183 let content = replace_tabs(parsed.content);
185 let line_num = parsed.line_num;
186 if line_num.is_empty() {
187 lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
188 } else {
189 lines.push(theme.fg_key(
190 ThemeKey::ToolDiffAdded,
191 &format!("+{} {}", line_num, content),
192 ));
193 }
194 i += 1;
195 } else {
196 let content = replace_tabs(parsed.content);
198 let line_num = parsed.line_num;
199 if line_num.is_empty() {
200 lines.push(theme.fg_key(ThemeKey::ToolDiffContext, &format!(" {}", content)));
201 } else {
202 lines.push(theme.fg_key(
203 ThemeKey::ToolDiffContext,
204 &format!(" {} {}", line_num, content),
205 ));
206 }
207 i += 1;
208 }
209 }
210
211 lines
212}
213
214fn replace_tabs(text: &str) -> String {
216 text.replace('\t', " ")
217}
218
219fn render_intra_line_diff(
227 old_content: &str,
228 new_content: &str,
229 old_line_num: &str,
230 new_line_num: &str,
231 output: &mut Vec<String>,
232 theme: &dyn Theme,
233) {
234 let changes = compute_word_diff(old_content, new_content);
235
236 let mut removed_line = String::new();
237 let mut added_line = String::new();
238
239 for change in &changes {
240 match change {
241 Change::Equal(text) => {
242 removed_line.push_str(text);
243 added_line.push_str(text);
244 }
245 Change::Removed(text) => {
246 let trimmed = text.trim_start();
248 if trimmed.len() < text.len() {
249 let ws = &text[..text.len() - trimmed.len()];
250 removed_line.push_str(ws);
251 }
252 if !trimmed.is_empty() {
253 removed_line.push_str(&theme.inverse(trimmed));
254 }
255 }
256 Change::Added(text) => {
257 let trimmed = text.trim_start();
259 if trimmed.len() < text.len() {
260 let ws = &text[..text.len() - trimmed.len()];
261 added_line.push_str(ws);
262 }
263 if !trimmed.is_empty() {
264 added_line.push_str(&theme.inverse(trimmed));
265 }
266 }
267 }
268 }
269
270 if old_line_num.is_empty() {
271 output.push(theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", removed_line)));
272 } else {
273 output.push(theme.fg_key(
274 ThemeKey::ToolDiffRemoved,
275 &format!("-{} {}", old_line_num, removed_line),
276 ));
277 }
278 if new_line_num.is_empty() {
279 output.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", added_line)));
280 } else {
281 output.push(theme.fg_key(
282 ThemeKey::ToolDiffAdded,
283 &format!("+{} {}", new_line_num, added_line),
284 ));
285 }
286}
287
288#[derive(Debug)]
290enum Change {
291 Equal(String),
292 Removed(String),
293 Added(String),
294}
295
296fn compute_word_diff(old: &str, new: &str) -> Vec<Change> {
301 let old_tokens = split_words(old);
302 let new_tokens = split_words(new);
303 let n = old_tokens.len();
304 let m = new_tokens.len();
305
306 let tokens_equal = |a: &str, b: &str| a.trim() == b.trim();
309
310 let mut dp = vec![vec![0usize; m + 1]; n + 1];
311 for i in 1..=n {
312 for j in 1..=m {
313 if tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
314 dp[i][j] = dp[i - 1][j - 1] + 1;
315 } else {
316 dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
317 }
318 }
319 }
320
321 let mut temp = Vec::new();
323 let mut i = n;
324 let mut j = m;
325 while i > 0 || j > 0 {
326 if i > 0 && j > 0 && tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
327 temp.push(Change::Equal(old_tokens[i - 1].clone()));
328 i -= 1;
329 j -= 1;
330 } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
331 temp.push(Change::Added(new_tokens[j - 1].clone()));
332 j -= 1;
333 } else {
334 temp.push(Change::Removed(old_tokens[i - 1].clone()));
335 i -= 1;
336 }
337 }
338 temp.reverse();
339
340 let mut merged: Vec<Change> = Vec::new();
342 for change in temp {
343 let should_merge = merged.last().is_some_and(|last| {
344 matches!(
345 (last, &change),
346 (Change::Equal(_), Change::Equal(_))
347 | (Change::Removed(_), Change::Removed(_))
348 | (Change::Added(_), Change::Added(_))
349 )
350 });
351
352 if should_merge {
353 if let Some(last) = merged.last_mut() {
354 let text = match change {
355 Change::Equal(t) | Change::Removed(t) | Change::Added(t) => t,
356 };
357 match last {
358 Change::Equal(t) => t.push_str(&text),
359 Change::Removed(t) => t.push_str(&text),
360 Change::Added(t) => t.push_str(&text),
361 }
362 }
363 } else {
364 merged.push(change);
365 }
366 }
367
368 merged
369}
370
371fn split_words(text: &str) -> Vec<String> {
383 let mut parts: Vec<String> = Vec::new();
387 let mut current: Vec<char> = Vec::new();
388 let mut in_whitespace = None;
389
390 for ch in text.chars() {
391 let is_ws = ch.is_whitespace();
392 match in_whitespace {
393 Some(ws) if ws == is_ws => current.push(ch),
394 Some(_) => {
395 parts.push(current.iter().collect());
396 current.clear();
397 current.push(ch);
398 in_whitespace = Some(is_ws);
399 }
400 None => {
401 current.push(ch);
402 in_whitespace = Some(is_ws);
403 }
404 }
405 }
406 if !current.is_empty() {
407 parts.push(current.iter().collect());
408 }
409
410 if parts.is_empty() {
411 return vec![];
412 }
413
414 let mut tokens: Vec<String> = Vec::new();
425 let mut prev_part: Option<&str> = None;
426
427 for part in &parts {
428 if part.is_empty() {
429 continue;
430 }
431 let is_ws = part.chars().all(|c| c.is_whitespace());
432
433 if is_ws {
434 if prev_part.is_none() {
435 tokens.push(part.clone());
437 } else {
438 if let Some(last) = tokens.last_mut() {
440 last.push_str(part);
441 }
442 }
443 } else if let Some(prev) = prev_part {
444 let prev_is_ws = prev.chars().all(|c| c.is_whitespace());
445 if prev_is_ws {
446 if tokens.last().map(|t| t.as_str()) == Some(prev) {
448 tokens.pop();
450 let mut merged = prev.to_string();
451 merged.push_str(part);
452 tokens.push(merged);
453 } else {
454 tokens.push(prev.to_string() + part);
456 }
457 } else {
458 tokens.push(part.clone());
459 }
460 } else {
461 tokens.push(part.clone());
462 }
463
464 prev_part = Some(part.as_str());
465 }
466
467 tokens
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn test_theme() -> crate::agent::ui::theme::RabTheme {
475 crate::agent::ui::theme::current_theme().clone()
476 }
477
478 #[test]
479 fn test_empty_diff() {
480 let theme = test_theme();
481 let result = render_diff("", &theme);
482 assert!(result.is_empty());
483 }
484
485 #[test]
486 fn test_skips_headers() {
487 let theme = test_theme();
488 let diff = "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,4 @@\n";
489 let result = render_diff(diff, &theme);
490 assert!(result.is_empty(), "should skip all headers");
491 }
492
493 #[test]
494 fn test_context_lines() {
495 crate::agent::ui::theme::init_theme(Some("dark"), false);
496 let theme = test_theme();
497 let diff = " line1\n line2\n";
498 let result = render_diff(diff, &theme);
499 assert_eq!(result.len(), 2);
500 assert!(result[0].contains("line1"));
501 assert!(result[0].starts_with("\x1b")); assert!(result[0].contains("\x1b[39m")); }
504
505 #[test]
506 fn test_removed_line() {
507 crate::agent::ui::theme::init_theme(Some("dark"), false);
508 let theme = test_theme();
509 let diff = "-old_line\n";
510 let result = render_diff(diff, &theme);
511 assert_eq!(result.len(), 1);
512 assert!(result[0].contains('-'));
514 assert!(result[0].contains("old_line"));
515 }
516
517 #[test]
518 fn test_added_line() {
519 crate::agent::ui::theme::init_theme(Some("dark"), false);
520 let theme = test_theme();
521 let diff = "+new_line\n";
522 let result = render_diff(diff, &theme);
523 assert_eq!(result.len(), 1);
524 assert!(result[0].contains('+'));
525 assert!(result[0].contains("new_line"));
526 }
527
528 #[test]
529 fn test_single_line_modification() {
530 crate::agent::ui::theme::init_theme(Some("dark"), false);
531 let theme = test_theme();
532 let diff = "-foo\n+bar\n";
533 let result = render_diff(diff, &theme);
534 assert_eq!(result.len(), 2);
535 assert!(result[0].contains('-'));
536 assert!(result[1].contains('+'));
537 assert!(
539 result[0].contains("\x1b[7m"),
540 "should have inverse on removed"
541 );
542 assert!(
543 result[1].contains("\x1b[7m"),
544 "should have inverse on added"
545 );
546 }
547
548 #[test]
549 fn test_single_line_modification_with_line_numbers() {
550 crate::agent::ui::theme::init_theme(Some("dark"), false);
551 let theme = test_theme();
552 let diff = "- 42 foo\n+ 42 bar\n";
553 let result = render_diff(diff, &theme);
554 assert_eq!(result.len(), 2);
555 assert!(
557 result[0].contains("-42"),
558 "should show line number on removed line: {:?}",
559 result[0]
560 );
561 assert!(
562 result[1].contains("+42"),
563 "should show line number on added line: {:?}",
564 result[1]
565 );
566 assert!(
568 result[0].contains("\x1b[7m"),
569 "should have inverse on removed"
570 );
571 }
572
573 #[test]
574 fn test_multi_line_removes() {
575 crate::agent::ui::theme::init_theme(Some("dark"), false);
576 let theme = test_theme();
577 let diff = "-a\n-b\n+c\n";
578 let result = render_diff(diff, &theme);
579 assert_eq!(result.len(), 3);
581 assert!(result[0].contains("-a"));
582 assert!(result[1].contains("-b"));
583 assert!(result[2].contains("+c"));
584 }
585
586 #[test]
587 fn test_multi_line_removes_no_intra_diff() {
588 crate::agent::ui::theme::init_theme(Some("dark"), false);
589 let theme = test_theme();
590 let diff = "-aaa\n-bbb\n+ccc\n+ddd\n";
591 let result = render_diff(diff, &theme);
592 assert_eq!(result.len(), 4);
593 assert!(
595 !result[0].contains("\x1b[7m"),
596 "no inverse on multi-line remove"
597 );
598 }
599
600 #[test]
601 fn test_compute_word_diff_basic() {
602 let changes = compute_word_diff("abc", "abd");
603 assert!(!changes.is_empty());
604 }
605
606 #[test]
607 fn test_compute_word_diff_identical() {
608 let changes = compute_word_diff("hello", "hello");
609 assert_eq!(changes.len(), 1);
610 assert!(matches!(changes[0], Change::Equal(_)));
611 }
612
613 #[test]
614 fn test_tabs_replaced() {
615 crate::agent::ui::theme::init_theme(Some("dark"), false);
616 let theme = test_theme();
617 let diff = "-\tindented\n";
618 let result = render_diff(diff, &theme);
619 assert_eq!(result.len(), 1);
620 assert!(!result[0].contains('\t'), "tabs should be replaced");
621 }
622
623 #[test]
624 fn test_context_line_format() {
625 crate::agent::ui::theme::init_theme(Some("dark"), false);
626 let theme = test_theme();
627 let diff = " context\n";
628 let result = render_diff(diff, &theme);
629 assert_eq!(result.len(), 1);
630 assert!(result[0].contains("context"));
631 assert!(result[0].starts_with("\x1b"));
632 }
633}