phoxal 0.43.1

Phoxal - production-oriented autonomous robot framework: the runtime engine and model (the api contract tree lives in phoxal-api, the typed bus in phoxal-bus).
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

pub mod v0;

const COMPONENT_FILE: &str = "component.yaml";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "schema")]
pub enum Component {
    #[serde(rename = "component/v0")]
    V0(v0::Component),
}

impl Component {
    pub fn read_from_dir(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        let component_string =
            std::fs::read_to_string(path.join(COMPONENT_FILE)).with_context(|| {
                format!(
                    "failed to read component file {}",
                    path.join(COMPONENT_FILE).display()
                )
            })?;
        let component: Self =
            serde_yaml::from_str(&component_string).with_context(|| "failed to parse component")?;
        let component_id = path
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or("<component>");
        component.validate_for_component(component_id)?;
        Ok(component)
    }

    pub fn read_from_string(string: &str) -> Result<Self> {
        let component: Self =
            serde_yaml::from_str(string).with_context(|| "failed to parse component")?;
        component.validate()?;
        Ok(component)
    }

    pub fn write_to_dir(&self, path: impl AsRef<Path>) -> Result<()> {
        let path = path.as_ref();
        std::fs::create_dir_all(path)
            .with_context(|| format!("failed to create component directory {}", path.display()))?;
        let yaml = serde_yaml::to_string(self)?;
        std::fs::write(path.join(COMPONENT_FILE), yaml).with_context(|| {
            format!(
                "failed to write component file {}",
                path.join(COMPONENT_FILE).display()
            )
        })?;
        Ok(())
    }

    pub fn validate(&self) -> Result<()> {
        self.validate_for_component("<inline>")
    }

    pub fn validate_for_component(&self, component_id: &str) -> Result<()> {
        match self {
            Self::V0(component) => component.validate_for_component(component_id),
        }
    }

    pub fn as_v0(&self) -> Option<&v0::Component> {
        match self {
            Self::V0(component) => Some(component),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::Component;
    use crate::model::component::v0::capability::Capability as V0Capability;
    use tempfile::tempdir;

    #[test]
    fn component_roundtrips_through_directory() -> anyhow::Result<()> {
        let temp_dir = tempdir()?;
        let component_dir = temp_dir.path().join("component");
        let component = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  motor:
    kind: motor
    command: velocity
    target:
      kind: joint
      id: motor_joint
"#,
        )?;

        component.write_to_dir(&component_dir)?;
        let loaded = Component::read_from_dir(&component_dir)?;

        assert!(
            loaded
                .as_v0()
                .expect("supported version")
                .capabilities
                .contains_key("motor")
        );
        Ok(())
    }

    #[test]
    fn component_validates_token_ids() -> anyhow::Result<()> {
        let result = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  InvalidCapability:
    kind: motor
    command: velocity
    target: { kind: joint, id: j }
"#,
        );
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("capability id 'InvalidCapability'")
        );

