Skip to main content

tldr_cli/commands/bugbot/
diff.rs

1//! Function-level AST diff for bugbot
2//!
3//! Wraps the existing `DiffArgs::run_to_report()` infrastructure to compare
4//! a baseline file (from git) against the current working-tree version.
5//! Exposes convenience helpers to categorize changes by type (inserted,
6//! updated, deleted functions).
7
8use std::path::Path;
9
10use anyhow::Result;
11
12use crate::commands::remaining::diff::DiffArgs;
13use crate::commands::remaining::types::{
14    ASTChange, ChangeType, DiffGranularity, DiffReport, NodeKind,
15};
16
17/// Compute function-level AST diff between a baseline file and the current file.
18///
19/// Both paths must point to existing files with the same language extension.
20/// The diff is performed at function granularity with `semantic_only` enabled
21/// so that whitespace/comment-only changes are excluded.
22pub fn diff_functions(baseline_path: &Path, current_path: &Path) -> Result<DiffReport> {
23    let diff_args = DiffArgs {
24        file_a: baseline_path.to_path_buf(),
25        file_b: current_path.to_path_buf(),
26        granularity: DiffGranularity::Function,
27        semantic_only: true,
28        output: None,
29    };
30    diff_args.run_to_report()
31}
32
33/// Compute function-level AST diff without the semantic-only filter.
34///
35/// This variant preserves formatting-only changes in the report, which
36/// can be useful when the caller needs to see all changes including
37/// whitespace and comment modifications.
38pub fn diff_functions_raw(baseline_path: &Path, current_path: &Path) -> Result<DiffReport> {
39    let diff_args = DiffArgs {
40        file_a: baseline_path.to_path_buf(),
41        file_b: current_path.to_path_buf(),
42        granularity: DiffGranularity::Function,
43        semantic_only: false,
44        output: None,
45    };
46    diff_args.run_to_report()
47}
48
49/// Returns true if the given `NodeKind` represents a function-like construct.
50///
51/// Currently matches `Function` and `Method`.
52fn is_function_like(kind: &NodeKind) -> bool {
53    matches!(kind, NodeKind::Function | NodeKind::Method)
54}
55
56/// Filter to only inserted functions (new functions added in the current file).
57pub fn inserted_functions(changes: &[ASTChange]) -> Vec<&ASTChange> {
58    changes
59        .iter()
60        .filter(|c| matches!(c.change_type, ChangeType::Insert))
61        .filter(|c| is_function_like(&c.node_kind))
62        .collect()
63}
64
65/// Filter to only updated functions (modified function bodies).
66pub fn updated_functions(changes: &[ASTChange]) -> Vec<&ASTChange> {
67    changes
68        .iter()
69        .filter(|c| matches!(c.change_type, ChangeType::Update))
70        .filter(|c| is_function_like(&c.node_kind))
71        .collect()
72}
73
74/// Filter to only deleted functions (functions removed in the current file).
75pub fn deleted_functions(changes: &[ASTChange]) -> Vec<&ASTChange> {
76    changes
77        .iter()
78        .filter(|c| matches!(c.change_type, ChangeType::Delete))
79        .filter(|c| is_function_like(&c.node_kind))
80        .collect()
81}
82
83/// Filter to only renamed functions (same body, different name).
84pub fn renamed_functions(changes: &[ASTChange]) -> Vec<&ASTChange> {
85    changes
86        .iter()
87        .filter(|c| matches!(c.change_type, ChangeType::Rename))
88        .filter(|c| is_function_like(&c.node_kind))
89        .collect()
90}
91
92/// Filter to all function-like changes regardless of change type.
93pub fn all_function_changes(changes: &[ASTChange]) -> Vec<&ASTChange> {
94    changes
95        .iter()
96        .filter(|c| is_function_like(&c.node_kind))
97        .collect()
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use std::io::Write;
104    use tempfile::NamedTempFile;
105
106    /// Write content to a temporary file with a `.rs` extension so tree-sitter
107    /// can detect Rust as the language.
108    fn write_temp_rs(content: &str) -> NamedTempFile {
109        let mut f = tempfile::Builder::new()
110            .suffix(".rs")
111            .tempfile()
112            .expect("create temp file");
113        f.write_all(content.as_bytes())
114            .expect("write temp file content");
115        f.flush().expect("flush temp file");
116        f
117    }
118
119    #[test]
120    fn test_diff_no_changes() {
121        let code = r#"
122fn hello() {
123    println!("hello");
124}
125
126fn world() -> i32 {
127    42
128}
129"#;
130        let baseline = write_temp_rs(code);
131        let current = write_temp_rs(code);
132
133        let report = diff_functions(baseline.path(), current.path()).expect("diff should succeed");
134
135        assert!(
136            report.identical,
137            "Identical files should produce an identical report"
138        );
139        assert!(
140            report.changes.is_empty(),
141            "Identical files should produce zero changes, got: {:?}",
142            report.changes
143        );
144    }
145
146    #[test]
147    fn test_diff_new_function_inserted() {
148        let baseline_code = r#"
149fn existing() {
150    println!("existing");
151}
152"#;
153        let current_code = r#"
154fn existing() {
155    println!("existing");
156}
157
158fn brand_new() {
159    println!("I am new");
160}
161"#;
162        let baseline = write_temp_rs(baseline_code);
163        let current = write_temp_rs(current_code);
164
165        let report = diff_functions(baseline.path(), current.path()).expect("diff should succeed");
166
167        assert!(
168            !report.identical,
169            "Files with a new function should not be identical"
170        );
171
172        let inserts = inserted_functions(&report.changes);
173        assert!(
174            !inserts.is_empty(),
175            "Should detect at least one inserted function, got changes: {:?}",
176            report.changes
177        );
178
179        // Verify the inserted function has the expected name
180        let names: Vec<&str> = inserts.iter().filter_map(|c| c.name.as_deref()).collect();
181        assert!(
182            names.contains(&"brand_new"),
183            "Inserted function should be named 'brand_new', got names: {:?}",
184            names
185        );
186    }
187
188    #[test]
189    fn test_diff_function_deleted() {
190        let baseline_code = r#"
191fn keeper() {
192    println!("I stay");
193}
194
195fn doomed() {
196    println!("I will be removed");
197}
198"#;
199        let current_code = r#"
200fn keeper() {
201    println!("I stay");
202}
203"#;
204        let baseline = write_temp_rs(baseline_code);
205        let current = write_temp_rs(current_code);
206
207        let report = diff_functions(baseline.path(), current.path()).expect("diff should succeed");
208
209        assert!(
210            !report.identical,
211            "Files with a deleted function should not be identical"
212        );
213
214        let deletes = deleted_functions(&report.changes);
215        assert!(
216            !deletes.is_empty(),
217            "Should detect at least one deleted function, got changes: {:?}",
218            report.changes
219        );
220
221        let names: Vec<&str> = deletes.iter().filter_map(|c| c.name.as_deref()).collect();
222        assert!(
223            names.contains(&"doomed"),
224            "Deleted function should be named 'doomed', got names: {:?}",
225            names
226        );
227    }
228
229    #[test]
230    fn test_diff_function_body_updated() {
231        let baseline_code = r#"
232fn compute() -> i32 {
233    let x = 1;
234    let y = 2;
235    x + y
236}
237"#;
238        let current_code = r#"
239fn compute() -> i32 {
240    let x = 10;
241    let y = 20;
242    x * y
243}
244"#;
245        let baseline = write_temp_rs(baseline_code);
246        let current = write_temp_rs(current_code);
247
248        let report = diff_functions(baseline.path(), current.path()).expect("diff should succeed");
249
250        assert!(
251            !report.identical,
252            "Files with a modified function body should not be identical"
253        );
254
255        let updates = updated_functions(&report.changes);
256        assert!(
257            !updates.is_empty(),
258            "Should detect at least one updated function, got changes: {:?}",
259            report.changes
260        );
261
262        let names: Vec<&str> = updates.iter().filter_map(|c| c.name.as_deref()).collect();
263        assert!(
264            names.contains(&"compute"),
265            "Updated function should be named 'compute', got names: {:?}",
266            names
267        );
268    }
269
270    #[test]
271    fn test_diff_whitespace_only() {
272        let baseline_code = "fn spaced() {\n    println!(\"hello\");\n}\n";
273        let current_code = "fn spaced() {\n        println!(\"hello\");\n}\n";
274
275        let baseline = write_temp_rs(baseline_code);
276        let current = write_temp_rs(current_code);
277
278        // With semantic_only=true (the default for diff_functions), whitespace
279        // changes should either be absent or classified as Format.
280        let report = diff_functions(baseline.path(), current.path()).expect("diff should succeed");
281
282        let semantic: Vec<&ASTChange> = report
283            .changes
284            .iter()
285            .filter(|c| !matches!(c.change_type, ChangeType::Format))
286            .collect();
287
288        assert!(
289            semantic.is_empty(),
290            "Whitespace-only changes should produce no semantic changes (with semantic_only), got: {:?}",
291            semantic
292        );
293    }
294
295    #[test]
296    fn test_all_function_changes_filter() {
297        let baseline_code = r#"
298fn alpha() {
299    println!("a");
300}
301
302fn beta() {
303    println!("b");
304}
305"#;
306        let current_code = r#"
307fn alpha() {
308    println!("a modified");
309}
310
311fn gamma() {
312    println!("c");
313}
314"#;
315        let baseline = write_temp_rs(baseline_code);
316        let current = write_temp_rs(current_code);
317
318        let report = diff_functions(baseline.path(), current.path()).expect("diff should succeed");
319
320        let func_changes = all_function_changes(&report.changes);
321        assert!(
322            func_changes.len() >= 2,
323            "Should have at least 2 function-level changes (update alpha, delete beta, insert gamma), got {}",
324            func_changes.len()
325        );
326    }
327
328    #[test]
329    fn test_diff_functions_raw_preserves_format_changes() {
330        let baseline_code = "fn fmt_test() {\n    let x = 1;\n}\n";
331        let current_code = "fn fmt_test() {\n        let x = 1;\n}\n";
332
333        let baseline = write_temp_rs(baseline_code);
334        let current = write_temp_rs(current_code);
335
336        // raw diff should succeed even if there are only formatting changes
337        let report =
338            diff_functions_raw(baseline.path(), current.path()).expect("diff should succeed");
339
340        // We don't assert on change count because the diff engine may or may
341        // not detect the indentation shift as a change. We just verify it
342        // doesn't error.
343        let _ = report;
344    }
345
346    #[test]
347    fn test_inserted_functions_ignores_non_function_changes() {
348        // Manually construct changes to test the filter helpers in isolation
349        let changes = vec![
350            ASTChange {
351                change_type: ChangeType::Insert,
352                node_kind: NodeKind::Function,
353                name: Some("func_a".to_string()),
354                old_location: None,
355                new_location: None,
356                old_text: None,
357                new_text: None,
358                similarity: None,
359                children: None,
360                base_changes: None,
361            },
362            ASTChange {
363                change_type: ChangeType::Insert,
364                node_kind: NodeKind::Class,
365                name: Some("ClassB".to_string()),
366                old_location: None,
367                new_location: None,
368                old_text: None,
369                new_text: None,
370                similarity: None,
371                children: None,
372                base_changes: None,
373            },
374            ASTChange {
375                change_type: ChangeType::Delete,
376                node_kind: NodeKind::Function,
377                name: Some("func_c".to_string()),
378                old_location: None,
379                new_location: None,
380                old_text: None,
381                new_text: None,
382                similarity: None,
383                children: None,
384                base_changes: None,
385            },
386            ASTChange {
387                change_type: ChangeType::Insert,
388                node_kind: NodeKind::Method,
389                name: Some("method_d".to_string()),
390                old_location: None,
391                new_location: None,
392                old_text: None,
393                new_text: None,
394                similarity: None,
395                children: None,
396                base_changes: None,
397            },
398        ];
399
400        let inserts = inserted_functions(&changes);
401        assert_eq!(
402            inserts.len(),
403            2,
404            "Should find 2 inserted function-like nodes (func_a + method_d)"
405        );
406        assert_eq!(inserts[0].name.as_deref(), Some("func_a"));
407        assert_eq!(inserts[1].name.as_deref(), Some("method_d"));
408
409        let deletes = deleted_functions(&changes);
410        assert_eq!(deletes.len(), 1, "Should find 1 deleted function");
411        assert_eq!(deletes[0].name.as_deref(), Some("func_c"));
412
413        let updates = updated_functions(&changes);
414        assert!(updates.is_empty(), "No updates in this set");
415
416        let renames = renamed_functions(&changes);
417        assert!(renames.is_empty(), "No renames in this set");
418    }
419}