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    // duplicate: false positive — matches `DevEnvironment::as_str` (packages/cli) and
55    // `StartTarget::as_str` (packages/launcher-service, which carries the mirroring half of
56    // this comment) on AST shape only: all are fieldless-variant-to-string `match` bodies.
57    // There's no shared string table between a driver kind, a dev environment, and a launcher
58    // start target to extract.
59    /// The `--type` value for this driver kind (e.g. `"camera"`).
60    pub fn as_str(self) -> &'static str {
61        match self {
62            DriverType::Camera => "camera",
63            DriverType::Motor => "motor",
64            DriverType::Sensor => "sensor",
65            DriverType::Imu => "imu",
66        }
67    }
68
69    /// Parse a `--type` value, case-insensitively. Returns an error listing the available
70    /// types (mirrors the "Available driver types: camera, motor, sensor, imu" hint from the
71    /// dead `TemplateService`) when `value` doesn't match one of them.
72    pub fn parse(value: &str) -> Result<Self> {
73        DriverType::ALL
74            .into_iter()
75            .find(|kind| kind.as_str().eq_ignore_ascii_case(value))
76            .ok_or_else(|| {
77                anyhow::anyhow!(
78                    "Unknown driver type '{}'. Available driver types: {}",
79                    value,
80                    DriverType::ALL.map(|kind| kind.as_str()).join(", ")
81                )
82            })
83    }
84
85    /// The embedded `src/lib.rs` template for this driver kind.
86    fn lib_rs_template(self) -> &'static str {
87        match self {
88            DriverType::Camera => CAMERA_LIB_RS_TEMPLATE,
89            DriverType::Motor => MOTOR_LIB_RS_TEMPLATE,
90            DriverType::Sensor => SENSOR_LIB_RS_TEMPLATE,
91            DriverType::Imu => IMU_LIB_RS_TEMPLATE,
92        }
93    }
94}
95
96/// Service for generating custom driver crates
97pub struct DriverGeneratorService;
98
99impl DriverGeneratorService {
100    /// Create a new DriverGeneratorService
101    pub fn new() -> Self {
102        Self
103    }
104
105    /// Generate a new driver crate in the project
106    ///
107    /// Creates the following structure:
108    /// ```text
109    /// drivers/<name>/
110    /// ├── Cargo.toml
111    /// └── src/
112    ///     ├── lib.rs   (type-specific boilerplate — camera/motor/sensor/imu)
113    ///     └── main.rs
114    /// ```
115    pub async fn generate_driver(&self, project_root: &Path, name: &str, driver_type: DriverType) -> Result<()> {
116        self.validate_driver_name(name)?;
117
118        let driver_dir = project_root.join(paths::project::DRIVERS_DIR).join(name);
119        if driver_dir.exists() {
120            anyhow::bail!("Driver '{}' already exists at {}", name, driver_dir.display());
121        }
122
123        let src_dir = driver_dir.join(paths::project::SRC_DIR);
124        tokio::fs::create_dir_all(&src_dir)
125            .await
126            .context("Failed to create driver directory")?;
127
128        let crate_name = self.to_snake_case(name);
129        let pascal_name = to_pascal_case(&crate_name);
130
131        let cargo_toml = render(CARGO_TOML_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
132        tokio::fs::write(driver_dir.join(paths::rust::CARGO_TOML), cargo_toml).await?;
133
134        let main_rs = render(MAIN_RS_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
135        tokio::fs::write(src_dir.join("main.rs"), main_rs).await?;
136
137        let lib_rs = render(
138            driver_type.lib_rs_template(),
139            &[("name", name), ("PascalName", &pascal_name)],
140        );
141        tokio::fs::write(src_dir.join("lib.rs"), lib_rs).await?;
142
143        add_workspace_member(project_root, &format!("{}/{}", paths::project::DRIVERS_DIR, name)).await?;
144
145        Ok(())
146    }
147
148    /// Validate driver name — same rules as node names (letters/numbers/hyphens/underscores,
149    /// must start with a letter): both become a Cargo package/crate name and a Rust struct name.
150    /// Shared with `BehaviorGeneratorService`/`NodeGeneratorService` via
151    /// `crate::services::generator_name::validate_generator_name` (was an exact triplicate,
152    /// LAB-2749).
153    fn validate_driver_name(&self, name: &str) -> Result<()> {
154        crate::services::generator_name::validate_generator_name("Driver", name)
155    }
156
157    /// Convert name to snake_case for the Rust crate name (kebab-case-aware, like
158    /// `NodeGeneratorService::to_snake_case`).
159    fn to_snake_case(&self, name: &str) -> String {
160        name.replace('-', "_")
161    }
162}
163
164impl Default for DriverGeneratorService {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use tempfile::TempDir;
174
175    async fn setup_project() -> TempDir {
176        let temp = TempDir::new().unwrap();
177        tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
178            .await
179            .unwrap();
180        temp
181    }
182
183    #[test]
184    fn driver_type_parse_accepts_all_known_types() {
185        assert_eq!(DriverType::parse("camera").unwrap(), DriverType::Camera);
186        assert_eq!(DriverType::parse("MOTOR").unwrap(), DriverType::Motor);
187        assert_eq!(DriverType::parse("Sensor").unwrap(), DriverType::Sensor);
188        assert_eq!(DriverType::parse("imu").unwrap(), DriverType::Imu);
189    }
190
191    #[test]
192    fn driver_type_parse_rejects_unknown_type_and_lists_available_ones() {
193        let err = DriverType::parse("gripper").unwrap_err();
194        assert!(err
195            .to_string()
196            .contains("Available driver types: camera, motor, sensor, imu"));
197    }
198
199    #[test]
200    fn validate_driver_name_rejects_leading_digit() {
201        let service = DriverGeneratorService::new();
202        let err = service.validate_driver_name("1camera").unwrap_err();
203        assert!(err.to_string().contains("must start with a letter"));
204    }
205
206    #[tokio::test]
207    async fn generate_driver_creates_expected_file_structure() {
208        let temp = setup_project().await;
209        let service = DriverGeneratorService::new();
210
211        service
212            .generate_driver(temp.path(), "my-camera", DriverType::Camera)
213            .await
214            .unwrap();
215
216        let driver_dir = temp.path().join("drivers/my-camera");
217        assert!(driver_dir.join("Cargo.toml").exists());
218        assert!(driver_dir.join("src/lib.rs").exists());
219        assert!(driver_dir.join("src/main.rs").exists());
220    }
221
222    #[tokio::test]
223    async fn generate_driver_fails_if_driver_already_exists() {
224        let temp = setup_project().await;
225        let service = DriverGeneratorService::new();
226
227        service
228            .generate_driver(temp.path(), "motor1", DriverType::Motor)
229            .await
230            .unwrap();
231        let err = service
232            .generate_driver(temp.path(), "motor1", DriverType::Motor)
233            .await
234            .unwrap_err();
235
236        assert!(err.to_string().contains("already exists"));
237    }
238
239    #[tokio::test]
240    async fn generate_driver_lib_rs_uses_pascal_case_and_type_specific_content() {
241        let temp = setup_project().await;
242        let service = DriverGeneratorService::new();
243
244        service
245            .generate_driver(temp.path(), "front-imu", DriverType::Imu)
246            .await
247            .unwrap();
248
249        let lib_rs = tokio::fs::read_to_string(temp.path().join("drivers/front-imu/src/lib.rs"))
250            .await
251            .unwrap();
252        assert!(lib_rs.contains("pub struct FrontImuNode"));
253        assert!(lib_rs.contains("FrontImuConfig"));
254        assert!(lib_rs.contains("Topic::new(\"/front-imu/data\")"));
255        assert!(lib_rs.contains("mecha10_core::node_main!(FrontImuNode, \"front-imu\");"));
256        assert!(lib_rs.contains("pub struct ImuData"));
257    }
258
259    #[tokio::test]
260    async fn generate_driver_updates_cargo_workspace_members() {
261        let temp = setup_project().await;
262        let service = DriverGeneratorService::new();
263
264        service
265            .generate_driver(temp.path(), "wheel-motor", DriverType::Motor)
266            .await
267            .unwrap();
268
269        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
270        let doc: toml::Value = toml::from_str(&content).unwrap();
271        let members = doc["workspace"]["members"].as_array().unwrap();
272        assert!(members.iter().any(|m| m.as_str() == Some("drivers/wheel-motor")));
273    }
274
275    #[tokio::test]
276    async fn generate_driver_does_not_touch_mecha10_json() {
277        let temp = setup_project().await;
278        let service = DriverGeneratorService::new();
279
280        service
281            .generate_driver(temp.path(), "bench-sensor", DriverType::Sensor)
282            .await
283            .unwrap();
284
285        assert!(!temp.path().join("mecha10.json").exists());
286    }
287}