Skip to main content

mecha10_templates/services/
driver_generator.rs

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