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,
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    /// 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_owned());
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_owned());
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_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    /// 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!(
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        // Check for moved symbols (new location)
219        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        // Build summary
248        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    /// Compare two versions of the same symbol
259    fn compare_symbols(&self, old: &SymbolSnapshot, new: &SymbolSnapshot) -> Vec<BreakingChange> {
260        let mut changes = Vec::new();
261
262        // Check visibility reduction
263        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        // Check return type change
284        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        // Check parameter changes
308        let param_changes = self.compare_parameters(old, new);
309        changes.extend(param_changes);
310
311        // Check async/sync change
312        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        // Check generic parameter changes
337        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        // Check extends/implements changes
356        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    /// Compare parameters between old and new versions
378    fn compare_parameters(
379        &self,
380        old: &SymbolSnapshot,
381        new: &SymbolSnapshot,
382    ) -> Vec<BreakingChange> {
383        let mut changes = Vec::new();
384
385        // Check if required parameters were added
386        if new.parameter_count > old.parameter_count {
387            // New parameters added - check if they might be required
388            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        // Check if parameters were removed
407        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        // Check for parameter type changes (simplified: compare parameter strings)
427        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    /// Check if visibility was reduced (breaking)
459    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    /// Build summary statistics
471    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
497/// Convenience function to detect breaking changes between file sets
498pub 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        // Private symbol removal should not be flagged as breaking
677        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            // func1 removed
696            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}