use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParticipantApis {
pub participant_id: String,
pub artifact_id: String,
pub participant_kind: ParticipantKind,
pub participant_class: ParticipantClass,
pub api_version: String,
pub bus_abi: Option<String>,
pub config_schema: Option<serde_json::Value>,
pub scope: ParticipantScope,
pub contracts: Vec<Contract>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ParticipantKind {
Service,
Driver,
Tool,
Simulator,
Other(String),
}
impl ParticipantKind {
#[must_use]
pub fn parse(s: &str) -> Self {
match s {
"service" => Self::Service,
"driver" => Self::Driver,
"tool" => Self::Tool,
"simulator" => Self::Simulator,
other => Self::Other(other.to_string()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Service => "service",
Self::Driver => "driver",
Self::Tool => "tool",
Self::Simulator => "simulator",
Self::Other(kind) => kind,
}
}
#[must_use]
pub const fn is_simulator(&self) -> bool {
matches!(self, Self::Simulator)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ParticipantClass {
#[default]
Checked,
Privileged,
}
impl ParticipantClass {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"checked" => Self::Checked,
"privileged" => Self::Privileged,
_ => return None,
})
}
#[must_use]
pub const fn is_checked(self) -> bool {
matches!(self, Self::Checked)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ParticipantScope {
#[default]
Graph,
ComponentInstance(String),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Contract {
pub family: String,
pub schema_id: String,
}
#[derive(Debug, Clone, Copy)]
pub struct CheckInput<'a> {
pub participants: &'a [ParticipantApis],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Problem {
ContractSchemaMismatch {
family: String,
schema_ids: Vec<(String, Vec<String>)>,
},
InvalidConfig {
runtime_id: String,
errors: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Report {
pub problems: Vec<Problem>,
}
impl Report {
#[must_use]
pub fn is_ok(&self) -> bool {
self.problems.is_empty()
}
}
#[must_use]
pub fn check_graph(participants: &[ParticipantApis]) -> Report {
check_plan(CheckInput { participants })
}
#[must_use]
pub fn check_plan(input: CheckInput<'_>) -> Report {
Report {
problems: schema_mismatches(input.participants),
}
}
fn schema_mismatches(participants: &[ParticipantApis]) -> Vec<Problem> {
let mut by_family: BTreeMap<String, BTreeMap<String, BTreeSet<String>>> = BTreeMap::new();
for p in participants {
for c in &p.contracts {
by_family
.entry(c.family.clone())
.or_default()
.entry(c.schema_id.clone())
.or_default()
.insert(p.participant_id.clone());
}
}
by_family
.into_iter()
.filter(|(_, schema_ids)| schema_ids.len() > 1)
.map(|(family, schema_ids)| Problem::ContractSchemaMismatch {
family,
schema_ids: schema_ids
.into_iter()
.map(|(schema_id, participants)| (schema_id, participants.into_iter().collect()))
.collect(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn contract(family: &str) -> Contract {
contract_with_schema(family, "deadbeef")
}
fn contract_with_schema(family: &str, schema_id: &str) -> Contract {
Contract {
family: family.to_string(),
schema_id: schema_id.to_string(),
}
}
fn participant(id: &str, api: &str, contracts: Vec<Contract>) -> ParticipantApis {
ParticipantApis {
participant_id: id.to_string(),
artifact_id: id.to_string(),
participant_kind: ParticipantKind::Service,
participant_class: ParticipantClass::Checked,
api_version: api.to_string(),
bus_abi: None,
config_schema: None,
scope: ParticipantScope::Graph,
contracts,
}
}
fn privileged_participant(id: &str, api: &str, contracts: Vec<Contract>) -> ParticipantApis {
ParticipantApis {
participant_id: id.to_string(),
artifact_id: id.to_string(),
participant_kind: ParticipantKind::Tool,
participant_class: ParticipantClass::Privileged,
api_version: api.to_string(),
bus_abi: None,
config_schema: None,
scope: ParticipantScope::Graph,
contracts,
}
}
#[test]
fn participant_kind_parse_preserves_unknown_kinds() {
assert_eq!(ParticipantKind::parse("service"), ParticipantKind::Service);
assert_eq!(ParticipantKind::parse("driver"), ParticipantKind::Driver);
assert_eq!(
ParticipantKind::parse("simulator"),
ParticipantKind::Simulator
);
assert_eq!(
ParticipantKind::parse("custom-kind"),
ParticipantKind::Other("custom-kind".to_string())
);
}
#[test]
fn participant_class_parse_round_trips_and_rejects_unknown() {
assert_eq!(
ParticipantClass::parse("checked"),
Some(ParticipantClass::Checked)
);
assert_eq!(
ParticipantClass::parse("privileged"),
Some(ParticipantClass::Privileged)
);
assert_eq!(ParticipantClass::parse("service"), None);
}
#[test]
fn healthy_pubsub_graph_has_no_problems() {
let graph = vec![
participant("mission", "y2026_1", vec![contract("drive::Target")]),
participant("drive", "y2026_1", vec![contract("drive::Target")]),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn healthy_query_graph_has_no_problems() {
let graph = vec![
participant(
"asset",
"y2026_1",
vec![
contract("asset::GetRequest"),
contract("asset::GetResponse"),
],
),
participant(
"client",
"y2026_1",
vec![
contract("asset::GetRequest"),
contract("asset::GetResponse"),
],
),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn contract_schema_mismatch_is_reported_per_family() {
let graph = vec![
participant(
"mission",
"y2026_1",
vec![contract_with_schema("drive::Target", "aaaa")],
),
participant(
"drive",
"y2026_2",
vec![contract_with_schema("drive::Target", "bbbb")],
),
];
let report = check_graph(&graph);
assert_eq!(
report.problems,
vec![Problem::ContractSchemaMismatch {
family: "drive::Target".to_string(),
schema_ids: vec![
("aaaa".to_string(), vec!["mission".to_string()]),
("bbbb".to_string(), vec!["drive".to_string()]),
],
}]
);
}
#[test]
fn matching_contract_schema_ids_pass_even_with_mixed_api_versions() {
let graph = vec![
participant(
"mission",
"y2026_1",
vec![contract_with_schema("drive::Target", "cafe")],
),
participant(
"drive",
"y2026_2",
vec![contract_with_schema("drive::Target", "cafe")],
),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn tool_kind_participant_contracts_still_participate_in_schema_agreement() {
let graph = vec![
participant(
"mission",
"y2026_1",
vec![contract_with_schema("drive::Target", "aaaa")],
),
participant(
"drive",
"y2026_1",
vec![contract_with_schema("drive::Target", "aaaa")],
),
privileged_participant(
"inspector",
"y2026_1",
vec![contract_with_schema("drive::Target", "bbbb")],
),
];
let report = check_graph(&graph);
assert_eq!(
report.problems,
vec![Problem::ContractSchemaMismatch {
family: "drive::Target".to_string(),
schema_ids: vec![
(
"aaaa".to_string(),
vec!["drive".to_string(), "mission".to_string()]
),
("bbbb".to_string(), vec!["inspector".to_string()]),
],
}]
);
}
#[test]
fn a_publisher_anywhere_satisfies_all_subscribers() {
let graph = vec![
participant("odometry", "y2026_1", vec![contract("odometry::State")]),
participant("localize", "y2026_1", vec![contract("odometry::State")]),
participant("map", "y2026_1", vec![contract("odometry::State")]),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn dangling_consumers_and_dangling_servers_are_legal() {
let participants = vec![
participant("mission", "y2026_1", vec![contract("mission::Command")]),
participant(
"asset",
"y2026_1",
vec![
contract("asset::GetRequest"),
contract("asset::GetResponse"),
],
),
];
let report = check_graph(&participants);
assert_eq!(report, Report::default());
}
#[test]
fn empty_graph_is_ok() {
assert!(check_graph(&[]).is_ok());
}
}