proto_sign/compat/
engine.rs

1//! Breaking change detection engine
2//!
3//! This module provides the main engine for detecting breaking changes between
4//! two Protocol Buffer files, using the simplified bulk rule registry system.
5
6use 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/// Configuration for breaking change detection
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct BreakingConfig {
15    /// Categories to enable (if empty, uses default rules)
16    #[serde(default)]
17    pub use_categories: Vec<String>,
18    /// Specific rules to enable (overrides categories if specified)
19    #[serde(default)]
20    pub use_rules: Vec<String>,
21    /// Rules to explicitly disable
22    #[serde(default)]
23    pub except_rules: Vec<String>,
24    /// Files or directories to ignore
25    #[serde(default)]
26    pub ignore: Vec<String>,
27    /// Rule-specific file ignores
28    #[serde(default)]
29    pub ignore_only: std::collections::HashMap<String, Vec<String>>,
30    /// Whether to ignore unstable packages
31    #[serde(default)]
32    pub ignore_unstable_packages: bool,
33    /// Service name suffixes that cannot be changed
34    #[serde(default)]
35    pub service_no_change_suffixes: Vec<String>,
36    /// Message name suffixes that cannot be changed
37    #[serde(default)]
38    pub message_no_change_suffixes: Vec<String>,
39    /// Enum name suffixes that cannot be changed
40    #[serde(default)]
41    pub enum_no_change_suffixes: Vec<String>,
42}
43
44impl BreakingConfig {
45    /// Load configuration from YAML file
46    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    /// Load configuration from YAML string
52    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/// Result of breaking change detection
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct BreakingResult {
82    /// All breaking changes found
83    pub changes: Vec<BreakingChange>,
84    /// Whether any breaking changes were found
85    pub has_breaking_changes: bool,
86    /// Summary by category
87    pub summary: HashMap<String, usize>,
88    /// Rules that were executed
89    pub executed_rules: Vec<String>,
90    /// Rules that failed to execute
91    pub failed_rules: Vec<String>,
92}
93
94impl BreakingResult {
95    /// Create a new empty result
96    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    /// Add breaking changes to the result
107    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        // Update summary BEFORE moving changes
111        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        // Now add to changes list
118        self.changes.extend(new_changes);
119    }
120
121    /// Mark a rule as executed successfully
122    pub fn mark_rule_executed(&mut self, rule_id: String) {
123        self.executed_rules.push(rule_id);
124    }
125
126    /// Mark a rule as failed
127    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
138/// Main engine for breaking change detection
139pub struct BreakingEngine {
140    // Engine is stateless, uses bulk_rule_registry directly
141}
142
143impl BreakingEngine {
144    /// Create a new breaking change engine
145    pub fn new() -> Self {
146        Self {}
147    }
148
149    /// Check for breaking changes between two canonical files
150    pub fn check(
151        &self,
152        current: &CanonicalFile,
153        previous: &CanonicalFile,
154        config: &BreakingConfig,
155    ) -> BreakingResult {
156        let mut result = BreakingResult::new();
157
158        // Create rule context
159        let context = RuleContext {
160            current_file: "current".to_string(),
161            previous_file: Some("previous".to_string()),
162            metadata: HashMap::new(),
163        };
164
165        // Get all rules from bulk registry
166        let all_rules = bulk_rule_registry::get_bulk_rule_mapping();
167
168        // Execute selected rules based on configuration
169        for (rule_id, rule_fn) in all_rules.iter() {
170            // Skip rules that are explicitly excluded
171            if config.except_rules.contains(&rule_id.to_string()) {
172                continue;
173            }
174
175            // If specific rules are specified, only run those
176            if !config.use_rules.is_empty() && !config.use_rules.contains(&rule_id.to_string()) {
177                continue;
178            }
179
180            // If using categories, check if rule belongs to enabled categories
181            // For now, if use_rules is empty and use_categories is specified, we run based on categories
182            // This is a simplified implementation - real Buf logic is more complex
183            if config.use_rules.is_empty() && !config.use_categories.is_empty() {
184                // Simplified category matching - could be improved based on actual Buf logic
185                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    /// Get rule count from bulk registry
209    pub fn get_rule_count(&self) -> usize {
210        bulk_rule_registry::get_bulk_rule_count()
211    }
212
213    /// Verify bulk rules integrity
214    pub fn verify_rules(&self) -> Result<(), String> {
215        bulk_rule_registry::verify_bulk_rules()
216    }
217}
218
219/// Get categories for a rule (simplified mapping)
220fn get_rule_categories(rule_id: &str) -> Vec<String> {
221    match rule_id {
222        // FILE category rules
223        "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/FIELD rules in FILE category
241        "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 rules in FILE category
250        "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/RPC rules in FILE category
258        "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 category rules
266        "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        // WIRE category rules
273        "FIELD_WIRE_COMPATIBLE_TYPE" | "FIELD_WIRE_COMPATIBLE_CARDINALITY" => {
274            vec!["WIRE".to_string()]
275        }
276
277        // WIRE_JSON category rules
278        "FIELD_WIRE_JSON_COMPATIBLE_TYPE" | "FIELD_WIRE_JSON_COMPATIBLE_CARDINALITY" => {
279            vec!["WIRE_JSON".to_string()]
280        }
281
282        // Default to FILE category for unknown rules
283        _ => 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        // Create empty canonical files for testing
316        let current = CanonicalFile::default();
317        let previous = CanonicalFile::default();
318
319        let result = engine.check(&current, &previous, &config);
320
321        // Should execute rules without errors
322        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(&current, &previous, &config);
335
336        // Should not include the excluded rule
337        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(&current, &previous, &config);
352
353        // Empty files should have no breaking changes
354        assert!(!result.has_breaking_changes);
355        assert!(result.changes.is_empty());
356    }
357}