mecha10_cli/services/init_service.rs
1//! Init service for project initialization
2//!
3//! Handles business logic for creating new Mecha10 projects including:
4//! - Directory structure creation
5//! - Component addition
6//! - Framework path detection
7
8use crate::paths;
9use anyhow::Result;
10use std::path::Path;
11
12// Embedded node configs for standalone mode (V2 structure: dev/production)
13mod embedded_configs {
14 // Motor node (V2 structure)
15 pub const MOTOR_DEV: &str = include_str!("../../templates/configs/nodes/motor/dev/config.json");
16 pub const MOTOR_PRODUCTION: &str = include_str!("../../templates/configs/nodes/motor/production/config.json");
17}
18
19/// Service for initializing new Mecha10 projects
20///
21/// # Examples
22///
23/// ```rust,ignore
24/// use mecha10_cli::services::InitService;
25///
26/// let service = InitService::new();
27///
28/// // Create project structure
29/// service.create_project_directories(&project_path).await?;
30///
31/// // Add example nodes
32/// service.add_example_node(&project_path, "speaker").await?;
33/// ```
34pub struct InitService;
35
36impl InitService {
37 /// Create a new InitService
38 pub fn new() -> Self {
39 Self
40 }
41
42 /// Create project directory structure
43 ///
44 /// Creates all the necessary directories for a new Mecha10 project:
45 /// - nodes/ - Custom node implementations
46 /// - drivers/ - Hardware driver implementations
47 /// - types/ - Shared type definitions
48 /// - behaviors/ - Behavior tree definitions
49 /// - config/ - Configuration files
50 /// - logs/ - Runtime logs
51 /// - models/ - ML/AI models (ONNX, etc.) for remote nodes
52 /// - simulation/ - Simulation environments
53 /// - simulation/models/ - Robot physical models (for Godot)
54 /// - assets/ - Static assets for simulation and visualization
55 /// - assets/images/ - Image assets for simulation textures, UI, etc.
56 pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
57 let dirs = vec![
58 paths::project::NODES_DIR,
59 paths::project::DRIVERS_DIR,
60 paths::project::TYPES_DIR,
61 paths::project::BEHAVIORS_DIR,
62 paths::project::LOGS_DIR,
63 paths::project::MODELS_DIR,
64 paths::project::SIMULATION_DIR,
65 paths::project::SIMULATION_MODELS_DIR,
66 paths::project::ASSETS_DIR,
67 paths::project::ASSETS_IMAGES_DIR,
68 // Config directories (nodes grouped by scope: @mecha10/, @local/)
69 paths::config::NODES_DIR,
70 paths::config::SIMULATION_DIR,
71 ];
72
73 for dir in dirs {
74 let dir_path = path.join(dir);
75 tokio::fs::create_dir_all(&dir_path).await?;
76
77 // Create .gitkeep to ensure empty directories are tracked
78 tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
79 }
80
81 Ok(())
82 }
83
84 /// Add an example node from the catalog to the project
85 ///
86 /// This method:
87 /// 1. Searches the component catalog for the component
88 /// 2. Generates component files from templates (if any)
89 /// 3. Updates Cargo.toml (workspace members or dependencies)
90 /// 4. Updates mecha10.json configuration
91 ///
92 /// # Arguments
93 ///
94 /// * `project_root` - Root path of the project
95 /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
96 pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
97 use crate::component_catalog::ComponentCatalog;
98
99 // Load catalog and find component
100 let catalog = ComponentCatalog::new();
101 let components = catalog.search(component_name);
102
103 if components.is_empty() {
104 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
105 }
106
107 let component = &components[0];
108
109 // Generate component files from catalog templates (if any)
110 // For monorepo nodes like speaker/listener, files vec is empty
111 for file_template in &component.files {
112 let file_path = project_root.join(&file_template.path);
113
114 // Create parent directories
115 if let Some(parent) = file_path.parent() {
116 tokio::fs::create_dir_all(parent).await?;
117 }
118
119 // Write file content
120 tokio::fs::write(&file_path, &file_template.content).await?;
121 }
122
123 // Only update workspace Cargo.toml if files were generated
124 // For monorepo nodes, skip workspace member updates since there's no wrapper directory
125 if !component.files.is_empty() {
126 self.add_workspace_member(project_root, component_name).await?;
127 }
128
129 // For monorepo nodes, add as dependency to Cargo.toml
130 let is_monorepo_node = component.files.is_empty();
131 if is_monorepo_node {
132 self.add_dependency(project_root, component).await?;
133 }
134
135 // Update mecha10.json configuration
136 self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
137 .await?;
138
139 // Copy node configs from framework package to project
140 self.copy_node_configs(project_root, component_name).await?;
141
142 Ok(())
143 }
144
145 /// Add a workspace member to Cargo.toml
146 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
147 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
148 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
149
150 let new_member = format!("nodes/{}", component_name);
151 let updated_content = if content.contains("members = []") {
152 // Replace empty array
153 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
154 } else if let Some(start) = content.find("members = [") {
155 // Find the closing bracket
156 if let Some(end) = content[start..].find(']') {
157 let insert_pos = start + end;
158 // Check if there are existing members
159 let members_section = &content[start..insert_pos];
160 if members_section.contains('\"') {
161 // Has existing members, add comma and new member
162 format!(
163 "{}\n \"{}\",{}",
164 &content[..insert_pos],
165 new_member,
166 &content[insert_pos..]
167 )
168 } else {
169 // Empty but not [], add member
170 format!(
171 "{}\n \"{}\",\n{}",
172 &content[..insert_pos],
173 new_member,
174 &content[insert_pos..]
175 )
176 }
177 } else {
178 content
179 }
180 } else {
181 content
182 };
183
184 tokio::fs::write(&cargo_toml_path, updated_content).await?;
185 Ok(())
186 }
187
188 /// Add a dependency to Cargo.toml
189 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
190 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
191 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
192
193 // Get the actual package name from the component's cargo dependencies
194 let package_name = if let Some(dep) = component.cargo_dependencies.first() {
195 dep.name.clone()
196 } else {
197 // Fallback to old behavior if no cargo dependencies specified
198 format!("mecha10-nodes-{}", component.id)
199 };
200
201 // Add dependency after mecha10-core if not already present
202 if !content.contains(&package_name) {
203 let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
204 // Find end of line
205 if let Some(newline_pos) = content[pos..].find('\n') {
206 let insert_pos = pos + newline_pos + 1;
207 format!(
208 "{}{} = \"0.1\"\n{}",
209 &content[..insert_pos],
210 package_name,
211 &content[insert_pos..]
212 )
213 } else {
214 content
215 }
216 } else {
217 content
218 };
219
220 tokio::fs::write(&cargo_toml_path, updated_content).await?;
221 }
222
223 Ok(())
224 }
225
226 /// Add node entry to mecha10.json
227 async fn add_node_to_config(
228 &self,
229 project_root: &Path,
230 component_name: &str,
231 _component: &crate::component_catalog::Component,
232 is_monorepo_node: bool,
233 ) -> Result<()> {
234 use crate::services::ConfigService;
235
236 let config_path = project_root.join(paths::PROJECT_CONFIG);
237 let mut config = ConfigService::load_from(&config_path).await?;
238
239 // Create node identifier based on source
240 let node_identifier = if is_monorepo_node {
241 // Framework node: @mecha10/node-name
242 format!("@mecha10/{}", component_name)
243 } else {
244 // Project node: @local/node-name
245 format!("@local/{}", component_name)
246 };
247
248 // Add node using the new format
249 config.nodes.add_node(&node_identifier);
250
251 // Save updated config
252 let config_json = serde_json::to_string_pretty(&config)?;
253 tokio::fs::write(&config_path, config_json).await?;
254
255 Ok(())
256 }
257
258 /// Copy behavior tree templates from framework to project
259 ///
260 /// Copies seed templates from `packages/behavior-runtime/seeds/` to
261 /// `{project}/behaviors/` for use with the behavior tree system.
262 ///
263 /// # Arguments
264 ///
265 /// * `project_root` - Root path of the project
266 pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
267 // Detect framework path to find source templates
268 // Skip silently if framework path not available (standalone CLI mode)
269 let framework_path = match self.detect_framework_path() {
270 Ok(path) => path,
271 Err(_) => return Ok(()), // Not in dev mode, skip copying
272 };
273 let source_templates_path = std::path::PathBuf::from(&framework_path)
274 .join("packages")
275 .join("behavior-runtime")
276 .join("seeds");
277
278 // Check if source templates directory exists
279 if !source_templates_path.exists() {
280 // No templates to copy - this is okay
281 return Ok(());
282 }
283
284 // Create destination directory
285 let dest_templates_path = project_root.join("behaviors");
286 tokio::fs::create_dir_all(&dest_templates_path).await?;
287
288 // Copy all .json templates
289 let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
290 while let Some(entry) = entries.next_entry().await? {
291 let path = entry.path();
292 if path.extension().and_then(|s| s.to_str()) == Some("json") {
293 if let Some(filename) = path.file_name() {
294 let dest_file = dest_templates_path.join(filename);
295 tokio::fs::copy(&path, &dest_file).await?;
296 }
297 }
298 }
299
300 Ok(())
301 }
302
303 /// Copy simulation configuration file from framework to project
304 ///
305 /// Copies `packages/simulation/configs/config.json` to
306 /// `{project}/configs/simulation/config.json`
307 ///
308 /// The config file contains both dev and production sections:
309 /// ```json
310 /// {
311 /// "dev": { ... },
312 /// "production": { ... }
313 /// }
314 /// ```
315 ///
316 /// # Arguments
317 ///
318 /// * `project_root` - Root path of the project
319 pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
320 // Detect framework path to find source config
321 // Skip silently if framework path not available (standalone CLI mode)
322 let framework_path = match self.detect_framework_path() {
323 Ok(path) => path,
324 Err(_) => return Ok(()), // Not in dev mode, skip copying
325 };
326 let source_config = std::path::PathBuf::from(&framework_path)
327 .join("packages")
328 .join("simulation")
329 .join("configs")
330 .join("config.json");
331
332 // Check if source config exists
333 if !source_config.exists() {
334 return Ok(());
335 }
336
337 // Create destination directory and copy file
338 let dest_dir = project_root.join("configs").join("simulation");
339 tokio::fs::create_dir_all(&dest_dir).await?;
340
341 let dest_file = dest_dir.join("config.json");
342 tokio::fs::copy(&source_config, &dest_file).await?;
343
344 Ok(())
345 }
346
347 /// Copy simulation asset files from framework to project
348 ///
349 /// Copies image assets from `packages/simulation/environments/basic_arena/assets/images/`
350 /// to `{project}/assets/images/`
351 ///
352 /// This includes cat images (aiko.jpg, phoebe.jpg) used in the basic arena environment
353 ///
354 /// # Arguments
355 ///
356 /// * `project_root` - Root path of the project
357 pub async fn copy_simulation_assets(&self, project_root: &Path) -> Result<()> {
358 // Detect framework path to find source assets
359 // Skip silently if framework path not available (standalone CLI mode)
360 let framework_path = match self.detect_framework_path() {
361 Ok(path) => path,
362 Err(_) => return Ok(()), // Not in dev mode, skip copying
363 };
364 let source_assets_path = std::path::PathBuf::from(&framework_path)
365 .join("packages")
366 .join("simulation")
367 .join("environments")
368 .join("basic_arena")
369 .join("assets")
370 .join("images");
371
372 // Check if source assets directory exists
373 if !source_assets_path.exists() {
374 // No assets to copy - this is okay
375 return Ok(());
376 }
377
378 // Create destination directory
379 let dest_assets_path = project_root.join("assets").join("images");
380 tokio::fs::create_dir_all(&dest_assets_path).await?;
381
382 // Copy image files
383 let images = vec!["aiko.jpg", "phoebe.jpg"];
384 for image in images {
385 let source_file = source_assets_path.join(image);
386 if source_file.exists() {
387 let dest_file = dest_assets_path.join(image);
388 tokio::fs::copy(&source_file, &dest_file).await?;
389 }
390 }
391
392 Ok(())
393 }
394
395 /// Copy node configuration file from framework package to project
396 ///
397 /// Supports two config formats:
398 /// - V2 (preferred): `packages/nodes/{node}/configs/dev/config.json` and `production/config.json`
399 /// → `{project}/configs/dev/nodes/{node}/config.json` and `production/nodes/{node}/config.json`
400 /// - Legacy: `packages/nodes/{node}/configs/config.json`
401 /// → `{project}/configs/nodes/@mecha10/{node}/config.json`
402 ///
403 /// Falls back to embedded configs for standalone CLI mode when framework path unavailable.
404 ///
405 /// # Arguments
406 ///
407 /// * `project_root` - Root path of the project
408 /// * `component_name` - Name of the node (e.g., "speaker", "motor")
409 async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
410 // Try framework path first, fall back to embedded configs
411 match self.detect_framework_path() {
412 Ok(framework_path) => {
413 self.copy_node_configs_from_framework(project_root, component_name, &framework_path)
414 .await
415 }
416 Err(_) => {
417 // Standalone mode: use embedded configs
418 self.write_embedded_node_configs(project_root, component_name).await
419 }
420 }
421 }
422
423 /// Copy node configs from framework path
424 ///
425 /// Creates environment-aware config: configs/nodes/@mecha10/{node}/config.json
426 /// with `dev` and `production` keys at the top level.
427 ///
428 /// Source can be either:
429 /// - V2 structure: `packages/nodes/{node}/configs/dev/config.json` and `production/config.json`
430 /// - Legacy: `packages/nodes/{node}/configs/config.json` (used for both environments)
431 async fn copy_node_configs_from_framework(
432 &self,
433 project_root: &Path,
434 component_name: &str,
435 framework_path: &str,
436 ) -> Result<()> {
437 let source_base = std::path::PathBuf::from(framework_path)
438 .join("packages")
439 .join("nodes")
440 .join(component_name)
441 .join("configs");
442
443 // Destination path for framework nodes
444 let dest_dir = project_root
445 .join("configs")
446 .join("nodes")
447 .join("@mecha10")
448 .join(component_name);
449
450 // Check for V2 config structure (dev/ and production/ directories)
451 let v2_dev_config = source_base.join("dev").join("config.json");
452 let v2_prod_config = source_base.join("production").join("config.json");
453
454 if v2_dev_config.exists() {
455 // Read dev config
456 let dev_content = tokio::fs::read_to_string(&v2_dev_config).await?;
457 let dev_json: serde_json::Value = serde_json::from_str(&dev_content)?;
458
459 // Read production config (fall back to dev if not present)
460 let prod_json: serde_json::Value = if v2_prod_config.exists() {
461 let prod_content = tokio::fs::read_to_string(&v2_prod_config).await?;
462 serde_json::from_str(&prod_content)?
463 } else {
464 dev_json.clone()
465 };
466
467 // Create merged config with dev/production keys
468 let merged = serde_json::json!({
469 "dev": dev_json,
470 "production": prod_json
471 });
472
473 tokio::fs::create_dir_all(&dest_dir).await?;
474 let dest_file = dest_dir.join("config.json");
475 let merged_content = serde_json::to_string_pretty(&merged)?;
476 tokio::fs::write(&dest_file, merged_content).await?;
477 return Ok(());
478 }
479
480 // Legacy single config.json format
481 let legacy_config = source_base.join("config.json");
482 if legacy_config.exists() {
483 let content = tokio::fs::read_to_string(&legacy_config).await?;
484 let config_json: serde_json::Value = serde_json::from_str(&content)?;
485
486 // Check if config already has environment-aware structure (dev/production keys)
487 let final_config = if let Some(obj) = config_json.as_object() {
488 if obj.contains_key("dev") || obj.contains_key("production") {
489 // Already environment-aware, use as-is
490 config_json
491 } else {
492 // Not environment-aware, wrap with dev/production keys
493 serde_json::json!({
494 "dev": config_json.clone(),
495 "production": config_json
496 })
497 }
498 } else {
499 // Not an object, wrap it
500 serde_json::json!({
501 "dev": config_json.clone(),
502 "production": config_json
503 })
504 };
505
506 tokio::fs::create_dir_all(&dest_dir).await?;
507 let dest_file = dest_dir.join("config.json");
508 let merged_content = serde_json::to_string_pretty(&final_config)?;
509 tokio::fs::write(&dest_file, merged_content).await?;
510 }
511
512 Ok(())
513 }
514
515 /// Write embedded node configs for standalone mode
516 ///
517 /// Creates environment-aware config: configs/nodes/@mecha10/{node}/config.json
518 /// with `dev` and `production` keys at the top level.
519 async fn write_embedded_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
520 // Get embedded configs for this node (both dev and production)
521 let configs: Option<(&str, &str)> = match component_name {
522 "motor" => Some((embedded_configs::MOTOR_DEV, embedded_configs::MOTOR_PRODUCTION)),
523 // Add more nodes here as needed
524 _ => None,
525 };
526
527 if let Some((dev_content, prod_content)) = configs {
528 // Parse both configs
529 let dev_json: serde_json::Value = serde_json::from_str(dev_content)?;
530 let prod_json: serde_json::Value = serde_json::from_str(prod_content)?;
531
532 // Create merged config with dev/production keys
533 let merged = serde_json::json!({
534 "dev": dev_json,
535 "production": prod_json
536 });
537
538 // Write to: configs/nodes/@mecha10/{node}/config.json
539 let dest_dir = project_root
540 .join("configs")
541 .join("nodes")
542 .join("@mecha10")
543 .join(component_name);
544 tokio::fs::create_dir_all(&dest_dir).await?;
545 let dest_file = dest_dir.join("config.json");
546 let merged_content = serde_json::to_string_pretty(&merged)?;
547 tokio::fs::write(&dest_file, merged_content).await?;
548 }
549
550 Ok(())
551 }
552
553 /// Copy all default node configuration files from framework to project
554 ///
555 /// Copies configs for all default nodes from `packages/nodes/{node}/configs/`
556 /// to `{project}/configs/nodes/@mecha10/{node}/config.json`
557 ///
558 /// This is called during `mecha10 init` to set up node configs.
559 /// Skips silently if framework path is not available (standalone CLI mode).
560 ///
561 /// # Arguments
562 ///
563 /// * `project_root` - Root path of the project
564 pub async fn copy_all_node_configs(&self, project_root: &Path) -> Result<()> {
565 // Default nodes that should have configs copied
566 let default_nodes = [
567 "behavior-executor",
568 "image-classifier",
569 "imu",
570 "listener",
571 "llm-command",
572 "motor",
573 "object-detector",
574 "simulation-bridge",
575 "speaker",
576 "teleop",
577 "websocket-bridge",
578 ];
579
580 for node in default_nodes {
581 self.copy_node_configs(project_root, node).await?;
582 }
583
584 Ok(())
585 }
586
587 /// Detect the framework path for development mode
588 ///
589 /// Searches for the mecha10-monorepo in the following order:
590 /// 1. MECHA10_FRAMEWORK_PATH environment variable
591 /// 2. Walking up the directory tree from current directory
592 ///
593 /// This is used when creating projects in development mode to link
594 /// to the local framework instead of published crates.
595 ///
596 /// # Returns
597 ///
598 /// The absolute path to the framework root, or an error if not found.
599 pub fn detect_framework_path(&self) -> Result<String> {
600 // First check if MECHA10_FRAMEWORK_PATH is set
601 if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
602 let path_buf = std::path::PathBuf::from(&path);
603
604 // Expand ~ to home directory if present
605 let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
606 if let Ok(home) = std::env::var("HOME") {
607 std::path::PathBuf::from(home).join(stripped)
608 } else {
609 path_buf.clone()
610 }
611 } else {
612 path_buf.clone()
613 };
614
615 // Validate that this is actually the framework directory
616 let core_path = expanded_path.join("packages").join("core");
617 if core_path.exists() {
618 return Ok(expanded_path.to_string_lossy().to_string());
619 } else {
620 return Err(anyhow::anyhow!(
621 "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
622 Expected to find packages/core directory at that location.",
623 path
624 ));
625 }
626 }
627
628 // Fall back to walking up from current directory
629 let mut current = std::env::current_dir()?;
630
631 loop {
632 // Check if this directory contains packages/core (framework marker)
633 let core_path = current.join("packages").join("core");
634 if core_path.exists() {
635 // Found the framework root
636 return Ok(current.to_string_lossy().to_string());
637 }
638
639 // Check if we reached the filesystem root
640 if !current.pop() {
641 return Err(anyhow::anyhow!(
642 "Could not detect framework path. Either:\n\
643 1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
644 2. Run from within the mecha10-monorepo directory\n\n\
645 Example: export MECHA10_FRAMEWORK_PATH=~/src/mecha-industries/mecha10"
646 ));
647 }
648 }
649 }
650}
651
652impl Default for InitService {
653 fn default() -> Self {
654 Self::new()
655 }
656}