Skip to main content

mecha10_templates/services/
driver_generator.rs

1//! Driver generator service
2//!
3//! Generates custom hardware driver crates for Mecha10 projects.
4
5use crate::paths;
6use crate::services::cargo_workspace::add_workspace_member;
7use crate::templates::render::render;
8use crate::utils::to_pascal_case;
9use anyhow::{Context, Result};
10use std::path::Path;
11
12const CARGO_TOML_TEMPLATE: &str = include_str!("../../templates/drivers/Cargo.toml.template");
13const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/drivers/main.rs.template");
14const CAMERA_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/camera.rs.template");
15const MOTOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/motor.rs.template");
16const SENSOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/sensor.rs.template");
17const IMU_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/imu.rs.template");
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DriverType {
21    Camera,
22    Motor,
23    Sensor,
24    Imu,
25}
26
27impl DriverType {
28    pub const ALL: [DriverType; 4] = [
29        DriverType::Camera,
30        DriverType::Motor,
31        DriverType::Sensor,
32        DriverType::Imu,
33    ];
34
35    /// The `--type` value for this driver kind (e.g. `"camera"`).
36    pub fn as_str(self) -> &'static str {
37        match self {
38            DriverType::Camera => "camera",
39            DriverType::Motor => "motor",
40            DriverType::Sensor => "sensor",
41            DriverType::Imu => "imu",
42        }
43    }
44
45    /// Parse a `--type` value, case-insensitively. Returns an error listing the available
46    /// types when `value` doesn't match one of them.
47    pub fn parse(value: &str) -> Result<Self> {
48        DriverType::ALL
49            .into_iter()
50            .find(|kind| kind.as_str().eq_ignore_ascii_case(value))
51            .ok_or_else(|| {
52                anyhow::anyhow!(
53                    "Unknown driver type '{}'. Available driver types: {}",
54                    value,
55                    DriverType::ALL.map(|kind| kind.as_str()).join(", ")
56                )
57            })
58    }
59
60    /// The embedded `src/lib.rs` template for this driver kind.
61    fn lib_rs_template(self) -> &'static str {
62        match self {
63            DriverType::Camera => CAMERA_LIB_RS_TEMPLATE,
64            DriverType::Motor => MOTOR_LIB_RS_TEMPLATE,
65            DriverType::Sensor => SENSOR_LIB_RS_TEMPLATE,
66            DriverType::Imu => IMU_LIB_RS_TEMPLATE,
67        }
68    }
69}
70
71pub struct DriverGeneratorService;
72
73impl DriverGeneratorService {
74    /// Create a new DriverGeneratorService
75    pub fn new() -> Self {
76        Self
77    }
78
79    /// Generate a new driver crate in the project
80    ///
81    /// Creates the following structure:
82    pub async fn generate_driver(&self, project_root: &Path, name: &str, driver_type: DriverType) -> Result<()> {
83        self.validate_driver_name(name)?;
84
85        let driver_dir = project_root.join(paths::project::DRIVERS_DIR).join(name);
86        if driver_dir.exists() {
87            anyhow::bail!("Driver '{}' already exists at {}", name, driver_dir.display());
88        }
89
90        let src_dir = driver_dir.join(paths::project::SRC_DIR);
91        tokio::fs::create_dir_all(&src_dir)
92            .await
93            .context("Failed to create driver directory")?;
94
95        let crate_name = self.to_snake_case(name);
96        let pascal_name = to_pascal_case(&crate_name);
97
98        let cargo_toml = render(CARGO_TOML_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
99        tokio::fs::write(driver_dir.join(paths::rust::CARGO_TOML), cargo_toml).await?;
100
101        let main_rs = render(MAIN_RS_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
102        tokio::fs::write(src_dir.join("main.rs"), main_rs).await?;
103
104        let lib_rs = render(
105            driver_type.lib_rs_template(),
106            &[("name", name), ("PascalName", &pascal_name)],
107        );
108        tokio::fs::write(src_dir.join("lib.rs"), lib_rs).await?;
109
110        add_workspace_member(project_root, &format!("{}/{}", paths::project::DRIVERS_DIR, name)).await?;
111
112        Ok(())
113    }
114
115    /// Validate driver name — same rules as node names.
116    fn validate_driver_name(&self, name: &str) -> Result<()> {
117        crate::services::generator_name::validate_generator_name("Driver", name)
118    }
119
120    /// Convert name to snake_case for the Rust crate name (kebab-case-aware, like
121    /// `NodeGeneratorService::to_snake_case`).
122    fn to_snake_case(&self, name: &str) -> String {
123        name.replace('-', "_")
124    }
125}
126
127impl Default for DriverGeneratorService {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use tempfile::TempDir;
137
138    async fn setup_project() -> TempDir {
139        let temp = TempDir::new().unwrap();
140        tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
141            .await
142            .unwrap();
143        temp
144    }
145
146    #[test]
147    fn driver_type_parse_accepts_all_known_types() {
148        assert_eq!(DriverType::parse("camera").unwrap(), DriverType::Camera);
149        assert_eq!(DriverType::parse("MOTOR").unwrap(), DriverType::Motor);
150        assert_eq!(DriverType::parse("Sensor").unwrap(), DriverType::Sensor);
151        assert_eq!(DriverType::parse("imu").unwrap(), DriverType::Imu);
152    }
153
154    #[test]
155    fn driver_type_parse_rejects_unknown_type_and_lists_available_ones() {
156        let err = DriverType::parse("gripper").unwrap_err();
157        assert!(err
158            .to_string()
159            .contains("Available driver types: camera, motor, sensor, imu"));
160    }
161
162    #[test]
163    fn validate_driver_name_rejects_leading_digit() {
164        let service = DriverGeneratorService::new();
165        let err = service.validate_driver_name("1camera").unwrap_err();
166        assert!(err.to_string().contains("must start with a letter"));
167    }
168
169    #[tokio::test]
170    async fn generate_driver_creates_expected_file_structure() {
171        let temp = setup_project().await;
172        let service = DriverGeneratorService::new();
173
174        service
175            .generate_driver(temp.path(), "my-camera", DriverType::Camera)
176            .await
177            .unwrap();
178
179        let driver_dir = temp.path().join("drivers/my-camera");
180        assert!(driver_dir.join("Cargo.toml").exists());
181        assert!(driver_dir.join("src/lib.rs").exists());
182        assert!(driver_dir.join("src/main.rs").exists());
183    }
184
185    #[tokio::test]
186    async fn generate_driver_fails_if_driver_already_exists() {
187        let temp = setup_project().await;
188        let service = DriverGeneratorService::new();
189
190        service
191            .generate_driver(temp.path(), "motor1", DriverType::Motor)
192            .await
193            .unwrap();
194        let err = service
195            .generate_driver(temp.path(), "motor1", DriverType::Motor)
196            .await
197            .unwrap_err();
198
199        assert!(err.to_string().contains("already exists"));
200    }
201
202    #[tokio::test]
203    async fn generate_driver_lib_rs_uses_pascal_case_and_type_specific_content() {
204        let temp = setup_project().await;
205        let service = DriverGeneratorService::new();
206
207        service
208            .generate_driver(temp.path(), "front-imu", DriverType::Imu)
209            .await
210            .unwrap();
211
212        let lib_rs = tokio::fs::read_to_string(temp.path().join("drivers/front-imu/src/lib.rs"))
213            .await
214            .unwrap();
215        assert!(lib_rs.contains("pub struct FrontImuNode"));
216        assert!(lib_rs.contains("FrontImuConfig"));
217        assert!(lib_rs.contains("Topic::new(\"/front-imu/data\")"));
218        assert!(lib_rs.contains("mecha10_core::node_main!(FrontImuNode, \"front-imu\");"));
219        assert!(lib_rs.contains("pub struct ImuData"));
220    }
221
222    #[tokio::test]
223    async fn generate_driver_updates_cargo_workspace_members() {
224        let temp = setup_project().await;
225        let service = DriverGeneratorService::new();
226
227        service
228            .generate_driver(temp.path(), "wheel-motor", DriverType::Motor)
229            .await
230            .unwrap();
231
232        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
233        let doc: toml::Value = toml::from_str(&content).unwrap();
234        let members = doc["workspace"]["members"].as_array().unwrap();
235        assert!(members.iter().any(|m| m.as_str() == Some("drivers/wheel-motor")));
236    }
237
238    #[tokio::test]
239    async fn generate_driver_does_not_touch_mecha10_json() {
240        let temp = setup_project().await;
241        let service = DriverGeneratorService::new();
242
243        service
244            .generate_driver(temp.path(), "bench-sensor", DriverType::Sensor)
245            .await
246            .unwrap();
247
248        assert!(!temp.path().join("mecha10.json").exists());
249    }
250}