mecha10-templates 0.8.2

Mecha10 project templates — code generation, init, and template services
Documentation
//! Driver generator service
//!
//! Generates custom hardware driver crates for Mecha10 projects.

use crate::paths;
use crate::services::cargo_workspace::add_workspace_member;
use crate::templates::render::render;
use crate::utils::to_pascal_case;
use anyhow::{Context, Result};
use std::path::Path;

const CARGO_TOML_TEMPLATE: &str = include_str!("../../templates/drivers/Cargo.toml.template");
const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/drivers/main.rs.template");
const CAMERA_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/camera.rs.template");
const MOTOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/motor.rs.template");
const SENSOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/sensor.rs.template");
const IMU_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/imu.rs.template");

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DriverType {
    Camera,
    Motor,
    Sensor,
    Imu,
}

impl DriverType {
    pub const ALL: [DriverType; 4] = [
        DriverType::Camera,
        DriverType::Motor,
        DriverType::Sensor,
        DriverType::Imu,
    ];

    /// The `--type` value for this driver kind (e.g. `"camera"`).
    pub fn as_str(self) -> &'static str {
        match self {
            DriverType::Camera => "camera",
            DriverType::Motor => "motor",
            DriverType::Sensor => "sensor",
            DriverType::Imu => "imu",
        }
    }

    /// Parse a `--type` value, case-insensitively. Returns an error listing the available
    /// types when `value` doesn't match one of them.
    pub fn parse(value: &str) -> Result<Self> {
        DriverType::ALL
            .into_iter()
            .find(|kind| kind.as_str().eq_ignore_ascii_case(value))
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "Unknown driver type '{}'. Available driver types: {}",
                    value,
                    DriverType::ALL.map(|kind| kind.as_str()).join(", ")
                )
            })
    }

    /// The embedded `src/lib.rs` template for this driver kind.
    fn lib_rs_template(self) -> &'static str {
        match self {
            DriverType::Camera => CAMERA_LIB_RS_TEMPLATE,
            DriverType::Motor => MOTOR_LIB_RS_TEMPLATE,
            DriverType::Sensor => SENSOR_LIB_RS_TEMPLATE,
            DriverType::Imu => IMU_LIB_RS_TEMPLATE,
        }
    }
}

pub struct DriverGeneratorService;

impl DriverGeneratorService {
    /// Create a new DriverGeneratorService
    pub fn new() -> Self {
        Self
    }

