use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use super::{Component, Motion, Role, capability};
const ROBOT_FILE: &str = "robot.yaml";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Robot {
pub api_version: String,
pub identity: Identity,
#[serde(default = "default_structure_path")]
pub structure: PathBuf,
pub phoxal_participants: PhoxalParticipants,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub user_participants: BTreeMap<String, UserParticipant>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tools: BTreeMap<String, Tool>,
pub motion: Motion,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<Network>,
pub components: Components,
#[serde(default, skip_serializing_if = "Bus::is_empty")]
pub bus: Bus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Identity {
pub id: String,
pub namespace: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Channel {
#[default]
Stable,
Latest,
Edge,
}
impl Channel {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Latest => "latest",
Self::Edge => "edge",
}
}
}
impl fmt::Display for Channel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PhoxalParticipants {
#[serde(default)]
pub channel: Channel,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub images: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UserParticipant {
pub path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bus_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(
default = "default_user_participant_framework",
skip_serializing_if = "is_match_platform"
)]
pub framework: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<UserParticipantBuild>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UserParticipantBuild {
#[serde(default = "default_build_context")]
pub context: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Bus {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub profiles: BTreeMap<String, BusProfile>,
}
impl Bus {
#[must_use]
pub fn is_empty(&self) -> bool {
self.profiles.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BusProfile {
pub connect: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tool {
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Network {
#[serde(default)]
pub uplink: Uplink,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls: Option<NetworkTls>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Uplink {
#[serde(default)]
pub endpoints: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkTls {
pub cert: PathBuf,
pub key: PathBuf,
pub ca: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Components {
pub sources: BTreeMap<String, ComponentSource>,
pub instances: BTreeMap<String, Component>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ComponentSource {
Git(SourceGit),
Path(SourcePath),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceGit {
pub git: String,
pub tag: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourcePath {
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
EmptyApiVersion,
EmptyIdentityId,
EmptyIdentityNamespace,
UnknownPlatformParticipantImage {
name: String,
},
UserParticipantShadowsPlatformParticipant {
name: String,
},
EmptyUserParticipantImage {
participant: String,
},
EmptyUserParticipantBusProfile {
participant: String,
},
UnknownUserParticipantBusProfile {
participant: String,
profile: String,
},
InvalidBusProfileName {
profile: String,
},
ReservedBusProfileName {
profile: String,
},
EmptyBusProfileConnect {
profile: String,
},
MissingComponentSource {
instance: String,
source: String,
},
InvalidToken {
field: String,
value: String,
},
EmptyComponentType {
instance: String,
},
EmptyMountLink {
instance: String,
},
EmptyRoleList {
instance: String,
capability: String,
},
RepeatedRole {
instance: String,
capability: String,
role: Role,
},
InvalidRuntimeClock {
instance: String,
},
InvalidKinematicField {
field: String,
message: String,
},
InvalidDirectionSign {
instance: String,
capability: String,
},
}
impl Robot {
pub fn read_from_dir(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
Self::read_from_string(
&std::fs::read_to_string(path.join(ROBOT_FILE)).with_context(|| {
format!(
"failed to read robot file {}",
path.join(ROBOT_FILE).display()
)
})?,
)
}
pub fn read_from_string(string: &str) -> Result<Self> {
crate::model::robot::Robot::read_from_string(string)
.map(crate::model::robot::Robot::into_v1)
}
pub fn parse_from_dir(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
Self::parse_from_string(
&std::fs::read_to_string(path.join(ROBOT_FILE)).with_context(|| {
format!(
"failed to read robot file {}",
path.join(ROBOT_FILE).display()
)
})?,
)
}
pub fn parse_from_string(string: &str) -> Result<Self> {
crate::model::robot::Robot::parse_from_string(string)
.map(crate::model::robot::Robot::into_v1)
}
pub fn write_to_dir(&self, path: impl AsRef<Path>) -> Result<()> {
crate::model::robot::Robot::V1(self.clone()).write_to_dir(path)
}
pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
self.validate_basics(&mut errors);
self.validate_bus_profiles(&mut errors);
self.validate_user_participants(&mut errors);
self.validate_component_sources(&mut errors);
self.validate_component_structure(&mut errors);
self.validate_driver_structure(&mut errors);
self.validate_role_hints(&mut errors);
self.validate_kinematics(&mut errors);
self.validate_numerics(&mut errors);
validation_result(errors)
}
pub fn validate_with(
&self,
platform_participant_names: &[&str],
) -> std::result::Result<(), Vec<ValidationError>> {
let mut errors = match self.validate() {
Ok(()) => Vec::new(),
Err(errors) => errors,
};
let platform_participant_names = platform_participant_names
.iter()
.copied()
.collect::<BTreeSet<_>>();
for participant_name in self.phoxal_participants.images.keys() {
if !platform_participant_names.contains(participant_name.as_str()) {
errors.push(ValidationError::UnknownPlatformParticipantImage {
name: participant_name.clone(),
});
}
}
for participant_name in self.user_participants.keys() {
if platform_participant_names.contains(participant_name.as_str()) {
errors.push(ValidationError::UserParticipantShadowsPlatformParticipant {
name: participant_name.clone(),
});
}
}
validation_result(errors)
}
#[must_use]
pub fn robot_id(&self) -> &str {
&self.identity.id
}
#[must_use]
pub fn namespace(&self) -> &str {
&self.identity.namespace
}
#[must_use]
pub fn components(&self) -> &BTreeMap<String, Component> {
&self.components.instances
}
#[must_use]
pub fn component_instance(&self, component_id: &str) -> Option<&Component> {
self.components.instances.get(component_id)
}
#[must_use]
pub fn parameter(
&self,
capability_ref: &crate::model::component::v1::CapabilityRef,
) -> Option<&capability::Parameters> {
self.component_instance(&capability_ref.component_id)
.and_then(|component| component.parameters.get(&capability_ref.capability_id))
}
#[must_use]
pub fn used_component_types(&self) -> BTreeSet<&str> {
self.components
.instances
.values()
.map(|component| component.component.as_str())
.collect()
}
fn validate_basics(&self, errors: &mut Vec<ValidationError>) {
if self.api_version.trim().is_empty() {
errors.push(ValidationError::EmptyApiVersion);
}
if self.identity.id.trim().is_empty() {
errors.push(ValidationError::EmptyIdentityId);
}
if self.identity.namespace.trim().is_empty() {
errors.push(ValidationError::EmptyIdentityNamespace);
}
}
fn validate_component_sources(&self, errors: &mut Vec<ValidationError>) {
for (instance_name, instance) in &self.components.instances {
if !self.components.sources.contains_key(&instance.component) {
errors.push(ValidationError::MissingComponentSource {
instance: instance_name.clone(),
source: instance.component.clone(),
});
}
}
}
fn validate_bus_profiles(&self, errors: &mut Vec<ValidationError>) {
for (profile, config) in &self.bus.profiles {
if profile == "default" {
errors.push(ValidationError::ReservedBusProfileName {
profile: profile.clone(),
});
}
if !crate::model::component::v1::is_valid_token(profile) {
errors.push(ValidationError::InvalidBusProfileName {
profile: profile.clone(),
});
}
if config.connect.trim().is_empty() {
errors.push(ValidationError::EmptyBusProfileConnect {
profile: profile.clone(),
});
}
}
}
fn validate_user_participants(&self, errors: &mut Vec<ValidationError>) {
for (participant, config) in &self.user_participants {
if config
.image
.as_deref()
.is_some_and(|image| image.trim().is_empty())
{
errors.push(ValidationError::EmptyUserParticipantImage {
participant: participant.clone(),
});
}
if let Some(profile) = &config.bus_profile {
if profile.trim().is_empty() {
errors.push(ValidationError::EmptyUserParticipantBusProfile {
participant: participant.clone(),
});
} else if profile != "default" && !self.bus.profiles.contains_key(profile) {
errors.push(ValidationError::UnknownUserParticipantBusProfile {
participant: participant.clone(),
profile: profile.clone(),
});
}
}
}
}
}
impl Components {
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty() && self.instances.is_empty()
}
}
impl Deref for Components {
type Target = BTreeMap<String, Component>;
fn deref(&self) -> &Self::Target {
&self.instances
}
}
impl DerefMut for Components {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.instances
}
}
impl<'a> IntoIterator for &'a Components {
type Item = (&'a String, &'a Component);
type IntoIter = std::collections::btree_map::Iter<'a, String, Component>;
fn into_iter(self) -> Self::IntoIter {
self.instances.iter()
}
}
impl<'a> IntoIterator for &'a mut Components {
type Item = (&'a String, &'a mut Component);
type IntoIter = std::collections::btree_map::IterMut<'a, String, Component>;
fn into_iter(self) -> Self::IntoIter {
self.instances.iter_mut()
}
}
impl fmt::Display for ValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyApiVersion => formatter.write_str("api_version must not be empty"),
Self::EmptyIdentityId => formatter.write_str("identity.id must not be empty"),
Self::EmptyIdentityNamespace => {
formatter.write_str("identity.namespace must not be empty")
}
Self::UnknownPlatformParticipantImage { name } => write!(
formatter,
"phoxal_participants.images.{name} is not a platform participant"
),
Self::UserParticipantShadowsPlatformParticipant { name } => {
write!(
formatter,
"user_participants.{name} shadows a platform participant"
)
}
Self::EmptyUserParticipantImage { participant } => {
write!(
formatter,
"user_participants.{participant}.image must not be empty"
)
}
Self::EmptyUserParticipantBusProfile { participant } => {
write!(
formatter,
"user_participants.{participant}.bus_profile must not be empty"
)
}
Self::UnknownUserParticipantBusProfile {
participant,
profile,
} => write!(
formatter,
"user_participants.{participant}.bus_profile references unknown bus profile '{profile}'"
),
Self::InvalidBusProfileName { profile } => write!(
formatter,
"bus.profiles.{profile} must contain only lowercase ASCII letters, digits, '_' or '-'"
),
Self::ReservedBusProfileName { profile } => {
write!(
formatter,
"bus.profiles.{profile} is reserved; omit the implicit default profile"
)
}
Self::EmptyBusProfileConnect { profile } => {
write!(
formatter,
"bus.profiles.{profile}.connect must not be empty"
)
}
Self::MissingComponentSource { instance, source } => write!(
formatter,
"components.instances.{instance}.component references missing source '{source}'"
),
Self::InvalidToken { field, value } => write!(
formatter,
"{field} value '{value}' must contain only lowercase ASCII letters, digits, '_' or '-'"
),
Self::EmptyComponentType { instance } => write!(
formatter,
"components.instances.{instance}.component must not be empty"
),
Self::EmptyMountLink { instance } => {
write!(
formatter,
"components.instances.{instance}.mount_link must not be empty"
)
}
Self::EmptyRoleList {
instance,
capability,
} => write!(
formatter,
"components.instances.{instance}.roles.{capability} must list at least one role"
),
Self::RepeatedRole {
instance,
capability,
role,
} => write!(
formatter,
"components.instances.{instance}.roles.{capability} repeats role '{role}'"
),
Self::InvalidRuntimeClock { instance } => write!(
formatter,
"components.instances.{instance}.driver.runtime_clock_ms must be > 0"
),
Self::InvalidKinematicField { field, message } => {
write!(formatter, "motion.kinematic.{field} {message}")
}
Self::InvalidDirectionSign {
instance,
capability,
} => write!(
formatter,
"components.instances.{instance}.parameters.{capability}.direction_sign must be either -1 or 1"
),
}
}
}
fn validation_result(
errors: Vec<ValidationError>,
) -> std::result::Result<(), Vec<ValidationError>> {
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn default_structure_path() -> PathBuf {
PathBuf::from("structure.urdf")
}
fn default_user_participant_framework() -> String {
"match-platform".to_string()
}
fn is_match_platform(framework: &str) -> bool {
framework == "match-platform"
}
fn default_build_context() -> PathBuf {
PathBuf::from(".")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{Channel, Robot, UserParticipantBuild};
#[test]
fn user_participant_with_only_path_defaults_framework_and_build() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
motion:
kinematic:
kind: omnidirectional
actuators:
- drive.motor
encoders: []
components:
sources:
drive:
path: ../component/drive
instances:
drive:
component: drive
mount_link: drive_link
parameters:
motor:
kind: motor
"#,
)?;
let participant = robot
.user_participants
.get("autonomy")
.expect("user participant should parse");
assert_eq!(participant.path, PathBuf::from("participants/autonomy"));
assert_eq!(participant.framework, "match-platform");
assert_eq!(participant.image, None);
assert_eq!(participant.bus_profile, None);
assert_eq!(participant.config, None);
assert_eq!(participant.build, None);
assert!(robot.bus.profiles.is_empty());
Ok(())
}
#[test]
fn user_participant_parses_framework_and_full_build_recipe() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
framework: "0.9.0"
build:
context: container
dockerfile: Dockerfile.participant
target: participant
motion:
kinematic:
kind: omnidirectional
actuators:
- drive.motor
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
let participant = robot
.user_participants
.get("autonomy")
.expect("user participant should parse");
assert_eq!(participant.framework, "0.9.0");
assert_eq!(
participant.build,
Some(UserParticipantBuild {
context: PathBuf::from("container"),
dockerfile: Some(PathBuf::from("Dockerfile.participant")),
target: Some("participant".to_string()),
})
);
Ok(())
}
#[test]
fn bus_profiles_and_user_participant_deploy_fields_parse_and_validate() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
image: ghcr.io/acme/autonomy@sha256:abc
bus_profile: mcu_serial
config:
max_linear_speed_mps: 0.6
enabled: true
motion:
kinematic:
kind: omnidirectional
actuators:
- drive.motor
encoders: []
components:
sources:
drive:
path: ../component/drive
instances:
drive:
component: drive
mount_link: drive_link
parameters:
motor:
kind: motor
bus:
profiles:
mcu_serial:
connect: serial//dev/ttyACM0#baudrate=115200
"#,
)?;
let participant = robot
.user_participants
.get("autonomy")
.expect("user participant should parse");
assert_eq!(
participant.image.as_deref(),
Some("ghcr.io/acme/autonomy@sha256:abc")
);
assert_eq!(participant.bus_profile.as_deref(), Some("mcu_serial"));
assert_eq!(
participant
.config
.as_ref()
.and_then(|config| config.get("enabled"))
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
robot
.bus
.profiles
.get("mcu_serial")
.map(|profile| profile.connect.as_str()),
Some("serial//dev/ttyACM0#baudrate=115200")
);
robot
.validate()
.expect("bus profile reference should validate");
Ok(())
}
#[test]
fn user_participant_rejects_unknown_bus_profile() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
bus_profile: mcu_serial
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
let errors = robot
.validate()
.expect_err("unknown bus profile should fail validation");
assert!(
errors.contains(&super::ValidationError::UnknownUserParticipantBusProfile {
participant: "autonomy".to_string(),
profile: "mcu_serial".to_string(),
})
);
Ok(())
}
#[test]
fn default_bus_profile_is_implicit_and_must_not_be_declared() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
bus_profile: default
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
bus:
profiles:
default:
connect: tcp/127.0.0.1:7447
"#,
)?;
let errors = robot
.validate()
.expect_err("declared default profile should fail validation");
assert!(
errors.contains(&super::ValidationError::ReservedBusProfileName {
profile: "default".to_string(),
})
);
assert!(
!errors.iter().any(|error| matches!(
error,
super::ValidationError::UnknownUserParticipantBusProfile { .. }
)),
"bus_profile: default should refer to the implicit default"
);
Ok(())
}
#[test]
fn user_participant_build_rejects_unknown_fields() {
let error = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
build:
bogus: 1
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)
.expect_err("unknown build fields should fail to parse");
assert!(
format!("{error:#}").contains("unknown field `bogus`"),
"got: {error:#}"
);
}
#[test]
fn user_participant_round_trips_and_omits_default_framework_and_empty_build()
-> anyhow::Result<()> {
let default_robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
let default_yaml = serde_yaml::to_string(&crate::model::robot::Robot::V1(default_robot))?;
assert!(
!default_yaml.contains("framework:"),
"default framework should be omitted: {default_yaml}"
);
assert!(
!default_yaml.contains("build:"),
"empty build recipe should be omitted: {default_yaml}"
);
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
user_participants:
autonomy:
path: participants/autonomy
framework: "0.9.0"
build:
context: container
dockerfile: Dockerfile.participant
target: participant
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
let yaml = serde_yaml::to_string(&crate::model::robot::Robot::V1(robot.clone()))?;
let reparsed = Robot::parse_from_string(&yaml)?;
assert_eq!(reparsed.user_participants, robot.user_participants);
Ok(())
}
#[test]
fn phoxal_participants_channel_defaults_to_stable() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants: {}
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
assert_eq!(robot.phoxal_participants.channel, Channel::Stable);
assert_eq!(robot.phoxal_participants.channel.as_str(), "stable");
assert_eq!(robot.phoxal_participants.channel.to_string(), "stable");
assert!(robot.phoxal_participants.images.is_empty());
Ok(())
}
#[test]
fn phoxal_participants_rejects_invalid_channel() {
let error = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: experimental
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)
.expect_err("invalid phoxal_participants channel should fail to parse");
assert!(
format!("{error:#}").contains("unknown variant `experimental`"),
"got: {error:#}"
);
}
#[test]
fn phoxal_participants_rejects_old_version_field() {
let error = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
version: "latest"
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)
.expect_err("old phoxal_participants version field should fail to parse");
assert!(
format!("{error:#}").contains("unknown field `version`"),
"got: {error:#}"
);
}
#[test]
fn phoxal_participants_images_parse_and_validate_against_platform_participants()
-> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: latest
images:
drive: ghcr.io/phoxal/runtime-drive:y2026_1-v0.8.4
motion:
kinematic:
kind: omnidirectional
actuators:
- drive.motor
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
assert_eq!(robot.phoxal_participants.channel, Channel::Latest);
assert_eq!(
robot.phoxal_participants.images.get("drive"),
Some(&"ghcr.io/phoxal/runtime-drive:y2026_1-v0.8.4".to_string())
);
robot
.validate_with(&["drive"])
.expect("known platform image key should validate");
let errors = robot
.validate_with(&["odometry"])
.expect_err("unknown platform image key should fail validation");
assert!(
errors.contains(&super::ValidationError::UnknownPlatformParticipantImage {
name: "drive".to_string(),
})
);
assert_eq!(
super::ValidationError::UnknownPlatformParticipantImage {
name: "drive".to_string(),
}
.to_string(),
"phoxal_participants.images.drive is not a platform participant"
);
Ok(())
}
#[test]
fn robot_manifest_requires_schema_v0_and_api_version() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: y2026_1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
assert_eq!(robot.api_version, "y2026_1");
let yaml = serde_yaml::to_string(&crate::model::robot::Robot::V1(robot))?;
assert!(
yaml.starts_with("schema: v0\napi_version: y2026_1\n"),
"schema and api_version should be the first root keys: {yaml}"
);
let old_manifest_error = Robot::parse_from_string(
r#"
version: v1
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)
.expect_err("old version discriminator should no longer parse");
assert!(
format!("{old_manifest_error:#}").contains("schema"),
"got: {old_manifest_error:#}"
);
Ok(())
}
#[test]
fn empty_api_version_is_validation_error() -> anyhow::Result<()> {
let robot = Robot::parse_from_string(
r#"
schema: v0
api_version: " "
identity:
id: test-bot
namespace: dev
phoxal_participants:
channel: stable
motion:
kinematic:
kind: omnidirectional
actuators: []
encoders: []
components:
sources: {}
instances: {}
"#,
)?;
let errors = robot
.validate()
.expect_err("blank api_version should fail validation");
assert!(errors.contains(&super::ValidationError::EmptyApiVersion));
assert_eq!(
super::ValidationError::EmptyApiVersion.to_string(),
"api_version must not be empty"
);
Ok(())
}
}