1use std::collections::{BTreeMap, BTreeSet};
9
10use facet::Facet;
11use phon_engine::Registry;
12use phon_engine::plan::{CompatDirection, compatibility_direction};
13use phon_schema::{Schema, SchemaId};
14use vox_types::{BindingDirection, ServiceDescriptor};
15
16#[derive(Debug)]
17pub enum SchemaCompatError {
18 Snapshot {
19 service: &'static str,
20 method: &'static str,
21 direction: BindingDirection,
22 message: String,
23 },
24 Hex(String),
25 SchemaClosure(String),
26}
27
28impl std::fmt::Display for SchemaCompatError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 Self::Snapshot {
32 service,
33 method,
34 direction,
35 message,
36 } => write!(
37 f,
38 "failed to snapshot {service}.{method} {direction:?}: {message}"
39 ),
40 Self::Hex(message) => write!(f, "{message}"),
41 Self::SchemaClosure(message) => write!(f, "{message}"),
42 }
43 }
44}
45
46impl std::error::Error for SchemaCompatError {}
47
48#[derive(Facet, Clone, Debug)]
49pub struct SchemaCompatSnapshot {
50 pub services: Vec<ServiceSchemaSnapshot>,
51}
52
53#[derive(Facet, Clone, Debug)]
54pub struct ServiceSchemaSnapshot {
55 pub service_name: String,
56 pub methods: Vec<MethodSchemaSnapshot>,
57}
58
59#[derive(Facet, Clone, Debug)]
60pub struct MethodSchemaSnapshot {
61 pub method_name: String,
62 pub method_id: String,
63 pub args: SchemaRootSnapshot,
64 pub response: SchemaRootSnapshot,
65}
66
67#[derive(Facet, Clone, Debug)]
68pub struct SchemaRootSnapshot {
69 pub root: String,
70 pub closure: String,
71}
72
73#[derive(Facet, Clone, Debug)]
74pub struct SchemaCompatReport {
75 pub comparisons: Vec<SchemaCompatComparison>,
76}
77
78#[derive(Facet, Clone, Debug)]
79pub struct SchemaCompatComparison {
80 pub service_name: String,
81 pub method_name: String,
82 pub direction: SchemaCompatComparisonDirection,
83 pub status: SchemaCompatStatus,
84 pub note: Option<String>,
85}
86
87#[derive(Facet, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
88#[repr(u8)]
89pub enum SchemaCompatComparisonDirection {
90 Args,
91 Response,
92 Method,
93}
94
95#[derive(Facet, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
96#[repr(u8)]
97pub enum SchemaCompatStatus {
98 Backward,
99 Forward,
100 Bidirectional,
101 Incompatible,
102}
103
104impl From<CompatDirection> for SchemaCompatStatus {
105 fn from(value: CompatDirection) -> Self {
106 match value {
107 CompatDirection::Backward => Self::Backward,
108 CompatDirection::Forward => Self::Forward,
109 CompatDirection::Bidirectional => Self::Bidirectional,
110 CompatDirection::Incompatible => Self::Incompatible,
111 }
112 }
113}
114
115#[derive(Facet, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
116pub struct SchemaCompatAcknowledgement {
117 pub service_name: String,
118 pub method_name: String,
119 pub direction: SchemaCompatComparisonDirection,
120}
121
122#[derive(Facet, Clone, Debug)]
123pub struct SchemaCompatPolicy {
124 pub acknowledged_breaking: Vec<SchemaCompatAcknowledgement>,
125}
126
127#[derive(Facet, Clone, Debug)]
128pub struct SchemaCompatPolicyOutcome {
129 pub unacknowledged_breaking: Vec<SchemaCompatComparison>,
130 pub stale_acknowledgements: Vec<SchemaCompatAcknowledgement>,
131}
132
133impl SchemaCompatPolicyOutcome {
134 #[must_use]
135 pub fn is_ok(&self) -> bool {
136 self.unacknowledged_breaking.is_empty() && self.stale_acknowledgements.is_empty()
137 }
138}
139
140pub fn snapshot_services(
142 services: &[&'static ServiceDescriptor],
143) -> Result<SchemaCompatSnapshot, SchemaCompatError> {
144 let mut service_snapshots = Vec::with_capacity(services.len());
145 for service in services {
146 let mut methods = Vec::with_capacity(service.methods.len());
147 for method in service.methods {
148 let args = snapshot_root(
149 service.service_name,
150 method.method_name,
151 BindingDirection::Args,
152 method.args_shape,
153 )?;
154 let response = snapshot_root(
155 service.service_name,
156 method.method_name,
157 BindingDirection::Response,
158 method.response_wire_shape,
159 )?;
160 methods.push(MethodSchemaSnapshot {
161 method_name: method.method_name.to_string(),
162 method_id: hex_u64(method.id.0),
163 args,
164 response,
165 });
166 }
167 service_snapshots.push(ServiceSchemaSnapshot {
168 service_name: service.service_name.to_string(),
169 methods,
170 });
171 }
172 Ok(SchemaCompatSnapshot {
173 services: service_snapshots,
174 })
175}
176
177pub fn compare_snapshots(
180 older: &SchemaCompatSnapshot,
181 newer: &SchemaCompatSnapshot,
182) -> Result<SchemaCompatReport, SchemaCompatError> {
183 let registry = registry_for_snapshots(older, newer)?;
184 let mut comparisons = Vec::new();
185
186 let old_services = service_map(older);
187 let new_services = service_map(newer);
188 let service_names = old_services
189 .keys()
190 .chain(new_services.keys())
191 .copied()
192 .collect::<BTreeSet<_>>();
193
194 for service_name in service_names {
195 match (
196 old_services.get(service_name).copied(),
197 new_services.get(service_name).copied(),
198 ) {
199 (Some(old_service), Some(new_service)) => {
200 compare_service(old_service, new_service, ®istry, &mut comparisons)?;
201 }
202 (Some(_), None) => comparisons.push(SchemaCompatComparison {
203 service_name: service_name.to_string(),
204 method_name: "*".to_string(),
205 direction: SchemaCompatComparisonDirection::Method,
206 status: SchemaCompatStatus::Incompatible,
207 note: Some("service removed".to_string()),
208 }),
209 (None, Some(_)) => comparisons.push(SchemaCompatComparison {
210 service_name: service_name.to_string(),
211 method_name: "*".to_string(),
212 direction: SchemaCompatComparisonDirection::Method,
213 status: SchemaCompatStatus::Bidirectional,
214 note: Some("service added".to_string()),
215 }),
216 (None, None) => unreachable!("service name came from one of the maps"),
217 }
218 }
219
220 Ok(SchemaCompatReport { comparisons })
221}
222
223pub fn method_intersection_snapshot(
224 snapshot: &SchemaCompatSnapshot,
225 reference: &SchemaCompatSnapshot,
226) -> SchemaCompatSnapshot {
227 let reference_services = service_map(reference);
228 let services = snapshot
229 .services
230 .iter()
231 .filter_map(|service| {
232 let reference_service = reference_services.get(service.service_name.as_str())?;
233 let reference_methods = reference_service
234 .methods
235 .iter()
236 .map(|method| method.method_name.as_str())
237 .collect::<BTreeSet<_>>();
238 let methods = service
239 .methods
240 .iter()
241 .filter(|method| reference_methods.contains(method.method_name.as_str()))
242 .cloned()
243 .collect::<Vec<_>>();
244 Some(ServiceSchemaSnapshot {
245 service_name: service.service_name.clone(),
246 methods,
247 })
248 })
249 .collect();
250 SchemaCompatSnapshot { services }
251}
252
253#[must_use]
255pub fn enforce_policy(
256 report: &SchemaCompatReport,
257 policy: &SchemaCompatPolicy,
258) -> SchemaCompatPolicyOutcome {
259 let acknowledged = policy
260 .acknowledged_breaking
261 .iter()
262 .cloned()
263 .collect::<BTreeSet<_>>();
264 let actual_breaking = report
265 .comparisons
266 .iter()
267 .filter(|comparison| comparison.status == SchemaCompatStatus::Incompatible)
268 .map(|comparison| SchemaCompatAcknowledgement {
269 service_name: comparison.service_name.clone(),
270 method_name: comparison.method_name.clone(),
271 direction: comparison.direction,
272 })
273 .collect::<BTreeSet<_>>();
274
275 let unacknowledged_breaking = report
276 .comparisons
277 .iter()
278 .filter(|comparison| comparison.status == SchemaCompatStatus::Incompatible)
279 .filter(|comparison| {
280 !acknowledged.contains(&SchemaCompatAcknowledgement {
281 service_name: comparison.service_name.clone(),
282 method_name: comparison.method_name.clone(),
283 direction: comparison.direction,
284 })
285 })
286 .cloned()
287 .collect();
288
289 let stale_acknowledgements = acknowledged.difference(&actual_breaking).cloned().collect();
290
291 SchemaCompatPolicyOutcome {
292 unacknowledged_breaking,
293 stale_acknowledgements,
294 }
295}
296
297fn compare_service(
298 old_service: &ServiceSchemaSnapshot,
299 new_service: &ServiceSchemaSnapshot,
300 registry: &Registry,
301 comparisons: &mut Vec<SchemaCompatComparison>,
302) -> Result<(), SchemaCompatError> {
303 let old_methods = method_map(old_service);
304 let new_methods = method_map(new_service);
305 let method_names = old_methods
306 .keys()
307 .chain(new_methods.keys())
308 .copied()
309 .collect::<BTreeSet<_>>();
310
311 for method_name in method_names {
312 match (
313 old_methods.get(method_name).copied(),
314 new_methods.get(method_name).copied(),
315 ) {
316 (Some(old_method), Some(new_method)) => {
317 compare_root(
318 &old_service.service_name,
319 method_name,
320 SchemaCompatComparisonDirection::Args,
321 &old_method.args,
322 &new_method.args,
323 registry,
324 comparisons,
325 )?;
326 compare_root(
327 &old_service.service_name,
328 method_name,
329 SchemaCompatComparisonDirection::Response,
330 &old_method.response,
331 &new_method.response,
332 registry,
333 comparisons,
334 )?;
335 }
336 (Some(_), None) => comparisons.push(SchemaCompatComparison {
337 service_name: old_service.service_name.clone(),
338 method_name: method_name.to_string(),
339 direction: SchemaCompatComparisonDirection::Method,
340 status: SchemaCompatStatus::Incompatible,
341 note: Some("method removed".to_string()),
342 }),
343 (None, Some(_)) => comparisons.push(SchemaCompatComparison {
344 service_name: new_service.service_name.clone(),
345 method_name: method_name.to_string(),
346 direction: SchemaCompatComparisonDirection::Method,
347 status: SchemaCompatStatus::Bidirectional,
348 note: Some("method added".to_string()),
349 }),
350 (None, None) => unreachable!("method name came from one of the maps"),
351 }
352 }
353
354 Ok(())
355}
356
357fn compare_root(
358 service_name: &str,
359 method_name: &str,
360 direction: SchemaCompatComparisonDirection,
361 older: &SchemaRootSnapshot,
362 newer: &SchemaRootSnapshot,
363 registry: &Registry,
364 comparisons: &mut Vec<SchemaCompatComparison>,
365) -> Result<(), SchemaCompatError> {
366 let older_root = parse_schema_id(&older.root)?;
367 let newer_root = parse_schema_id(&newer.root)?;
368 comparisons.push(SchemaCompatComparison {
369 service_name: service_name.to_string(),
370 method_name: method_name.to_string(),
371 direction,
372 status: compatibility_direction(older_root, newer_root, registry).into(),
373 note: None,
374 });
375 Ok(())
376}
377
378fn snapshot_root(
379 service: &'static str,
380 method: &'static str,
381 direction: BindingDirection,
382 shape: &'static facet::Shape,
383) -> Result<SchemaRootSnapshot, SchemaCompatError> {
384 let closure =
385 vox_phon::schema_bytes_for_shape(shape).map_err(|error| SchemaCompatError::Snapshot {
386 service,
387 method,
388 direction,
389 message: error.to_string(),
390 })?;
391 let bundle =
392 vox_phon::parse_schema_bytes(&closure).map_err(|error| SchemaCompatError::Snapshot {
393 service,
394 method,
395 direction,
396 message: error.to_string(),
397 })?;
398 Ok(SchemaRootSnapshot {
399 root: hex_u64(bundle.root.as_u64()),
400 closure: hex_bytes(&closure),
401 })
402}
403
404fn registry_for_snapshots(
405 older: &SchemaCompatSnapshot,
406 newer: &SchemaCompatSnapshot,
407) -> Result<Registry, SchemaCompatError> {
408 let mut schemas = BTreeMap::<u64, Schema>::new();
409 for root in roots(older).chain(roots(newer)) {
410 let closure = parse_hex(&root.closure)?;
411 let bundle = vox_phon::parse_schema_bytes(&closure)
412 .map_err(|error| SchemaCompatError::SchemaClosure(error.to_string()))?;
413 let expected_root = parse_schema_id(&root.root)?;
414 if bundle.root != expected_root {
415 return Err(SchemaCompatError::SchemaClosure(format!(
416 "schema closure root {} did not match snapshot root {}",
417 hex_u64(bundle.root.as_u64()),
418 root.root
419 )));
420 }
421 for schema in bundle.schemas {
422 schemas.entry(schema.id.as_u64()).or_insert(schema);
423 }
424 }
425 Ok(Registry::new(schemas.into_values()))
426}
427
428fn roots(snapshot: &SchemaCompatSnapshot) -> impl Iterator<Item = &SchemaRootSnapshot> {
429 snapshot.services.iter().flat_map(|service| {
430 service
431 .methods
432 .iter()
433 .flat_map(|method| [&method.args, &method.response])
434 })
435}
436
437fn service_map(snapshot: &SchemaCompatSnapshot) -> BTreeMap<&str, &ServiceSchemaSnapshot> {
438 snapshot
439 .services
440 .iter()
441 .map(|service| (service.service_name.as_str(), service))
442 .collect()
443}
444
445fn method_map(service: &ServiceSchemaSnapshot) -> BTreeMap<&str, &MethodSchemaSnapshot> {
446 service
447 .methods
448 .iter()
449 .map(|method| (method.method_name.as_str(), method))
450 .collect()
451}
452
453fn parse_schema_id(hex: &str) -> Result<SchemaId, SchemaCompatError> {
454 let trimmed = hex.strip_prefix("0x").unwrap_or(hex);
455 u64::from_str_radix(trimmed, 16)
456 .map(SchemaId::from_raw)
457 .map_err(|error| SchemaCompatError::Hex(format!("invalid schema id {hex:?}: {error}")))
458}
459
460fn parse_hex(hex: &str) -> Result<Vec<u8>, SchemaCompatError> {
461 let trimmed = hex.strip_prefix("0x").unwrap_or(hex);
462 if !trimmed.len().is_multiple_of(2) {
463 return Err(SchemaCompatError::Hex(format!(
464 "hex string has odd length: {hex:?}"
465 )));
466 }
467 let mut out = Vec::with_capacity(trimmed.len() / 2);
468 let bytes = trimmed.as_bytes();
469 for pair in bytes.chunks_exact(2) {
470 let hi = hex_nibble(pair[0])?;
471 let lo = hex_nibble(pair[1])?;
472 out.push((hi << 4) | lo);
473 }
474 Ok(out)
475}
476
477fn hex_nibble(byte: u8) -> Result<u8, SchemaCompatError> {
478 match byte {
479 b'0'..=b'9' => Ok(byte - b'0'),
480 b'a'..=b'f' => Ok(byte - b'a' + 10),
481 b'A'..=b'F' => Ok(byte - b'A' + 10),
482 _ => Err(SchemaCompatError::Hex(format!(
483 "invalid hex digit {:?}",
484 char::from(byte)
485 ))),
486 }
487}
488
489fn hex_u64(value: u64) -> String {
490 format!("{value:016x}")
491}
492
493fn hex_bytes(bytes: &[u8]) -> String {
494 let mut out = String::with_capacity(bytes.len() * 2);
495 for byte in bytes {
496 out.push_str(&format!("{byte:02x}"));
497 }
498 out
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 fn testbed_policy() -> SchemaCompatPolicy {
506 SchemaCompatPolicy {
507 acknowledged_breaking: vec![
508 SchemaCompatAcknowledgement {
509 service_name: "Testbed".to_string(),
510 method_name: "echo_measurement".to_string(),
511 direction: SchemaCompatComparisonDirection::Args,
512 },
513 SchemaCompatAcknowledgement {
514 service_name: "Testbed".to_string(),
515 method_name: "echo_measurement".to_string(),
516 direction: SchemaCompatComparisonDirection::Response,
517 },
518 ],
519 }
520 }
521
522 fn evolved_testbed_snapshots() -> (SchemaCompatSnapshot, SchemaCompatSnapshot) {
523 let old_services = spec_proto::all_services();
524 let old = snapshot_services(&old_services).expect("old snapshot");
525 let evolved_services = [spec_proto::evolved::testbed_service_descriptor()];
526 let new = snapshot_services(&evolved_services).expect("new snapshot");
527 (method_intersection_snapshot(&old, &new), new)
528 }
529
530 fn snapshot_without_method(
531 snapshot: &SchemaCompatSnapshot,
532 service_name: &str,
533 method_name: &str,
534 ) -> SchemaCompatSnapshot {
535 let mut snapshot = snapshot.clone();
536 let service = snapshot
537 .services
538 .iter_mut()
539 .find(|service| service.service_name == service_name)
540 .expect("service in snapshot");
541 let before = service.methods.len();
542 service
543 .methods
544 .retain(|method| method.method_name != method_name);
545 assert_eq!(
546 service.methods.len(),
547 before - 1,
548 "method {service_name}.{method_name} should exist in snapshot"
549 );
550 snapshot
551 }
552
553 #[test]
555 fn snapshots_include_method_args_and_response_roots() {
556 let services = spec_proto::all_services();
557 let snapshot = snapshot_services(&services).expect("snapshot services");
558 let testbed = snapshot
559 .services
560 .iter()
561 .find(|service| service.service_name == "Testbed")
562 .expect("testbed service");
563 let echo_profile = testbed
564 .methods
565 .iter()
566 .find(|method| method.method_name == "echo_profile")
567 .expect("echo_profile method");
568
569 assert!(!echo_profile.args.root.is_empty());
570 assert!(!echo_profile.args.closure.is_empty());
571 assert!(!echo_profile.response.root.is_empty());
572 assert!(!echo_profile.response.closure.is_empty());
573 }
574
575 #[test]
578 fn compares_evolved_testbed_schema_directions() {
579 let (old, new) = evolved_testbed_snapshots();
580 let report = compare_snapshots(&old, &new).expect("compat report");
581
582 let echo_profile_args = report
583 .comparisons
584 .iter()
585 .find(|comparison| {
586 comparison.method_name == "echo_profile"
587 && comparison.direction == SchemaCompatComparisonDirection::Args
588 })
589 .expect("echo_profile args comparison");
590 assert_eq!(echo_profile_args.status, SchemaCompatStatus::Bidirectional);
591
592 let echo_measurement_args = report
593 .comparisons
594 .iter()
595 .find(|comparison| {
596 comparison.method_name == "echo_measurement"
597 && comparison.direction == SchemaCompatComparisonDirection::Args
598 })
599 .expect("echo_measurement args comparison");
600 assert_eq!(
601 echo_measurement_args.status,
602 SchemaCompatStatus::Incompatible
603 );
604 }
605
606 #[test]
608 fn compare_reports_added_methods_as_compatible() {
609 let services = spec_proto::all_services();
610 let new = snapshot_services(&services).expect("new snapshot");
611 let old = snapshot_without_method(&new, "Testbed", "echo_profile");
612 let report = compare_snapshots(&old, &new).expect("compat report");
613
614 let added_method = report
615 .comparisons
616 .iter()
617 .find(|comparison| {
618 comparison.service_name == "Testbed"
619 && comparison.method_name == "echo_profile"
620 && comparison.direction == SchemaCompatComparisonDirection::Method
621 })
622 .expect("added method comparison");
623 assert_eq!(added_method.status, SchemaCompatStatus::Bidirectional);
624 assert_eq!(added_method.note.as_deref(), Some("method added"));
625 }
626
627 #[test]
629 fn compare_reports_removed_methods_as_breaking() {
630 let services = spec_proto::all_services();
631 let old = snapshot_services(&services).expect("old snapshot");
632 let new = snapshot_without_method(&old, "Testbed", "echo_profile");
633 let report = compare_snapshots(&old, &new).expect("compat report");
634
635 let removed_method = report
636 .comparisons
637 .iter()
638 .find(|comparison| {
639 comparison.service_name == "Testbed"
640 && comparison.method_name == "echo_profile"
641 && comparison.direction == SchemaCompatComparisonDirection::Method
642 })
643 .expect("removed method comparison");
644 assert_eq!(removed_method.status, SchemaCompatStatus::Incompatible);
645 assert_eq!(removed_method.note.as_deref(), Some("method removed"));
646 }
647
648 #[test]
650 fn policy_requires_acknowledging_breaking_changes() {
651 let (old, new) = evolved_testbed_snapshots();
652 let report = compare_snapshots(&old, &new).expect("compat report");
653
654 let rejected = enforce_policy(
655 &report,
656 &SchemaCompatPolicy {
657 acknowledged_breaking: Vec::new(),
658 },
659 );
660 assert!(!rejected.is_ok());
661 assert!(!rejected.unacknowledged_breaking.is_empty());
662
663 let accepted = enforce_policy(&report, &testbed_policy());
664 assert!(accepted.is_ok(), "{accepted:?}");
665 }
666}