    /// Generate a new driver crate in the project
    ///
    /// Creates the following structure:
    pub async fn generate_driver(&self, project_root: &Path, name: &str, driver_type: DriverType) -> Result<()> {
        self.validate_driver_name(name)?;

        let driver_dir = project_root.join(paths::project::DRIVERS_DIR).join(name);
        if driver_dir.exists() {
            anyhow::bail!("Driver '{}' already exists at {}", name, driver_dir.display());
        }

        let src_dir = driver_dir.join(paths::project::SRC_DIR);
        tokio::fs::create_dir_all(&src_dir)
            .await
            .context("Failed to create driver directory")?;

        let crate_name = self.to_snake_case(name);
        let pascal_name = to_pascal_case(&crate_name);

        let cargo_toml = render(CARGO_TOML_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
        tokio::fs::write(driver_dir.join(paths::rust::CARGO_TOML), cargo_toml).await?;

        let main_rs = render(MAIN_RS_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
        tokio::fs::write(src_dir.join("main.rs"), main_rs).await?;

        let lib_rs = render(
            driver_type.lib_rs_template(),
            &[("name", name), ("PascalName", &pascal_name)],
        );
        tokio::fs::write(src_dir.join("lib.rs"), lib_rs).await?;

        add_workspace_member(project_root, &format!("{}/{}", paths::project::DRIVERS_DIR, name)).await?;

        Ok(())
    }

    /// Validate driver name — same rules as node names.
    fn validate_driver_name(&self, name: &str) -> Result<()> {
        crate::services::generator_name::validate_generator_name("Driver", name)
    }

    /// Convert name to snake_case for the Rust crate name (kebab-case-aware, like
    /// `NodeGeneratorService::to_snake_case`).
    fn to_snake_case(&self, name: &str) -> String {
        name.replace('-', "_")
    }
}

impl Default for DriverGeneratorService {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    async fn setup_project() -> TempDir {
        let temp = TempDir::new().unwrap();
        tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
            .await
            .unwrap();
        temp
    }

    #[test]
    fn driver_type_parse_accepts_all_known_types() {
        assert_eq!(DriverType::parse("camera").unwrap(), DriverType::Camera);
        assert_eq!(DriverType::parse("MOTOR").unwrap(), DriverType::Motor);
        assert_eq!(DriverType::parse("Sensor").unwrap(), DriverType::Sensor);
        assert_eq!(DriverType::parse("imu").unwrap(), DriverType::Imu);
    }

    #[test]
    fn driver_type_parse_rejects_unknown_type_and_lists_available_ones() {
        let err = DriverType::parse("gripper").unwrap_err();
        assert!(err
            .to_string()
            .contains("Available driver types: camera, motor, sensor, imu"));
    }

    #[test]
    fn validate_driver_name_rejects_leading_digit() {
        let service = DriverGeneratorService::new();
        let err = service.validate_driver_name("1camera").unwrap_err();
        assert!(err.to_string().contains("must start with a letter"));
    }

    #[tokio::test]
    async fn generate_driver_creates_expected_file_structure() {
        let temp = setup_project().await;
        let service = DriverGeneratorService::new();

        service
            .generate_driver(temp.path(), "my-camera", DriverType::Camera)
            .await
            .unwrap();

        let driver_dir = temp.path().join("drivers/my-camera");
        assert!(driver_dir.join("Cargo.toml").exists());
        assert!(driver_dir.join("src/lib.rs").exists());
        assert!(driver_dir.join("src/main.rs").exists());
    }

    #[tokio::test]
    async fn generate_driver_fails_if_driver_already_exists() {
        let temp = setup_project().await;
        let service = DriverGeneratorService::new();

        service
            .generate_driver(temp.path(), "motor1", DriverType::Motor)
            .await
            .unwrap();
        let err = service
            .generate_driver(temp.path(), "motor1", DriverType::Motor)
            .await
            .unwrap_err();

        assert!(err.to_string().contains("already exists"));
    }

    #[tokio::test]
    async fn generate_driver_lib_rs_uses_pascal_case_and_type_specific_content() {
        let temp = setup_project().await;
        let service = DriverGeneratorService::new();

        service
            .generate_driver(temp.path(), "front-imu", DriverType::Imu)
            .await
            .unwrap();

        let lib_rs = tokio::fs::read_to_string(temp.path().join("drivers/front-imu/src/lib.rs"))
            .await
            .unwrap();
        assert!(lib_rs.contains("pub struct FrontImuNode"));
        assert!(lib_rs.contains("FrontImuConfig"));
        assert!(lib_rs.contains("Topic::new(\"/front-imu/data\")"));
        assert!(lib_rs.contains("mecha10_core::node_main!(FrontImuNode, \"front-imu\");"));
        assert!(lib_rs.contains("pub struct ImuData"));
    }

    #[tokio::test]
    async fn generate_driver_updates_cargo_workspace_members() {
        let temp = setup_project().await;
        let service = DriverGeneratorService::new();

        service
            .generate_driver(temp.path(), "wheel-motor", DriverType::Motor)
            .await
            .unwrap();

        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
        let doc: toml::Value = toml::from_str(&content).unwrap();
        let members = doc["workspace"]["members"].as_array().unwrap();
        assert!(members.iter().any(|m| m.as_str() == Some("drivers/wheel-motor")));
    }

    #[tokio::test]
    async fn generate_driver_does_not_touch_mecha10_json() {
        let temp = setup_project().await;
        let service = DriverGeneratorService::new();

        service
            .generate_driver(temp.path(), "bench-sensor", DriverType::Sensor)
            .await
            .unwrap();

        assert!(!temp.path().join("mecha10.json").exists());
    }
}