mecha10_templates/services/
driver_generator.rs1use 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");
20const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/drivers/main.rs.template");
22const CAMERA_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/camera.rs.template");
24const MOTOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/motor.rs.template");
26const SENSOR_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/sensor.rs.template");
28const IMU_LIB_RS_TEMPLATE: &str = include_str!("../../templates/drivers/imu.rs.template");
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DriverType {
38 Camera,
39 Motor,
40 Sensor,
41 Imu,
42}
43
44impl DriverType {
45 pub const ALL: [DriverType; 4] = [
48 DriverType::Camera,
49 DriverType::Motor,
50 DriverType::Sensor,
51 DriverType::Imu,
52 ];
53
54 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 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 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
91pub struct DriverGeneratorService;
93
94impl DriverGeneratorService {
95 pub fn new() -> Self {
97 Self
98 }
99
100 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 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 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 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 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}