1use crate::{
4 CompatibilityAnalysisResult, CompatibilityError, CompatibilityLevel, ProtocolChange, Result,
5};
6use actr_protocol::ServiceSpec;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct BreakingChange {
13 pub rule: String,
15 pub file: String,
17 pub location: String,
19 pub message: String,
21}
22
23#[derive(Debug)]
25pub struct CompatibilityAnalysis {
26 pub compatibility: proto_sign::Compatibility,
28 pub changes: Vec<ProtocolChange>,
30 pub breaking_changes: Vec<BreakingChange>,
32}
33
34pub struct ServiceCompatibility;
36
37impl ServiceCompatibility {
38 pub fn analyze_compatibility(
40 base_service: &ServiceSpec,
41 candidate_service: &ServiceSpec,
42 ) -> Result<CompatibilityAnalysisResult> {
43 Self::validate_service(base_service, "base")?;
45 Self::validate_service(candidate_service, "candidate")?;
46
47 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 let mut all_changes = Vec::new();
61 let mut breaking_changes = Vec::new();
62 let mut overall_compatibility = proto_sign::Compatibility::Green;
63
64 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 for (file_name, base_content) in &base_files {
78 if let Some(candidate_content) = candidate_files.get(file_name) {
79 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 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 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 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 fn analyze_file_pair(
130 file_name: &str,
131 base_content: &str,
132 candidate_content: &str,
133 ) -> Result<CompatibilityAnalysis> {
134 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 let compatibility = base_spec.compare_with(&candidate_spec);
151
152 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 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 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 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 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 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 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 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(), 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 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 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 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 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 match result {
525 Err(CompatibilityError::ProtoParseError { .. }) => {
526 }
528 Err(_) => {
529 }
531 Ok(analysis) => {
532 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 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(), 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 if let Err(CompatibilityError::ProtoParseError { file_name, .. }) = result {
564 assert_eq!(file_name, "bad-base.proto");
565 }
566 }
568
569 #[test]
570 fn test_backward_compatible_changes() {
571 let base_proto = r#"
573 syntax = "proto3";
574 message User {
575 string name = 1;
576 }
577 "#;
578
579 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 assert!(matches!(
597 result.level,
598 CompatibilityLevel::FullyCompatible | CompatibilityLevel::BackwardCompatible
599 ));
600 }
601
602 #[test]
603 fn test_mixed_compatibility_merge() {
604 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(), 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(), 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 assert!(matches!(
651 result.level,
652 CompatibilityLevel::FullyCompatible
653 | CompatibilityLevel::BackwardCompatible
654 | CompatibilityLevel::BreakingChanges
655 ));
656
657 assert!(result.level != CompatibilityLevel::FullyCompatible || !result.changes.is_empty());
659 }
660}