tldr_cli/commands/bugbot/
diff.rs1use 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
17pub 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
33pub 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
49fn is_function_like(kind: &NodeKind) -> bool {
53 matches!(kind, NodeKind::Function | NodeKind::Method)
54}
55
56pub 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
65pub 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
74pub 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
83pub 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
92pub 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 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 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 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 let report =
338 diff_functions_raw(baseline.path(), current.path()).expect("diff should succeed");
339
340 let _ = report;
344 }
345
346 #[test]
347 fn test_inserted_functions_ignores_non_function_changes() {
348 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}