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