use crate::validation::{ValidationError, ValidationResult};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct TypeId(String);
impl TypeId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TypeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for TypeId {
fn from(s: &str) -> Self {
Self::new(s)
}
}
pub type JsonValidatorFn = fn(&Value) -> ValidationResult<()>;
pub(crate) const DESERIALISE_ERROR_PREFIX: &str = "could not deserialise as ";
pub type Proto3ZeroFn = fn() -> Value;
pub fn default_proto3_zero() -> Value {
Value::Object(serde_json::Map::new())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FieldFormat {
Text,
Number { unit: Option<&'static str> },
Percentage,
Boolean,
Timestamp,
Position,
Enum { variants: &'static [&'static str] },
Nested { nested_type_id: TypeId },
List { item_format: Box<FieldFormat> },
JsonString,
BlobRef,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FieldDescriptor {
pub name: &'static str,
pub label: &'static str,
pub format: FieldFormat,
}
impl FieldDescriptor {
pub fn new(name: &'static str, label: &'static str, format: FieldFormat) -> Self {
Self {
name,
label,
format,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct TypeDescriptor {
pub id: TypeId,
pub name: String,
pub version: String,
pub canonical_collection: Option<String>,
pub validate_json: JsonValidatorFn,
pub proto3_zero_fn: Proto3ZeroFn,
pub fields: Vec<FieldDescriptor>,
}
impl TypeDescriptor {
pub fn new(
id: TypeId,
name: impl Into<String>,
version: impl Into<String>,
validate_json: JsonValidatorFn,
) -> Self {
Self {
id,
name: name.into(),
version: version.into(),
canonical_collection: None,
validate_json,
proto3_zero_fn: default_proto3_zero,
fields: Vec::new(),
}
}
pub fn proto3_zero(&self) -> Value {
(self.proto3_zero_fn)()
}
}
pub trait TypeRegistry: Send + Sync {
fn get(&self, id: &TypeId) -> Option<&TypeDescriptor>;
fn for_collection(&self, collection: &str) -> Option<&TypeDescriptor>;
fn iter(&self) -> Box<dyn Iterator<Item = &TypeDescriptor> + '_>;
}
#[derive(Debug, Default, Clone)]
pub struct BuiltinRegistry {
by_id: HashMap<TypeId, Arc<TypeDescriptor>>,
by_collection: HashMap<String, Arc<TypeDescriptor>>,
}
impl BuiltinRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, desc: TypeDescriptor) -> &mut Self {
let arc = Arc::new(desc);
if let Some(coll) = arc.canonical_collection.as_ref() {
self.by_collection.insert(coll.clone(), Arc::clone(&arc));
}
self.by_id.insert(arc.id.clone(), arc);
self
}
pub fn with_peat_schema_types() -> Self {
let mut r = Self::new();
r.register(descriptors::capability());
r.register(descriptors::node_config());
r.register(descriptors::node_state());
r.register(descriptors::cell_config());
r.register(descriptors::cell_state());
r.register(descriptors::track());
r.register(descriptors::hierarchical_command());
r.register(descriptors::marker());
r
}
}
impl TypeRegistry for BuiltinRegistry {
fn get(&self, id: &TypeId) -> Option<&TypeDescriptor> {
self.by_id.get(id).map(|a| a.as_ref())
}
fn for_collection(&self, collection: &str) -> Option<&TypeDescriptor> {
self.by_collection.get(collection).map(|a| a.as_ref())
}
fn iter(&self) -> Box<dyn Iterator<Item = &TypeDescriptor> + '_> {
Box::new(self.by_id.values().map(|a| a.as_ref()))
}
}
mod descriptors {
use super::*;
pub fn capability() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let msg = crate::capability::v1::Capability::deserialize(value).map_err(|e| {
ValidationError::InvalidValue(format!("{DESERIALISE_ERROR_PREFIX}Capability: {e}"))
})?;
crate::validation::validate_capability(&msg)
}
const CAPABILITY_TYPE_VARIANTS: &[&str] = &[
"Unspecified",
"Sensor",
"Compute",
"Communication",
"Mobility",
"Payload",
"Emergent",
];
fn proto3_zero() -> Value {
serde_json::to_value(crate::capability::v1::Capability::default())
.expect("proto3 message serialises to JSON cleanly")
}
TypeDescriptor {
id: TypeId::new("peat.capability.v1.Capability"),
name: "Capability".to_string(),
version: "v1".to_string(),
canonical_collection: Some("capabilities".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "id",
label: "ID",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "name",
label: "Name",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "capability_type",
label: "Type",
format: FieldFormat::Enum {
variants: CAPABILITY_TYPE_VARIANTS,
},
},
FieldDescriptor {
name: "confidence",
label: "Confidence",
format: FieldFormat::Percentage,
},
FieldDescriptor {
name: "metadata_json",
label: "Metadata",
format: FieldFormat::JsonString,
},
FieldDescriptor {
name: "registered_at",
label: "Registered",
format: FieldFormat::Timestamp,
},
],
}
}
pub fn node_config() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let msg = crate::node::v1::NodeConfig::deserialize(value).map_err(|e| {
ValidationError::InvalidValue(format!("{DESERIALISE_ERROR_PREFIX}NodeConfig: {e}"))
})?;
crate::validation::validate_node_config(&msg)
}
fn proto3_zero() -> Value {
serde_json::to_value(crate::node::v1::NodeConfig::default())
.expect("proto3 message serialises to JSON cleanly")
}
TypeDescriptor {
id: TypeId::new("peat.node.v1.NodeConfig"),
name: "NodeConfig".to_string(),
version: "v1".to_string(),
canonical_collection: Some("node-configs".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "id",
label: "ID",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "node_type",
label: "Node",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "capabilities",
label: "Capabilities",
format: FieldFormat::List {
item_format: Box::new(FieldFormat::Nested {
nested_type_id: TypeId::new("peat.capability.v1.Capability"),
}),
},
},
FieldDescriptor {
name: "comm_range_m",
label: "Comm Range",
format: FieldFormat::Number { unit: Some("m") },
},
FieldDescriptor {
name: "max_speed_mps",
label: "Max Speed",
format: FieldFormat::Number { unit: Some("m/s") },
},
FieldDescriptor {
name: "operator_binding",
label: "Operator",
format: FieldFormat::JsonString,
},
FieldDescriptor {
name: "created_at",
label: "Created",
format: FieldFormat::Timestamp,
},
],
}
}
pub fn node_state() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let msg = crate::node::v1::NodeState::deserialize(value).map_err(|e| {
ValidationError::InvalidValue(format!("{DESERIALISE_ERROR_PREFIX}NodeState: {e}"))
})?;
crate::validation::validate_node_state(&msg)
}
const HEALTH_STATUS_VARIANTS: &[&str] =
&["Unspecified", "Nominal", "Degraded", "Critical", "Failed"];
const PHASE_VARIANTS: &[&str] = &["Unspecified", "Discovery", "Cell", "Hierarchy"];
fn proto3_zero() -> Value {
serde_json::to_value(crate::node::v1::NodeState::default())
.expect("proto3 message serialises to JSON cleanly")
}
TypeDescriptor {
id: TypeId::new("peat.node.v1.NodeState"),
name: "NodeState".to_string(),
version: "v1".to_string(),
canonical_collection: Some("node-states".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "position",
label: "Position",
format: FieldFormat::Position,
},
FieldDescriptor {
name: "fuel_minutes",
label: "Fuel",
format: FieldFormat::Number { unit: Some("min") },
},
FieldDescriptor {
name: "health",
label: "Health",
format: FieldFormat::Enum {
variants: HEALTH_STATUS_VARIANTS,
},
},
FieldDescriptor {
name: "phase",
label: "Phase",
format: FieldFormat::Enum {
variants: PHASE_VARIANTS,
},
},
FieldDescriptor {
name: "cell_id",
label: "Cell",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "zone_id",
label: "Zone",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "timestamp",
label: "Updated",
format: FieldFormat::Timestamp,
},
],
}
}
pub fn cell_config() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let msg = crate::cell::v1::CellConfig::deserialize(value).map_err(|e| {
ValidationError::InvalidValue(format!("{DESERIALISE_ERROR_PREFIX}CellConfig: {e}"))
})?;
crate::validation::validate_cell_config(&msg)
}
fn proto3_zero() -> Value {
serde_json::to_value(crate::cell::v1::CellConfig::default())
.expect("proto3 message serialises to JSON cleanly")
}
TypeDescriptor {
id: TypeId::new("peat.cell.v1.CellConfig"),
name: "CellConfig".to_string(),
version: "v1".to_string(),
canonical_collection: Some("cell-configs".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "id",
label: "ID",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "max_size",
label: "Max Size",
format: FieldFormat::Number { unit: None },
},
FieldDescriptor {
name: "min_size",
label: "Min Size",
format: FieldFormat::Number { unit: None },
},
FieldDescriptor {
name: "created_at",
label: "Created",
format: FieldFormat::Timestamp,
},
],
}
}
pub fn cell_state() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let msg = crate::cell::v1::CellState::deserialize(value).map_err(|e| {
ValidationError::InvalidValue(format!("{DESERIALISE_ERROR_PREFIX}CellState: {e}"))
})?;
crate::validation::validate_cell_state(&msg)
}
fn proto3_zero() -> Value {
serde_json::to_value(crate::cell::v1::CellState::default())
.expect("proto3 message serialises to JSON cleanly")
}
TypeDescriptor {
id: TypeId::new("peat.cell.v1.CellState"),
name: "CellState".to_string(),
version: "v1".to_string(),
canonical_collection: Some("cell-states".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "config",
label: "Config",
format: FieldFormat::Nested {
nested_type_id: TypeId::new("peat.cell.v1.CellConfig"),
},
},
FieldDescriptor {
name: "leader_id",
label: "Leader",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "members",
label: "Members",
format: FieldFormat::List {
item_format: Box::new(FieldFormat::Text),
},
},
FieldDescriptor {
name: "capabilities",
label: "Capabilities",
format: FieldFormat::List {
item_format: Box::new(FieldFormat::Nested {
nested_type_id: TypeId::new("peat.capability.v1.Capability"),
}),
},
},
FieldDescriptor {
name: "cohort_id",
label: "Cohort",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "timestamp",
label: "Updated",
format: FieldFormat::Timestamp,
},
],
}
}
pub fn track() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let msg = crate::track::v1::Track::deserialize(value).map_err(|e| {
ValidationError::InvalidValue(format!("{DESERIALISE_ERROR_PREFIX}Track: {e}"))
})?;
crate::validation::validate_track(&msg)
}
const TRACK_STATE_VARIANTS: &[&str] =
&["Unspecified", "Tentative", "Confirmed", "Lost", "Dropped"];
const SOURCE_TYPE_VARIANTS: &[&str] =
&["Unspecified", "Sensor", "AI Model", "Human", "Fused"];
fn proto3_zero() -> Value {
serde_json::to_value(crate::track::v1::Track::default())
.expect("proto3 message serialises to JSON cleanly")
}
TypeDescriptor {
id: TypeId::new("peat.track.v1.Track"),
name: "Track".to_string(),
version: "v1".to_string(),
canonical_collection: Some("tracks".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "track_id",
label: "Track ID",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "classification",
label: "Classification",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "confidence",
label: "Confidence",
format: FieldFormat::Percentage,
},
FieldDescriptor {
name: "position",
label: "Position",
format: FieldFormat::Position,
},
FieldDescriptor {
name: "state",
label: "State",
format: FieldFormat::Enum {
variants: TRACK_STATE_VARIANTS,
},
},
FieldDescriptor {
name: "source",
label: "Source",
format: FieldFormat::JsonString,
},
FieldDescriptor {
name: "source_type",
label: "Source Type",
format: FieldFormat::Enum {
variants: SOURCE_TYPE_VARIANTS,
},
},
FieldDescriptor {
name: "attributes_json",
label: "Attributes",
format: FieldFormat::JsonString,
},
FieldDescriptor {
name: "first_seen",
label: "First Seen",
format: FieldFormat::Timestamp,
},
FieldDescriptor {
name: "last_seen",
label: "Last Seen",
format: FieldFormat::Timestamp,
},
FieldDescriptor {
name: "observation_count",
label: "Observations",
format: FieldFormat::Number { unit: None },
},
],
}
}
pub fn hierarchical_command() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let msg = crate::command::v1::HierarchicalCommand::deserialize(value).map_err(|e| {
ValidationError::InvalidValue(format!(
"{DESERIALISE_ERROR_PREFIX}HierarchicalCommand: {e}"
))
})?;
crate::validation::validate_hierarchical_command(&msg)
}
const PRIORITY_VARIANTS: &[&str] =
&["Unspecified", "Routine", "Priority", "Immediate", "Flash"];
const BUFFER_POLICY_VARIANTS: &[&str] = &[
"Unspecified",
"Buffer and Retry",
"Drop on Partition",
"Require Immediate Delivery",
];
const CONFLICT_POLICY_VARIANTS: &[&str] = &[
"Unspecified",
"Last Write Wins",
"Highest Priority Wins",
"Highest Authority Wins",
"Merge Compatible",
"Reject Conflict",
];
const ACK_POLICY_VARIANTS: &[&str] = &[
"Unspecified",
"No Ack Required",
"Ack Required",
"Ack Majority Required",
"Ack Leader Only",
];
fn proto3_zero() -> Value {
serde_json::to_value(crate::command::v1::HierarchicalCommand::default())
.expect("proto3 message serialises to JSON cleanly")
}
TypeDescriptor {
id: TypeId::new("peat.command.v1.HierarchicalCommand"),
name: "HierarchicalCommand".to_string(),
version: "v1".to_string(),
canonical_collection: Some("commands".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "command_id",
label: "Command ID",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "originator_id",
label: "Originator",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "target",
label: "Target",
format: FieldFormat::JsonString,
},
FieldDescriptor {
name: "priority",
label: "Priority",
format: FieldFormat::Enum {
variants: PRIORITY_VARIANTS,
},
},
FieldDescriptor {
name: "buffer_policy",
label: "Buffer Policy",
format: FieldFormat::Enum {
variants: BUFFER_POLICY_VARIANTS,
},
},
FieldDescriptor {
name: "conflict_policy",
label: "Conflict Policy",
format: FieldFormat::Enum {
variants: CONFLICT_POLICY_VARIANTS,
},
},
FieldDescriptor {
name: "acknowledgment_policy",
label: "Ack Policy",
format: FieldFormat::Enum {
variants: ACK_POLICY_VARIANTS,
},
},
FieldDescriptor {
name: "issued_at",
label: "Issued",
format: FieldFormat::Timestamp,
},
FieldDescriptor {
name: "expires_at",
label: "Expires",
format: FieldFormat::Timestamp,
},
FieldDescriptor {
name: "version",
label: "Version",
format: FieldFormat::Number { unit: None },
},
],
}
}
pub fn marker() -> TypeDescriptor {
fn validate(value: &Value) -> ValidationResult<()> {
let obj = value.as_object().ok_or_else(|| {
ValidationError::InvalidValue("marker document must be a JSON object".to_string())
})?;
for required in ["uid", "type", "lat", "lon"] {
if !obj.contains_key(required) {
return Err(ValidationError::MissingField(required.to_string()));
}
}
let lat = obj["lat"]
.as_f64()
.ok_or_else(|| ValidationError::InvalidValue("lat must be a number".to_string()))?;
let lon = obj["lon"]
.as_f64()
.ok_or_else(|| ValidationError::InvalidValue("lon must be a number".to_string()))?;
if !(-90.0..=90.0).contains(&lat) {
return Err(ValidationError::InvalidValue(format!(
"lat {lat} must be between -90 and 90"
)));
}
if !(-180.0..=180.0).contains(&lon) {
return Err(ValidationError::InvalidValue(format!(
"lon {lon} must be between -180 and 180"
)));
}
Ok(())
}
fn proto3_zero() -> Value {
serde_json::json!({
"uid": "",
"type": "",
"lat": 0.0,
"lon": 0.0,
"hae": null,
"ts": 0,
"callsign": null,
"color": null,
"cell_id": null,
"deleted": false
})
}
TypeDescriptor {
id: TypeId::new("peat.marker.v1.Marker"),
name: "Marker".to_string(),
version: "v1".to_string(),
canonical_collection: Some("markers".to_string()),
validate_json: validate,
proto3_zero_fn: proto3_zero,
fields: vec![
FieldDescriptor {
name: "uid",
label: "UID",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "type",
label: "CoT Type",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "lat",
label: "Latitude",
format: FieldFormat::Number { unit: Some("°") },
},
FieldDescriptor {
name: "lon",
label: "Longitude",
format: FieldFormat::Number { unit: Some("°") },
},
FieldDescriptor {
name: "hae",
label: "Altitude",
format: FieldFormat::Number {
unit: Some("m HAE"),
},
},
FieldDescriptor {
name: "ts",
label: "Dropped",
format: FieldFormat::Timestamp,
},
FieldDescriptor {
name: "callsign",
label: "Callsign",
format: FieldFormat::Text,
},
FieldDescriptor {
name: "cell_id",
label: "Cell",
format: FieldFormat::Text,
},
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn typeid_display_and_str() {
let id = TypeId::new("peat.capability.v1.Capability");
assert_eq!(id.as_str(), "peat.capability.v1.Capability");
assert_eq!(id.to_string(), "peat.capability.v1.Capability");
}
#[test]
fn builtin_registry_resolves_core_types_by_id() {
let r = BuiltinRegistry::with_peat_schema_types();
for id in [
"peat.capability.v1.Capability",
"peat.node.v1.NodeConfig",
"peat.node.v1.NodeState",
"peat.cell.v1.CellConfig",
"peat.cell.v1.CellState",
] {
let desc = r.get(&TypeId::new(id));
assert!(desc.is_some(), "missing core type: {id}");
assert_eq!(desc.unwrap().id.as_str(), id);
}
}
#[test]
fn builtin_registry_resolves_by_canonical_collection() {
let r = BuiltinRegistry::with_peat_schema_types();
assert_eq!(
r.for_collection("capabilities").map(|d| d.id.as_str()),
Some("peat.capability.v1.Capability")
);
assert_eq!(
r.for_collection("node-configs").map(|d| d.id.as_str()),
Some("peat.node.v1.NodeConfig")
);
assert!(r.for_collection("unknown-collection").is_none());
}
#[test]
fn iter_lists_all_registered_types() {
let r = BuiltinRegistry::with_peat_schema_types();
let ids: Vec<&str> = r.iter().map(|d| d.id.as_str()).collect();
assert!(ids.contains(&"peat.capability.v1.Capability"));
assert!(ids.contains(&"peat.node.v1.NodeConfig"));
assert!(ids.contains(&"peat.cell.v1.CellState"));
assert!(ids.contains(&"peat.track.v1.Track"));
assert!(ids.contains(&"peat.command.v1.HierarchicalCommand"));
assert!(ids.contains(&"peat.marker.v1.Marker"));
assert_eq!(ids.len(), 8);
}
fn capability_json(confidence: f32) -> serde_json::Value {
json!({
"id": "cap-1",
"name": "thermal-sensor",
"capability_type": 0,
"confidence": confidence,
"metadata_json": "{}",
"registered_at": null,
})
}
#[test]
fn capability_json_validator_accepts_well_formed_input() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("capabilities").unwrap();
let result = (desc.validate_json)(&capability_json(0.95));
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn capability_json_validator_rejects_invalid_confidence() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("capabilities").unwrap();
let err =
(desc.validate_json)(&capability_json(1.5)).expect_err("expected validation error");
assert!(matches!(err, ValidationError::InvalidConfidence(_)));
}
#[test]
fn capability_json_validator_rejects_malformed_json() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("capabilities").unwrap();
let value = json!({"not_a_capability": true});
let err = (desc.validate_json)(&value).expect_err("expected deserialise error");
match err {
ValidationError::InvalidValue(msg) => assert!(
msg.contains("Capability"),
"expected message naming Capability; got {msg}"
),
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn consumer_can_extend_with_own_types() {
fn always_ok(_v: &Value) -> ValidationResult<()> {
Ok(())
}
let mut custom = TypeDescriptor::new(
TypeId::new("example.app.v1.Widget"),
"Widget",
"v1",
always_ok,
);
custom.canonical_collection = Some("widgets".to_string());
custom.fields = vec![FieldDescriptor::new("label", "Label", FieldFormat::Text)];
let mut r = BuiltinRegistry::new();
r.register(custom);
assert!(r.get(&TypeId::new("example.app.v1.Widget")).is_some());
assert_eq!(
r.for_collection("widgets").map(|d| d.id.as_str()),
Some("example.app.v1.Widget")
);
}
#[test]
fn core_types_carry_field_metadata() {
let r = BuiltinRegistry::with_peat_schema_types();
let cap = r.for_collection("capabilities").unwrap();
let field_names: Vec<&str> = cap.fields.iter().map(|f| f.name).collect();
assert_eq!(
field_names,
vec![
"id",
"name",
"capability_type",
"confidence",
"metadata_json",
"registered_at"
]
);
let confidence = cap.fields.iter().find(|f| f.name == "confidence").unwrap();
assert_eq!(confidence.format, FieldFormat::Percentage);
let cap_type = cap
.fields
.iter()
.find(|f| f.name == "capability_type")
.unwrap();
match &cap_type.format {
FieldFormat::Enum { variants } => {
assert_eq!(variants[0], "Unspecified");
assert_eq!(variants[1], "Sensor");
assert_eq!(variants[5], "Payload");
}
other => panic!("expected Enum format, got {other:?}"),
}
}
#[test]
fn node_state_position_and_units_are_typed() {
let r = BuiltinRegistry::with_peat_schema_types();
let ns = r.for_collection("node-states").unwrap();
let position = ns.fields.iter().find(|f| f.name == "position").unwrap();
assert_eq!(position.format, FieldFormat::Position);
let fuel = ns.fields.iter().find(|f| f.name == "fuel_minutes").unwrap();
assert_eq!(fuel.format, FieldFormat::Number { unit: Some("min") });
}
#[test]
fn nested_descriptors_link_via_type_id() {
let r = BuiltinRegistry::with_peat_schema_types();
let nc = r.for_collection("node-configs").unwrap();
let caps = nc.fields.iter().find(|f| f.name == "capabilities").unwrap();
match &caps.format {
FieldFormat::List { item_format } => match item_format.as_ref() {
FieldFormat::Nested { nested_type_id } => {
assert_eq!(nested_type_id.as_str(), "peat.capability.v1.Capability");
assert!(r.get(nested_type_id).is_some());
}
other => panic!("expected Nested item format, got {other:?}"),
},
other => panic!("expected List format, got {other:?}"),
}
}
mod fixtures {
use serde_json::{json, Value};
pub fn node_config() -> Value {
json!({
"id": "node-1",
"node_type": "UAV",
"capabilities": [],
"comm_range_m": 1000.0,
"max_speed_mps": 10.0,
"operator_binding": null,
"created_at": null,
})
}
pub fn node_state() -> Value {
json!({
"position": {
"latitude": 38.0,
"longitude": -122.0,
"altitude": 0.0,
},
"fuel_minutes": 60,
"health": 1, "phase": 1, "cell_id": null,
"zone_id": null,
"timestamp": null,
})
}
pub fn cell_config() -> Value {
json!({
"id": "cell-1",
"max_size": 8,
"min_size": 2,
"created_at": null,
})
}
pub fn cell_state() -> Value {
json!({
"config": {
"id": "cell-1",
"max_size": 8,
"min_size": 2,
"created_at": null,
},
"leader_id": null,
"members": [],
"capabilities": [],
"platoon_id": null,
"timestamp": null,
})
}
}
#[test]
fn node_config_validator_accepts_well_formed_input() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("node-configs").unwrap();
let result = (desc.validate_json)(&fixtures::node_config());
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn node_state_validator_accepts_well_formed_input() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("node-states").unwrap();
let result = (desc.validate_json)(&fixtures::node_state());
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn cell_config_validator_accepts_well_formed_input() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("cell-configs").unwrap();
let result = (desc.validate_json)(&fixtures::cell_config());
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn cell_state_validator_accepts_well_formed_input() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("cell-states").unwrap();
let result = (desc.validate_json)(&fixtures::cell_state());
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn node_state_validator_rejects_out_of_range_latitude() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("node-states").unwrap();
let mut bad = fixtures::node_state();
bad["position"]["latitude"] = serde_json::json!(95.0);
let err = (desc.validate_json)(&bad).expect_err("expected validation error");
match err {
ValidationError::InvalidValue(msg) => {
assert!(msg.contains("latitude"), "got {msg}");
}
other => panic!("expected InvalidValue (range), got {other:?}"),
}
}
#[test]
fn cell_config_validator_rejects_undersize_min() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("cell-configs").unwrap();
let mut bad = fixtures::cell_config();
bad["min_size"] = serde_json::json!(1);
let err = (desc.validate_json)(&bad).expect_err("expected validation error");
assert!(
matches!(err, ValidationError::ConstraintViolation(_)),
"{err:?}"
);
}
#[test]
fn every_nested_reference_in_builtin_registry_resolves() {
let r = BuiltinRegistry::with_peat_schema_types();
fn collect_nested_refs(fmt: &FieldFormat, out: &mut Vec<TypeId>) {
match fmt {
FieldFormat::Nested { nested_type_id } => out.push(nested_type_id.clone()),
FieldFormat::List { item_format } => {
collect_nested_refs(item_format, out);
}
_ => {}
}
}
let mut unresolved = Vec::new();
for desc in r.iter() {
for field in &desc.fields {
let mut refs = Vec::new();
collect_nested_refs(&field.format, &mut refs);
for nested in refs {
if r.get(&nested).is_none() {
unresolved.push(format!(
"{}::{} → {} (unregistered)",
desc.id, field.name, nested
));
}
}
}
}
assert!(
unresolved.is_empty(),
"Builtin registry has unresolved Nested references:\n {}",
unresolved.join("\n ")
);
}
#[test]
fn registry_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<BuiltinRegistry>();
fn _accepts(_: &dyn TypeRegistry) {}
}
#[test]
fn proto3_zero_is_object_for_every_registered_type() {
let r = BuiltinRegistry::with_peat_schema_types();
for desc in r.iter() {
let zero = desc.proto3_zero();
assert!(
zero.is_object(),
"{}::proto3_zero() returned non-object value: {zero}",
desc.id
);
}
}
#[test]
fn proto3_zero_deserialises_cleanly_for_every_registered_type() {
let r = BuiltinRegistry::with_peat_schema_types();
for desc in r.iter() {
let zero = desc.proto3_zero();
match (desc.validate_json)(&zero) {
Ok(()) => {} Err(ValidationError::InvalidValue(msg))
if msg.starts_with(DESERIALISE_ERROR_PREFIX) =>
{
panic!(
"{}::proto3_zero() produced a value that fails strict prost-derived \
deserialise — the round-trip-by-construction property is broken: {msg}",
desc.id
);
}
Err(_other) => {
}
}
}
}
#[test]
fn proto3_zero_capability_matches_default_serialised() {
let r = BuiltinRegistry::with_peat_schema_types();
let desc = r.for_collection("capabilities").expect("registered");
let zero = desc.proto3_zero();
let obj = zero.as_object().expect("object");
assert_eq!(
obj.get("id"),
Some(&Value::String(String::new())),
"Capability::proto3_zero must contain id = \"\" (proto3 string default)"
);
assert_eq!(
obj.get("confidence"),
Some(&Value::Number(serde_json::Number::from_f64(0.0).unwrap())),
"Capability::proto3_zero must contain confidence = 0.0 (proto3 float default)"
);
assert_eq!(
obj.get("metadata_json"),
Some(&Value::String(String::new())),
"Capability::proto3_zero must contain metadata_json = \"\" (real string field, not optional)"
);
}
#[test]
fn proto3_zero_fallback_for_descriptor_built_via_new_is_empty_object() {
fn no_op_validate(_: &Value) -> ValidationResult<()> {
Ok(())
}
let desc = TypeDescriptor::new(
TypeId::new("test.fallback.v1.Anon"),
"Anon",
"v1",
no_op_validate,
);
let zero = desc.proto3_zero();
assert_eq!(
zero,
Value::Object(serde_json::Map::new()),
"descriptor built via new(…) without proto3_zero_fn must fall back to {{}}"
);
}
}