1use similar::{ChangeTag, TextDiff};
8
9pub const DEFAULT_DIFF_MAX_LINES: usize = 120;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct UnifiedDiff {
16 pub text: String,
20 pub added: usize,
22 pub removed: usize,
24 pub truncated: bool,
26}
27
28impl UnifiedDiff {
29 pub fn stats(&self) -> String {
31 format!("+{} -{}", self.added, self.removed)
32 }
33}
34
35pub fn unified_diff(old: &str, new: &str, max_lines: usize) -> Option<UnifiedDiff> {
38 if old == new {
39 return None;
40 }
41 let diff = TextDiff::from_lines(old, new);
42 let mut added = 0usize;
43 let mut removed = 0usize;
44 for change in diff.iter_all_changes() {
45 match change.tag() {
46 ChangeTag::Insert => added += 1,
47 ChangeTag::Delete => removed += 1,
48 ChangeTag::Equal => {}
49 }
50 }
51
52 let max_lines = max_lines.max(4);
53 let mut lines: Vec<String> = Vec::new();
54 let mut truncated = false;
55 'outer: for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
56 if lines.len() + 1 > max_lines {
57 truncated = true;
58 break;
59 }
60 lines.push(hunk.header().to_string());
61 for change in hunk.iter_changes() {
62 if lines.len() + 1 > max_lines {
63 truncated = true;
64 break 'outer;
65 }
66 let sign = match change.tag() {
67 ChangeTag::Insert => "+",
68 ChangeTag::Delete => "-",
69 ChangeTag::Equal => " ",
70 };
71 let value = change.value();
72 lines.push(format!(
73 "{sign}{}",
74 value.strip_suffix('\n').unwrap_or(value)
75 ));
76 }
77 }
78 if truncated {
79 lines.push(format!("… (diff truncated at {max_lines} lines)"));
80 }
81 Some(UnifiedDiff {
82 text: lines.join("\n"),
83 added,
84 removed,
85 truncated,
86 })
87}
88
89pub const FILE_DIFF_METADATA_KEY: &str = "file_diff";
93
94pub fn file_diff_metadata(path: &str, diff: &UnifiedDiff) -> serde_json::Value {
96 serde_json::json!({
97 FILE_DIFF_METADATA_KEY: {
98 "path": path,
99 "diff": diff.text,
100 "added": diff.added,
101 "removed": diff.removed,
102 "truncated": diff.truncated,
103 }
104 })
105}
106
107pub fn format_diff_chat_message(
110 path: &str,
111 diff_text: &str,
112 added: usize,
113 removed: usize,
114) -> String {
115 format!("📝 `{path}` (+{added} -{removed})\n```diff\n{diff_text}\n```")
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn identical_content_yields_none() {
124 assert!(unified_diff("a\nb\n", "a\nb\n", 120).is_none());
125 }
126
127 #[test]
128 fn counts_added_and_removed_lines() {
129 let old = "one\ntwo\nthree\n";
130 let new = "one\n2\nthree\nfour\n";
131 let d = unified_diff(old, new, 120).expect("diff");
132 assert_eq!(d.added, 2); assert_eq!(d.removed, 1); assert_eq!(d.stats(), "+2 -1");
135 assert!(!d.truncated);
136 assert!(d.text.contains("-two"));
137 assert!(d.text.contains("+2"));
138 assert!(d.text.contains("+four"));
139 assert!(d.text.contains("@@"));
140 }
141
142 #[test]
143 fn truncates_at_max_lines() {
144 let old = (0..200).map(|i| format!("line{i}\n")).collect::<String>();
145 let new = (0..200).map(|i| format!("LINE{i}\n")).collect::<String>();
146 let d = unified_diff(&old, &new, 20).expect("diff");
147 assert!(d.truncated);
148 assert_eq!(d.text.lines().count(), 21);
150 assert!(d.text.ends_with("… (diff truncated at 20 lines)"));
151 assert_eq!(d.added, 200);
153 assert_eq!(d.removed, 200);
154 }
155
156 #[test]
157 fn new_file_diff_from_empty() {
158 let d = unified_diff("", "hello\nworld\n", 120).expect("diff");
159 assert_eq!(d.added, 2);
160 assert_eq!(d.removed, 0);
161 }
162
163 #[test]
164 fn chat_message_format() {
165 let msg = format_diff_chat_message("src/x.rs", "@@ -1 +1 @@\n-a\n+b", 1, 1);
166 assert!(msg.starts_with("📝 `src/x.rs` (+1 -1)\n```diff\n"));
167 assert!(msg.ends_with("\n```"));
168 }
169
170 #[test]
171 fn metadata_shape() {
172 let d = unified_diff("a\n", "b\n", 120).unwrap();
173 let meta = file_diff_metadata("f.txt", &d);
174 let fd = &meta[FILE_DIFF_METADATA_KEY];
175 assert_eq!(fd["path"], "f.txt");
176 assert_eq!(fd["added"], 1);
177 assert_eq!(fd["removed"], 1);
178 assert_eq!(fd["truncated"], false);
179 }
180}