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 anyhow::Result;
9use std::path::Path;
10
11/// Service for initializing new Mecha10 projects
12///
13/// # Examples
14///
15/// ```rust,ignore
16/// use mecha10_cli::services::InitService;
17///
18/// let service = InitService::new();
19///
20/// // Create project structure
21/// service.create_project_directories(&project_path).await?;
22///
23/// // Add example nodes
24/// service.add_example_node(&project_path, "speaker").await?;
25/// ```
26pub struct InitService;
27
28impl InitService {
29 /// Create a new InitService
30 pub fn new() -> Self {
31 Self
32 }
33
34 /// Create project directory structure
35 ///
36 /// Creates all the necessary directories for a new Mecha10 project:
37 /// - nodes/ - Custom node implementations
38 /// - drivers/ - Hardware driver implementations
39 /// - types/ - Shared type definitions
40 /// - behaviors/ - Behavior tree definitions
41 /// - config/ - Configuration files
42 /// - logs/ - Runtime logs
43 /// - simulation/ - Simulation environments
44 /// - simulation/models/ - Robot physical models (for Godot)
45 /// - embedded/models/ - ML model configurations and weights
46 /// - assets/ - Static assets for simulation and visualization
47 /// - assets/images/ - Image assets for simulation textures, UI, etc.
48 pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
49 let dirs = vec![
50 "nodes",
51 "drivers",
52 "types",
53 "behaviors",
54 "logs",
55 "simulation",
56 "simulation/models",
57 "embedded/models",
58 "assets",
59 "assets/images",
60 // V2 config directories
61 "configs/dev/nodes",
62 "configs/dev/simulation",
63 "configs/production/nodes",
64 "configs/production/simulation",
65 ];
66
67 for dir in dirs {
68 let dir_path = path.join(dir);
69 tokio::fs::create_dir_all(&dir_path).await?;
70
71 // Create .gitkeep to ensure empty directories are tracked
72 tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
73 }
74
75 Ok(())
76 }
77
78 /// Add an example node from the catalog to the project
79 ///
80 /// This method:
81 /// 1. Searches the component catalog for the component
82 /// 2. Generates component files from templates (if any)
83 /// 3. Updates Cargo.toml (workspace members or dependencies)
84 /// 4. Updates mecha10.json configuration
85 ///
86 /// # Arguments
87 ///
88 /// * `project_root` - Root path of the project
89 /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
90 pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
91 use crate::component_catalog::ComponentCatalog;
92
93 // Load catalog and find component
94 let catalog = ComponentCatalog::new();
95 let components = catalog.search(component_name);
96
97 if components.is_empty() {
98 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
99 }
100
101 let component = &components[0];
102
103 // Generate component files from catalog templates (if any)
104 // For monorepo nodes like speaker/listener, files vec is empty
105 for file_template in &component.files {
106 let file_path = project_root.join(&file_template.path);
107
108 // Create parent directories
109 if let Some(parent) = file_path.parent() {
110 tokio::fs::create_dir_all(parent).await?;
111 }
112
113 // Write file content
114 tokio::fs::write(&file_path, &file_template.content).await?;
115 }
116
117 // Only update workspace Cargo.toml if files were generated
118 // For monorepo nodes, skip workspace member updates since there's no wrapper directory
119 if !component.files.is_empty() {
120 self.add_workspace_member(project_root, component_name).await?;
121 }
122
123 // For monorepo nodes, add as dependency to Cargo.toml
124 let is_monorepo_node = component.files.is_empty();
125 if is_monorepo_node {
126 self.add_dependency(project_root, component).await?;
127 }
128
129 // Update mecha10.json configuration
130 self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
131 .await?;
132
133 // Copy node configs from framework package to project
134 self.copy_node_configs(project_root, component_name).await?;
135
136 Ok(())
137 }
138
139 /// Add a workspace member to Cargo.toml
140 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
141 let cargo_toml_path = project_root.join("Cargo.toml");
142 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
143
144 let new_member = format!("nodes/{}", component_name);
145 let updated_content = if content.contains("members = []") {
146 // Replace empty array
147 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
148 } else if let Some(start) = content.find("members = [") {
149 // Find the closing bracket
150 if let Some(end) = content[start..].find(']') {
151 let insert_pos = start + end;
152 // Check if there are existing members
153 let members_section = &content[start..insert_pos];
154 if members_section.contains('\"') {
155 // Has existing members, add comma and new member
156 format!(
157 "{}\n \"{}\",{}",
158 &content[..insert_pos],
159 new_member,
160 &content[insert_pos..]
161 )
162 } else {
163 // Empty but not [], add member
164 format!(
165 "{}\n \"{}\",\n{}",
166 &content[..insert_pos],
167 new_member,
168 &content[insert_pos..]
169 )
170 }
171 } else {
172 content
173 }
174 } else {
175 content
176 };
177
178 tokio::fs::write(&cargo_toml_path, updated_content).await?;
179 Ok(())
180 }
181
182 /// Add a dependency to Cargo.toml
183 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
184 let cargo_toml_path = project_root.join("Cargo.toml");
185 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
186
187 // Get the actual package name from the component's cargo dependencies
188 let package_name = if let Some(dep) = component.cargo_dependencies.first() {
189 dep.name.clone()
190 } else {
191 // Fallback to old behavior if no cargo dependencies specified
192 format!("mecha10-nodes-{}", component.id)
193 };
194
195 // Add dependency after mecha10-core if not already present
196 if !content.contains(&package_name) {
197 let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
198 // Find end of line
199 if let Some(newline_pos) = content[pos..].find('\n') {
200 let insert_pos = pos + newline_pos + 1;
201 format!(
202 "{}{} = \"0.1\"\n{}",
203 &content[..insert_pos],
204 package_name,
205 &content[insert_pos..]
206 )
207 } else {
208 content
209 }
210 } else {
211 content
212 };
213
214 tokio::fs::write(&cargo_toml_path, updated_content).await?;
215 }
216
217 Ok(())
218 }
219
220 /// Add node entry to mecha10.json
221 async fn add_node_to_config(
222 &self,
223 project_root: &Path,
224 component_name: &str,
225 component: &crate::component_catalog::Component,
226 is_monorepo_node: bool,
227 ) -> Result<()> {
228 use crate::services::ConfigService;
229 use crate::types::NodeEntry;
230
231 let config_path = project_root.join("mecha10.json");
232 let mut config = ConfigService::load_from(&config_path).await?;
233
234 // For monorepo nodes, use the monorepo package name instead of local path
235 let node_path = if is_monorepo_node {
236 // Use the path from component catalog (e.g., "mecha10-nodes-diagnostics")
237 component
238 .mecha10_config
239 .custom_node
240 .as_ref()
241 .map(|n| n.path.clone())
242 .unwrap_or_else(|| format!("mecha10-nodes-{}", component_name))
243 } else {
244 // Use local path for wrapper nodes
245 format!("nodes/{}", component_name)
246 };
247
248 let node_entry = NodeEntry {
249 name: component_name.to_string(),
250 path: node_path,
251 config: None,
252 description: Some(component.description.clone()),
253 run_target: None,
254 enabled: true,
255 };
256 config.nodes.custom.push(node_entry);
257
258 // Save updated config
259 let config_json = serde_json::to_string_pretty(&config)?;
260 tokio::fs::write(&config_path, config_json).await?;
261
262 Ok(())
263 }
264
265 /// Copy behavior tree templates from framework to project
266 ///
267 /// Copies seed templates from `packages/behavior-runtime/seeds/` to
268 /// `{project}/behaviors/` for use with the behavior tree system.
269 ///
270 /// # Arguments
271 ///
272 /// * `project_root` - Root path of the project
273 pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
274 // Detect framework path to find source templates
275 // Skip silently if framework path not available (standalone CLI mode)
276 let framework_path = match self.detect_framework_path() {
277 Ok(path) => path,
278 Err(_) => return Ok(()), // Not in dev mode, skip copying
279 };
280 let source_templates_path = std::path::PathBuf::from(&framework_path)
281 .join("packages")
282 .join("behavior-runtime")
283 .join("seeds");
284
285 // Check if source templates directory exists
286 if !source_templates_path.exists() {
287 // No templates to copy - this is okay
288 return Ok(());
289 }
290
291 // Create destination directory
292 let dest_templates_path = project_root.join("behaviors");
293 tokio::fs::create_dir_all(&dest_templates_path).await?;
294
295 // Copy all .json templates
296 let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
297 while let Some(entry) = entries.next_entry().await? {
298 let path = entry.path();
299 if path.extension().and_then(|s| s.to_str()) == Some("json") {
300 if let Some(filename) = path.file_name() {
301 let dest_file = dest_templates_path.join(filename);
302 tokio::fs::copy(&path, &dest_file).await?;
303 }
304 }
305 }
306
307 Ok(())
308 }
309
310 /// Copy simulation configuration files from framework to project
311 ///
312 /// Copies configs from `packages/simulation/configs/` to
313 /// `{project}/configs/{env}/simulation/config.json`
314 ///
315 /// # Arguments
316 ///
317 /// * `project_root` - Root path of the project
318 pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
319 // Detect framework path to find source configs
320 // Skip silently if framework path not available (standalone CLI mode)
321 let framework_path = match self.detect_framework_path() {
322 Ok(path) => path,
323 Err(_) => return Ok(()), // Not in dev mode, skip copying
324 };
325 let source_configs_path = std::path::PathBuf::from(&framework_path)
326 .join("packages")
327 .join("simulation")
328 .join("configs");
329
330 // Check if source configs directory exists
331 if !source_configs_path.exists() {
332 // No configs to copy - this is okay, might be using old version
333 return Ok(());
334 }
335
336 // Copy dev config
337 let dev_src = source_configs_path.join("dev/config.json");
338 if dev_src.exists() {
339 let dev_dest = project_root.join("configs/dev/simulation/config.json");
340 if let Some(parent) = dev_dest.parent() {
341 tokio::fs::create_dir_all(parent).await?;
342 }
343 tokio::fs::copy(&dev_src, &dev_dest).await?;
344 }
345
346 // Copy production config
347 let prod_src = source_configs_path.join("production/config.json");
348 if prod_src.exists() {
349 let prod_dest = project_root.join("configs/production/simulation/config.json");
350 if let Some(parent) = prod_dest.parent() {
351 tokio::fs::create_dir_all(parent).await?;
352 }
353 tokio::fs::copy(&prod_src, &prod_dest).await?;
354 }
355
356 Ok(())
357 }
358
359 /// Copy simulation Docker files from framework to project
360 ///
361 /// Copies Docker files from `packages/simulation/docker/` to
362 /// `{project}/simulation/`
363 ///
364 /// # Arguments
365 ///
366 /// * `project_root` - Root path of the project
367 pub async fn copy_simulation_docker_files(&self, project_root: &Path) -> Result<()> {
368 // Detect framework path to find source files
369 // Skip silently if framework path not available (standalone CLI mode)
370 let framework_path = match self.detect_framework_path() {
371 Ok(path) => path,
372 Err(_) => return Ok(()), // Not in dev mode, skip copying
373 };
374 let source_docker_path = std::path::PathBuf::from(&framework_path)
375 .join("packages")
376 .join("simulation")
377 .join("docker");
378
379 // Check if source docker directory exists
380 if !source_docker_path.exists() {
381 // No docker files to copy - this is okay, might be using old version
382 return Ok(());
383 }
384
385 let dest_dir = project_root.join("simulation");
386 tokio::fs::create_dir_all(&dest_dir).await?;
387
388 // Copy Dockerfile
389 let dockerfile_src = source_docker_path.join("Dockerfile");
390 if dockerfile_src.exists() {
391 tokio::fs::copy(&dockerfile_src, dest_dir.join("Dockerfile")).await?;
392 }
393
394 // Copy entrypoint.sh
395 let entrypoint_src = source_docker_path.join("entrypoint.sh");
396 if entrypoint_src.exists() {
397 tokio::fs::copy(&entrypoint_src, dest_dir.join("entrypoint.sh")).await?;
398
399 // Make entrypoint.sh executable on Unix systems
400 #[cfg(unix)]
401 {
402 use std::os::unix::fs::PermissionsExt;
403 let mut perms = tokio::fs::metadata(dest_dir.join("entrypoint.sh")).await?.permissions();
404 perms.set_mode(0o755);
405 tokio::fs::set_permissions(dest_dir.join("entrypoint.sh"), perms).await?;
406 }
407 }
408
409 // Copy .dockerignore
410 let dockerignore_src = source_docker_path.join(".dockerignore");
411 if dockerignore_src.exists() {
412 tokio::fs::copy(&dockerignore_src, dest_dir.join(".dockerignore")).await?;
413 }
414
415 Ok(())
416 }
417
418 /// Copy simulation asset files from framework to project
419 ///
420 /// Copies image assets from `packages/simulation/environments/basic_arena/assets/images/`
421 /// to `{project}/assets/images/`
422 ///
423 /// This includes cat images (aiko.jpg, phoebe.jpg) used in the basic arena environment
424 ///
425 /// # Arguments
426 ///
427 /// * `project_root` - Root path of the project
428 pub async fn copy_simulation_assets(&self, project_root: &Path) -> Result<()> {
429 // Detect framework path to find source assets
430 // Skip silently if framework path not available (standalone CLI mode)
431 let framework_path = match self.detect_framework_path() {
432 Ok(path) => path,
433 Err(_) => return Ok(()), // Not in dev mode, skip copying
434 };
435 let source_assets_path = std::path::PathBuf::from(&framework_path)
436 .join("packages")
437 .join("simulation")
438 .join("environments")
439 .join("basic_arena")
440 .join("assets")
441 .join("images");
442
443 // Check if source assets directory exists
444 if !source_assets_path.exists() {
445 // No assets to copy - this is okay
446 return Ok(());
447 }
448
449 // Create destination directory
450 let dest_assets_path = project_root.join("assets").join("images");
451 tokio::fs::create_dir_all(&dest_assets_path).await?;
452
453 // Copy image files
454 let images = vec!["aiko.jpg", "phoebe.jpg"];
455 for image in images {
456 let source_file = source_assets_path.join(image);
457 if source_file.exists() {
458 let dest_file = dest_assets_path.join(image);
459 tokio::fs::copy(&source_file, &dest_file).await?;
460 }
461 }
462
463 Ok(())
464 }
465
466 /// Copy node configuration files from framework package to project
467 ///
468 /// Copies configs from `packages/nodes/{node}/configs/` to
469 /// `{project}/configs/{env}/nodes/{node}/config.json`
470 ///
471 /// # Arguments
472 ///
473 /// * `project_root` - Root path of the project
474 /// * `component_name` - Name of the node (e.g., "speaker", "listener")
475 async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
476 // Detect framework path to find source configs
477 // Skip silently if framework path not available (standalone CLI mode)
478 let framework_path = match self.detect_framework_path() {
479 Ok(path) => path,
480 Err(_) => return Ok(()), // Not in dev mode, skip copying
481 };
482 let source_configs_path = std::path::PathBuf::from(&framework_path)
483 .join("packages")
484 .join("nodes")
485 .join(component_name)
486 .join("configs");
487
488 // Check if source configs directory exists
489 if !source_configs_path.exists() {
490 // No configs to copy - this is okay, node might not have configs yet
491 return Ok(());
492 }
493
494 // Copy configs for each environment
495 for env in &["dev", "production", "common"] {
496 let source_env_path = source_configs_path.join(env).join("config.json");
497
498 if source_env_path.exists() {
499 let dest_env_path = project_root
500 .join("configs")
501 .join(env)
502 .join("nodes")
503 .join(component_name);
504
505 // Create destination directory
506 tokio::fs::create_dir_all(&dest_env_path).await?;
507
508 // Copy config file
509 let dest_file = dest_env_path.join("config.json");
510 tokio::fs::copy(&source_env_path, &dest_file).await?;
511 }
512 }
513
514 Ok(())
515 }
516
517 /// Detect the framework path for development mode
518 ///
519 /// Searches for the mecha10-monorepo in the following order:
520 /// 1. MECHA10_FRAMEWORK_PATH environment variable
521 /// 2. Walking up the directory tree from current directory
522 ///
523 /// This is used when creating projects in development mode to link
524 /// to the local framework instead of published crates.
525 ///
526 /// # Returns
527 ///
528 /// The absolute path to the framework root, or an error if not found.
529 pub fn detect_framework_path(&self) -> Result<String> {
530 // First check if MECHA10_FRAMEWORK_PATH is set
531 if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
532 let path_buf = std::path::PathBuf::from(&path);
533
534 // Expand ~ to home directory if present
535 let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
536 if let Ok(home) = std::env::var("HOME") {
537 std::path::PathBuf::from(home).join(stripped)
538 } else {
539 path_buf.clone()
540 }
541 } else {
542 path_buf.clone()
543 };
544
545 // Validate that this is actually the framework directory
546 let core_path = expanded_path.join("packages").join("core");
547 if core_path.exists() {
548 return Ok(expanded_path.to_string_lossy().to_string());
549 } else {
550 return Err(anyhow::anyhow!(
551 "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
552 Expected to find packages/core directory at that location.",
553 path
554 ));
555 }
556 }
557
558 // Fall back to walking up from current directory
559 let mut current = std::env::current_dir()?;
560
561 loop {
562 // Check if this directory contains packages/core (framework marker)
563 let core_path = current.join("packages").join("core");
564 if core_path.exists() {
565 // Found the framework root
566 return Ok(current.to_string_lossy().to_string());
567 }
568
569 // Check if we reached the filesystem root
570 if !current.pop() {
571 return Err(anyhow::anyhow!(
572 "Could not detect framework path. Either:\n\
573 1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
574 2. Run from within the mecha10-monorepo directory\n\n\
575 Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
576 ));
577 }
578 }
579 }
580}
581
582impl Default for InitService {
583 fn default() -> Self {
584 Self::new()
585 }
586}