phoxal 0.27.0

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
//! Data types for authored source robot manifests.
//!
//! The crate root is the version dispatcher for `robot.yaml`. Schema wire
//! types live under [`v1`]; consumers that need the v1 struct directly can
//! import [`RobotV1`] or [`v1::Robot`].
//!
//! Parsing is strict: every struct denies unknown fields, `serde_yaml`
//! natively rejects duplicate mapping keys and YAML-1.1-only booleans
//! (`yes`/`no`/`on`/`off`) coerced into typed `bool` fields, and
//! [`strict_yaml::check`] additionally rejects anchors/aliases, merge keys,
//! explicit tags, and multi-document streams before the manifest reaches
//! `serde_yaml::from_str`.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

mod strict_yaml;
pub mod v1;

pub use v1::Robot as RobotV1;
pub use v1::ValidationError;

const ROBOT_FILE: &str = "robot.yaml";

/// Version dispatcher for `robot.yaml`.
///
/// On the wire, manifests use `schema: v0` for the manifest grammar. The
/// greenfield reset restarted the schema at v0, while the Rust types keep their
/// historical `v1` names.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "schema")]
pub enum Robot {
    #[serde(rename = "v0")]
    V1(v1::Robot),
}

impl Robot {
    pub fn read_from_dir(path: impl AsRef<Path>) -> Result<Self> {
        let robot = Self::parse_from_dir(path)?;
        robot.validate().map_err(validation_error)?;
        Ok(robot)
    }

    pub fn read_from_string(text: &str) -> Result<Self> {
        let robot = Self::parse_from_string(text)?;
        robot.validate().map_err(validation_error)?;
        Ok(robot)
    }

    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(text: &str) -> Result<Self> {
        strict_yaml::check(text).context("failed to parse robot")?;
        serde_yaml::from_str(text).with_context(|| "failed to parse robot")
    }

    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 robot directory {}", path.display()))?;
        let yaml = serde_yaml::to_string(self)?;
        std::fs::write(path.join(ROBOT_FILE), yaml).with_context(|| {
            format!(
                "failed to write robot file {}",
                path.join(ROBOT_FILE).display()
            )
        })?;
        Ok(())
    }

    pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
        match self {
            Self::V1(robot) => robot.validate(),
        }
    }

    pub fn validate_with(
        &self,
        platform_participant_names: &[&str],
    ) -> std::result::Result<(), Vec<ValidationError>> {
        match self {
            Self::V1(robot) => robot.validate_with(platform_participant_names),
        }
    }

    #[must_use]
    pub fn as_v1(&self) -> &v1::Robot {
        match self {
            Self::V1(robot) => robot,
        }
    }

    #[must_use]
    pub fn into_v1(self) -> v1::Robot {
        match self {
            Self::V1(robot) => robot,
        }
    }
}

fn validation_error(errors: Vec<ValidationError>) -> anyhow::Error {
    let message = errors
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join("\n");
    anyhow::anyhow!("Robot errors:\n{message}")
}