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            name: name.to_string(),
262            description: Some("Test service".to_string()),
263            fingerprint: semantic_fp,
264            protobufs: vec![actr_protocol::service_spec::Protobuf {
265                package: "test.proto".to_string(),
266                content: proto_content.to_string(),
267                fingerprint: "file-fp".to_string(),
268            }],
269            published_at: None,
270            tags: vec![],
271        }
272    }
273
274    #[test]
275    fn test_identical_services() {
276        let proto_content = r#" 
277            syntax = "proto3";
278            message TestMessage {
279                string name = 1;
280            }
281        "#;
282
283        let service1 = create_test_service("test", "1.0.0", proto_content);
284        let service2 = create_test_service("test", "1.0.0", proto_content);
285
286        let result = ServiceCompatibility::analyze_compatibility(&service1, &service2).unwrap();
287        assert_eq!(result.level, CompatibilityLevel::FullyCompatible);
288        assert_eq!(result.changes.len(), 0);
289        assert_eq!(result.breaking_changes.len(), 0);
290    }
291
292    #[test]
293    fn test_is_breaking() {
294        let base_proto = r#" 
295            syntax = "proto3";
296            message User {
297                string name = 1;
298                string email = 2;
299            }
300        "#;
301
302        let candidate_proto = r#" 
303            syntax = "proto3";
304            message User {
305                string name = 1;
306                // email field removed - breaking change
307            }
308        "#;
309
310        let base_service = create_test_service("user", "1.0.0", base_proto);
311        let candidate_service = create_test_service("user", "1.1.0", candidate_proto);
312
313        let is_breaking =
314            ServiceCompatibility::is_breaking(&base_service, &candidate_service).unwrap();
315        assert!(is_breaking);
316    }
317
318    #[test]
319    fn test_service_validation_errors() {
320        // Test empty proto files
321        let empty_service = ServiceSpec {
322            name: "empty".to_string(),
323            description: None,
324            fingerprint: "fp".to_string(),
325            protobufs: vec![],
326            published_at: None,
327            tags: vec![],
328        };
329
330        let valid_service =
331            create_test_service("valid", "1.0.0", "syntax = \"proto3\"; message Test {}");
332
333        let result = ServiceCompatibility::analyze_compatibility(&empty_service, &valid_service);
334        assert!(result.is_err());
335        assert!(matches!(
336            result.unwrap_err(),
337            CompatibilityError::NoProtoFiles { .. }
338        ));
339
340        // Test empty proto content
341        let empty_content_service = ServiceSpec {
342            name: "empty-content".to_string(),
343            description: None,
344            fingerprint: "fp".to_string(),
345            protobufs: vec![actr_protocol::service_spec::Protobuf {
346                package: "empty.proto".to_string(),
347                content: "   \n  \t  ".to_string(), // Only whitespace
348                fingerprint: "fp".to_string(),
349            }],
350            published_at: None,
351            tags: vec![],
352        };
353
354        let result =
355            ServiceCompatibility::analyze_compatibility(&empty_content_service, &valid_service);
356        assert!(result.is_err());
357        assert!(matches!(
358            result.unwrap_err(),
359            CompatibilityError::InvalidService(_)
360        ));
361    }
362
363    #[test]
364    fn test_file_removed_breaking_change() {
365        let base_service = ServiceSpec {
366            name: "base-service".to_string(),
367            description: None,
368            fingerprint: "fp1".to_string(),
369            protobufs: vec![
370                actr_protocol::service_spec::Protobuf {
371                    package: "user.proto".to_string(),
372                    content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
373                    fingerprint: "fp-user".to_string(),
374                },
375                actr_protocol::service_spec::Protobuf {
376                    package: "order.proto".to_string(),
377                    content: "syntax = \"proto3\"; message Order { string id = 1; }".to_string(),
378                    fingerprint: "fp-order".to_string(),
379                },
380            ],
381            published_at: None,
382            tags: vec![],
383        };
384
385        // Remove order.proto file
386        let candidate_service = ServiceSpec {
387            name: "candidate-service".to_string(),
388            description: None,
389            fingerprint: "fp2".to_string(),
390            protobufs: vec![actr_protocol::service_spec::Protobuf {
391                package: "user.proto".to_string(),
392                content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
393                fingerprint: "fp-user".to_string(),
394            }],
395            published_at: None,
396            tags: vec![],
397        };
398
399        let result =
400            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
401        assert_eq!(result.level, CompatibilityLevel::BreakingChanges);
402        assert!(!result.breaking_changes.is_empty());
403
404        let file_removed = result
405            .breaking_changes
406            .iter()
407            .any(|bc| bc.rule == "FILE_REMOVED" && bc.file == "order.proto");
408        assert!(
409            file_removed,
410            "Should detect file removal as breaking change"
411        );
412    }
413
414    #[test]
415    fn test_file_added_non_breaking() {
416        let base_service = ServiceSpec {
417            name: "base-service".to_string(),
418            description: None,
419            fingerprint: "fp1".to_string(),
420            protobufs: vec![actr_protocol::service_spec::Protobuf {
421                package: "user.proto".to_string(),
422                content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
423                fingerprint: "fp-user".to_string(),
424            }],
425            published_at: None,
426            tags: vec![],
427        };
428
429        // Add order.proto file
430        let candidate_service = ServiceSpec {
431            name: "candidate-service".to_string(),
432            description: None,
433            fingerprint: "fp2".to_string(),
434            protobufs: vec![
435                actr_protocol::service_spec::Protobuf {
436                    package: "user.proto".to_string(),
437                    content: "syntax = \"proto3\"; message User { string name = 1; }".to_string(),
438                    fingerprint: "fp-user".to_string(),
439                },
440                actr_protocol::service_spec::Protobuf {
441                    package: "order.proto".to_string(),
442                    content: "syntax = \"proto3\"; message Order { string id = 1; }".to_string(),
443                    fingerprint: "fp-order".to_string(),
444                },
445            ],
446            published_at: None,
447            tags: vec![],
448        };
449
450        let result =
451            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
452
453        // Should be backward compatible (adding files is generally safe)
454        let file_added = result
455            .changes
456            .iter()
457            .any(|c| c.change_type == "FILE_ADDED" && c.file_name == "order.proto");
458        assert!(file_added, "Should detect file addition");
459
460        let breaking_file_changes = result
461            .breaking_changes
462            .iter()
463            .any(|bc| bc.rule == "FILE_ADDED");
464        assert!(
465            !breaking_file_changes,
466            "Adding files should not be breaking"
467        );
468    }
469
470    #[test]
471    fn test_breaking_changes() {
472        let base_proto = r#"
473            syntax = "proto3";
474            message User {
475                string name = 1;
476                string email = 2;
477            }
478        "#;
479
480        let candidate_proto = r#"
481            syntax = "proto3";
482            message User {
483                string name = 1;
484                // email removed
485            }
486        "#;
487
488        let base_service = create_test_service("user", "1.0.0", base_proto);
489        let candidate_service = create_test_service("user", "1.1.0", candidate_proto);
490
491        let changes =
492            ServiceCompatibility::breaking_changes(&base_service, &candidate_service).unwrap();
493        assert!(!changes.is_empty());
494
495        let has_breaking_proto_change = changes.iter().any(|bc| bc.rule == "BREAKING_PROTO_CHANGE");
496        assert!(
497            has_breaking_proto_change,
498            "Should identify breaking proto changes"
499        );
500    }
501
502    #[test]
503    fn test_proto_parse_error() {
504        let base_service =
505            create_test_service("valid", "1.0.0", "syntax = \"proto3\"; message Valid {}");
506
507        // Use a more definitively invalid proto content that proto-sign will reject
508        let invalid_service = ServiceSpec {
509            name: "invalid-service".to_string(),
510            description: None,
511            fingerprint: "fp".to_string(),
512            protobufs: vec![actr_protocol::service_spec::Protobuf {
513                package: "invalid.proto".to_string(),
514                content: "completely invalid proto syntax { {{ ??? }}}".to_string(),
515                fingerprint: "fp".to_string(),
516            }],
517            published_at: None,
518            tags: vec![],
519        };
520
521        let result = ServiceCompatibility::analyze_compatibility(&base_service, &invalid_service);
522
523        // If proto-sign can somehow parse our invalid content, just check we get an error of some kind
524        match result {
525            Err(CompatibilityError::ProtoParseError { .. }) => {
526                // This is what we expect
527            }
528            Err(_) => {
529                // Any error is acceptable for invalid proto content
530            }
531            Ok(analysis) => {
532                // If it somehow succeeds, at least verify we get some kind of change detection
533                assert!(
534                    analysis.level != CompatibilityLevel::FullyCompatible
535                        || !analysis.changes.is_empty()
536                );
537            }
538        }
539    }
540
541    #[test]
542    fn test_base_service_proto_parse_error() {
543        // Test error in base service parsing
544        let invalid_base_service = ServiceSpec {
545            name: "invalid-base-service".to_string(),
546            description: None,
547            fingerprint: "fp".to_string(),
548            protobufs: vec![actr_protocol::service_spec::Protobuf {
549                package: "bad-base.proto".to_string(),
550                content: "syntax = \"proto3\"; message".to_string(), // Incomplete syntax
551                fingerprint: "fp".to_string(),
552            }],
553            published_at: None,
554            tags: vec![],
555        };
556
557        let valid_service =
558            create_test_service("valid", "1.0.0", "syntax = \"proto3\"; message Valid {}");
559
560        let result =
561            ServiceCompatibility::analyze_compatibility(&invalid_base_service, &valid_service);
562        // Should get some kind of error (either parse error or analysis error)
563        if let Err(CompatibilityError::ProtoParseError { file_name, .. }) = result {
564            assert_eq!(file_name, "bad-base.proto");
565        }
566        // Other errors or success are also acceptable
567    }
568
569    #[test]
570    fn test_backward_compatible_changes() {
571        // Create a scenario that should trigger BackwardCompatible (Yellow) level
572        let base_proto = r#" 
573            syntax = "proto3";
574            message User {
575                string name = 1;
576            }
577        "#;
578
579        // Add a field (typically backward compatible)
580        let candidate_proto = r#" 
581            syntax = "proto3";
582            message User {
583                string name = 1;
584                string email = 2;  // New optional field
585            }
586        "#;
587
588        let base_service = create_test_service("user", "1.0.0", base_proto);
589        let candidate_service = create_test_service("user", "1.1.0", candidate_proto);
590
591        let result =
592            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
593
594        // The result might be FullyCompatible or BackwardCompatible depending on proto-sign's assessment
595        // This test helps ensure we cover the Yellow compatibility path if it occurs
596        assert!(matches!(
597            result.level,
598            CompatibilityLevel::FullyCompatible | CompatibilityLevel::BackwardCompatible
599        ));
600    }
601
602    #[test]
603    fn test_mixed_compatibility_merge() {
604        // Create a scenario with multiple files having different compatibility levels
605        let base_service = ServiceSpec {
606            name: "base-service".to_string(),
607            description: None,
608            fingerprint: "fp1".to_string(),
609            protobufs: vec![
610                actr_protocol::service_spec::Protobuf {
611                    package: "stable.proto".to_string(),
612                    content: "syntax = \"proto3\"; message Stable { string name = 1; }".to_string(),
613                    fingerprint: "fp-stable".to_string(),
614                },
615                actr_protocol::service_spec::Protobuf {
616                    package: "evolving.proto".to_string(),
617                    content: "syntax = \"proto3\"; message Evolving { string id = 1; }".to_string(),
618                    fingerprint: "fp-evolving1".to_string(),
619                },
620            ],
621            published_at: None,
622            tags: vec![],
623        };
624
625        let candidate_service =
626            ServiceSpec {
627                name: "candidate-service".to_string(),
628                description: None,
629                fingerprint: "fp2".to_string(),
630                protobufs: vec![
631                actr_protocol::service_spec::Protobuf {
632                    package: "stable.proto".to_string(),
633                    content: "syntax = \"proto3\"; message Stable { string name = 1; }".to_string(), // No change
634                    fingerprint: "fp-stable".to_string(),
635                },
636                actr_protocol::service_spec::Protobuf {
637                    package: "evolving.proto".to_string(),
638                    content: "syntax = \"proto3\"; message Evolving { string id = 1; string type = 2; }".to_string(), // Add field
639                    fingerprint: "fp-evolving2".to_string(),
640                },
641            ],
642                published_at: None,
643                tags: vec![],
644            };
645
646        let result =
647            ServiceCompatibility::analyze_compatibility(&base_service, &candidate_service).unwrap();
648
649        // This test helps exercise the compatibility merging logic
650        assert!(matches!(
651            result.level,
652            CompatibilityLevel::FullyCompatible
653                | CompatibilityLevel::BackwardCompatible
654                | CompatibilityLevel::BreakingChanges
655        ));
656
657        // Should have detected at least one change
658        assert!(result.level != CompatibilityLevel::FullyCompatible || !result.changes.is_empty());
659    }
660}