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 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 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 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(), 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 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 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 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 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 match result {
517 Err(CompatibilityError::ProtoParseError { .. }) => {
518 }
520 Err(_) => {
521 }
523 Ok(analysis) => {
524 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 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(), 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 if let Err(CompatibilityError::ProtoParseError { file_name, .. }) = result {
555 assert_eq!(file_name, "bad-base.proto");
556 }
557 }
559
560 #[test]
561 fn test_backward_compatible_changes() {
562 let base_proto = r#"
564 syntax = "proto3";
565 message User {
566 string name = 1;
567 }
568 "#;
569
570 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 assert!(matches!(
588 result.level,
589 CompatibilityLevel::FullyCompatible | CompatibilityLevel::BackwardCompatible
590 ));
591 }
592
593 #[test]
594 fn test_mixed_compatibility_merge() {
595 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(), 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(), 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 assert!(matches!(
640 result.level,
641 CompatibilityLevel::FullyCompatible
642 | CompatibilityLevel::BackwardCompatible
643 | CompatibilityLevel::BreakingChanges
644 ));
645
646 assert!(result.level != CompatibilityLevel::FullyCompatible || !result.changes.is_empty());
648 }
649}