mecha10_templates/services/
init_service.rs1use crate::paths;
8use anyhow::Result;
9use std::path::Path;
10
11pub struct InitService;
27
28impl InitService {
29 pub fn new() -> Self {
31 Self
32 }
33
34 pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
50 let dirs = vec![
51 paths::project::NODES_DIR,
52 paths::project::DRIVERS_DIR,
53 paths::project::TYPES_DIR,
54 paths::project::BEHAVIORS_DIR,
55 paths::project::LOGS_DIR,
56 paths::project::MODELS_DIR,
57 paths::project::SIMULATION_DIR,
58 paths::project::SIMULATION_MODELS_DIR,
59 paths::project::SIMULATION_ENVIRONMENTS_DIR,
60 paths::project::SIMULATION_TASKS_DIR,
61 paths::project::ASSETS_DIR,
62 paths::project::ASSETS_IMAGES_DIR,
63 paths::config::NODES_DIR,
65 paths::config::SIMULATION_DIR,
66 ];
67
68 for dir in dirs {
69 let dir_path = path.join(dir);
70 tokio::fs::create_dir_all(&dir_path).await?;
71
72 tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
74 }
75
76 Ok(())
77 }
78
79 pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
92 use crate::component_catalog::ComponentCatalog;
93
94 let catalog = ComponentCatalog::new();
96 let components = catalog.search(component_name);
97
98 if components.is_empty() {
99 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
100 }
101
102 let component = &components[0];
103
104 for file_template in &component.files {
107 let file_path = project_root.join(&file_template.path);
108
109 if let Some(parent) = file_path.parent() {
111 tokio::fs::create_dir_all(parent).await?;
112 }
113
114 tokio::fs::write(&file_path, &file_template.content).await?;
116 }
117
118 if !component.files.is_empty() {
121 self.add_workspace_member(project_root, component_name).await?;
122 }
123
124 let is_monorepo_node = component.files.is_empty();
126 if is_monorepo_node {
127 self.add_dependency(project_root, component).await?;
128 }
129
130 self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
132 .await?;
133
134 Ok(())
135 }
136
137 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
139 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
140 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
141
142 let new_member = format!("nodes/{}", component_name);
143 let updated_content = if content.contains("members = []") {
144 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
146 } else if let Some(start) = content.find("members = [") {
147 if let Some(end) = content[start..].find(']') {
149 let insert_pos = start + end;
150 let members_section = &content[start..insert_pos];
152 if members_section.contains('\"') {
153 format!(
155 "{}\n \"{}\",{}",
156 &content[..insert_pos],
157 new_member,
158 &content[insert_pos..]
159 )
160 } else {
161 format!(
163 "{}\n \"{}\",\n{}",
164 &content[..insert_pos],
165 new_member,
166 &content[insert_pos..]
167 )
168 }
169 } else {
170 content
171 }
172 } else {
173 content
174 };
175
176 tokio::fs::write(&cargo_toml_path, updated_content).await?;
177 Ok(())
178 }
179
180 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
182 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
183 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
184
185 let package_name = if let Some(dep) = component.cargo_dependencies.first() {
187 dep.name.clone()
188 } else {
189 format!("mecha10-nodes-{}", component.id)
191 };
192
193 if !content.contains(&package_name) {
195 let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
196 if let Some(newline_pos) = content[pos..].find('\n') {
198 let insert_pos = pos + newline_pos + 1;
199 format!(
200 "{}{} = \"0.1\"\n{}",
201 &content[..insert_pos],
202 package_name,
203 &content[insert_pos..]
204 )
205 } else {
206 content
207 }
208 } else {
209 content
210 };
211
212 tokio::fs::write(&cargo_toml_path, updated_content).await?;
213 }
214
215 Ok(())
216 }
217
218 async fn add_node_to_config(
220 &self,
221 project_root: &Path,
222 component_name: &str,
223 component: &crate::component_catalog::Component,
224 is_monorepo_node: bool,
225 ) -> Result<()> {
226 use crate::services::ConfigService;
227 use mecha10_cli_core::types::project::{LifecycleConfig, ModeConfig};
228 use std::collections::HashMap;
229
230 let config_path = project_root.join(paths::PROJECT_CONFIG);
231 let mut config = ConfigService::load_from(&config_path).await?;
232
233 let (node_identifier, version) = if is_monorepo_node {
235 (
237 format!("@mecha10/{}", component_name),
238 env!("CARGO_PKG_VERSION").to_string(),
239 )
240 } else {
241 (component_name.to_string(), "0.1.0".to_string())
246 };
247
248 config.nodes.add_node(&node_identifier, &version);
250
251 let relevant_modes = component.relevant_lifecycle_modes();
258 if !relevant_modes.is_empty() {
259 let lifecycle = config
260 .lifecycle
261 .get_or_insert_with(|| LifecycleConfig { modes: HashMap::new() });
262
263 for mode in relevant_modes {
264 let mode_config = lifecycle
265 .modes
266 .entry(mode.to_string())
267 .or_insert_with(|| ModeConfig { nodes: Vec::new() });
268
269 if !mode_config.nodes.contains(&node_identifier) {
270 mode_config.nodes.push(node_identifier.clone());
271 }
272 }
273 }
274
275 let config_json = serde_json::to_string_pretty(&config)?;
277 tokio::fs::write(&config_path, config_json).await?;
278
279 Ok(())
280 }
281}
282
283impl Default for InitService {
284 fn default() -> Self {
285 Self::new()
286 }
287}
288
289#[cfg(test)]
290mod add_node_to_config_tests {
291 use super::*;
292 use crate::component_catalog::{Component, ComponentType, Mecha10Config};
293 use mecha10_cli_core::services::ConfigService;
294
295 fn minimal_project_config_json() -> &'static str {
297 r#"{
298 "name": "test-project",
299 "version": "0.1.0",
300 "robot": { "id": "test-robot" }
301 }"#
302 }
303
304 fn placeholder_component(name: &str) -> Component {
308 Component {
309 id: name.to_string(),
310 name: name.to_string(),
311 description: String::new(),
312 component_type: ComponentType::Package,
313 version: "0.1.0".to_string(),
314 category: "custom".to_string(),
315 tags: Vec::new(),
316 cargo_dependencies: Vec::new(),
317 files: Vec::new(),
318 mecha10_config: Mecha10Config {
319 driver: None,
320 custom_node: None,
321 behavior: None,
322 },
323 }
324 }
325
326 fn ai_perception_component(name: &str) -> Component {
331 Component {
332 tags: vec!["vision".to_string()],
333 ..placeholder_component(name)
334 }
335 }
336
337 #[tokio::test]
338 async fn mints_project_nodes_with_plain_identifier() {
339 let temp_dir = tempfile::tempdir().expect("create temp dir");
340 let project_root = temp_dir.path();
341 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
342 .await
343 .expect("write mecha10.json");
344
345 let service = InitService::new();
346 let component = placeholder_component("my-custom");
347
348 service
349 .add_node_to_config(project_root, "my-custom", &component, false)
350 .await
351 .expect("add_node_to_config should succeed");
352
353 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
354 .await
355 .expect("reload config");
356
357 assert!(
358 config.nodes.identifiers().contains(&"my-custom"),
359 "expected plain identifier 'my-custom' in nodes, got: {:?}",
360 config.nodes.identifiers()
361 );
362 assert!(
363 !config.nodes.identifiers().contains(&"@local/my-custom"),
364 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
365 config.nodes.identifiers()
366 );
367 }
368
369 #[tokio::test]
370 async fn mints_framework_nodes_with_mecha10_scope() {
371 let temp_dir = tempfile::tempdir().expect("create temp dir");
372 let project_root = temp_dir.path();
373 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
374 .await
375 .expect("write mecha10.json");
376
377 let service = InitService::new();
378 let component = placeholder_component("speaker");
379
380 service
381 .add_node_to_config(project_root, "speaker", &component, true)
382 .await
383 .expect("add_node_to_config should succeed");
384
385 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
386 .await
387 .expect("reload config");
388
389 assert!(
390 config.nodes.identifiers().contains(&"@mecha10/speaker"),
391 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
392 config.nodes.identifiers()
393 );
394 }
395
396 #[tokio::test]
397 async fn appends_ai_perception_node_to_simulation_and_hardware_modes_when_lifecycle_absent() {
398 let temp_dir = tempfile::tempdir().expect("create temp dir");
399 let project_root = temp_dir.path();
400 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
403 .await
404 .expect("write mecha10.json");
405
406 let service = InitService::new();
407 let component = ai_perception_component("image-classifier");
408
409 service
410 .add_node_to_config(project_root, "image-classifier", &component, true)
411 .await
412 .expect("add_node_to_config should succeed");
413
414 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
415 .await
416 .expect("reload config");
417
418 let lifecycle = config
419 .lifecycle
420 .expect("lifecycle should be created when a relevant node is added");
421 for mode in ["simulation", "hardware"] {
422 let mode_nodes = &lifecycle
423 .modes
424 .get(mode)
425 .unwrap_or_else(|| panic!("expected '{mode}' mode to be created"))
426 .nodes;
427 assert!(
428 mode_nodes.contains(&"@mecha10/image-classifier".to_string()),
429 "expected @mecha10/image-classifier in lifecycle.modes.{mode}.nodes, got {mode_nodes:?}"
430 );
431 }
432 }
433
434 #[tokio::test]
435 async fn appends_ai_perception_node_to_existing_mode_lists_without_duplicating() {
436 let temp_dir = tempfile::tempdir().expect("create temp dir");
437 let project_root = temp_dir.path();
438 let config_with_lifecycle = r#"{
439 "name": "test-project",
440 "version": "0.1.0",
441 "robot": { "id": "test-robot" },
442 "lifecycle": {
443 "modes": {
444 "simulation": { "nodes": ["@mecha10/simulator"] },
445 "hardware": { "nodes": ["@mecha10/websocket-bridge", "@mecha10/image-classifier"] }
446 }
447 }
448 }"#;
449 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_lifecycle)
450 .await
451 .expect("write mecha10.json");
452
453 let service = InitService::new();
454 let component = ai_perception_component("image-classifier");
455
456 service
457 .add_node_to_config(project_root, "image-classifier", &component, true)
458 .await
459 .expect("add_node_to_config should succeed");
460
461 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
462 .await
463 .expect("reload config");
464 let lifecycle = config.lifecycle.expect("lifecycle should be preserved");
465
466 let simulation_nodes = &lifecycle.modes.get("simulation").unwrap().nodes;
467 assert_eq!(
468 simulation_nodes,
469 &vec![
470 "@mecha10/simulator".to_string(),
471 "@mecha10/image-classifier".to_string()
472 ],
473 "expected image-classifier appended to the existing simulation mode nodes"
474 );
475
476 let hardware_nodes = &lifecycle.modes.get("hardware").unwrap().nodes;
477 assert_eq!(
478 hardware_nodes,
479 &vec![
480 "@mecha10/websocket-bridge".to_string(),
481 "@mecha10/image-classifier".to_string()
482 ],
483 "expected no duplicate @mecha10/image-classifier entry in hardware mode nodes, got {hardware_nodes:?}"
484 );
485 }
486
487 #[tokio::test]
488 async fn plain_component_does_not_create_lifecycle_config() {
489 let temp_dir = tempfile::tempdir().expect("create temp dir");
490 let project_root = temp_dir.path();
491 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
492 .await
493 .expect("write mecha10.json");
494
495 let service = InitService::new();
496 let component = placeholder_component("my-custom");
497
498 service
499 .add_node_to_config(project_root, "my-custom", &component, false)
500 .await
501 .expect("add_node_to_config should succeed");
502
503 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
504 .await
505 .expect("reload config");
506
507 assert!(
508 config.lifecycle.is_none(),
509 "a component with no relevant lifecycle modes must not synthesize a lifecycle config"
510 );
511 }
512}