| 1 | //! Build unified-diff strings for tool results. |
| 2 | //! |
| 3 | //! `edit_file` and `write_file` capture the file contents before and after |
| 4 | //! the mutation and emit a unified diff at the head of their `ToolResult` |
| 5 | //! output. The TUI's `output_looks_like_diff` detector then routes the |
| 6 | //! payload through `diff_render::render_diff`, which renders it with line |
| 7 | //! numbers and coloured `+`/`-` gutters (#505). |
| 8 | //! |
| 9 | //! The diff is also a strict UX upgrade for the model — it sees exactly |
| 10 | //! which lines changed instead of a one-line summary. |
| 11 | |
| 12 | use similar::TextDiff; |
| 13 | |
| 14 | /// Build a unified diff between `old` and `new` keyed at `path`. |
| 15 | /// |
| 16 | /// Returns an empty string when the inputs are byte-identical so callers |
| 17 | /// can skip the "no changes" header. The output uses git-style `--- a/...` |
| 18 | /// / `+++ b/...` headers and three lines of context — matching the format |
| 19 | /// the TUI's `diff_render::render_diff` already understands. |
| 20 | #[must_use] |
| 21 | pub fn make_unified_diff(path: &str, old: &str, new: &str) -> String { |
| 22 | if old == new { |
| 23 | return String::new(); |
| 24 | } |
| 25 | let a = format!("a/{path}"); |
| 26 | let b = format!("b/{path}"); |
| 27 | let diff = TextDiff::from_lines(old, new); |
| 28 | diff.unified_diff() |
| 29 | .context_radius(3) |
| 30 | .header(&a, &b) |
| 31 | .to_string() |
| 32 | } |
| 33 | |
| 34 | #[cfg(test)] |
| 35 | mod tests { |
| 36 | use super::*; |
| 37 | |
| 38 | #[test] |
| 39 | fn identical_inputs_emit_empty_diff() { |
| 40 | let s = "hello\nworld\n"; |
| 41 | assert!(make_unified_diff("foo.txt", s, s).is_empty()); |
| 42 | } |
| 43 | |
| 44 | #[test] |
| 45 | fn replacement_emits_minus_plus_pair() { |
| 46 | let old = "alpha\nbeta\ngamma\n"; |
| 47 | let new = "alpha\nBETA\ngamma\n"; |
| 48 | let diff = make_unified_diff("foo.txt", old, new); |
| 49 | assert!(diff.contains("--- a/foo.txt"), "{diff}"); |
| 50 | assert!(diff.contains("+++ b/foo.txt"), "{diff}"); |
| 51 | assert!(diff.contains("-beta"), "{diff}"); |
| 52 | assert!(diff.contains("+BETA"), "{diff}"); |
| 53 | } |
| 54 | |
| 55 | #[test] |
| 56 | fn new_file_renders_against_empty_old() { |
| 57 | let new = "first line\nsecond line\n"; |
| 58 | let diff = make_unified_diff("new.txt", "", new); |
| 59 | assert!(diff.contains("--- a/new.txt"), "{diff}"); |
| 60 | assert!(diff.contains("+++ b/new.txt"), "{diff}"); |
| 61 | assert!(diff.contains("+first line"), "{diff}"); |
| 62 | assert!(diff.contains("+second line"), "{diff}"); |
| 63 | } |
| 64 | |
| 65 | #[test] |
| 66 | fn diff_contains_hunk_header_so_tui_renders_it() { |
| 67 | // The TUI detector scans the first 5 lines for `@@`. Make sure the |
| 68 | // unified diff puts a hunk header within that window so the |
| 69 | // diff-aware renderer kicks in (#505). |
| 70 | let diff = make_unified_diff("foo.txt", "a\n", "b\n"); |
| 71 | let head: Vec<&str> = diff.lines().take(5).collect(); |
| 72 | assert!( |
| 73 | head.iter().any(|line| line.starts_with("@@")), |
| 74 | "expected hunk header in first 5 lines; got {head:?}" |
| 75 | ); |
| 76 | } |
| 77 | } |
| 78 |