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 /// - assets/ - Static assets for simulation and visualization
46 /// - assets/images/ - Image assets for simulation textures, UI, etc.
47 pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
48 let dirs = vec![
49 "nodes",
50 "drivers",
51 "types",
52 "behaviors",
53 "logs",
54 "simulation",
55 "simulation/models",
56 "assets",
57 "assets/images",
58 // V2 config directories (simulation is dev-only)
59 "configs/dev/nodes",
60 "configs/dev/simulation",
61 "configs/production/nodes",
62 ];
63
64 for dir in dirs {
65 let dir_path = path.join(dir);
66 tokio::fs::create_dir_all(&dir_path).await?;
67
68 // Create .gitkeep to ensure empty directories are tracked
69 tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
70 }
71
72 Ok(())
73 }
74
75 /// Add an example node from the catalog to the project
76 ///
77 /// This method:
78 /// 1. Searches the component catalog for the component
79 /// 2. Generates component files from templates (if any)
80 /// 3. Updates Cargo.toml (workspace members or dependencies)
81 /// 4. Updates mecha10.json configuration
82 ///
83 /// # Arguments
84 ///
85 /// * `project_root` - Root path of the project
86 /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
87 pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
88 use crate::component_catalog::ComponentCatalog;
89
90 // Load catalog and find component
91 let catalog = ComponentCatalog::new();
92 let components = catalog.search(component_name);
93
94 if components.is_empty() {
95 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
96 }
97
98 let component = &components[0];
99
100 // Generate component files from catalog templates (if any)
101 // For monorepo nodes like speaker/listener, files vec is empty
102 for file_template in &component.files {
103 let file_path = project_root.join(&file_template.path);
104
105 // Create parent directories
106 if let Some(parent) = file_path.parent() {
107 tokio::fs::create_dir_all(parent).await?;
108 }
109
110 // Write file content
111 tokio::fs::write(&file_path, &file_template.content).await?;
112 }
113
114 // Only update workspace Cargo.toml if files were generated
115 // For monorepo nodes, skip workspace member updates since there's no wrapper directory
116 if !component.files.is_empty() {
117 self.add_workspace_member(project_root, component_name).await?;
118 }
119
120 // For monorepo nodes, add as dependency to Cargo.toml
121 let is_monorepo_node = component.files.is_empty();
122 if is_monorepo_node {
123 self.add_dependency(project_root, component).await?;
124 }
125
126 // Update mecha10.json configuration
127 self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
128 .await?;
129
130 // Copy node configs from framework package to project
131 self.copy_node_configs(project_root, component_name).await?;
132
133 Ok(())
134 }
135
136 /// Add a workspace member to Cargo.toml
137 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
138 let cargo_toml_path = project_root.join("Cargo.toml");
139 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
140
141 let new_member = format!("nodes/{}", component_name);
142 let updated_content = if content.contains("members = []") {
143 // Replace empty array
144 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
145 } else if let Some(start) = content.find("members = [") {
146 // Find the closing bracket
147 if let Some(end) = content[start..].find(']') {
148 let insert_pos = start + end;
149 // Check if there are existing members
150 let members_section = &content[start..insert_pos];
151 if members_section.contains('\"') {
152 // Has existing members, add comma and new member
153 format!(
154 "{}\n \"{}\",{}",
155 &content[..insert_pos],
156 new_member,
157 &content[insert_pos..]
158 )
159 } else {
160 // Empty but not [], add member
161 format!(
162 "{}\n \"{}\",\n{}",
163 &content[..insert_pos],
164 new_member,
165 &content[insert_pos..]
166 )
167 }
168 } else {
169 content
170 }
171 } else {
172 content
173 };
174
175 tokio::fs::write(&cargo_toml_path, updated_content).await?;
176 Ok(())
177 }
178
179 /// Add a dependency to Cargo.toml
180 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
181 let cargo_toml_path = project_root.join("Cargo.toml");
182 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
183
184 // Get the actual package name from the component's cargo dependencies
185 let package_name = if let Some(dep) = component.cargo_dependencies.first() {
186 dep.name.clone()
187 } else {
188 // Fallback to old behavior if no cargo dependencies specified
189 format!("mecha10-nodes-{}", component.id)
190 };
191
192 // Add dependency after mecha10-core if not already present
193 if !content.contains(&package_name) {
194 let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
195 // Find end of line
196 if let Some(newline_pos) = content[pos..].find('\n') {
197 let insert_pos = pos + newline_pos + 1;
198 format!(
199 "{}{} = \"0.1\"\n{}",
200 &content[..insert_pos],
201 package_name,
202 &content[insert_pos..]
203 )
204 } else {
205 content
206 }
207 } else {
208 content
209 };
210
211 tokio::fs::write(&cargo_toml_path, updated_content).await?;
212 }
213
214 Ok(())
215 }
216
217 /// Add node entry to mecha10.json
218 async fn add_node_to_config(
219 &self,
220 project_root: &Path,
221 component_name: &str,
222 component: &crate::component_catalog::Component,
223 is_monorepo_node: bool,
224 ) -> Result<()> {
225 use crate::services::ConfigService;
226 use crate::types::NodeEntry;
227
228 let config_path = project_root.join("mecha10.json");
229 let mut config = ConfigService::load_from(&config_path).await?;
230
231 // For monorepo nodes, use the monorepo package name instead of local path
232 let node_path = if is_monorepo_node {
233 // Use the path from component catalog (e.g., "mecha10-nodes-diagnostics")
234 component
235 .mecha10_config
236 .custom_node
237 .as_ref()
238 .map(|n| n.path.clone())
239 .unwrap_or_else(|| format!("mecha10-nodes-{}", component_name))
240 } else {
241 // Use local path for wrapper nodes
242 format!("nodes/{}", component_name)
243 };
244
245 let node_entry = NodeEntry {
246 name: component_name.to_string(),
247 path: node_path,
248 config: None,
249 description: Some(component.description.clone()),
250 run_target: None,
251 enabled: true,
252 };
253 config.nodes.custom.push(node_entry);
254
255 // Save updated config
256 let config_json = serde_json::to_string_pretty(&config)?;
257 tokio::fs::write(&config_path, config_json).await?;
258
259 Ok(())
260 }
261
262 /// Copy behavior tree templates from framework to project
263 ///
264 /// Copies seed templates from `packages/behavior-runtime/seeds/` to
265 /// `{project}/behaviors/` for use with the behavior tree system.
266 ///
267 /// # Arguments
268 ///
269 /// * `project_root` - Root path of the project
270 pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
271 // Detect framework path to find source templates
272 // Skip silently if framework path not available (standalone CLI mode)
273 let framework_path = match self.detect_framework_path() {
274 Ok(path) => path,
275 Err(_) => return Ok(()), // Not in dev mode, skip copying
276 };
277 let source_templates_path = std::path::PathBuf::from(&framework_path)
278 .join("packages")
279 .join("behavior-runtime")
280 .join("seeds");
281
282 // Check if source templates directory exists
283 if !source_templates_path.exists() {
284 // No templates to copy - this is okay
285 return Ok(());
286 }
287
288 // Create destination directory
289 let dest_templates_path = project_root.join("behaviors");
290 tokio::fs::create_dir_all(&dest_templates_path).await?;
291
292 // Copy all .json templates
293 let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
294 while let Some(entry) = entries.next_entry().await? {
295 let path = entry.path();
296 if path.extension().and_then(|s| s.to_str()) == Some("json") {
297 if let Some(filename) = path.file_name() {
298 let dest_file = dest_templates_path.join(filename);
299 tokio::fs::copy(&path, &dest_file).await?;
300 }
301 }
302 }
303
304 Ok(())
305 }
306
307 /// Copy simulation configuration files from framework to project
308 ///
309 /// Copies config from `packages/simulation/configs/config.json` to
310 /// `{project}/configs/dev/simulation/config.json`.
311 /// Simulation is dev-only, so no production config is needed.
312 ///
313 /// # Arguments
314 ///
315 /// * `project_root` - Root path of the project
316 pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
317 // Detect framework path to find source configs
318 // Skip silently if framework path not available (standalone CLI mode)
319 let framework_path = match self.detect_framework_path() {
320 Ok(path) => path,
321 Err(_) => return Ok(()), // Not in dev mode, skip copying
322 };
323 let source_configs_path = std::path::PathBuf::from(&framework_path)
324 .join("packages")
325 .join("simulation")
326 .join("configs");
327
328 // Check if source configs directory exists
329 if !source_configs_path.exists() {
330 // No configs to copy - this is okay, might be using old version
331 return Ok(());
332 }
333
334 // Copy simulation config (simulation is dev-only)
335 let src = source_configs_path.join("config.json");
336 if src.exists() {
337 let dest = project_root.join("configs/dev/simulation/config.json");
338 if let Some(parent) = dest.parent() {
339 tokio::fs::create_dir_all(parent).await?;
340 }
341 tokio::fs::copy(&src, &dest).await?;
342 }
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 files from framework package to project
396 ///
397 /// Copies configs from `packages/nodes/{node}/configs/` to
398 /// `{project}/configs/{env}/nodes/{node}/config.json`
399 ///
400 /// # Arguments
401 ///
402 /// * `project_root` - Root path of the project
403 /// * `component_name` - Name of the node (e.g., "speaker", "listener")
404 async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
405 // Detect framework path to find source configs
406 // Skip silently if framework path not available (standalone CLI mode)
407 let framework_path = match self.detect_framework_path() {
408 Ok(path) => path,
409 Err(_) => return Ok(()), // Not in dev mode, skip copying
410 };
411 let source_configs_path = std::path::PathBuf::from(&framework_path)
412 .join("packages")
413 .join("nodes")
414 .join(component_name)
415 .join("configs");
416
417 // Check if source configs directory exists
418 if !source_configs_path.exists() {
419 // No configs to copy - this is okay, node might not have configs yet
420 return Ok(());
421 }
422
423 // Copy configs for each environment
424 for env in &["dev", "production", "common"] {
425 let source_env_path = source_configs_path.join(env).join("config.json");
426
427 if source_env_path.exists() {
428 let dest_env_path = project_root
429 .join("configs")
430 .join(env)
431 .join("nodes")
432 .join(component_name);
433
434 // Create destination directory
435 tokio::fs::create_dir_all(&dest_env_path).await?;
436
437 // Copy config file
438 let dest_file = dest_env_path.join("config.json");
439 tokio::fs::copy(&source_env_path, &dest_file).await?;
440 }
441 }
442
443 Ok(())
444 }
445
446 /// Copy all default node configuration files from framework to project
447 ///
448 /// Copies configs for all default nodes from `packages/nodes/{node}/configs/`
449 /// to `{project}/configs/{env}/nodes/{node}/config.json`
450 ///
451 /// This is called during `mecha10 init` to set up node configs.
452 /// Skips silently if framework path is not available (standalone CLI mode).
453 ///
454 /// # Arguments
455 ///
456 /// * `project_root` - Root path of the project
457 pub async fn copy_all_node_configs(&self, project_root: &Path) -> Result<()> {
458 // Default nodes that should have configs copied
459 let default_nodes = [
460 "behavior-executor",
461 "image-classifier",
462 "llm-command",
463 "object-detector",
464 "simulation-bridge",
465 "websocket-bridge",
466 ];
467
468 for node in default_nodes {
469 self.copy_node_configs(project_root, node).await?;
470 }
471
472 Ok(())
473 }
474
475 /// Detect the framework path for development mode
476 ///
477 /// Searches for the mecha10-monorepo in the following order:
478 /// 1. MECHA10_FRAMEWORK_PATH environment variable
479 /// 2. Walking up the directory tree from current directory
480 ///
481 /// This is used when creating projects in development mode to link
482 /// to the local framework instead of published crates.
483 ///
484 /// # Returns
485 ///
486 /// The absolute path to the framework root, or an error if not found.
487 pub fn detect_framework_path(&self) -> Result<String> {
488 // First check if MECHA10_FRAMEWORK_PATH is set
489 if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
490 let path_buf = std::path::PathBuf::from(&path);
491
492 // Expand ~ to home directory if present
493 let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
494 if let Ok(home) = std::env::var("HOME") {
495 std::path::PathBuf::from(home).join(stripped)
496 } else {
497 path_buf.clone()
498 }
499 } else {
500 path_buf.clone()
501 };
502
503 // Validate that this is actually the framework directory
504 let core_path = expanded_path.join("packages").join("core");
505 if core_path.exists() {
506 return Ok(expanded_path.to_string_lossy().to_string());
507 } else {
508 return Err(anyhow::anyhow!(
509 "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
510 Expected to find packages/core directory at that location.",
511 path
512 ));
513 }
514 }
515
516 // Fall back to walking up from current directory
517 let mut current = std::env::current_dir()?;
518
519 loop {
520 // Check if this directory contains packages/core (framework marker)
521 let core_path = current.join("packages").join("core");
522 if core_path.exists() {
523 // Found the framework root
524 return Ok(current.to_string_lossy().to_string());
525 }
526
527 // Check if we reached the filesystem root
528 if !current.pop() {
529 return Err(anyhow::anyhow!(
530 "Could not detect framework path. Either:\n\
531 1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
532 2. Run from within the mecha10-monorepo directory\n\n\
533 Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
534 ));
535 }
536 }
537 }
538}
539
540impl Default for InitService {
541 fn default() -> Self {
542 Self::new()
543 }
544}