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