opendev_tools_impl/
diff_preview.rs1use std::collections::HashMap;
7
8use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
9
10#[derive(Debug)]
12pub struct DiffPreviewTool;
13
14#[async_trait::async_trait]
15impl BaseTool for DiffPreviewTool {
16 fn name(&self) -> &str {
17 "diff_preview"
18 }
19
20 fn description(&self) -> &str {
21 "Generate a unified diff between two versions of a file's content. \
22 Shows additions, removals, and change statistics."
23 }
24
25 fn parameter_schema(&self) -> serde_json::Value {
26 serde_json::json!({
27 "type": "object",
28 "properties": {
29 "file_path": {
30 "type": "string",
31 "description": "Path to the file (used in diff header)"
32 },
33 "original": {
34 "type": "string",
35 "description": "Original file content"
36 },
37 "modified": {
38 "type": "string",
39 "description": "Modified file content"
40 },
41 "context_lines": {
42 "type": "integer",
43 "description": "Number of context lines to show (default: 3)"
44 }
45 },
46 "required": ["file_path", "original", "modified"]
47 })
48 }
49
50 async fn execute(
51 &self,
52 args: HashMap<String, serde_json::Value>,
53 _ctx: &ToolContext,
54 ) -> ToolResult {
55 let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
56 Some(p) => p,
57 None => return ToolResult::fail("file_path is required"),
58 };
59
60 let original = match args.get("original").and_then(|v| v.as_str()) {
61 Some(o) => o,
62 None => return ToolResult::fail("original content is required"),
63 };
64
65 let modified = match args.get("modified").and_then(|v| v.as_str()) {
66 Some(m) => m,
67 None => return ToolResult::fail("modified content is required"),
68 };
69
70 let context_lines = args
71 .get("context_lines")
72 .and_then(|v| v.as_u64())
73 .unwrap_or(3) as usize;
74
75 let original_lines: Vec<&str> = original.split('\n').collect();
76 let modified_lines: Vec<&str> = modified.split('\n').collect();
77
78 let diff_output = unified_diff(
80 &original_lines,
81 &modified_lines,
82 &format!("a/{file_path}"),
83 &format!("b/{file_path}"),
84 context_lines,
85 );
86
87 let mut added = 0usize;
89 let mut removed = 0usize;
90 for line in diff_output.lines() {
91 if line.starts_with('+') && !line.starts_with("+++") {
92 added += 1;
93 } else if line.starts_with('-') && !line.starts_with("---") {
94 removed += 1;
95 }
96 }
97
98 let mut output_parts = Vec::new();
99 output_parts.push(format!("File: {file_path}"));
100 output_parts.push("\u{2500}".repeat(50));
101
102 if diff_output.is_empty() {
103 output_parts.push("No changes detected.".to_string());
104 } else {
105 output_parts.push(diff_output);
106 }
107
108 output_parts.push("\u{2500}".repeat(50));
109 output_parts.push(format!("Changes: +{added} -{removed}"));
110
111 let output = output_parts.join("\n");
112
113 let mut metadata = HashMap::new();
114 metadata.insert("lines_added".into(), serde_json::json!(added));
115 metadata.insert("lines_removed".into(), serde_json::json!(removed));
116 metadata.insert("lines_changed".into(), serde_json::json!(added + removed));
117 metadata.insert("file_path".into(), serde_json::json!(file_path));
118
119 ToolResult::ok_with_metadata(output, metadata)
120 }
121
122 fn display_meta(&self) -> Option<ToolDisplayMeta> {
123 Some(ToolDisplayMeta {
124 verb: "Diff",
125 label: "file",
126 category: "FileWrite",
127 primary_arg_keys: &["file_path"],
128 })
129 }
130}
131
132#[derive(Debug, Clone, PartialEq)]
134enum Edit {
135 Keep(usize, usize), Remove(usize), Add(usize), }
139
140fn compute_edit_script(original: &[&str], modified: &[&str]) -> Vec<Edit> {
142 let n = original.len();
143 let m = modified.len();
144
145 let mut table = vec![vec![0u32; m + 1]; n + 1];
147 for i in 1..=n {
148 for j in 1..=m {
149 if original[i - 1] == modified[j - 1] {
150 table[i][j] = table[i - 1][j - 1] + 1;
151 } else {
152 table[i][j] = table[i - 1][j].max(table[i][j - 1]);
153 }
154 }
155 }
156
157 let mut edits = Vec::new();
159 let mut i = n;
160 let mut j = m;
161
162 while i > 0 || j > 0 {
163 if i > 0 && j > 0 && original[i - 1] == modified[j - 1] {
164 edits.push(Edit::Keep(i - 1, j - 1));
165 i -= 1;
166 j -= 1;
167 } else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
168 edits.push(Edit::Add(j - 1));
169 j -= 1;
170 } else {
171 edits.push(Edit::Remove(i - 1));
172 i -= 1;
173 }
174 }
175
176 edits.reverse();
177 edits
178}
179
180fn unified_diff(
182 original: &[&str],
183 modified: &[&str],
184 from_file: &str,
185 to_file: &str,
186 context: usize,
187) -> String {
188 let edits = compute_edit_script(original, modified);
189
190 if edits.iter().all(|e| matches!(e, Edit::Keep(_, _))) {
192 return String::new();
193 }
194
195 let mut output = Vec::new();
196 output.push(format!("--- {from_file}"));
197 output.push(format!("+++ {to_file}"));
198
199 let change_indices: Vec<usize> = edits
201 .iter()
202 .enumerate()
203 .filter(|(_, e)| !matches!(e, Edit::Keep(_, _)))
204 .map(|(i, _)| i)
205 .collect();
206
207 if change_indices.is_empty() {
208 return String::new();
209 }
210
211 let mut groups: Vec<(usize, usize)> = Vec::new(); let mut group_start = change_indices[0];
214 let mut group_end = change_indices[0];
215
216 for &idx in &change_indices[1..] {
217 if idx - group_end <= context * 2 + 1 {
218 group_end = idx;
219 } else {
220 groups.push((group_start, group_end));
221 group_start = idx;
222 group_end = idx;
223 }
224 }
225 groups.push((group_start, group_end));
226
227 for (start, end) in groups {
229 let hunk_start = start.saturating_sub(context);
230 let hunk_end = (end + context + 1).min(edits.len());
231
232 let mut orig_start = 0usize;
233 let mut mod_start = 0usize;
234 let mut orig_count = 0usize;
235 let mut mod_count = 0usize;
236 let mut lines = Vec::new();
237 let mut first = true;
238
239 for edit in &edits[hunk_start..hunk_end] {
240 match edit {
241 Edit::Keep(oi, mi) => {
242 if first {
243 orig_start = *oi;
244 mod_start = *mi;
245 first = false;
246 }
247 lines.push(format!(" {}", original[*oi]));
248 orig_count += 1;
249 mod_count += 1;
250 }
251 Edit::Remove(oi) => {
252 if first {
253 orig_start = *oi;
254 mod_start = if *oi > 0 { *oi } else { 0 };
255 first = false;
256 }
257 lines.push(format!("-{}", original[*oi]));
258 orig_count += 1;
259 }
260 Edit::Add(mi) => {
261 if first {
262 orig_start = if *mi > 0 { *mi } else { 0 };
263 mod_start = *mi;
264 first = false;
265 }
266 lines.push(format!("+{}", modified[*mi]));
267 mod_count += 1;
268 }
269 }
270 }
271
272 output.push(format!(
273 "@@ -{},{} +{},{} @@",
274 orig_start + 1,
275 orig_count,
276 mod_start + 1,
277 mod_count
278 ));
279 output.extend(lines);
280 }
281
282 output.join("\n")
283}
284
285#[cfg(test)]
286#[path = "diff_preview_tests.rs"]
287mod tests;