        Ok(())
    }

    #[test]
    fn component_parses_and_round_trips_gtin() -> anyhow::Result<()> {
        let component = Component::read_from_string(
            "schema: component/v0\ngtin: \"1234567890123\"\ncapabilities: {}\n",
        )?;
        assert_eq!(
            component
                .as_v0()
                .expect("supported version")
                .gtin
                .as_ref()
                .map(crate::model::component::v0::Gtin::as_str),
            Some("1234567890123")
        );
        let yaml = serde_yaml::to_string(&component)?;
        let reparsed = Component::read_from_string(&yaml)?;
        assert_eq!(
            reparsed
                .as_v0()
                .expect("supported version")
                .gtin
                .as_ref()
                .map(crate::model::component::v0::Gtin::as_str),
            Some("1234567890123")
        );
        Ok(())
    }

    #[test]
    fn component_rejects_non_positive_motor_gear_ratio() {
        let result = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  motor:
    kind: motor
    command: velocity
    gear_ratio: 0.0
    target: { kind: joint, id: motor_joint }
"#,
        );
        let error = result.expect_err("motor gear_ratio must be enforced");
        assert!(
            format!("{error:#}").contains("capabilities.motor.gear_ratio must be > 0"),
            "got: {error:#}"
        );
    }

    #[test]
    fn component_rejects_non_positive_encoder_values() {
        let result = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  encoder:
    kind: encoder
    publish_rate_hz: 50.0
    gear_ratio: 0.0
    counts_per_revolution: 0
    target: { kind: joint, id: motor_joint }
"#,
        );
        let error = result.expect_err("encoder gear_ratio and counts must be enforced");
        let message = format!("{error:#}");
        assert!(
            message.contains("capabilities.encoder.gear_ratio must be > 0"),
            "got: {message}"
        );
        assert!(
            message.contains("capabilities.encoder.counts_per_revolution must be > 0"),
            "got: {message}"
        );
    }

    #[test]
    fn component_rejects_malformed_gtin() {
        let result =
            Component::read_from_string("schema: component/v0\ngtin: \"123\"\ncapabilities: {}\n");
        let error = result.expect_err("13-digit GTIN must be enforced");
        assert!(format!("{error:#}").contains("13 digits"), "got: {error:#}");
    }

    #[test]
    fn component_without_gtin_is_none_and_omits_on_serialize() -> anyhow::Result<()> {
        let component = Component::read_from_string("schema: component/v0\ncapabilities: {}\n")?;
        assert!(component.as_v0().expect("supported version").gtin.is_none());
        let yaml = serde_yaml::to_string(&component)?;
        assert!(
            !yaml.contains("gtin"),
            "gtin must be omitted when absent: {yaml}"
        );
        Ok(())
    }

    #[test]
    fn component_parses_range_capability() -> anyhow::Result<()> {
        let component = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  range:
    kind: range
    publish_rate_hz: 20.0
    min_range_m: 0.04
    max_range_m: 4.0
    field_of_view_rad: 0.471239
    target:
      kind: link
      id: sensor_link
"#,
        )?;

        assert!(matches!(
            component
                .as_v0()
                .expect("supported version")
                .capabilities
                .get("range"),
            Some(crate::model::component::v0::capability::Capability::Range(
                _
            ))
        ));
        Ok(())
    }

    #[test]
    fn component_parses_emergency_stop_capability() -> anyhow::Result<()> {
        let component = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  e_stop:
    kind: emergency_stop
    target:
      kind: link
      id: button_link
"#,
        )?;

        assert!(matches!(
            component
                .as_v0()
                .expect("supported version")
                .capabilities
                .get("e_stop"),
            Some(crate::model::component::v0::capability::Capability::EmergencyStop(_))
        ));
        Ok(())
    }

    #[test]
    fn camera_native_envelope_roundtrips_without_component_profiles() -> anyhow::Result<()> {
        let component = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  rgb:
    kind: camera
    mode: rgb
    publish_rate_hz: 30.0
    width_px: 640
    height_px: 480
    field_of_view_rad: 1.204277
    target: { kind: link, id: camera_link }
  native_only:
    kind: camera
    mode: rgb
    publish_rate_hz: 15.0
    width_px: 320
    height_px: 240
    target: { kind: link, id: native_camera_link }
"#,
        )?;

        let serialized = serde_yaml::to_string(&component)?;
        let reparsed = Component::read_from_string(&serialized)?;
        let capabilities = &reparsed.as_v0().expect("supported version").capabilities;

        match capabilities.get("rgb") {
            Some(V0Capability::Camera(camera)) => {
                assert_eq!(camera.width_px, 640);
                assert_eq!(camera.height_px, 480);
                assert_eq!(camera.publish_rate_hz, 30.0);
            }
            _ => panic!("expected rgb camera capability"),
        }

        match capabilities.get("native_only") {
            Some(V0Capability::Camera(camera)) => {
                assert_eq!(camera.width_px, 320);
                assert_eq!(camera.height_px, 240);
                assert_eq!(camera.publish_rate_hz, 15.0);
            }
            _ => panic!("expected native_only camera capability"),
        }

        Ok(())
    }

    #[test]
    fn component_profiles_list_is_rejected() {
        let result = Component::read_from_string(
            r#"
schema: component/v0
capabilities:
  rgb:
    kind: camera
    mode: rgb
    publish_rate_hz: 30.0
    width_px: 640
    height_px: 480
    target: { kind: link, id: camera_link }
    profiles:
        publish_rate_hz: 5.0
        width_px: 320
        height_px: 240
        encoding: jpeg
"#,
        );

        let message = format!("{:#}", result.unwrap_err());
        assert!(message.contains("unknown field `profiles`"));
    }
}