1use std::collections::HashMap;
7
8use serde::Serialize;
9
10use crate::gir::{ComplexityMetrics, NodeKind, Visibility};
11use crate::graph::CodeGraph;
12use crate::symbol_id::SymbolId;
13
14#[derive(Debug, Clone, Serialize)]
16pub struct DiffEntry {
17 pub name: String,
18 pub kind: NodeKind,
19 pub file_path: String,
20 pub line: u32,
21 pub visibility: Visibility,
22}
23
24#[derive(Debug, Clone, Serialize)]
26pub struct ChangedSymbol {
27 pub name: String,
28 pub kind: NodeKind,
29 pub file_path: String,
30 pub line: u32,
31 pub changes: Vec<ChangeDetail>,
32}
33
34#[derive(Debug, Clone, Serialize)]
36pub enum ChangeDetail {
37 SignatureChanged {
38 old: Option<String>,
39 new: Option<String>,
40 },
41 VisibilityChanged {
42 old: Visibility,
43 new: Visibility,
44 },
45 Moved {
46 old_file: String,
47 old_line: u32,
48 new_file: String,
49 new_line: u32,
50 },
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct BreakingChange {
56 pub severity: Severity,
57 pub description: String,
58 pub symbol_name: String,
59 pub kind: NodeKind,
60 pub file_path: String,
61 pub line: u32,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65pub enum Severity {
66 Error,
67 Warning,
68}
69
70#[derive(Debug, Clone, Serialize)]
72pub struct ComplexityChange {
73 pub name: String,
74 pub file_path: String,
75 pub line: u32,
76 pub old: ComplexityMetrics,
77 pub new: ComplexityMetrics,
78 pub cyclomatic_delta: i32,
79 pub cognitive_delta: i32,
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct GraphDiff {
85 pub removed_symbols: Vec<DiffEntry>,
86 pub added_symbols: Vec<DiffEntry>,
87 pub changed_symbols: Vec<ChangedSymbol>,
88 pub breaking_changes: Vec<BreakingChange>,
89 pub complexity_changes: Vec<ComplexityChange>,
90 pub new_dead_code: Vec<DiffEntry>,
91}
92
93pub fn diff_graphs(base: &CodeGraph, head: &CodeGraph) -> GraphDiff {
95 let base_map: HashMap<SymbolId, _> = base.all_nodes().map(|n| (n.id, n)).collect();
97 let head_map: HashMap<SymbolId, _> = head.all_nodes().map(|n| (n.id, n)).collect();
98
99 let base_by_name_kind: HashMap<(&str, NodeKind), Vec<_>> = {
101 let mut m: HashMap<(&str, NodeKind), Vec<_>> = HashMap::new();
102 for n in base.all_nodes() {
103 m.entry((&n.name, n.kind)).or_default().push(n);
104 }
105 m
106 };
107
108 let mut removed_symbols = Vec::new();
109 let mut added_symbols = Vec::new();
110 let mut changed_symbols = Vec::new();
111 let mut breaking_changes = Vec::new();
112 let mut complexity_changes = Vec::new();
113
114 for (id, node) in &base_map {
116 if !head_map.contains_key(id) {
117 let moved = head
119 .find_by_name(&node.name)
120 .iter()
121 .any(|h| h.kind == node.kind && !base_map.contains_key(&h.id));
122
123 if !moved {
124 removed_symbols.push(DiffEntry {
125 name: node.name.clone(),
126 kind: node.kind,
127 file_path: node.file_path.to_string_lossy().into(),
128 line: node.span.start_line,
129 visibility: node.visibility,
130 });
131
132 if is_public_api(node.visibility) && is_api_relevant_kind(node.kind) {
134 breaking_changes.push(BreakingChange {
135 severity: Severity::Error,
136 description: format!(
137 "Removed public {:?} `{}`",
138 node.kind, node.name
139 ),
140 symbol_name: node.name.clone(),
141 kind: node.kind,
142 file_path: node.file_path.to_string_lossy().into(),
143 line: node.span.start_line,
144 });
145 }
146 }
147 }
148 }
149
150 for (id, node) in &head_map {
152 if !base_map.contains_key(id) {
153 let moved = base_by_name_kind
154 .get(&(node.name.as_str(), node.kind))
155 .map_or(false, |base_nodes| {
156 base_nodes.iter().any(|b| !head_map.contains_key(&b.id))
157 });
158
159 if !moved {
160 added_symbols.push(DiffEntry {
161 name: node.name.clone(),
162 kind: node.kind,
163 file_path: node.file_path.to_string_lossy().into(),
164 line: node.span.start_line,
165 visibility: node.visibility,
166 });
167 }
168 }
169 }
170
171 for (id, base_node) in &base_map {
173 if let Some(head_node) = head_map.get(id) {
174 let mut changes = Vec::new();
175
176 if base_node.signature != head_node.signature {
178 changes.push(ChangeDetail::SignatureChanged {
179 old: base_node.signature.clone(),
180 new: head_node.signature.clone(),
181 });
182
183 if is_public_api(base_node.visibility) && is_api_relevant_kind(base_node.kind) {
184 breaking_changes.push(BreakingChange {
185 severity: Severity::Warning,
186 description: format!(
187 "Signature changed for public {:?} `{}`",
188 base_node.kind, base_node.name
189 ),
190 symbol_name: base_node.name.clone(),
191 kind: base_node.kind,
192 file_path: head_node.file_path.to_string_lossy().into(),
193 line: head_node.span.start_line,
194 });
195 }
196 }
197
198 if base_node.visibility != head_node.visibility {
200 changes.push(ChangeDetail::VisibilityChanged {
201 old: base_node.visibility,
202 new: head_node.visibility,
203 });
204
205 if is_public_api(base_node.visibility) && !is_public_api(head_node.visibility) {
206 breaking_changes.push(BreakingChange {
207 severity: Severity::Error,
208 description: format!(
209 "Visibility narrowed for `{}`: {:?} -> {:?}",
210 base_node.name, base_node.visibility, head_node.visibility
211 ),
212 symbol_name: base_node.name.clone(),
213 kind: base_node.kind,
214 file_path: head_node.file_path.to_string_lossy().into(),
215 line: head_node.span.start_line,
216 });
217 }
218 }
219
220 if base_node.file_path != head_node.file_path
222 || base_node.span.start_line != head_node.span.start_line
223 {
224 changes.push(ChangeDetail::Moved {
225 old_file: base_node.file_path.to_string_lossy().into(),
226 old_line: base_node.span.start_line,
227 new_file: head_node.file_path.to_string_lossy().into(),
228 new_line: head_node.span.start_line,
229 });
230 }
231
232 if let (Some(old_cx), Some(new_cx)) =
234 (&base_node.complexity, &head_node.complexity)
235 {
236 let cyc_delta = new_cx.cyclomatic as i32 - old_cx.cyclomatic as i32;
237 let cog_delta = new_cx.cognitive as i32 - old_cx.cognitive as i32;
238
239 if cyc_delta != 0 || cog_delta != 0 {
240 complexity_changes.push(ComplexityChange {
241 name: head_node.name.clone(),
242 file_path: head_node.file_path.to_string_lossy().into(),
243 line: head_node.span.start_line,
244 old: *old_cx,
245 new: *new_cx,
246 cyclomatic_delta: cyc_delta,
247 cognitive_delta: cog_delta,
248 });
249 }
250 }
251
252 if !changes.is_empty() {
253 changed_symbols.push(ChangedSymbol {
254 name: head_node.name.clone(),
255 kind: head_node.kind,
256 file_path: head_node.file_path.to_string_lossy().into(),
257 line: head_node.span.start_line,
258 changes,
259 });
260 }
261 }
262 }
263
264 let new_dead_code: Vec<DiffEntry> = head
266 .all_nodes()
267 .filter(|n| n.kind.is_callable())
268 .filter(|n| !base_map.contains_key(&n.id))
269 .filter(|n| {
270 head.callers(n.id).is_empty()
271 && n.name != "main"
272 && n.name != "__init__"
273 && !n.name.starts_with("test_")
274 })
275 .map(|n| DiffEntry {
276 name: n.name.clone(),
277 kind: n.kind,
278 file_path: n.file_path.to_string_lossy().into(),
279 line: n.span.start_line,
280 visibility: n.visibility,
281 })
282 .collect();
283
284 GraphDiff {
285 removed_symbols,
286 added_symbols,
287 changed_symbols,
288 breaking_changes,
289 complexity_changes,
290 new_dead_code,
291 }
292}
293
294pub fn format_diff_text(diff: &GraphDiff) -> String {
296 let mut out = String::new();
297
298 if !diff.breaking_changes.is_empty() {
300 out.push_str(&format!(
301 "BREAKING CHANGES ({}):\n",
302 diff.breaking_changes.len()
303 ));
304 for bc in &diff.breaking_changes {
305 let icon = match bc.severity {
306 Severity::Error => "ERROR",
307 Severity::Warning => "WARN ",
308 };
309 out.push_str(&format!(
310 " [{}] {} ({}:{})\n",
311 icon, bc.description, bc.file_path, bc.line
312 ));
313 }
314 out.push('\n');
315 }
316
317 out.push_str(&format!(
319 "Summary: +{} added, -{} removed, ~{} changed\n",
320 diff.added_symbols.len(),
321 diff.removed_symbols.len(),
322 diff.changed_symbols.len(),
323 ));
324
325 if !diff.added_symbols.is_empty() {
326 out.push_str(&format!("\nAdded ({}):\n", diff.added_symbols.len()));
327 for s in &diff.added_symbols {
328 out.push_str(&format!(
329 " + {:?} {} ({}:{})\n",
330 s.kind, s.name, s.file_path, s.line
331 ));
332 }
333 }
334
335 if !diff.removed_symbols.is_empty() {
336 out.push_str(&format!("\nRemoved ({}):\n", diff.removed_symbols.len()));
337 for s in &diff.removed_symbols {
338 out.push_str(&format!(
339 " - {:?} {} ({}:{})\n",
340 s.kind, s.name, s.file_path, s.line
341 ));
342 }
343 }
344
345 if !diff.changed_symbols.is_empty() {
346 out.push_str(&format!("\nChanged ({}):\n", diff.changed_symbols.len()));
347 for s in &diff.changed_symbols {
348 out.push_str(&format!(" ~ {:?} {} ({}:{})\n", s.kind, s.name, s.file_path, s.line));
349 for change in &s.changes {
350 match change {
351 ChangeDetail::SignatureChanged { old, new } => {
352 out.push_str(&format!(
353 " signature: {} -> {}\n",
354 old.as_deref().unwrap_or("(none)"),
355 new.as_deref().unwrap_or("(none)")
356 ));
357 }
358 ChangeDetail::VisibilityChanged { old, new } => {
359 out.push_str(&format!(" visibility: {:?} -> {:?}\n", old, new));
360 }
361 ChangeDetail::Moved {
362 old_file,
363 old_line,
364 new_file,
365 new_line,
366 } => {
367 out.push_str(&format!(
368 " moved: {}:{} -> {}:{}\n",
369 old_file, old_line, new_file, new_line
370 ));
371 }
372 }
373 }
374 }
375 }
376
377 if !diff.complexity_changes.is_empty() {
378 out.push_str(&format!(
379 "\nComplexity changes ({}):\n",
380 diff.complexity_changes.len()
381 ));
382 for c in &diff.complexity_changes {
383 let cyc_arrow = if c.cyclomatic_delta > 0 { "+" } else { "" };
384 let cog_arrow = if c.cognitive_delta > 0 { "+" } else { "" };
385 out.push_str(&format!(
386 " {} ({}:{}): cyclomatic {}{}, cognitive {}{}\n",
387 c.name, c.file_path, c.line, cyc_arrow, c.cyclomatic_delta, cog_arrow, c.cognitive_delta
388 ));
389 }
390 }
391
392 if !diff.new_dead_code.is_empty() {
393 out.push_str(&format!(
394 "\nNew dead code ({}):\n",
395 diff.new_dead_code.len()
396 ));
397 for s in &diff.new_dead_code {
398 out.push_str(&format!(
399 " ! {:?} {} ({}:{})\n",
400 s.kind, s.name, s.file_path, s.line
401 ));
402 }
403 }
404
405 out
406}
407
408fn is_public_api(vis: Visibility) -> bool {
409 matches!(vis, Visibility::Public | Visibility::Exported)
410}
411
412fn is_api_relevant_kind(kind: NodeKind) -> bool {
413 matches!(
414 kind,
415 NodeKind::Function
416 | NodeKind::Method
417 | NodeKind::Class
418 | NodeKind::Struct
419 | NodeKind::Enum
420 | NodeKind::Interface
421 | NodeKind::Trait
422 | NodeKind::TypeAlias
423 | NodeKind::Constant
424 | NodeKind::Property
425 | NodeKind::Field
426 )
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::gir::{GirNode, Language, Span};
433 use std::path::PathBuf;
434
435 fn make_node(name: &str, kind: NodeKind, line: u32) -> GirNode {
436 GirNode::new(
437 name.to_string(),
438 kind,
439 PathBuf::from("test.py"),
440 Span::new(line, 0, line + 5, 0),
441 Language::Python,
442 )
443 }
444
445 fn make_public_node(name: &str, kind: NodeKind, line: u32) -> GirNode {
446 let mut n = make_node(name, kind, line);
447 n.visibility = Visibility::Public;
448 n
449 }
450
451 #[test]
452 fn detect_added_and_removed() {
453 let mut base = CodeGraph::new();
454 let mut head = CodeGraph::new();
455
456 base.add_node(make_node("old_func", NodeKind::Function, 1));
457 base.add_node(make_node("shared_func", NodeKind::Function, 10));
458
459 head.add_node(make_node("shared_func", NodeKind::Function, 10));
460 head.add_node(make_node("new_func", NodeKind::Function, 20));
461
462 let diff = diff_graphs(&base, &head);
463
464 assert_eq!(diff.removed_symbols.len(), 1);
465 assert_eq!(diff.removed_symbols[0].name, "old_func");
466 assert_eq!(diff.added_symbols.len(), 1);
467 assert_eq!(diff.added_symbols[0].name, "new_func");
468 }
469
470 #[test]
471 fn detect_breaking_change_removal() {
472 let mut base = CodeGraph::new();
473 let head = CodeGraph::new();
474
475 base.add_node(make_public_node("public_api", NodeKind::Function, 1));
476
477 let diff = diff_graphs(&base, &head);
478
479 assert_eq!(diff.breaking_changes.len(), 1);
480 assert_eq!(diff.breaking_changes[0].severity, Severity::Error);
481 assert!(diff.breaking_changes[0].description.contains("Removed"));
482 }
483
484 #[test]
485 fn detect_signature_change() {
486 let mut base = CodeGraph::new();
487 let mut head = CodeGraph::new();
488
489 let mut n1 = make_public_node("my_func", NodeKind::Function, 1);
490 n1.signature = Some("fn my_func(a: i32)".into());
491
492 let mut n2 = make_public_node("my_func", NodeKind::Function, 1);
493 n2.signature = Some("fn my_func(a: i32, b: i32)".into());
494
495 base.add_node(n1);
496 head.add_node(n2);
497
498 let diff = diff_graphs(&base, &head);
499
500 assert_eq!(diff.changed_symbols.len(), 1);
501 assert!(diff.breaking_changes.iter().any(|bc| bc.description.contains("Signature changed")));
502 }
503
504 #[test]
505 fn detect_new_dead_code() {
506 let mut base = CodeGraph::new();
507 let mut head = CodeGraph::new();
508
509 base.add_node(make_node("existing", NodeKind::Function, 1));
511
512 head.add_node(make_node("existing", NodeKind::Function, 1));
514 head.add_node(make_node("unused_new", NodeKind::Function, 20));
515
516 let diff = diff_graphs(&base, &head);
517
518 assert_eq!(diff.new_dead_code.len(), 1);
519 assert_eq!(diff.new_dead_code[0].name, "unused_new");
520 }
521
522 #[test]
523 fn no_false_positive_for_test_functions() {
524 let mut base = CodeGraph::new();
525 let mut head = CodeGraph::new();
526
527 base.add_node(make_node("existing", NodeKind::Function, 1));
528 head.add_node(make_node("existing", NodeKind::Function, 1));
529 head.add_node(make_node("test_something", NodeKind::Function, 20));
530
531 let diff = diff_graphs(&base, &head);
532
533 assert!(diff.new_dead_code.is_empty());
535 }
536
537 #[test]
538 fn both_graphs_empty() {
539 let base = CodeGraph::new();
540 let head = CodeGraph::new();
541 let diff = diff_graphs(&base, &head);
542 assert!(diff.removed_symbols.is_empty());
543 assert!(diff.added_symbols.is_empty());
544 assert!(diff.changed_symbols.is_empty());
545 assert!(diff.breaking_changes.is_empty());
546 assert!(diff.new_dead_code.is_empty());
547 }
548
549 #[test]
550 fn visibility_change_is_breaking() {
551 let mut base = CodeGraph::new();
552 let mut head = CodeGraph::new();
553
554 base.add_node(make_public_node("api_func", NodeKind::Function, 1));
555 let n = make_node("api_func", NodeKind::Function, 1);
557 head.add_node(n);
558
559 let diff = diff_graphs(&base, &head);
560 assert!(diff.breaking_changes.iter().any(|bc| bc.description.contains("Visibility")));
561 }
562}