infiniloom_engine/analysis/
breaking_changes.rs

1//! Breaking change detection between two versions of code
2//!
3//! Compares symbols between git refs to detect API breaking changes
4//! such as removed symbols, signature changes, visibility changes, etc.
5
6use crate::analysis::types::{
7    BreakingChange, BreakingChangeReport, BreakingChangeSummary, BreakingChangeType, ChangeSeverity,
8};
9use crate::types::{Symbol, SymbolKind, Visibility};
10use std::collections::HashMap;
11
12/// Detects breaking changes between two versions
13pub struct BreakingChangeDetector {
14    /// Symbols from old version (key: qualified name or name)
15    old_symbols: HashMap<String, SymbolSnapshot>,
16    /// Symbols from new version
17    new_symbols: HashMap<String, SymbolSnapshot>,
18    /// Old git ref
19    old_ref: String,
20    /// New git ref
21    new_ref: String,
22}
23
24/// Snapshot of a symbol for comparison
25#[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    /// Create a new detector
45    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    /// Add symbols from old version
55    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    /// Add symbols from new version
64    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    /// Convert Symbol to SymbolSnapshot
73    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        // Parse signature to extract parameters, return type, etc.
81        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    /// Parse signature to extract components
103    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            // Check for async
114            is_async = sig.contains("async ");
115
116            // Count generics (simplified: count < and >)
117            generic_count = sig.matches('<').count();
118
119            // Try to extract parameters (between ( and ))
120            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                        // Split by comma, but respect nested brackets
125                        parameters = self.split_parameters(params_str);
126                    }
127                }
128            }
129
130            // Try to extract return type (after -> or :)
131            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                // Check if this colon is for return type (not in parameter type)
135                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    /// Split parameters respecting nested brackets
146    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    /// Detect all breaking changes
181    pub fn detect(&self) -> BreakingChangeReport {
182        let mut changes = Vec::new();
183
184        // Check for removed symbols
185        for (name, old) in &self.old_symbols {
186            // Only check public API
187            if !matches!(old.visibility, Visibility::Public) {
188                continue;
189            }
190
191            if let Some(new) = self.new_symbols.get(name) {
192                // Symbol exists in both - check for changes
193                changes.extend(self.compare_symbols(old, new));
194            } else {
195                // Symbol was removed
196                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        // Check for moved symbols (new location)
212        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        // Build summary
241        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    /// Compare two versions of the same symbol
252    fn compare_symbols(&self, old: &SymbolSnapshot, new: &SymbolSnapshot) -> Vec<BreakingChange> {
253        let mut changes = Vec::new();
254
255        // Check visibility reduction
256        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        // Check return type change
275        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        // Check parameter changes
299        let param_changes = self.compare_parameters(old, new);
300        changes.extend(param_changes);
301
302        // Check async/sync change
303        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        // Check generic parameter changes
328        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        // Check extends/implements changes
347        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    /// Compare parameters between old and new versions
369    fn compare_parameters(&self, old: &SymbolSnapshot, new: &SymbolSnapshot) -> Vec<BreakingChange> {
370        let mut changes = Vec::new();
371
372        // Check if required parameters were added
373        if new.parameter_count > old.parameter_count {
374            // New parameters added - check if they might be required
375            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        // Check if parameters were removed
397        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        // Check for parameter type changes (simplified: compare parameter strings)
420        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    /// Check if visibility was reduced (breaking)
452    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    /// Build summary statistics
464    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
492/// Convenience function to detect breaking changes between file sets
493pub 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        // Private symbol removal should not be flagged as breaking
661        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            // func1 removed
675            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}