1use super::ValidationRule;
4use crate::config::SemanticConfig;
5use crate::validator::{ValidationSeverity, Violation};
6use smelt_core::{IntentRecord, SemanticChange, SemanticDelta};
7
8pub struct BreakingChangeChecker {
10 config: SemanticConfig,
11}
12
13impl BreakingChangeChecker {
14 pub fn new(config: SemanticConfig) -> Self {
16 Self { config }
17 }
18
19 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 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}