1use crate::analysis::types::{
7 BreakingChange, BreakingChangeReport, BreakingChangeSummary, BreakingChangeType, ChangeSeverity,
8};
9use crate::types::{Symbol, SymbolKind, Visibility};
10use std::collections::HashMap;
11
12pub struct BreakingChangeDetector {
14 old_symbols: HashMap<String, SymbolSnapshot>,
16 new_symbols: HashMap<String, SymbolSnapshot>,
18 old_ref: String,
20 new_ref: String,
22}
23
24#[derive(Debug, Clone)]
26struct SymbolSnapshot {
27 name: String,
28 qualified_name: String,
29 kind: SymbolKind,
30 signature: Option<String>,
31 visibility: Visibility,
32 file_path: String,
33 line: u32,
34 extends: Option<String>,
35 implements: Vec<String>,
36 is_async: bool,
37 parameter_count: usize,
38 parameters: Vec<String>,
39 return_type: Option<String>,
40 generic_count: usize,
41}
42
43impl BreakingChangeDetector {
44 pub fn new(old_ref: impl Into<String>, new_ref: impl Into<String>) -> Self {
46 Self {
47 old_symbols: HashMap::new(),
48 new_symbols: HashMap::new(),
49 old_ref: old_ref.into(),
50 new_ref: new_ref.into(),
51 }
52 }
53
54 pub fn add_old_symbols(&mut self, file_path: &str, symbols: &[Symbol]) {
56 for symbol in symbols {
57 let snapshot = self.symbol_to_snapshot(symbol, file_path);
58 self.old_symbols
59 .insert(snapshot.qualified_name.clone(), snapshot);
60 }
61 }
62
63 pub fn add_new_symbols(&mut self, file_path: &str, symbols: &[Symbol]) {
65 for symbol in symbols {
66 let snapshot = self.symbol_to_snapshot(symbol, file_path);
67 self.new_symbols
68 .insert(snapshot.qualified_name.clone(), snapshot);
69 }
70 }
71
72 fn symbol_to_snapshot(&self, symbol: &Symbol, file_path: &str) -> SymbolSnapshot {
74 let qualified_name = if let Some(ref parent) = symbol.parent {
75 format!("{}::{}", parent, symbol.name)
76 } else {
77 symbol.name.clone()
78 };
79
80 let (parameters, return_type, is_async, generic_count) =
82 self.parse_signature(&symbol.signature);
83
84 SymbolSnapshot {
85 name: symbol.name.clone(),
86 qualified_name,
87 kind: symbol.kind,
88 signature: symbol.signature.clone(),
89 visibility: symbol.visibility,
90 file_path: file_path.to_owned(),
91 line: symbol.start_line,
92 extends: symbol.extends.clone(),
93 implements: symbol.implements.clone(),
94 is_async,
95 parameter_count: parameters.len(),
96 parameters,
97 return_type,
98 generic_count,
99 }
100 }
101
102 fn parse_signature(
104 &self,
105 signature: &Option<String>,
106 ) -> (Vec<String>, Option<String>, bool, usize) {
107 let mut parameters = Vec::new();
108 let mut return_type = None;
109 let mut is_async = false;
110 let mut generic_count = 0;
111
112 if let Some(sig) = signature {
113 is_async = sig.contains("async ");
115
116 generic_count = sig.matches('<').count();
118
119 if let Some(start) = sig.find('(') {
121 if let Some(end) = sig.rfind(')') {
122 let params_str = &sig[start + 1..end];
123 if !params_str.trim().is_empty() {
124 parameters = self.split_parameters(params_str);
126 }
127 }
128 }
129
130 if let Some(arrow_pos) = sig.find("->") {
132 return_type = Some(sig[arrow_pos + 2..].trim().to_owned());
133 } else if let Some(colon_pos) = sig.rfind(':') {
134 let after = &sig[colon_pos + 1..];
136 if !after.contains(',') && !after.contains('(') {
137 return_type = Some(after.trim().to_owned());
138 }
139 }
140 }
141
142 (parameters, return_type, is_async, generic_count)
143 }
144
145 fn split_parameters(&self, params_str: &str) -> Vec<String> {
147 let mut params = Vec::new();
148 let mut current = String::new();
149 let mut depth = 0;
150
151 for c in params_str.chars() {
152 match c {
153 '<' | '(' | '[' | '{' => {
154 depth += 1;
155 current.push(c);
156 },
157 '>' | ')' | ']' | '}' => {
158 depth -= 1;
159 current.push(c);
160 },
161 ',' if depth == 0 => {
162 let trimmed = current.trim();
163 if !trimmed.is_empty() {
164 params.push(trimmed.to_owned());
165 }
166 current.clear();
167 },
168 _ => current.push(c),
169 }
170 }
171
172 let trimmed = current.trim();
173 if !trimmed.is_empty() {
174 params.push(trimmed.to_owned());
175 }
176
177 params
178 }
179
180 pub fn detect(&self) -> BreakingChangeReport {
182 let mut changes = Vec::new();
183
184 for (name, old) in &self.old_symbols {
186 if !matches!(old.visibility, Visibility::Public) {
188 continue;
189 }
190
191 if let Some(new) = self.new_symbols.get(name) {
192 changes.extend(self.compare_symbols(old, new));
194 } else {
195 changes.push(BreakingChange {
197 change_type: BreakingChangeType::Removed,
198 symbol_name: old.name.clone(),
199 symbol_kind: format!("{:?}", old.kind),
200 file_path: old.file_path.clone(),
201 line: None,
202 old_signature: old.signature.clone(),
203 new_signature: None,
204 description: format!(
205 "Public {} '{}' was removed",
206 format!("{:?}", old.kind).to_lowercase(),
207 old.name
208 ),
209 severity: ChangeSeverity::Critical,
210 migration_hint: Some(format!(
211 "Remove usage of '{}' or find an alternative",
212 old.name
213 )),
214 });
215 }
216 }
217
218 for (name, new) in &self.new_symbols {
220 if let Some(old) = self.old_symbols.get(name) {
221 if old.file_path != new.file_path
222 && matches!(old.visibility, Visibility::Public)
223 && matches!(new.visibility, Visibility::Public)
224 {
225 changes.push(BreakingChange {
226 change_type: BreakingChangeType::Moved,
227 symbol_name: old.name.clone(),
228 symbol_kind: format!("{:?}", old.kind),
229 file_path: new.file_path.clone(),
230 line: Some(new.line),
231 old_signature: Some(old.file_path.clone()),
232 new_signature: Some(new.file_path.clone()),
233 description: format!(
234 "'{}' moved from '{}' to '{}'",
235 old.name, old.file_path, new.file_path
236 ),
237 severity: ChangeSeverity::Medium,
238 migration_hint: Some(format!(
239 "Update import path from '{}' to '{}'",
240 old.file_path, new.file_path
241 )),
242 });
243 }
244 }
245 }
246
247 let summary = self.build_summary(&changes);
249
250 BreakingChangeReport {
251 old_ref: self.old_ref.clone(),
252 new_ref: self.new_ref.clone(),
253 changes,
254 summary,
255 }
256 }
257
258 fn compare_symbols(&self, old: &SymbolSnapshot, new: &SymbolSnapshot) -> Vec<BreakingChange> {
260 let mut changes = Vec::new();
261
262 if self.is_visibility_reduced(&old.visibility, &new.visibility) {
264 changes.push(BreakingChange {
265 change_type: BreakingChangeType::VisibilityReduced,
266 symbol_name: old.name.clone(),
267 symbol_kind: format!("{:?}", old.kind),
268 file_path: new.file_path.clone(),
269 line: Some(new.line),
270 old_signature: Some(format!("{:?}", old.visibility)),
271 new_signature: Some(format!("{:?}", new.visibility)),
272 description: format!(
273 "Visibility of '{}' reduced from {:?} to {:?}",
274 old.name, old.visibility, new.visibility
275 ),
276 severity: ChangeSeverity::Critical,
277 migration_hint: Some(
278 "This symbol may no longer be accessible from your code".to_owned(),
279 ),
280 });
281 }
282
283 if old.return_type != new.return_type {
285 if let (Some(old_ret), Some(new_ret)) = (&old.return_type, &new.return_type) {
286 changes.push(BreakingChange {
287 change_type: BreakingChangeType::ReturnTypeChanged,
288 symbol_name: old.name.clone(),
289 symbol_kind: format!("{:?}", old.kind),
290 file_path: new.file_path.clone(),
291 line: Some(new.line),
292 old_signature: Some(old_ret.clone()),
293 new_signature: Some(new_ret.clone()),
294 description: format!(
295 "Return type of '{}' changed from '{}' to '{}'",
296 old.name, old_ret, new_ret
297 ),
298 severity: ChangeSeverity::High,
299 migration_hint: Some(format!(
300 "Update code that uses return value of '{}' to handle new type '{}'",
301 old.name, new_ret
302 )),
303 });
304 }
305 }
306
307 let param_changes = self.compare_parameters(old, new);
309 changes.extend(param_changes);
310
311 if old.is_async != new.is_async {
313 changes.push(BreakingChange {
314 change_type: BreakingChangeType::AsyncChanged,
315 symbol_name: old.name.clone(),
316 symbol_kind: format!("{:?}", old.kind),
317 file_path: new.file_path.clone(),
318 line: Some(new.line),
319 old_signature: Some(if old.is_async { "async" } else { "sync" }.to_owned()),
320 new_signature: Some(if new.is_async { "async" } else { "sync" }.to_owned()),
321 description: format!(
322 "'{}' changed from {} to {}",
323 old.name,
324 if old.is_async { "async" } else { "sync" },
325 if new.is_async { "async" } else { "sync" }
326 ),
327 severity: ChangeSeverity::High,
328 migration_hint: Some(format!(
329 "Update call sites of '{}' to {} the result",
330 old.name,
331 if new.is_async { "await" } else { "not await" }
332 )),
333 });
334 }
335
336 if old.generic_count != new.generic_count {
338 changes.push(BreakingChange {
339 change_type: BreakingChangeType::GenericChanged,
340 symbol_name: old.name.clone(),
341 symbol_kind: format!("{:?}", old.kind),
342 file_path: new.file_path.clone(),
343 line: Some(new.line),
344 old_signature: Some(format!("{} type parameters", old.generic_count)),
345 new_signature: Some(format!("{} type parameters", new.generic_count)),
346 description: format!(
347 "Generic type parameters of '{}' changed from {} to {}",
348 old.name, old.generic_count, new.generic_count
349 ),
350 severity: ChangeSeverity::High,
351 migration_hint: Some("Update type arguments at call sites".to_owned()),
352 });
353 }
354
355 if old.extends != new.extends {
357 changes.push(BreakingChange {
358 change_type: BreakingChangeType::TypeConstraintChanged,
359 symbol_name: old.name.clone(),
360 symbol_kind: format!("{:?}", old.kind),
361 file_path: new.file_path.clone(),
362 line: Some(new.line),
363 old_signature: old.extends.clone(),
364 new_signature: new.extends.clone(),
365 description: format!(
366 "Base class of '{}' changed from {:?} to {:?}",
367 old.name, old.extends, new.extends
368 ),
369 severity: ChangeSeverity::Medium,
370 migration_hint: None,
371 });
372 }
373
374 changes
375 }
376
377 fn compare_parameters(
379 &self,
380 old: &SymbolSnapshot,
381 new: &SymbolSnapshot,
382 ) -> Vec<BreakingChange> {
383 let mut changes = Vec::new();
384
385 if new.parameter_count > old.parameter_count {
387 let added_count = new.parameter_count - old.parameter_count;
389 changes.push(BreakingChange {
390 change_type: BreakingChangeType::ParameterAdded,
391 symbol_name: old.name.clone(),
392 symbol_kind: format!("{:?}", old.kind),
393 file_path: new.file_path.clone(),
394 line: Some(new.line),
395 old_signature: old.signature.clone(),
396 new_signature: new.signature.clone(),
397 description: format!("'{}' has {} new parameter(s)", old.name, added_count),
398 severity: ChangeSeverity::High,
399 migration_hint: Some(format!(
400 "Add {} new argument(s) to calls to '{}'",
401 added_count, old.name
402 )),
403 });
404 }
405
406 if new.parameter_count < old.parameter_count {
408 let removed_count = old.parameter_count - new.parameter_count;
409 changes.push(BreakingChange {
410 change_type: BreakingChangeType::ParameterRemoved,
411 symbol_name: old.name.clone(),
412 symbol_kind: format!("{:?}", old.kind),
413 file_path: new.file_path.clone(),
414 line: Some(new.line),
415 old_signature: old.signature.clone(),
416 new_signature: new.signature.clone(),
417 description: format!("'{}' has {} fewer parameter(s)", old.name, removed_count),
418 severity: ChangeSeverity::High,
419 migration_hint: Some(format!(
420 "Remove {} argument(s) from calls to '{}'",
421 removed_count, old.name
422 )),
423 });
424 }
425
426 let min_len = old.parameters.len().min(new.parameters.len());
428 for i in 0..min_len {
429 if old.parameters[i] != new.parameters[i] {
430 changes.push(BreakingChange {
431 change_type: BreakingChangeType::ParameterTypeChanged,
432 symbol_name: old.name.clone(),
433 symbol_kind: format!("{:?}", old.kind),
434 file_path: new.file_path.clone(),
435 line: Some(new.line),
436 old_signature: Some(old.parameters[i].clone()),
437 new_signature: Some(new.parameters[i].clone()),
438 description: format!(
439 "Parameter {} of '{}' changed from '{}' to '{}'",
440 i + 1,
441 old.name,
442 old.parameters[i],
443 new.parameters[i]
444 ),
445 severity: ChangeSeverity::High,
446 migration_hint: Some(format!(
447 "Update argument {} in calls to '{}'",
448 i + 1,
449 old.name
450 )),
451 });
452 }
453 }
454
455 changes
456 }
457
458 fn is_visibility_reduced(&self, old: &Visibility, new: &Visibility) -> bool {
460 let visibility_level = |v: &Visibility| match v {
461 Visibility::Public => 3,
462 Visibility::Protected => 2,
463 Visibility::Internal => 1,
464 Visibility::Private => 0,
465 };
466
467 visibility_level(new) < visibility_level(old)
468 }
469
470 fn build_summary(&self, changes: &[BreakingChange]) -> BreakingChangeSummary {
472 let mut summary =
473 BreakingChangeSummary { total: changes.len() as u32, ..Default::default() };
474
475 let mut affected_files = std::collections::HashSet::new();
476 let mut affected_symbols = std::collections::HashSet::new();
477
478 for change in changes {
479 match change.severity {
480 ChangeSeverity::Critical => summary.critical += 1,
481 ChangeSeverity::High => summary.high += 1,
482 ChangeSeverity::Medium => summary.medium += 1,
483 ChangeSeverity::Low => summary.low += 1,
484 }
485
486 affected_files.insert(&change.file_path);
487 affected_symbols.insert(&change.symbol_name);
488 }
489
490 summary.files_affected = affected_files.len() as u32;
491 summary.symbols_affected = affected_symbols.len() as u32;
492
493 summary
494 }
495}
496
497pub fn detect_breaking_changes(
499 old_ref: &str,
500 old_files: &[(String, Vec<Symbol>)],
501 new_ref: &str,
502 new_files: &[(String, Vec<Symbol>)],
503) -> BreakingChangeReport {
504 let mut detector = BreakingChangeDetector::new(old_ref, new_ref);
505
506 for (path, symbols) in old_files {
507 detector.add_old_symbols(path, symbols);
508 }
509
510 for (path, symbols) in new_files {
511 detector.add_new_symbols(path, symbols);
512 }
513
514 detector.detect()
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn make_symbol(
522 name: &str,
523 kind: SymbolKind,
524 visibility: Visibility,
525 signature: Option<&str>,
526 ) -> Symbol {
527 Symbol {
528 name: name.to_owned(),
529 kind,
530 visibility,
531 signature: signature.map(String::from),
532 start_line: 1,
533 end_line: 10,
534 ..Default::default()
535 }
536 }
537
538 #[test]
539 fn test_removed_symbol() {
540 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
541
542 let old_symbols = vec![
543 make_symbol(
544 "removed_func",
545 SymbolKind::Function,
546 Visibility::Public,
547 Some("fn removed_func()"),
548 ),
549 make_symbol(
550 "kept_func",
551 SymbolKind::Function,
552 Visibility::Public,
553 Some("fn kept_func()"),
554 ),
555 ];
556
557 let new_symbols = vec![make_symbol(
558 "kept_func",
559 SymbolKind::Function,
560 Visibility::Public,
561 Some("fn kept_func()"),
562 )];
563
564 detector.add_old_symbols("test.rs", &old_symbols);
565 detector.add_new_symbols("test.rs", &new_symbols);
566
567 let report = detector.detect();
568
569 assert!(report.changes.iter().any(|c| {
570 c.symbol_name == "removed_func" && c.change_type == BreakingChangeType::Removed
571 }));
572 }
573
574 #[test]
575 fn test_visibility_reduction() {
576 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
577
578 let old_symbols = vec![make_symbol(
579 "my_func",
580 SymbolKind::Function,
581 Visibility::Public,
582 Some("fn my_func()"),
583 )];
584
585 let new_symbols = vec![make_symbol(
586 "my_func",
587 SymbolKind::Function,
588 Visibility::Private,
589 Some("fn my_func()"),
590 )];
591
592 detector.add_old_symbols("test.rs", &old_symbols);
593 detector.add_new_symbols("test.rs", &new_symbols);
594
595 let report = detector.detect();
596
597 assert!(report.changes.iter().any(|c| {
598 c.symbol_name == "my_func" && c.change_type == BreakingChangeType::VisibilityReduced
599 }));
600 }
601
602 #[test]
603 fn test_parameter_added() {
604 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
605
606 let old_symbols = vec![make_symbol(
607 "my_func",
608 SymbolKind::Function,
609 Visibility::Public,
610 Some("fn my_func(a: i32)"),
611 )];
612
613 let new_symbols = vec![make_symbol(
614 "my_func",
615 SymbolKind::Function,
616 Visibility::Public,
617 Some("fn my_func(a: i32, b: i32)"),
618 )];
619
620 detector.add_old_symbols("test.rs", &old_symbols);
621 detector.add_new_symbols("test.rs", &new_symbols);
622
623 let report = detector.detect();
624
625 assert!(report.changes.iter().any(|c| {
626 c.symbol_name == "my_func" && c.change_type == BreakingChangeType::ParameterAdded
627 }));
628 }
629
630 #[test]
631 fn test_async_change() {
632 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
633
634 let old_symbols = vec![make_symbol(
635 "fetch",
636 SymbolKind::Function,
637 Visibility::Public,
638 Some("fn fetch()"),
639 )];
640
641 let new_symbols = vec![make_symbol(
642 "fetch",
643 SymbolKind::Function,
644 Visibility::Public,
645 Some("async fn fetch()"),
646 )];
647
648 detector.add_old_symbols("test.rs", &old_symbols);
649 detector.add_new_symbols("test.rs", &new_symbols);
650
651 let report = detector.detect();
652
653 assert!(report.changes.iter().any(|c| {
654 c.symbol_name == "fetch" && c.change_type == BreakingChangeType::AsyncChanged
655 }));
656 }
657
658 #[test]
659 fn test_private_symbols_ignored() {
660 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
661
662 let old_symbols = vec![make_symbol(
663 "private_func",
664 SymbolKind::Function,
665 Visibility::Private,
666 Some("fn private_func()"),
667 )];
668
669 let new_symbols: Vec<Symbol> = vec![];
670
671 detector.add_old_symbols("test.rs", &old_symbols);
672 detector.add_new_symbols("test.rs", &new_symbols);
673
674 let report = detector.detect();
675
676 assert!(report.changes.is_empty());
678 }
679
680 #[test]
681 fn test_summary() {
682 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
683
684 let old_symbols = vec![
685 make_symbol("func1", SymbolKind::Function, Visibility::Public, Some("fn func1()")),
686 make_symbol(
687 "func2",
688 SymbolKind::Function,
689 Visibility::Public,
690 Some("fn func2(a: i32)"),
691 ),
692 ];
693
694 let new_symbols = vec![
695 make_symbol(
697 "func2",
698 SymbolKind::Function,
699 Visibility::Public,
700 Some("fn func2(a: i32, b: i32)"),
701 ),
702 ];
703
704 detector.add_old_symbols("test.rs", &old_symbols);
705 detector.add_new_symbols("test.rs", &new_symbols);
706
707 let report = detector.detect();
708
709 assert!(report.summary.total >= 2);
710 assert!(report.summary.files_affected >= 1);
711 assert!(report.summary.symbols_affected >= 2);
712 }
713}