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/// Service for initializing new Mecha10 projects
13///
14/// # Examples
15///
16/// ```rust,ignore
17/// use mecha10_cli::services::InitService;
18///
19/// let service = InitService::new();
20///
21/// // Create project structure
22/// service.create_project_directories(&project_path).await?;
23///
24/// // Add example nodes
25/// service.add_example_node(&project_path, "speaker").await?;
26/// ```
27pub struct InitService;
28
29impl InitService {
30 /// Create a new InitService
31 pub fn new() -> Self {
32 Self
33 }
34
35 /// Create project directory structure
36 ///
37 /// Creates all the necessary directories for a new Mecha10 project:
38 /// - nodes/ - Custom node implementations
39 /// - drivers/ - Hardware driver implementations
40 /// - types/ - Shared type definitions
41 /// - behaviors/ - Behavior tree definitions
42 /// - config/ - Configuration files
43 /// - logs/ - Runtime logs
44 /// - simulation/ - Simulation environments
45 /// - simulation/models/ - Robot physical models (for Godot)
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 paths::project::NODES_DIR,
51 paths::project::DRIVERS_DIR,
52 paths::project::TYPES_DIR,
53 paths::project::BEHAVIORS_DIR,
54 paths::project::LOGS_DIR,
55 paths::project::SIMULATION_DIR,
56 paths::project::SIMULATION_MODELS_DIR,
57 paths::project::ASSETS_DIR,
58 paths::project::ASSETS_IMAGES_DIR,
59 // Config directories (nodes grouped by scope: @mecha10/, @local/)
60 paths::config::NODES_DIR,
61 paths::config::SIMULATION_DIR,
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(paths::rust::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(paths::rust::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
227 let config_path = project_root.join(paths::PROJECT_CONFIG);
228 let mut config = ConfigService::load_from(&config_path).await?;
229
230 // Create node identifier based on source
231 let node_identifier = if is_monorepo_node {
232 // Framework node: @mecha10/node-name
233 format!("@mecha10/{}", component_name)
234 } else {
235 // Project node: @local/node-name
236 format!("@local/{}", component_name)
237 };
238
239 // Add node using the new format
240 config.nodes.add_node(&node_identifier);
241
242 // Save updated config
243 let config_json = serde_json::to_string_pretty(&config)?;
244 tokio::fs::write(&config_path, config_json).await?;
245
246 Ok(())
247 }
248
249 /// Copy behavior tree templates from framework to project
250 ///
251 /// Copies seed templates from `packages/behavior-runtime/seeds/` to
252 /// `{project}/behaviors/` for use with the behavior tree system.
253 ///
254 /// # Arguments
255 ///
256 /// * `project_root` - Root path of the project
257 pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
258 // Detect framework path to find source templates
259 // Skip silently if framework path not available (standalone CLI mode)
260 let framework_path = match self.detect_framework_path() {
261 Ok(path) => path,
262 Err(_) => return Ok(()), // Not in dev mode, skip copying
263 };
264 let source_templates_path = std::path::PathBuf::from(&framework_path)
265 .join("packages")
266 .join("behavior-runtime")
267 .join("seeds");
268
269 // Check if source templates directory exists
270 if !source_templates_path.exists() {
271 // No templates to copy - this is okay
272 return Ok(());
273 }
274
275 // Create destination directory
276 let dest_templates_path = project_root.join("behaviors");
277 tokio::fs::create_dir_all(&dest_templates_path).await?;
278
279 // Copy all .json templates
280 let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
281 while let Some(entry) = entries.next_entry().await? {
282 let path = entry.path();
283 if path.extension().and_then(|s| s.to_str()) == Some("json") {
284 if let Some(filename) = path.file_name() {
285 let dest_file = dest_templates_path.join(filename);
286 tokio::fs::copy(&path, &dest_file).await?;
287 }
288 }
289 }
290
291 Ok(())
292 }
293
294 /// Copy simulation configuration file from framework to project
295 ///
296 /// Copies `packages/simulation/configs/config.json` to
297 /// `{project}/configs/simulation/config.json`
298 ///
299 /// The config file contains both dev and production sections:
300 /// ```json
301 /// {
302 /// "dev": { ... },
303 /// "production": { ... }
304 /// }
305 /// ```
306 ///
307 /// # Arguments
308 ///
309 /// * `project_root` - Root path of the project
310 pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
311 // Detect framework path to find source config
312 // Skip silently if framework path not available (standalone CLI mode)
313 let framework_path = match self.detect_framework_path() {
314 Ok(path) => path,
315 Err(_) => return Ok(()), // Not in dev mode, skip copying
316 };
317 let source_config = std::path::PathBuf::from(&framework_path)
318 .join("packages")
319 .join("simulation")
320 .join("configs")
321 .join("config.json");
322
323 // Check if source config exists
324 if !source_config.exists() {
325 return Ok(());
326 }
327
328 // Create destination directory and copy file
329 let dest_dir = project_root.join("configs").join("simulation");
330 tokio::fs::create_dir_all(&dest_dir).await?;
331
332 let dest_file = dest_dir.join("config.json");
333 tokio::fs::copy(&source_config, &dest_file).await?;
334
335 Ok(())
336 }
337
338 /// Copy simulation asset files from framework to project
339 ///
340 /// Copies image assets from `packages/simulation/environments/basic_arena/assets/images/`
341 /// to `{project}/assets/images/`
342 ///
343 /// This includes cat images (aiko.jpg, phoebe.jpg) used in the basic arena environment
344 ///
345 /// # Arguments
346 ///
347 /// * `project_root` - Root path of the project
348 pub async fn copy_simulation_assets(&self, project_root: &Path) -> Result<()> {
349 // Detect framework path to find source assets
350 // Skip silently if framework path not available (standalone CLI mode)
351 let framework_path = match self.detect_framework_path() {
352 Ok(path) => path,
353 Err(_) => return Ok(()), // Not in dev mode, skip copying
354 };
355 let source_assets_path = std::path::PathBuf::from(&framework_path)
356 .join("packages")
357 .join("simulation")
358 .join("environments")
359 .join("basic_arena")
360 .join("assets")
361 .join("images");
362
363 // Check if source assets directory exists
364 if !source_assets_path.exists() {
365 // No assets to copy - this is okay
366 return Ok(());
367 }
368
369 // Create destination directory
370 let dest_assets_path = project_root.join("assets").join("images");
371 tokio::fs::create_dir_all(&dest_assets_path).await?;
372
373 // Copy image files
374 let images = vec!["aiko.jpg", "phoebe.jpg"];
375 for image in images {
376 let source_file = source_assets_path.join(image);
377 if source_file.exists() {
378 let dest_file = dest_assets_path.join(image);
379 tokio::fs::copy(&source_file, &dest_file).await?;
380 }
381 }
382
383 Ok(())
384 }
385
386 /// Copy node configuration file from framework package to project
387 ///
388 /// Copies `packages/nodes/{node}/configs/config.json` to
389 /// `{project}/configs/nodes/@mecha10/{node}/config.json`
390 ///
391 /// The config file contains both dev and production sections:
392 /// ```json
393 /// {
394 /// "dev": { ... },
395 /// "production": { ... }
396 /// }
397 /// ```
398 ///
399 /// # Arguments
400 ///
401 /// * `project_root` - Root path of the project
402 /// * `component_name` - Name of the node (e.g., "speaker", "listener")
403 async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
404 // Detect framework path to find source config
405 // Skip silently if framework path not available (standalone CLI mode)
406 let framework_path = match self.detect_framework_path() {
407 Ok(path) => path,
408 Err(_) => return Ok(()), // Not in dev mode, skip copying
409 };
410 let source_config = std::path::PathBuf::from(&framework_path)
411 .join("packages")
412 .join("nodes")
413 .join(component_name)
414 .join("configs")
415 .join("config.json");
416
417 // Check if source config exists
418 if !source_config.exists() {
419 return Ok(());
420 }
421
422 // Config path: configs/nodes/@mecha10/{node}/config.json
423 let dest_dir = project_root
424 .join("configs")
425 .join("nodes")
426 .join("@mecha10")
427 .join(component_name);
428
429 // Create destination directory and copy file
430 tokio::fs::create_dir_all(&dest_dir).await?;
431
432 let dest_file = dest_dir.join("config.json");
433 tokio::fs::copy(&source_config, &dest_file).await?;
434
435 Ok(())
436 }
437
438 /// Copy all default node configuration files from framework to project
439 ///
440 /// Copies configs for all default nodes from `packages/nodes/{node}/configs/`
441 /// to `{project}/configs/nodes/@mecha10/{node}/config.json`
442 ///
443 /// This is called during `mecha10 init` to set up node configs.
444 /// Skips silently if framework path is not available (standalone CLI mode).
445 ///
446 /// # Arguments
447 ///
448 /// * `project_root` - Root path of the project
449 pub async fn copy_all_node_configs(&self, project_root: &Path) -> Result<()> {
450 // Default nodes that should have configs copied
451 let default_nodes = [
452 "behavior-executor",
453 "image-classifier",
454 "listener",
455 "llm-command",
456 "object-detector",
457 "simulation-bridge",
458 "speaker",
459 "teleop",
460 "websocket-bridge",
461 ];
462
463 for node in default_nodes {
464 self.copy_node_configs(project_root, node).await?;
465 }
466
467 Ok(())
468 }
469
470 /// Detect the framework path for development mode
471 ///
472 /// Searches for the mecha10-monorepo in the following order:
473 /// 1. MECHA10_FRAMEWORK_PATH environment variable
474 /// 2. Walking up the directory tree from current directory
475 ///
476 /// This is used when creating projects in development mode to link
477 /// to the local framework instead of published crates.
478 ///
479 /// # Returns
480 ///
481 /// The absolute path to the framework root, or an error if not found.
482 pub fn detect_framework_path(&self) -> Result<String> {
483 // First check if MECHA10_FRAMEWORK_PATH is set
484 if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
485 let path_buf = std::path::PathBuf::from(&path);
486
487 // Expand ~ to home directory if present
488 let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
489 if let Ok(home) = std::env::var("HOME") {
490 std::path::PathBuf::from(home).join(stripped)
491 } else {
492 path_buf.clone()
493 }
494 } else {
495 path_buf.clone()
496 };
497
498 // Validate that this is actually the framework directory
499 let core_path = expanded_path.join("packages").join("core");
500 if core_path.exists() {
501 return Ok(expanded_path.to_string_lossy().to_string());
502 } else {
503 return Err(anyhow::anyhow!(
504 "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
505 Expected to find packages/core directory at that location.",
506 path
507 ));
508 }
509 }
510
511 // Fall back to walking up from current directory
512 let mut current = std::env::current_dir()?;
513
514 loop {
515 // Check if this directory contains packages/core (framework marker)
516 let core_path = current.join("packages").join("core");
517 if core_path.exists() {
518 // Found the framework root
519 return Ok(current.to_string_lossy().to_string());
520 }
521
522 // Check if we reached the filesystem root
523 if !current.pop() {
524 return Err(anyhow::anyhow!(
525 "Could not detect framework path. Either:\n\
526 1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
527 2. Run from within the mecha10-monorepo directory\n\n\
528 Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
529 ));
530 }
531 }
532 }
533}
534
535impl Default for InitService {
536 fn default() -> Self {
537 Self::new()
538 }
539}