Skip to main content

smelt_validator/rules/
breaking.rs

1//! Breaking change detection
2
3use super::ValidationRule;
4use crate::config::SemanticConfig;
5use crate::validator::{ValidationSeverity, Violation};
6use smelt_core::{IntentRecord, SemanticChange, SemanticDelta};
7
8/// Checks for breaking changes in semantic deltas
9pub struct BreakingChangeChecker {
10    config: SemanticConfig,
11}
12
13impl BreakingChangeChecker {
14    /// Create a new breaking change checker
15    pub fn new(config: SemanticConfig) -> Self {
16        Self { config }
17    }
18
19    /// Check if a change is breaking
20    fn is_breaking_change(&self, change: &SemanticChange) -> Option<Violation> {
21        match change {
22            SemanticChange::FunctionRemoved {
23                name,
24                file,
25                was_public,
26            } => {
27                if *was_public {
28                    Some(Violation {
29                        rule: "breaking-change".to_string(),
30                        severity: if self.config.breaking_changes_error {
31                            ValidationSeverity::Error
32                        } else {
33                            ValidationSeverity::Warning
34                        },
35                        message: format!("Public function '{}' was removed", name),
36                        location: Some(file.clone()),
37                        suggestion: Some(
38                            "Mark as deprecated instead of removing, or ensure no external consumers".to_string(),
39                        ),
40                    })
41                } else {
42                    None
43                }
44            }
45
46            SemanticChange::SignatureChanged {
47                name,
48                file,
49                old_signature,
50                new_signature,
51                is_breaking,
52            } => {
53                if *is_breaking {
54                    Some(Violation {
55                        rule: "breaking-change".to_string(),
56                        severity: if self.config.breaking_changes_error {
57                            ValidationSeverity::Error
58                        } else {
59                            ValidationSeverity::Warning
60                        },
61                        message: format!(
62                            "Breaking signature change in '{}': '{}' -> '{}'",
63                            name, old_signature, new_signature
64                        ),
65                        location: Some(file.clone()),
66                        suggestion: Some(
67                            "Add overload or default parameter to maintain backward compatibility"
68                                .to_string(),
69                        ),
70                    })
71                } else {
72                    None
73                }
74            }
75
76            SemanticChange::TypeRemoved {
77                name,
78                file,
79                was_public,
80            } => {
81                if *was_public {
82                    Some(Violation {
83                        rule: "breaking-change".to_string(),
84                        severity: if self.config.breaking_changes_error {
85                            ValidationSeverity::Error
86                        } else {
87                            ValidationSeverity::Warning
88                        },
89                        message: format!("Public type '{}' was removed", name),
90                        location: Some(file.clone()),
91                        suggestion: Some(
92                            "Mark as deprecated instead of removing, or provide migration path"
93                                .to_string(),
94                        ),
95                    })
96                } else {
97                    None
98                }
99            }
100
101            SemanticChange::TypeModified {
102                name,
103                file,
104                fields_removed,
105                is_breaking,
106                ..
107            } => {
108                if *is_breaking && !fields_removed.is_empty() {
109                    Some(Violation {
110                        rule: "breaking-change".to_string(),
111                        severity: if self.config.breaking_changes_error {
112                            ValidationSeverity::Error
113                        } else {
114                            ValidationSeverity::Warning
115                        },
116                        message: format!(
117                            "Breaking change in type '{}': fields removed: {}",
118                            name,
119                            fields_removed.join(", ")
120                        ),
121                        location: Some(file.clone()),
122                        suggestion: Some(
123                            "Mark fields as deprecated instead of removing".to_string(),
124                        ),
125                    })
126                } else {
127                    None
128                }
129            }
130
131            SemanticChange::VisibilityChanged {
132                name,
133                file,
134                old_visibility,
135                new_visibility,
136            } => {
137                use smelt_core::Visibility;
138                // Reducing visibility is a breaking change
139                let is_reduction = matches!(
140                    (old_visibility, new_visibility),
141                    (Visibility::Public, Visibility::Internal)
142                        | (Visibility::Public, Visibility::Private)
143                        | (Visibility::Internal, Visibility::Private)
144                );
145
146                if is_reduction {
147                    Some(Violation {
148                        rule: "breaking-change".to_string(),
149                        severity: if self.config.breaking_changes_error {
150                            ValidationSeverity::Error
151                        } else {
152                            ValidationSeverity::Warning
153                        },
154                        message: format!(
155                            "Visibility reduced for '{}': {:?} -> {:?}",
156                            name, old_visibility, new_visibility
157                        ),
158                        location: Some(file.clone()),
159                        suggestion: Some(
160                            "Ensure no external consumers before reducing visibility".to_string(),
161                        ),
162                    })
163                } else {
164                    None
165                }
166            }
167
168            _ => None,
169        }
170    }
171}
172
173impl ValidationRule for BreakingChangeChecker {
174    fn name(&self) -> &'static str {
175        "breaking-changes"
176    }
177
178    fn validate(&self, delta: &SemanticDelta, _intent: Option<&IntentRecord>) -> Vec<Violation> {
179        if !self.config.check_breaking_changes {
180            return Vec::new();
181        }
182
183        delta
184            .changes
185            .iter()
186            .filter_map(|change| self.is_breaking_change(change))
187            .collect()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use chrono::Utc;
195    use smelt_core::{ImpactSummary, Visibility};
196    use uuid::Uuid;
197
198    fn make_delta(changes: Vec<SemanticChange>) -> SemanticDelta {
199        SemanticDelta {
200            id: Uuid::new_v4(),
201            intent_id: Uuid::new_v4(),
202            timestamp: Utc::now(),
203            from_snapshot: Uuid::new_v4(),
204            to_snapshot: Uuid::new_v4(),
205            changes,
206            impact_summary: ImpactSummary::default(),
207        }
208    }
209
210    #[test]
211    fn test_public_function_removal() {
212        let checker = BreakingChangeChecker::new(SemanticConfig::default());
213
214        let delta = make_delta(vec![SemanticChange::FunctionRemoved {
215            name: "process".to_string(),
216            file: "lib.rs".to_string(),
217            was_public: true,
218        }]);
219
220        let violations = checker.validate(&delta, None);
221        assert_eq!(violations.len(), 1);
222        assert_eq!(violations[0].rule, "breaking-change");
223    }
224
225    #[test]
226    fn test_private_function_removal_ok() {
227        let checker = BreakingChangeChecker::new(SemanticConfig::default());
228
229        let delta = make_delta(vec![SemanticChange::FunctionRemoved {
230            name: "helper".to_string(),
231            file: "lib.rs".to_string(),
232            was_public: false,
233        }]);
234
235        let violations = checker.validate(&delta, None);
236        assert!(violations.is_empty());
237    }
238
239    #[test]
240    fn test_visibility_reduction() {
241        let checker = BreakingChangeChecker::new(SemanticConfig::default());
242
243        let delta = make_delta(vec![SemanticChange::VisibilityChanged {
244            name: "MyStruct".to_string(),
245            file: "lib.rs".to_string(),
246            old_visibility: Visibility::Public,
247            new_visibility: Visibility::Private,
248        }]);
249
250        let violations = checker.validate(&delta, None);
251        assert_eq!(violations.len(), 1);
252    }
253
254    #[test]
255    fn test_disabled_check() {
256        let config = SemanticConfig {
257            check_breaking_changes: false,
258            ..Default::default()
259        };
260
261        let checker = BreakingChangeChecker::new(config);
262
263        let delta = make_delta(vec![SemanticChange::FunctionRemoved {
264            name: "process".to_string(),
265            file: "lib.rs".to_string(),
266            was_public: true,
267        }]);
268
269        let violations = checker.validate(&delta, None);
270        assert!(violations.is_empty());
271    }
272}