Skip to main content

opendev_tools_impl/
diff_preview.rs

1//! Diff preview tool — generate and display unified diffs between file versions.
2//!
3//! Provides diff generation for showing file changes in a human-readable format.
4//! Uses a simple LCS-based diff algorithm to produce unified-style output.
5
6use std::collections::HashMap;
7
8use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
9
10/// Tool for generating diff previews.
11#[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        // Generate unified diff
79        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        // Calculate stats
88        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/// An edit operation.
133#[derive(Debug, Clone, PartialEq)]
134enum Edit {
135    Keep(usize, usize), // (original_idx, modified_idx)
136    Remove(usize),      // original_idx
137    Add(usize),         // modified_idx
138}
139
140/// Compute the edit script between two sequences using LCS.
141fn compute_edit_script(original: &[&str], modified: &[&str]) -> Vec<Edit> {
142    let n = original.len();
143    let m = modified.len();
144
145    // Build LCS table
146    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    // Backtrack to get edit script
158    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
180/// Generate a unified diff between two sets of lines.
181fn 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    // Check if there are any actual changes
191    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    // Identify indices in the edit array that represent changes
200    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    // Group nearby changes into hunks
212    let mut groups: Vec<(usize, usize)> = Vec::new(); // (first_change_idx, last_change_idx) in edits
213    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    // Render each group as a hunk
228    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;