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 =
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 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 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 let report =
353 diff_functions_raw(baseline.path(), current.path()).expect("diff should succeed");
354
355 let _ = report;
359 }
360
361 #[test]
362 fn test_inserted_functions_ignores_non_function_changes() {
363 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}