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 =
134            diff_functions(baseline.path(), current.path()).expect("diff should succeed");
135
136        assert!(
137            report.identical,
138            "Identical files should produce an identical report"
139        );
140        assert!(
141            report.changes.is_empty(),
142            "Identical files should produce zero changes, got: {:?}",
143            report.changes
144        );
145    }
146
147    #[test]
148    fn test_diff_new_function_inserted() {
149        let baseline_code = r#"
150fn existing() {
151    println!("existing");
152}
153"#;
154        let current_code = r#"
155fn existing() {
156    println!("existing");
157}
158
159fn brand_new() {
160    println!("I am new");
161}
162"#;
163        let baseline = write_temp_rs(baseline_code);
164        let current = write_temp_rs(current_code);
165
166        let report =
167            diff_functions(baseline.path(), current.path()).expect("diff should succeed");
168
169        assert!(
170            !report.identical,
171            "Files with a new function should not be identical"
172        );
173
174        let inserts = inserted_functions(&report.changes);
175        assert!(
176            !inserts.is_empty(),
177            "Should detect at least one inserted function, got changes: {:?}",
178            report.changes
179        );
180
181        // Verify the inserted function has the expected name
182        let names: Vec<&str> = inserts
183            .iter()
184            .filter_map(|c| c.name.as_deref())
185            .collect();
186        assert!(
187            names.contains(&"brand_new"),
188            "Inserted function should be named 'brand_new', got names: {:?}",
189            names
190        );
191    }
192
193    #[test]
194    fn test_diff_function_deleted() {
195        let baseline_code = r#"
196fn keeper() {
197    println!("I stay");
198}
199
200fn doomed() {
201    println!("I will be removed");
202}
203"#;
204        let current_code = r#"
205fn keeper() {
206    println!("I stay");
207}
208"#;
209        let baseline = write_temp_rs(baseline_code);
210        let current = write_temp_rs(current_code);
211
212        let report =
213            diff_functions(baseline.path(), current.path()).expect("diff should succeed");
214
215        assert!(
216            !report.identical,
217            "Files with a deleted function should not be identical"
218        );
219
220        let deletes = deleted_functions(&report.changes);
221        assert!(
222            !deletes.is_empty(),
223            "Should detect at least one deleted function, got changes: {:?}",
224            report.changes
225        );
226
227        let names: Vec<&str> = deletes
228            .iter()
229            .filter_map(|c| c.name.as_deref())
230            .collect();
231        assert!(
232            names.contains(&"doomed"),
233            "Deleted function should be named 'doomed', got names: {:?}",
234            names
235        );
236    }
237
238    #[test]
239    fn test_diff_function_body_updated() {
240        let baseline_code = r#"
241fn compute() -> i32 {
242    let x = 1;
243    let y = 2;
244    x + y
245}
246"#;
247        let current_code = r#"
248fn compute() -> i32 {
249    let x = 10;
250    let y = 20;
251    x * y
252}
253"#;
254        let baseline = write_temp_rs(baseline_code);
255        let current = write_temp_rs(current_code);
256
257        let report =
258            diff_functions(baseline.path(), current.path()).expect("diff should succeed");
259
260        assert!(
261            !report.identical,
262            "Files with a modified function body should not be identical"
263        );
264
265        let updates = updated_functions(&report.changes);
266        assert!(
267            !updates.is_empty(),
268            "Should detect at least one updated function, got changes: {:?}",
269            report.changes
270        );
271
272        let names: Vec<&str> = updates
273            .iter()
274            .filter_map(|c| c.name.as_deref())
275            .collect();
276        assert!(
277            names.contains(&"compute"),
278            "Updated function should be named 'compute', got names: {:?}",
279            names
280        );
281    }
282
283    #[test]
284    fn test_diff_whitespace_only() {
285        let baseline_code = "fn spaced() {\n    println!(\"hello\");\n}\n";
286        let current_code = "fn spaced() {\n        println!(\"hello\");\n}\n";
287
288        let baseline = write_temp_rs(baseline_code);
289        let current = write_temp_rs(current_code);
290
291        // With semantic_only=true (the default for diff_functions), whitespace
292        // changes should either be absent or classified as Format.
293        let report =
294            diff_functions(baseline.path(), current.path()).expect("diff should succeed");
295
296        let semantic: Vec<&ASTChange> = report
297            .changes
298            .iter()
299            .filter(|c| !matches!(c.change_type, ChangeType::Format))
300            .collect();
301
302        assert!(
303            semantic.is_empty(),
304            "Whitespace-only changes should produce no semantic changes (with semantic_only), got: {:?}",
305            semantic
306        );
307    }
308
309    #[test]
310    fn test_all_function_changes_filter() {
311        let baseline_code = r#"
312fn alpha() {
313    println!("a");
314}
315
316fn beta() {
317    println!("b");
318}
319"#;
320        let current_code = r#"
321fn alpha() {
322    println!("a modified");
323}
324
325fn gamma() {
326    println!("c");
327}
328"#;
329        let baseline = write_temp_rs(baseline_code);
330        let current = write_temp_rs(current_code);
331
332        let report =
333            diff_functions(baseline.path(), current.path()).expect("diff should succeed");
334
335        let func_changes = all_function_changes(&report.changes);
336        assert!(
337            func_changes.len() >= 2,
338            "Should have at least 2 function-level changes (update alpha, delete beta, insert gamma), got {}",
339            func_changes.len()
340        );
341    }
342
343    #[test]
344    fn test_diff_functions_raw_preserves_format_changes() {
345        let baseline_code = "fn fmt_test() {\n    let x = 1;\n}\n";
346        let current_code = "fn fmt_test() {\n        let x = 1;\n}\n";
347
348        let baseline = write_temp_rs(baseline_code);
349        let current = write_temp_rs(current_code);
350
351        // raw diff should succeed even if there are only formatting changes
352        let report =
353            diff_functions_raw(baseline.path(), current.path()).expect("diff should succeed");
354
355        // We don't assert on change count because the diff engine may or may
356        // not detect the indentation shift as a change. We just verify it
357        // doesn't error.
358        let _ = report;
359    }
360
361    #[test]
362    fn test_inserted_functions_ignores_non_function_changes() {
363        // Manually construct changes to test the filter helpers in isolation
364        let changes = vec![
365            ASTChange {
366                change_type: ChangeType::Insert,
367                node_kind: NodeKind::Function,
368                name: Some("func_a".to_string()),
369                old_location: None,
370                new_location: None,
371                old_text: None,
372                new_text: None,
373                similarity: None,
374                children: None,
375                base_changes: None,
376            },
377            ASTChange {
378                change_type: ChangeType::Insert,
379                node_kind: NodeKind::Class,
380                name: Some("ClassB".to_string()),
381                old_location: None,
382                new_location: None,
383                old_text: None,
384                new_text: None,
385                similarity: None,
386                children: None,
387                base_changes: None,
388            },
389            ASTChange {
390                change_type: ChangeType::Delete,
391                node_kind: NodeKind::Function,
392                name: Some("func_c".to_string()),
393                old_location: None,
394                new_location: None,
395                old_text: None,
396                new_text: None,
397                similarity: None,
398                children: None,
399                base_changes: None,
400            },
401            ASTChange {
402                change_type: ChangeType::Insert,
403                node_kind: NodeKind::Method,
404                name: Some("method_d".to_string()),
405                old_location: None,
406                new_location: None,
407                old_text: None,
408                new_text: None,
409                similarity: None,
410                children: None,
411                base_changes: None,
412            },
413        ];
414
415        let inserts = inserted_functions(&changes);
416        assert_eq!(
417            inserts.len(),
418            2,
419            "Should find 2 inserted function-like nodes (func_a + method_d)"
420        );
421        assert_eq!(inserts[0].name.as_deref(), Some("func_a"));
422        assert_eq!(inserts[1].name.as_deref(), Some("method_d"));
423
424        let deletes = deleted_functions(&changes);
425        assert_eq!(deletes.len(), 1, "Should find 1 deleted function");
426        assert_eq!(deletes[0].name.as_deref(), Some("func_c"));
427
428        let updates = updated_functions(&changes);
429        assert!(updates.is_empty(), "No updates in this set");
430
431        let renames = renamed_functions(&changes);
432        assert!(renames.is_empty(), "No renames in this set");
433    }
434}