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.clone(),
88 signature: symbol.signature.clone(),
89 visibility: symbol.visibility.clone(),
90 file_path: file_path.to_string(),
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_string());
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_string());
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_string());
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_string());
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!("Public {} '{}' was removed", format!("{:?}", old.kind).to_lowercase(), old.name),
205 severity: ChangeSeverity::Critical,
206 migration_hint: Some(format!("Remove usage of '{}' or find an alternative", old.name)),
207 });
208 }
209 }
210
211 for (name, new) in &self.new_symbols {
213 if let Some(old) = self.old_symbols.get(name) {
214 if old.file_path != new.file_path
215 && matches!(old.visibility, Visibility::Public)
216 && matches!(new.visibility, Visibility::Public)
217 {
218 changes.push(BreakingChange {
219 change_type: BreakingChangeType::Moved,
220 symbol_name: old.name.clone(),
221 symbol_kind: format!("{:?}", old.kind),
222 file_path: new.file_path.clone(),
223 line: Some(new.line),
224 old_signature: Some(old.file_path.clone()),
225 new_signature: Some(new.file_path.clone()),
226 description: format!(
227 "'{}' moved from '{}' to '{}'",
228 old.name, old.file_path, new.file_path
229 ),
230 severity: ChangeSeverity::Medium,
231 migration_hint: Some(format!(
232 "Update import path from '{}' to '{}'",
233 old.file_path, new.file_path
234 )),
235 });
236 }
237 }
238 }
239
240 let summary = self.build_summary(&changes);
242
243 BreakingChangeReport {
244 old_ref: self.old_ref.clone(),
245 new_ref: self.new_ref.clone(),
246 changes,
247 summary,
248 }
249 }
250
251 fn compare_symbols(&self, old: &SymbolSnapshot, new: &SymbolSnapshot) -> Vec<BreakingChange> {
253 let mut changes = Vec::new();
254
255 if self.is_visibility_reduced(&old.visibility, &new.visibility) {
257 changes.push(BreakingChange {
258 change_type: BreakingChangeType::VisibilityReduced,
259 symbol_name: old.name.clone(),
260 symbol_kind: format!("{:?}", old.kind),
261 file_path: new.file_path.clone(),
262 line: Some(new.line),
263 old_signature: Some(format!("{:?}", old.visibility)),
264 new_signature: Some(format!("{:?}", new.visibility)),
265 description: format!(
266 "Visibility of '{}' reduced from {:?} to {:?}",
267 old.name, old.visibility, new.visibility
268 ),
269 severity: ChangeSeverity::Critical,
270 migration_hint: Some("This symbol may no longer be accessible from your code".to_string()),
271 });
272 }
273
274 if old.return_type != new.return_type {
276 if let (Some(old_ret), Some(new_ret)) = (&old.return_type, &new.return_type) {
277 changes.push(BreakingChange {
278 change_type: BreakingChangeType::ReturnTypeChanged,
279 symbol_name: old.name.clone(),
280 symbol_kind: format!("{:?}", old.kind),
281 file_path: new.file_path.clone(),
282 line: Some(new.line),
283 old_signature: Some(old_ret.clone()),
284 new_signature: Some(new_ret.clone()),
285 description: format!(
286 "Return type of '{}' changed from '{}' to '{}'",
287 old.name, old_ret, new_ret
288 ),
289 severity: ChangeSeverity::High,
290 migration_hint: Some(format!(
291 "Update code that uses return value of '{}' to handle new type '{}'",
292 old.name, new_ret
293 )),
294 });
295 }
296 }
297
298 let param_changes = self.compare_parameters(old, new);
300 changes.extend(param_changes);
301
302 if old.is_async != new.is_async {
304 changes.push(BreakingChange {
305 change_type: BreakingChangeType::AsyncChanged,
306 symbol_name: old.name.clone(),
307 symbol_kind: format!("{:?}", old.kind),
308 file_path: new.file_path.clone(),
309 line: Some(new.line),
310 old_signature: Some(if old.is_async { "async" } else { "sync" }.to_string()),
311 new_signature: Some(if new.is_async { "async" } else { "sync" }.to_string()),
312 description: format!(
313 "'{}' changed from {} to {}",
314 old.name,
315 if old.is_async { "async" } else { "sync" },
316 if new.is_async { "async" } else { "sync" }
317 ),
318 severity: ChangeSeverity::High,
319 migration_hint: Some(format!(
320 "Update call sites of '{}' to {} the result",
321 old.name,
322 if new.is_async { "await" } else { "not await" }
323 )),
324 });
325 }
326
327 if old.generic_count != new.generic_count {
329 changes.push(BreakingChange {
330 change_type: BreakingChangeType::GenericChanged,
331 symbol_name: old.name.clone(),
332 symbol_kind: format!("{:?}", old.kind),
333 file_path: new.file_path.clone(),
334 line: Some(new.line),
335 old_signature: Some(format!("{} type parameters", old.generic_count)),
336 new_signature: Some(format!("{} type parameters", new.generic_count)),
337 description: format!(
338 "Generic type parameters of '{}' changed from {} to {}",
339 old.name, old.generic_count, new.generic_count
340 ),
341 severity: ChangeSeverity::High,
342 migration_hint: Some("Update type arguments at call sites".to_string()),
343 });
344 }
345
346 if old.extends != new.extends {
348 changes.push(BreakingChange {
349 change_type: BreakingChangeType::TypeConstraintChanged,
350 symbol_name: old.name.clone(),
351 symbol_kind: format!("{:?}", old.kind),
352 file_path: new.file_path.clone(),
353 line: Some(new.line),
354 old_signature: old.extends.clone(),
355 new_signature: new.extends.clone(),
356 description: format!(
357 "Base class of '{}' changed from {:?} to {:?}",
358 old.name, old.extends, new.extends
359 ),
360 severity: ChangeSeverity::Medium,
361 migration_hint: None,
362 });
363 }
364
365 changes
366 }
367
368 fn compare_parameters(&self, old: &SymbolSnapshot, new: &SymbolSnapshot) -> Vec<BreakingChange> {
370 let mut changes = Vec::new();
371
372 if new.parameter_count > old.parameter_count {
374 let added_count = new.parameter_count - old.parameter_count;
376 changes.push(BreakingChange {
377 change_type: BreakingChangeType::ParameterAdded,
378 symbol_name: old.name.clone(),
379 symbol_kind: format!("{:?}", old.kind),
380 file_path: new.file_path.clone(),
381 line: Some(new.line),
382 old_signature: old.signature.clone(),
383 new_signature: new.signature.clone(),
384 description: format!(
385 "'{}' has {} new parameter(s)",
386 old.name, added_count
387 ),
388 severity: ChangeSeverity::High,
389 migration_hint: Some(format!(
390 "Add {} new argument(s) to calls to '{}'",
391 added_count, old.name
392 )),
393 });
394 }
395
396 if new.parameter_count < old.parameter_count {
398 let removed_count = old.parameter_count - new.parameter_count;
399 changes.push(BreakingChange {
400 change_type: BreakingChangeType::ParameterRemoved,
401 symbol_name: old.name.clone(),
402 symbol_kind: format!("{:?}", old.kind),
403 file_path: new.file_path.clone(),
404 line: Some(new.line),
405 old_signature: old.signature.clone(),
406 new_signature: new.signature.clone(),
407 description: format!(
408 "'{}' has {} fewer parameter(s)",
409 old.name, removed_count
410 ),
411 severity: ChangeSeverity::High,
412 migration_hint: Some(format!(
413 "Remove {} argument(s) from calls to '{}'",
414 removed_count, old.name
415 )),
416 });
417 }
418
419 let min_len = old.parameters.len().min(new.parameters.len());
421 for i in 0..min_len {
422 if old.parameters[i] != new.parameters[i] {
423 changes.push(BreakingChange {
424 change_type: BreakingChangeType::ParameterTypeChanged,
425 symbol_name: old.name.clone(),
426 symbol_kind: format!("{:?}", old.kind),
427 file_path: new.file_path.clone(),
428 line: Some(new.line),
429 old_signature: Some(old.parameters[i].clone()),
430 new_signature: Some(new.parameters[i].clone()),
431 description: format!(
432 "Parameter {} of '{}' changed from '{}' to '{}'",
433 i + 1,
434 old.name,
435 old.parameters[i],
436 new.parameters[i]
437 ),
438 severity: ChangeSeverity::High,
439 migration_hint: Some(format!(
440 "Update argument {} in calls to '{}'",
441 i + 1,
442 old.name
443 )),
444 });
445 }
446 }
447
448 changes
449 }
450
451 fn is_visibility_reduced(&self, old: &Visibility, new: &Visibility) -> bool {
453 let visibility_level = |v: &Visibility| match v {
454 Visibility::Public => 3,
455 Visibility::Protected => 2,
456 Visibility::Internal => 1,
457 Visibility::Private => 0,
458 };
459
460 visibility_level(new) < visibility_level(old)
461 }
462
463 fn build_summary(&self, changes: &[BreakingChange]) -> BreakingChangeSummary {
465 let mut summary = BreakingChangeSummary {
466 total: changes.len() as u32,
467 ..Default::default()
468 };
469
470 let mut affected_files = std::collections::HashSet::new();
471 let mut affected_symbols = std::collections::HashSet::new();
472
473 for change in changes {
474 match change.severity {
475 ChangeSeverity::Critical => summary.critical += 1,
476 ChangeSeverity::High => summary.high += 1,
477 ChangeSeverity::Medium => summary.medium += 1,
478 ChangeSeverity::Low => summary.low += 1,
479 }
480
481 affected_files.insert(&change.file_path);
482 affected_symbols.insert(&change.symbol_name);
483 }
484
485 summary.files_affected = affected_files.len() as u32;
486 summary.symbols_affected = affected_symbols.len() as u32;
487
488 summary
489 }
490}
491
492pub fn detect_breaking_changes(
494 old_ref: &str,
495 old_files: &[(String, Vec<Symbol>)],
496 new_ref: &str,
497 new_files: &[(String, Vec<Symbol>)],
498) -> BreakingChangeReport {
499 let mut detector = BreakingChangeDetector::new(old_ref, new_ref);
500
501 for (path, symbols) in old_files {
502 detector.add_old_symbols(path, symbols);
503 }
504
505 for (path, symbols) in new_files {
506 detector.add_new_symbols(path, symbols);
507 }
508
509 detector.detect()
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 fn make_symbol(
517 name: &str,
518 kind: SymbolKind,
519 visibility: Visibility,
520 signature: Option<&str>,
521 ) -> Symbol {
522 Symbol {
523 name: name.to_string(),
524 kind,
525 visibility,
526 signature: signature.map(String::from),
527 start_line: 1,
528 end_line: 10,
529 ..Default::default()
530 }
531 }
532
533 #[test]
534 fn test_removed_symbol() {
535 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
536
537 let old_symbols = vec![
538 make_symbol("removed_func", SymbolKind::Function, Visibility::Public, Some("fn removed_func()")),
539 make_symbol("kept_func", SymbolKind::Function, Visibility::Public, Some("fn kept_func()")),
540 ];
541
542 let new_symbols = vec![
543 make_symbol("kept_func", SymbolKind::Function, Visibility::Public, Some("fn kept_func()")),
544 ];
545
546 detector.add_old_symbols("test.rs", &old_symbols);
547 detector.add_new_symbols("test.rs", &new_symbols);
548
549 let report = detector.detect();
550
551 assert!(report.changes.iter().any(|c| {
552 c.symbol_name == "removed_func" && c.change_type == BreakingChangeType::Removed
553 }));
554 }
555
556 #[test]
557 fn test_visibility_reduction() {
558 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
559
560 let old_symbols = vec![make_symbol(
561 "my_func",
562 SymbolKind::Function,
563 Visibility::Public,
564 Some("fn my_func()"),
565 )];
566
567 let new_symbols = vec![make_symbol(
568 "my_func",
569 SymbolKind::Function,
570 Visibility::Private,
571 Some("fn my_func()"),
572 )];
573
574 detector.add_old_symbols("test.rs", &old_symbols);
575 detector.add_new_symbols("test.rs", &new_symbols);
576
577 let report = detector.detect();
578
579 assert!(report.changes.iter().any(|c| {
580 c.symbol_name == "my_func"
581 && c.change_type == BreakingChangeType::VisibilityReduced
582 }));
583 }
584
585 #[test]
586 fn test_parameter_added() {
587 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
588
589 let old_symbols = vec![make_symbol(
590 "my_func",
591 SymbolKind::Function,
592 Visibility::Public,
593 Some("fn my_func(a: i32)"),
594 )];
595
596 let new_symbols = vec![make_symbol(
597 "my_func",
598 SymbolKind::Function,
599 Visibility::Public,
600 Some("fn my_func(a: i32, b: i32)"),
601 )];
602
603 detector.add_old_symbols("test.rs", &old_symbols);
604 detector.add_new_symbols("test.rs", &new_symbols);
605
606 let report = detector.detect();
607
608 assert!(report.changes.iter().any(|c| {
609 c.symbol_name == "my_func"
610 && c.change_type == BreakingChangeType::ParameterAdded
611 }));
612 }
613
614 #[test]
615 fn test_async_change() {
616 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
617
618 let old_symbols = vec![make_symbol(
619 "fetch",
620 SymbolKind::Function,
621 Visibility::Public,
622 Some("fn fetch()"),
623 )];
624
625 let new_symbols = vec![make_symbol(
626 "fetch",
627 SymbolKind::Function,
628 Visibility::Public,
629 Some("async fn fetch()"),
630 )];
631
632 detector.add_old_symbols("test.rs", &old_symbols);
633 detector.add_new_symbols("test.rs", &new_symbols);
634
635 let report = detector.detect();
636
637 assert!(report.changes.iter().any(|c| {
638 c.symbol_name == "fetch" && c.change_type == BreakingChangeType::AsyncChanged
639 }));
640 }
641
642 #[test]
643 fn test_private_symbols_ignored() {
644 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
645
646 let old_symbols = vec![make_symbol(
647 "private_func",
648 SymbolKind::Function,
649 Visibility::Private,
650 Some("fn private_func()"),
651 )];
652
653 let new_symbols: Vec<Symbol> = vec![];
654
655 detector.add_old_symbols("test.rs", &old_symbols);
656 detector.add_new_symbols("test.rs", &new_symbols);
657
658 let report = detector.detect();
659
660 assert!(report.changes.is_empty());
662 }
663
664 #[test]
665 fn test_summary() {
666 let mut detector = BreakingChangeDetector::new("v1.0", "v2.0");
667
668 let old_symbols = vec![
669 make_symbol("func1", SymbolKind::Function, Visibility::Public, Some("fn func1()")),
670 make_symbol("func2", SymbolKind::Function, Visibility::Public, Some("fn func2(a: i32)")),
671 ];
672
673 let new_symbols = vec![
674 make_symbol("func2", SymbolKind::Function, Visibility::Public, Some("fn func2(a: i32, b: i32)")),
676 ];
677
678 detector.add_old_symbols("test.rs", &old_symbols);
679 detector.add_new_symbols("test.rs", &new_symbols);
680
681 let report = detector.detect();
682
683 assert!(report.summary.total >= 2);
684 assert!(report.summary.files_affected >= 1);
685 assert!(report.summary.symbols_affected >= 2);
686 }
687}