actr_version/
compatibility.rs

1//! Service compatibility analysis using proto-sign for semantic protobuf analysis
2
3use crate::{
4    CompatibilityAnalysisResult, CompatibilityError, CompatibilityLevel, ProtocolChange, Result,
5};
6use actr_protocol::ServiceSpec;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Breaking change detected by proto-sign analysis
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct BreakingChange {
13    /// Rule that was violated (e.g., "FIELD_REMOVED")
14    pub rule: String,
15    /// File where the breaking change occurred
16    pub file: String,
17    /// Specific location (e.g., "User.email")
18    pub location: String,
19    /// Description of the breaking change
20    pub message: String,
21}
22
23/// Compatibility analysis with detailed breakdown
24#[derive(Debug)]
25pub struct CompatibilityAnalysis {
26    /// Overall compatibility assessment from proto-sign
27    pub compatibility: proto_sign::Compatibility,
28    /// All changes detected (breaking and non-breaking)
29    pub changes: Vec<ProtocolChange>,
30    /// Only breaking changes
31    pub breaking_changes: Vec<BreakingChange>,
32}
33
34/// Main service compatibility analyzer
35pub struct ServiceCompatibility;
36
37impl ServiceCompatibility {
38    /// Analyze compatibility between two ServiceSpec using both fingerprints and proto-sign
39    pub fn analyze_compatibility(
40        base_service: &ServiceSpec,
41        candidate_service: &ServiceSpec,
42    ) -> Result<CompatibilityAnalysisResult> {
43        // Validate input services
44        Self::validate_service(base_service, "base")?;
45        Self::validate_service(candidate_service, "candidate")?;
46
47        // Quick check: if semantic fingerprints are identical, no changes
48        if base_service.fingerprint == candidate_service.fingerprint {
49            return Ok(CompatibilityAnalysisResult {
50                level: CompatibilityLevel::FullyCompatible,
51                changes: vec![],
52                breaking_changes: vec![],
53                base_semantic_fingerprint: base_service.fingerprint.clone(),
54                candidate_semantic_fingerprint: candidate_service.fingerprint.clone(),
55                analyzed_at: chrono::Utc::now(),
56            });
57        }
58
59        // Perform detailed proto-sign analysis on changed files
60        let mut all_changes = Vec::new();
61        let mut breaking_changes = Vec::new();
62        let mut overall_compatibility = proto_sign::Compatibility::Green;
63
64        // Create package content maps (using package name directly)
65        let base_files: HashMap<String, String> = base_service
66            .protobufs
67            .iter()
68            .map(|f| (f.package.clone(), f.content.clone()))
69            .collect();
70        let candidate_files: HashMap<String, String> = candidate_service
71            .protobufs
72            .iter()
73            .map(|f| (f.package.clone(), f.content.clone()))
74            .collect();
75
76        // Analyze each file pair
77        for (file_name, base_content) in &base_files {
78            if let Some(candidate_content) = candidate_files.get(file_name) {
79                // File exists in both versions - analyze changes
80                let analysis = Self::analyze_file_pair(file_name, base_content, candidate_content)?;
81
82                all_changes.extend(analysis.changes);
83                breaking_changes.extend(analysis.breaking_changes);
84                overall_compatibility =
85                    Self::merge_compatibility(overall_compatibility, analysis.compatibility);
86            } else {
87                // File was removed - this is a breaking change
88                breaking_changes.push(BreakingChange {
89                    rule: "FILE_REMOVED".to_string(),
90                    file: file_name.clone(),
91                    location: file_name.clone(),
92                    message: format!("Proto file '{file_name}' was removed"),
93                });
94                overall_compatibility = proto_sign::Compatibility::Red;
95            }
96        }
97
98        // Check for newly added files (generally safe)
99        for file_name in candidate_files.keys() {
100            if !base_files.contains_key(file_name) {
101                all_changes.push(ProtocolChange {
102                    change_type: "FILE_ADDED".to_string(),
103                    file_name: file_name.clone(),
104                    location: file_name.clone(),
105                    description: format!("Proto file '{file_name}' was added"),
106                    is_breaking: false,
107                });
108            }
109        }
110
111        // Convert proto-sign compatibility to our enum
112        let level = match overall_compatibility {
113            proto_sign::Compatibility::Green => CompatibilityLevel::FullyCompatible,
114            proto_sign::Compatibility::Yellow => CompatibilityLevel::BackwardCompatible,
115            proto_sign::Compatibility::Red => CompatibilityLevel::BreakingChanges,
116        };
117
118        Ok(CompatibilityAnalysisResult {
119            level,
120            changes: all_changes,
121            breaking_changes,
122            base_semantic_fingerprint: base_service.fingerprint.clone(),
123            candidate_semantic_fingerprint: candidate_service.fingerprint.clone(),
124            analyzed_at: chrono::Utc::now(),
125        })
126    }
127
128    /// Analyze a single file pair using proto-sign
129    fn analyze_file_pair(
130        file_name: &str,
131        base_content: &str,
132        candidate_content: &str,
133    ) -> Result<CompatibilityAnalysis> {
134        // Parse proto specifications using proto-sign
135        let base_spec = proto_sign::Spec::try_from(base_content).map_err(|e| {
136            CompatibilityError::ProtoParseError {
137                file_name: file_name.to_string(),
138                source: e,
139            }
140        })?;
141
142        let candidate_spec = proto_sign::Spec::try_from(candidate_content).map_err(|e| {
143            CompatibilityError::ProtoParseError {
144                file_name: file_name.to_string(),
145                source: e,
146            }
147        })?;
148
149        // Perform proto-sign compatibility analysis
150        let compatibility = base_spec.compare_with(&candidate_spec);
151
152        // Convert proto-sign results to our format
153        let changes = if base_spec.fingerprint != candidate_spec.fingerprint {
154            vec![ProtocolChange {
155                change_type: "PROTO_CONTENT_CHANGED".to_string(),
156                file_name: file_name.to_string(),
157                location: file_name.to_string(),
158                description: format!("Proto file '{file_name}' has semantic changes"),
159                is_breaking: compatibility == proto_sign::Compatibility::Red,
160            }]
161        } else {
162            vec![]
163        };
164
165        let breaking_changes = if compatibility == proto_sign::Compatibility::Red {
166            vec![BreakingChange {
167                rule: "BREAKING_PROTO_CHANGE".to_string(),
168                file: file_name.to_string(),
169                location: file_name.to_string(),
170                message: format!("Breaking changes detected in '{file_name}'"),
171            }]
172        } else {
173            vec![]
174        };
175
176        Ok(CompatibilityAnalysis {
177            compatibility,
178            changes,
179            breaking_changes,
180        })
181    }
182
183    /// Validate that a service has required proto files
184    fn validate_service(service: &ServiceSpec, label: &str) -> Result<()> {
185        if service.protobufs.is_empty() {
186            return Err(CompatibilityError::NoProtoFiles {
187                service_name: format!("{label} service"),
188            });
189        }
190
191        for proto_file in &service.protobufs {
192            if proto_file.content.trim().is_empty() {
193                return Err(CompatibilityError::InvalidService(format!(
194                    "Package '{}' has empty content",
195                    proto_file.package
196                )));
197            }
198        }
199
200        Ok(())
201    }
202
203    /// Merge two compatibility levels (most restrictive wins)
204    fn merge_compatibility(
205        current: proto_sign::Compatibility,
206        new: proto_sign::Compatibility,
207    ) -> proto_sign::Compatibility {
208        match (current, new) {
209            (proto_sign::Compatibility::Red, _) | (_, proto_sign::Compatibility::Red) => {
210                proto_sign::Compatibility::Red
211            }
212            (proto_sign::Compatibility::Yellow, _) | (_, proto_sign::Compatibility::Yellow) => {
213                proto_sign::Compatibility::Yellow
214            }
215            _ => proto_sign::Compatibility::Green,
216        }
217    }
218
219    /// 检查是否存在破坏性变更(用于 CI/CD 快速判断)
220    ///
221    /// 注意:此方法内部会执行完整的兼容性分析。如果需要详细信息,
222    /// 建议直接调用 `analyze_compatibility` 并检查 `result.level`。
223    pub fn is_breaking(
224        base_service: &ServiceSpec,
225        candidate_service: &ServiceSpec,
226    ) -> Result<bool> {
227        let result = Self::analyze_compatibility(base_service, candidate_service)?;
228        Ok(matches!(result.level, CompatibilityLevel::BreakingChanges))
229    }
230
231    /// 获取破坏性变更列表(用于生成升级指南、错误报告等)
232    ///
233    /// 注意:此方法内部会执行完整的兼容性分析。如果需要其他信息,
234    /// 建议直接调用 `analyze_compatibility` 并使用 `result.breaking_changes`。
235    pub fn breaking_changes(
236        base_service: &ServiceSpec,
237        candidate_service: &ServiceSpec,
238    ) -> Result<Vec<BreakingChange>> {
239        let result = Self::analyze_compatibility(base_service, candidate_service)?;
240        Ok(result.breaking_changes)
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::{Fingerprint, ProtoFile};
248
249    fn create_test_service(_name: &str, _version: &str, proto_content: &str) -> ServiceSpec {
250        let proto_files = vec![ProtoFile {
251            name: "test.proto".to_string(),
252            content: proto_content.to_string(),
253            path: None,
254        }];
255
256        // Calculate real semantic fingerprint
257        let semantic_fp = Fingerprint::calculate_service_semantic_fingerprint(&proto_files)
258            .unwrap_or_else(|_| "test-fp".to_string());
259
260        ServiceSpec {
261            description: Some("Test service".to_string()),
262            fingerprint: semantic_fp,
263            protobufs: vec![actr_protocol::service_spec::Protobuf {
264                package: "test.proto".to_string(),
265                content: proto_content.to_string(),
266                fingerprint: "file-fp".to_string(),
267            }],
268            published_at: None,
269            tags: vec![],
270        }
271    }
272
273    #[test]
274    fn test_identical_services() {
275        let proto_content = r#" 
276            syntax = "proto3";
277            message TestMessage {
278                string name = 1;
279            }
280        "#;
281
282        let service1 = create_test_service("test", "1.0.0", proto_content);
283        let service2 = create_test_service("test", "1.0.0", proto_content);
284
285        let result = ServiceCompatibility::analyze_compatibility(&service1, &service2).unwrap();
286        assert_eq!(result.level, CompatibilityLevel::FullyCompatible);
287        assert_eq!(result.changes.len(), 0);
288        assert_eq!(result.breaking_changes.len(), 0);
289    }
290
291    #[test]
292    fn test_is_breaking() {
293        let base_proto = r#" 
294            syntax = "proto3";
295            message User {
296                string name = 1;
297                string email = 2;
298            }
299        "#;
300
301        let candidate_proto = r#" 
302            syntax = "proto3";
303            message User {
304                string name = 1;
305                // email field removed - breaking change
306            }
307        "#;
308
309        let base_service = create_test_service("user", "1.0.0", base_proto);
310        let candidate_service = create_test_service("user", "1.1.0", candidate_proto);
311
312        let is_breaking =
313            ServiceCompatibility::is_breaking(&base_service, &candidate_service).unwrap();
314        assert!(is_breaking);
315    }
316
317    #[test]
318    fn test_service_validation_errors() {
319        // Test empty proto files
320        let empty_service = ServiceSpec {
321            description: None,
322            fingerprint: "fp".to_string(),
323            protobufs: vec![],
324            published_at: None,
325            tags: vec![],
326        };
327
328        let valid_service =
329            create_test_service("valid", "1.0.0", "syntax = \"proto3\"; message Test {}");
330
331        let result = ServiceCompatibility::analyze_compatibility(&empty_service, &valid_service);
332        assert!(result.is_err());
333        assert!(matches!(
334            result.unwrap_err(),
335            CompatibilityError::NoProtoFiles { .. }
336        ));
337
338        // Test empty proto content
339        let empty_content_service = ServiceSpec {
340            description: None,
341            fingerprint: "fp".to_string(),
342            protobufs: vec![actr_protocol::service_spec::Protobuf {
343                package: "empty.proto".to_string(),
344                content: "   \n  \t  ".to_string(), // Only whitespace
345                fingerprint: "fp".to_string(),
346            }],
347            published_at: None,
348            tags: vec![],
349        };
350
351        let result =
352            ServiceCompatibility::analyze_compatibility(&empty_content_service, &valid_service);
353        assert!(result.is_err());
354        assert!(matches!(
355            result.unwrap_err(),
356            CompatibilityError::InvalidService(_)
357        ));
358    }
359
360    #[test]
361    fn test_file_removed_breaking_change() {
362        let base_service = ServiceSpec {
363            description: None,
364            fingerprint: "fp1".to_string(),
365            protobufs: vec![
366                actr_protocol::service_spec::Protobuf {
367                    package: "user.proto".to_string(),
368                    content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
369                    fingerprint: "fp-user".to_string(),
370                },
371                actr_protocol::service_spec::Protobuf {
372                    package: "order.proto".to_string(),
373                    content: "syntax = \"proto3\"; message Order { string id = 1; }".to_string(),
374                    fingerprint: "fp-order".to_string(),
375                },
376            ],
377            published_at: None,
378            tags: vec![],
379        };
380
381        // Remove order.proto file
382        let candidate_service = ServiceSpec {
383            description: None,
384            fingerprint: "fp2".to_string(),
385            protobufs: vec![actr_protocol::service_spec::Protobuf {
386                package: "user.proto".to_string(),
387                content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
388                fingerprint: "fp-user".to_string(),
389            }],
390            published_at: None,
391            tags: vec![],
392        };
393
394        let result =
395            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
396        assert_eq!(result.level, CompatibilityLevel::BreakingChanges);
397        assert!(!result.breaking_changes.is_empty());
398
399        let file_removed = result
400            .breaking_changes
401            .iter()
402            .any(|bc| bc.rule == "FILE_REMOVED" && bc.file == "order.proto");
403        assert!(
404            file_removed,
405            "Should detect file removal as breaking change"
406        );
407    }
408
409    #[test]
410    fn test_file_added_non_breaking() {
411        let base_service = ServiceSpec {
412            description: None,
413            fingerprint: "fp1".to_string(),
414            protobufs: vec![actr_protocol::service_spec::Protobuf {
415                package: "user.proto".to_string(),
416                content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
417                fingerprint: "fp-user".to_string(),
418            }],
419            published_at: None,
420            tags: vec![],
421        };
422
423        // Add order.proto file
424        let candidate_service = ServiceSpec {
425            description: None,
426            fingerprint: "fp2".to_string(),
427            protobufs: vec![
428                actr_protocol::service_spec::Protobuf {
429                    package: "user.proto".to_string(),
430                    content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
431                    fingerprint: "fp-user".to_string(),
432                },
433                actr_protocol::service_spec::Protobuf {
434                    package: "order.proto".to_string(),
435                    content: "syntax = \"proto3\"; message Order { string id = 1; }".to_string(),
436                    fingerprint: "fp-order".to_string(),
437                },
438            ],
439            published_at: None,
440            tags: vec![],
441        };
442
443        let result =
444            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
445
446        // Should be backward compatible (adding files is generally safe)
447        let file_added = result
448            .changes
449            .iter()
450            .any(|c| c.change_type == "FILE_ADDED" && c.file_name == "order.proto");
451        assert!(file_added, "Should detect file addition");
452
453        let breaking_file_changes = result
454            .breaking_changes
455            .iter()
456            .any(|bc| bc.rule == "FILE_ADDED");
457        assert!(
458            !breaking_file_changes,
459            "Adding files should not be breaking"
460        );
461    }
462
463    #[test]
464    fn test_breaking_changes() {
465        let base_proto = r#"
466            syntax = "proto3";
467            message User {
468                string name = 1;
469                string email = 2;
470            }
471        "#;
472
473        let candidate_proto = r#"
474            syntax = "proto3";
475            message User {
476                string name = 1;
477                // email removed
478            }
479        "#;
480
481        let base_service = create_test_service("user", "1.0.0", base_proto);
482        let candidate_service = create_test_service("user", "1.1.0", candidate_proto);
483
484        let changes =
485            ServiceCompatibility::breaking_changes(&base_service, &candidate_service).unwrap();
486        assert!(!changes.is_empty());
487
488        let has_breaking_proto_change = changes.iter().any(|bc| bc.rule == "BREAKING_PROTO_CHANGE");
489        assert!(
490            has_breaking_proto_change,
491            "Should identify breaking proto changes"
492        );
493    }
494
495    #[test]
496    fn test_proto_parse_error() {
497        let base_service =
498            create_test_service("valid", "1.0.0", "syntax = \"proto3\"; message Valid {}");
499
500        // Use a more definitively invalid proto content that proto-sign will reject
501        let invalid_service = ServiceSpec {
502            description: None,
503            fingerprint: "fp".to_string(),
504            protobufs: vec![actr_protocol::service_spec::Protobuf {
505                package: "invalid.proto".to_string(),
506                content: "completely invalid proto syntax { {{ ??? }}}".to_string(),
507                fingerprint: "fp".to_string(),
508            }],
509            published_at: None,
510            tags: vec![],
511        };
512
513        let result = ServiceCompatibility::analyze_compatibility(&base_service, &invalid_service);
514
515        // If proto-sign can somehow parse our invalid content, just check we get an error of some kind
516        match result {
517            Err(CompatibilityError::ProtoParseError { .. }) => {
518                // This is what we expect
519            }
520            Err(_) => {
521                // Any error is acceptable for invalid proto content
522            }
523            Ok(analysis) => {
524                // If it somehow succeeds, at least verify we get some kind of change detection
525                assert!(
526                    analysis.level != CompatibilityLevel::FullyCompatible
527                        || !analysis.changes.is_empty()
528                );
529            }
530        }
531    }
532
533    #[test]
534    fn test_base_service_proto_parse_error() {
535        // Test error in base service parsing
536        let invalid_base_service = ServiceSpec {
537            description: None,
538            fingerprint: "fp".to_string(),
539            protobufs: vec![actr_protocol::service_spec::Protobuf {
540                package: "bad-base.proto".to_string(),
541                content: "syntax = \"proto3\"; message".to_string(), // Incomplete syntax
542                fingerprint: "fp".to_string(),
543            }],
544            published_at: None,
545            tags: vec![],
546        };
547
548        let valid_service =
549            create_test_service("valid", "1.0.0", "syntax = \"proto3\"; message Valid {}");
550
551        let result =
552            ServiceCompatibility::analyze_compatibility(&invalid_base_service, &valid_service);
553        // Should get some kind of error (either parse error or analysis error)
554        if let Err(CompatibilityError::ProtoParseError { file_name, .. }) = result {
555            assert_eq!(file_name, "bad-base.proto");
556        }
557        // Other errors or success are also acceptable
558    }
559
560    #[test]
561    fn test_backward_compatible_changes() {
562        // Create a scenario that should trigger BackwardCompatible (Yellow) level
563        let base_proto = r#" 
564            syntax = "proto3";
565            message User {
566                string name = 1;
567            }
568        "#;
569
570        // Add a field (typically backward compatible)
571        let candidate_proto = r#" 
572            syntax = "proto3";
573            message User {
574                string name = 1;
575                string email = 2;  // New optional field
576            }
577        "#;
578
579        let base_service = create_test_service("user", "1.0.0", base_proto);
580        let candidate_service = create_test_service("user", "1.1.0", candidate_proto);
581
582        let result =
583            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
584
585        // The result might be FullyCompatible or BackwardCompatible depending on proto-sign's assessment
586        // This test helps ensure we cover the Yellow compatibility path if it occurs
587        assert!(matches!(
588            result.level,
589            CompatibilityLevel::FullyCompatible | CompatibilityLevel::BackwardCompatible
590        ));
591    }
592
593    #[test]
594    fn test_mixed_compatibility_merge() {
595        // Create a scenario with multiple files having different compatibility levels
596        let base_service = ServiceSpec {
597            description: None,
598            fingerprint: "fp1".to_string(),
599            protobufs: vec![
600                actr_protocol::service_spec::Protobuf {
601                    package: "stable.proto".to_string(),
602                    content: "syntax = \"proto3\"; message Stable { string name = 1; }".to_string(),
603                    fingerprint: "fp-stable".to_string(),
604                },
605                actr_protocol::service_spec::Protobuf {
606                    package: "evolving.proto".to_string(),
607                    content: "syntax = \"proto3\"; message Evolving { string id = 1; }".to_string(),
608                    fingerprint: "fp-evolving1".to_string(),
609                },
610            ],
611            published_at: None,
612            tags: vec![],
613        };
614
615        let candidate_service =
616            ServiceSpec {
617                description: None,
618                fingerprint: "fp2".to_string(),
619                protobufs: vec![
620                actr_protocol::service_spec::Protobuf {
621                    package: "stable.proto".to_string(),
622                    content: "syntax = \"proto3\"; message Stable { string name = 1; }".to_string(), // No change
623                    fingerprint: "fp-stable".to_string(),
624                },
625                actr_protocol::service_spec::Protobuf {
626                    package: "evolving.proto".to_string(),
627                    content: "syntax = \"proto3\"; message Evolving { string id = 1; string type = 2; }".to_string(), // Add field
628                    fingerprint: "fp-evolving2".to_string(),
629                },
630            ],
631                published_at: None,
632                tags: vec![],
633            };
634
635        let result =
636            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
637
638        // This test helps exercise the compatibility merging logic
639        assert!(matches!(
640            result.level,
641            CompatibilityLevel::FullyCompatible
642                | CompatibilityLevel::BackwardCompatible
643                | CompatibilityLevel::BreakingChanges
644        ));
645
646        // Should have detected at least one change
647        assert!(result.level != CompatibilityLevel::FullyCompatible || !result.changes.is_empty());
648    }
649}