proto_sign/compat/
engine.rs1use crate::canonical::CanonicalFile;
7use crate::compat::bulk_rule_registry;
8use crate::compat::types::{BreakingChange, RuleContext};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct BreakingConfig {
15 #[serde(default)]
17 pub use_categories: Vec<String>,
18 #[serde(default)]
20 pub use_rules: Vec<String>,
21 #[serde(default)]
23 pub except_rules: Vec<String>,
24 #[serde(default)]
26 pub ignore: Vec<String>,
27 #[serde(default)]
29 pub ignore_only: std::collections::HashMap<String, Vec<String>>,
30 #[serde(default)]
32 pub ignore_unstable_packages: bool,
33 #[serde(default)]
35 pub service_no_change_suffixes: Vec<String>,
36 #[serde(default)]
38 pub message_no_change_suffixes: Vec<String>,
39 #[serde(default)]
41 pub enum_no_change_suffixes: Vec<String>,
42}
43
44impl BreakingConfig {
45 pub fn from_yaml_file<P: AsRef<std::path::Path>>(path: P) -> anyhow::Result<Self> {
47 let content = std::fs::read_to_string(path)?;
48 Self::from_yaml_str(&content)
49 }
50
51 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
53 #[derive(serde::Deserialize)]
54 struct ConfigFile {
55 breaking: Option<BreakingConfig>,
56 }
57
58 let config_file: ConfigFile = serde_yaml::from_str(yaml)?;
59 Ok(config_file.breaking.unwrap_or_default())
60 }
61}
62
63impl Default for BreakingConfig {
64 fn default() -> Self {
65 Self {
66 use_categories: vec!["FILE".to_string(), "PACKAGE".to_string()],
67 use_rules: Vec::new(),
68 except_rules: Vec::new(),
69 ignore: Vec::new(),
70 ignore_only: HashMap::new(),
71 ignore_unstable_packages: false,
72 service_no_change_suffixes: Vec::new(),
73 message_no_change_suffixes: Vec::new(),
74 enum_no_change_suffixes: Vec::new(),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct BreakingResult {
82 pub changes: Vec<BreakingChange>,
84 pub has_breaking_changes: bool,
86 pub summary: HashMap<String, usize>,
88 pub executed_rules: Vec<String>,
90 pub failed_rules: Vec<String>,
92}
93
94impl BreakingResult {
95 pub fn new() -> Self {
97 Self {
98 changes: Vec::new(),
99 has_breaking_changes: false,
100 summary: HashMap::new(),
101 executed_rules: Vec::new(),
102 failed_rules: Vec::new(),
103 }
104 }
105
106 pub fn add_changes(&mut self, new_changes: Vec<BreakingChange>) {
108 self.has_breaking_changes = !new_changes.is_empty() || self.has_breaking_changes;
109
110 for change in &new_changes {
112 for category in &change.categories {
113 *self.summary.entry(category.clone()).or_insert(0) += 1;
114 }
115 }
116
117 self.changes.extend(new_changes);
119 }
120
121 pub fn mark_rule_executed(&mut self, rule_id: String) {
123 self.executed_rules.push(rule_id);
124 }
125
126 pub fn mark_rule_failed(&mut self, rule_id: String) {
128 self.failed_rules.push(rule_id);
129 }
130}
131
132impl Default for BreakingResult {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138pub struct BreakingEngine {
140 }
142
143impl BreakingEngine {
144 pub fn new() -> Self {
146 Self {}
147 }
148
149 pub fn check(
151 &self,
152 current: &CanonicalFile,
153 previous: &CanonicalFile,
154 config: &BreakingConfig,
155 ) -> BreakingResult {
156 let mut result = BreakingResult::new();
157
158 let context = RuleContext {
160 current_file: "current".to_string(),
161 previous_file: Some("previous".to_string()),
162 metadata: HashMap::new(),
163 };
164
165 let all_rules = bulk_rule_registry::get_bulk_rule_mapping();
167
168 for (rule_id, rule_fn) in all_rules.iter() {
170 if config.except_rules.contains(&rule_id.to_string()) {
172 continue;
173 }
174
175 if !config.use_rules.is_empty() && !config.use_rules.contains(&rule_id.to_string()) {
177 continue;
178 }
179
180 if config.use_rules.is_empty() && !config.use_categories.is_empty() {
184 let rule_categories = get_rule_categories(rule_id);
186 let should_run = config
187 .use_categories
188 .iter()
189 .any(|cat| rule_categories.contains(cat));
190 if !should_run {
191 continue;
192 }
193 }
194
195 let rule_result = rule_fn(current, previous, &context);
196
197 if rule_result.success {
198 result.mark_rule_executed(rule_id.to_string());
199 result.add_changes(rule_result.changes);
200 } else {
201 result.mark_rule_failed(rule_id.to_string());
202 }
203 }
204
205 result
206 }
207
208 pub fn get_rule_count(&self) -> usize {
210 bulk_rule_registry::get_bulk_rule_count()
211 }
212
213 pub fn verify_rules(&self) -> Result<(), String> {
215 bulk_rule_registry::verify_bulk_rules()
216 }
217}
218
219fn get_rule_categories(rule_id: &str) -> Vec<String> {
221 match rule_id {
222 "FILE_SAME_PACKAGE"
224 | "FILE_NO_DELETE"
225 | "FILE_SAME_SYNTAX"
226 | "FILE_SAME_GO_PACKAGE"
227 | "FILE_SAME_JAVA_PACKAGE"
228 | "FILE_SAME_CSHARP_NAMESPACE"
229 | "FILE_SAME_RUBY_PACKAGE"
230 | "FILE_SAME_JAVA_MULTIPLE_FILES"
231 | "FILE_SAME_JAVA_OUTER_CLASSNAME"
232 | "FILE_SAME_OBJC_CLASS_PREFIX"
233 | "FILE_SAME_PHP_CLASS_PREFIX"
234 | "FILE_SAME_PHP_NAMESPACE"
235 | "FILE_SAME_PHP_METADATA_NAMESPACE"
236 | "FILE_SAME_SWIFT_PREFIX"
237 | "FILE_SAME_OPTIMIZE_FOR"
238 | "FILE_SAME_CC_GENERIC_SERVICES" => vec!["FILE".to_string()],
239
240 "MESSAGE_NO_DELETE"
242 | "FIELD_NO_DELETE"
243 | "FIELD_SAME_NAME"
244 | "FIELD_SAME_TYPE"
245 | "ONEOF_NO_DELETE"
246 | "MESSAGE_NO_REMOVE_STANDARD_DESCRIPTOR_ACCESSOR"
247 | "MESSAGE_SAME_MESSAGE_SET_WIRE_FORMAT" => vec!["FILE".to_string()],
248
249 "ENUM_NO_DELETE"
251 | "ENUM_VALUE_NO_DELETE"
252 | "ENUM_FIRST_VALUE_SAME"
253 | "ENUM_VALUE_SAME_NUMBER"
254 | "ENUM_ZERO_VALUE_SAME"
255 | "ENUM_ALLOW_ALIAS_SAME" => vec!["FILE".to_string()],
256
257 "SERVICE_NO_DELETE"
259 | "RPC_NO_DELETE"
260 | "RPC_SAME_REQUEST_TYPE"
261 | "RPC_SAME_RESPONSE_TYPE"
262 | "RPC_SAME_CLIENT_STREAMING"
263 | "RPC_SAME_SERVER_STREAMING" => vec!["FILE".to_string()],
264
265 "PACKAGE_NO_DELETE"
267 | "PACKAGE_ENUM_NO_DELETE"
268 | "PACKAGE_MESSAGE_NO_DELETE"
269 | "PACKAGE_SERVICE_NO_DELETE"
270 | "PACKAGE_EXTENSION_NO_DELETE" => vec!["PACKAGE".to_string()],
271
272 "FIELD_WIRE_COMPATIBLE_TYPE" | "FIELD_WIRE_COMPATIBLE_CARDINALITY" => {
274 vec!["WIRE".to_string()]
275 }
276
277 "FIELD_WIRE_JSON_COMPATIBLE_TYPE" | "FIELD_WIRE_JSON_COMPATIBLE_CARDINALITY" => {
279 vec!["WIRE_JSON".to_string()]
280 }
281
282 _ => vec!["FILE".to_string()],
284 }
285}
286
287impl Default for BreakingEngine {
288 fn default() -> Self {
289 Self::new()
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_default_config() {
299 let config = BreakingConfig::default();
300 assert_eq!(config.use_categories, vec!["FILE", "PACKAGE"]);
301 assert!(config.except_rules.is_empty());
302 }
303
304 #[test]
305 fn test_engine_creation() {
306 let engine = BreakingEngine::new();
307 assert!(engine.get_rule_count() > 0);
308 }
309
310 #[test]
311 fn test_rule_selection() {
312 let engine = BreakingEngine::new();
313 let config = BreakingConfig::default();
314
315 let current = CanonicalFile::default();
317 let previous = CanonicalFile::default();
318
319 let result = engine.check(¤t, &previous, &config);
320
321 assert!(!result.executed_rules.is_empty());
323 }
324
325 #[test]
326 fn test_rule_exclusion() {
327 let engine = BreakingEngine::new();
328 let mut config = BreakingConfig::default();
329 config.except_rules.push("FILE_SAME_PACKAGE".to_string());
330
331 let current = CanonicalFile::default();
332 let previous = CanonicalFile::default();
333
334 let result = engine.check(¤t, &previous, &config);
335
336 assert!(
338 !result
339 .executed_rules
340 .contains(&"FILE_SAME_PACKAGE".to_string())
341 );
342 }
343
344 #[test]
345 fn test_empty_check() {
346 let engine = BreakingEngine::new();
347 let config = BreakingConfig::default();
348 let current = CanonicalFile::default();
349 let previous = CanonicalFile::default();
350
351 let result = engine.check(¤t, &previous, &config);
352
353 assert!(!result.has_breaking_changes);
355 assert!(result.changes.is_empty());
356 }
357}