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
18/// `Cargo.toml` for a generated driver — shared across all driver types.
19const CARGO_TOML_TEMPLATE: &str = include_str!("../../templates/drivers/Cargo.toml.template");
20/// `src/main.rs` for a generated driver — shared across all driver types.
21const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/drivers/main.rs.template");
22/// `src/lib.rs` content for a generated `camera` driver.
23const CAMERA_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/camera.rs.template");
24/// `src/lib.rs` content for a generated `motor` driver.
25const MOTOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/motor.rs.template");
26/// `src/lib.rs` content for a generated `sensor` driver.
27const SENSOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/sensor.rs.template");
28/// `src/lib.rs` content for a generated `imu` driver.
29const IMU_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/imu.rs.template");
30
31/// The kinds of driver `mecha10 add driver <name> --type <kind>` knows how to scaffold.
32///
33/// This is the Rust-side enumeration the removed `TemplateEngine::list_templates`/
34/// `template_exists` runtime directory scan used to provide — a small match, not a filesystem
35/// walk, per LAB-2015's design guidance.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DriverType {
38    Camera,
39    Motor,
40    Sensor,
41    Imu,
42}
43
44impl DriverType {
45    /// All driver types `mecha10 add driver --type` accepts, in the order they should be
46    /// listed to the user.
47    pub const ALL: [DriverType; 4] = [
48        DriverType::Camera,
49        DriverType::Motor,
50        DriverType::Sensor,
51        DriverType::Imu,
52    ];
53
54    /// The `--type` value for this driver kind (e.g. `"camera"`).
55    pub fn as_str(self) -> &'static str {
56        match self {
57            DriverType::Camera => "camera",
58            DriverType::Motor => "motor",
59            DriverType::Sensor => "sensor",
60            DriverType::Imu => "imu",
61        }
62    }
63
64    /// Parse a `--type` value, case-insensitively. Returns an error listing the available
65    /// types (mirrors the "Available driver types: camera, motor, sensor, imu" hint from the
66    /// dead `TemplateService`) when `value` doesn't match one of them.
67    pub fn parse(value: &str) -> Result<Self> {
68        DriverType::ALL
69            .into_iter()
70            .find(|kind| kind.as_str().eq_ignore_ascii_case(value))
71            .ok_or_else(|| {
72                anyhow::anyhow!(
73                    "Unknown driver type '{}'. Available driver types: {}",
74                    value,
75                    DriverType::ALL.map(|kind| kind.as_str()).join(", ")
76                )
77            })
78    }
79
80    /// The embedded `src/lib.rs` template for this driver kind.
81    fn lib_rs_template(self) -> &'static str {
82        match self {
83            DriverType::Camera => CAMERA_LIB_RS_TEMPLATE,
84            DriverType::Motor => MOTOR_LIB_RS_TEMPLATE,
85            DriverType::Sensor => SENSOR_LIB_RS_TEMPLATE,
86            DriverType::Imu => IMU_LIB_RS_TEMPLATE,
87        }
88    }
89}
90
91/// Service for generating custom driver crates
92pub struct DriverGeneratorService;
93
94impl DriverGeneratorService {
95    /// Create a new DriverGeneratorService
96    pub fn new() -> Self {
97        Self
98    }
99
100    /// Generate a new driver crate in the project
101    ///
102    /// Creates the following structure:
103    /// ```text
104    /// drivers/<name>/
105    /// ├── Cargo.toml
106    /// └── src/
107    ///     ├── lib.rs   (type-specific boilerplate — camera/motor/sensor/imu)
108    ///     └── main.rs
109    /// ```
110    pub async fn generate_driver(&self, project_root: &Path, name: &str, driver_type: DriverType) -> Result<()> {
111        self.validate_driver_name(name)?;
112
113        let driver_dir = project_root.join(paths::project::DRIVERS_DIR).join(name);
114        if driver_dir.exists() {
115            anyhow::bail!("Driver '{}' already exists at {}", name, driver_dir.display());
116        }
117
118        let src_dir = driver_dir.join(paths::project::SRC_DIR);
119        tokio::fs::create_dir_all(&src_dir)
120            .await
121            .context("Failed to create driver directory")?;
122
123        let crate_name = self.to_snake_case(name);
124        let pascal_name = to_pascal_case(&crate_name);
125
126        let cargo_toml = render(CARGO_TOML_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
127        tokio::fs::write(driver_dir.join(paths::rust::CARGO_TOML), cargo_toml).await?;
128
129        let main_rs = render(MAIN_RS_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
130        tokio::fs::write(src_dir.join("main.rs"), main_rs).await?;
131
132        let lib_rs = render(
133            driver_type.lib_rs_template(),
134            &[("name", name), ("PascalName", &pascal_name)],
135        );
136        tokio::fs::write(src_dir.join("lib.rs"), lib_rs).await?;
137
138        add_workspace_member(project_root, &format!("{}/{}", paths::project::DRIVERS_DIR, name)).await?;
139
140        Ok(())
141    }
142
143    /// Validate driver name — same rules as node names (letters/numbers/hyphens/underscores,
144    /// must start with a letter): both become a Cargo package/crate name and a Rust struct name.
145    fn validate_driver_name(&self, name: &str) -> Result<()> {
146        if name.is_empty() {
147            anyhow::bail!("Driver name cannot be empty");
148        }
149
150        if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
151            anyhow::bail!(
152                "Driver name '{}' contains invalid characters. Use only letters, numbers, hyphens, and underscores.",
153                name
154            );
155        }
156
157        if !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
158            anyhow::bail!("Driver name must start with a letter");
159        }
160
161        Ok(())
162    }
163
164    /// Convert name to snake_case for the Rust crate name (kebab-case-aware, like
165    /// `NodeGeneratorService::to_snake_case`).
166    fn to_snake_case(&self, name: &str) -> String {
167        name.replace('-', "_")
168    }
169}
170
171impl Default for DriverGeneratorService {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use tempfile::TempDir;
181
182    async fn setup_project() -> TempDir {
183        let temp = TempDir::new().unwrap();
184        tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
185            .await
186            .unwrap();
187        temp
188    }
189
190    #[test]
191    fn driver_type_parse_accepts_all_known_types() {
192        assert_eq!(DriverType::parse("camera").unwrap(), DriverType::Camera);
193        assert_eq!(DriverType::parse("MOTOR").unwrap(), DriverType::Motor);
194        assert_eq!(DriverType::parse("Sensor").unwrap(), DriverType::Sensor);
195        assert_eq!(DriverType::parse("imu").unwrap(), DriverType::Imu);
196    }
197
198    #[test]
199    fn driver_type_parse_rejects_unknown_type_and_lists_available_ones() {
200        let err = DriverType::parse("gripper").unwrap_err();
201        assert!(err
202            .to_string()
203            .contains("Available driver types: camera, motor, sensor, imu"));
204    }
205
206    #[test]
207    fn validate_driver_name_rejects_leading_digit() {
208        let service = DriverGeneratorService::new();
209        let err = service.validate_driver_name("1camera").unwrap_err();
210        assert!(err.to_string().contains("must start with a letter"));
211    }
212
213    #[tokio::test]
214    async fn generate_driver_creates_expected_file_structure() {
215        let temp = setup_project().await;
216        let service = DriverGeneratorService::new();
217
218        service
219            .generate_driver(temp.path(), "my-camera", DriverType::Camera)
220            .await
221            .unwrap();
222
223        let driver_dir = temp.path().join("drivers/my-camera");
224        assert!(driver_dir.join("Cargo.toml").exists());
225        assert!(driver_dir.join("src/lib.rs").exists());
226        assert!(driver_dir.join("src/main.rs").exists());
227    }
228
229    #[tokio::test]
230    async fn generate_driver_fails_if_driver_already_exists() {
231        let temp = setup_project().await;
232        let service = DriverGeneratorService::new();
233
234        service
235            .generate_driver(temp.path(), "motor1", DriverType::Motor)
236            .await
237            .unwrap();
238        let err = service
239            .generate_driver(temp.path(), "motor1", DriverType::Motor)
240            .await
241            .unwrap_err();
242
243        assert!(err.to_string().contains("already exists"));
244    }
245
246    #[tokio::test]
247    async fn generate_driver_lib_rs_uses_pascal_case_and_type_specific_content() {
248        let temp = setup_project().await;
249        let service = DriverGeneratorService::new();
250
251        service
252            .generate_driver(temp.path(), "front-imu", DriverType::Imu)
253            .await
254            .unwrap();
255
256        let lib_rs = tokio::fs::read_to_string(temp.path().join("drivers/front-imu/src/lib.rs"))
257            .await
258            .unwrap();
259        assert!(lib_rs.contains("pub struct FrontImuNode"));
260        assert!(lib_rs.contains("FrontImuConfig"));
261        assert!(lib_rs.contains("Topic::new(\"/front-imu/data\")"));
262        assert!(lib_rs.contains("mecha10_core::node_main!(FrontImuNode, \"front-imu\");"));
263        // Type-specific content — an IMU driver, not a camera/motor/sensor one
264        assert!(lib_rs.contains("pub struct ImuData"));
265    }
266
267    #[tokio::test]
268    async fn generate_driver_updates_cargo_workspace_members() {
269        let temp = setup_project().await;
270        let service = DriverGeneratorService::new();
271
272        service
273            .generate_driver(temp.path(), "wheel-motor", DriverType::Motor)
274            .await
275            .unwrap();
276
277        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
278        let doc: toml::Value = toml::from_str(&content).unwrap();
279        let members = doc["workspace"]["members"].as_array().unwrap();
280        assert!(members.iter().any(|m| m.as_str() == Some("drivers/wheel-motor")));
281    }
282
283    #[tokio::test]
284    async fn generate_driver_does_not_touch_mecha10_json() {
285        // Drivers are library/bench-test crates a node depends on, not standalone
286        // `mecha10 dev`-managed framework nodes — generating one must not require (or write) a
287        // mecha10.json, unlike `NodeGeneratorService::generate_node`.
288        let temp = setup_project().await;
289        let service = DriverGeneratorService::new();
290
291        service
292            .generate_driver(temp.path(), "bench-sensor", DriverType::Sensor)
293            .await
294            .unwrap();
295
296        assert!(!temp.path().join("mecha10.json").exists());
297    }
298}