Skip to main content

mecha10_templates/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
7use crate::paths;
8use anyhow::Result;
9use std::path::Path;
10
11pub struct InitService;
12
13impl InitService {
14    /// Create a new InitService
15    pub fn new() -> Self {
16        Self
17    }
18
19    /// Create project directory structure
20    ///
21    /// Creates all the necessary directories for a new Mecha10 project:
22    /// - nodes/ - Custom node implementations
23    /// - drivers/ - Hardware driver implementations
24    /// - types/ - Shared type definitions
25    /// - configs/behaviors/ - Behavior tree definitions (LAB-2072: nested under configs/ so
26    ///   `mecha10 config push` syncs them)
27    /// - config/ - Configuration files
28    /// - logs/ - Runtime logs
29    /// - models/ - ML/AI models (ONNX, etc.) for remote nodes
30    /// - models/catalog/ - bind-mounted into the `ollama` Compose service; must pre-exist so an
31    ///   Ollama-only project (no ONNX catalog pull yet) doesn't hit a missing-bind-mount-target
32    ///   error on `mecha10 dev`'s first `docker compose up`
33    /// - configs/simulation/ - Simulation environments
34    /// - configs/simulation/models/ - Robot physical models
35    /// - configs/rl/tasks/ - RL task-curriculum configs (LAB-2429 - not under configs/simulation/,
36    ///   see `paths::project::RL_TASKS_DIR`'s doc comment)
37    /// - configs/simulation/images/ - Billboard image assets for simulation environments/tasks
38    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
39        let dirs = vec![
40            paths::project::NODES_DIR,
41            paths::project::DRIVERS_DIR,
42            paths::project::TYPES_DIR,
43            paths::project::BEHAVIORS_DIR,
44            paths::project::LOGS_DIR,
45            paths::project::MODELS_DIR,
46            paths::project::MODELS_CATALOG_DIR,
47            paths::project::SIMULATION_DIR,
48            paths::project::SIMULATION_MODELS_DIR,
49            paths::project::SIMULATION_ENVIRONMENTS_DIR,
50            paths::project::SIMULATION_IMAGES_DIR,
51            paths::project::RL_TASKS_DIR,
52            paths::config::NODES_DIR,
53        ];
54
55        for dir in dirs {
56            let dir_path = path.join(dir);
57            tokio::fs::create_dir_all(&dir_path).await?;
58        }
59
60        Ok(())
61    }
62
63    /// Add an example node from the catalog to the project
64    ///
65    /// This method:
66    /// 1. Searches the component catalog for the component
67    /// 2. Generates component files from templates (if any)
68    /// 3. Updates Cargo.toml (workspace members or dependencies)
69    /// 4. Updates mecha10.json configuration
70    ///
71    /// # Arguments
72    ///
73    /// * `project_root` - Root path of the project
74    /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
75    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
76        use crate::component_catalog::ComponentCatalog;
77
78        let catalog = ComponentCatalog::new();
79        let components = catalog.search(component_name);
80
81        if components.is_empty() {
82            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
83        }
84
85        let component = &components[0];
86
87        for file_template in &component.files {
88            let file_path = project_root.join(&file_template.path);
89
90            if let Some(parent) = file_path.parent() {
91                tokio::fs::create_dir_all(parent).await?;
92            }
93
94            tokio::fs::write(&file_path, &file_template.content).await?;
95        }
96
97        if !component.files.is_empty() {
98            self.add_workspace_member(project_root, component_name).await?;
99        }
100
101        let is_monorepo_node = component.files.is_empty();
102        if is_monorepo_node {
103            self.add_dependency(project_root, component).await?;
104        }
105
106        self.add_node_to_config(project_root, component_name, is_monorepo_node)
107            .await?;
108
109        Ok(())
110    }
111
112    /// Add a workspace member to Cargo.toml
113    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
114        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
115        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
116
117        let new_member = format!("nodes/{}", component_name);
118        let updated_content = if content.contains("members = []") {
119            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
120        } else if let Some(start) = content.find("members = [") {
121            if let Some(end) = content[start..].find(']') {
122                let insert_pos = start + end;
123                let members_section = &content[start..insert_pos];
124                if members_section.contains('\"') {
125                    format!(
126                        "{}\n    \"{}\",{}",
127                        &content[..insert_pos],
128                        new_member,
129                        &content[insert_pos..]
130                    )
131                } else {
132                    format!(
133                        "{}\n    \"{}\",\n{}",
134                        &content[..insert_pos],
135                        new_member,
136                        &content[insert_pos..]
137                    )
138                }
139            } else {
140                content
141            }
142        } else {
143            content
144        };
145
146        tokio::fs::write(&cargo_toml_path, updated_content).await?;
147        Ok(())
148    }
149
150    /// Add a dependency to Cargo.toml
151    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
152        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
153        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
154
155        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
156            dep.name.clone()
157        } else {
158            format!("mecha10-nodes-{}", component.id)
159        };
160
161        if !content.contains(&package_name) {
162            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
163                if let Some(newline_pos) = content[pos..].find('\n') {
164                    let insert_pos = pos + newline_pos + 1;
165                    format!(
166                        "{}{} = \"0.1\"\n{}",
167                        &content[..insert_pos],
168                        package_name,
169                        &content[insert_pos..]
170                    )
171                } else {
172                    content
173                }
174            } else {
175                content
176            };
177
178            tokio::fs::write(&cargo_toml_path, updated_content).await?;
179        }
180
181        Ok(())
182    }
183
184    /// Add node entry to mecha10.json
185    ///
186    /// Only mints the `nodes` map entry now - `targets.<key>.nodes` placement for every
187    /// framework node selected during `mecha10 init` is written up front by
188    /// `handlers/init.rs`'s `build_node_placement` (sourced live from `mecha10-api`'s
189    /// `GET /api/nodes/catalog` `targets` field), before this per-node pass
190    /// (`handlers/init.rs::perform_init`'s `add_example_node` loop) ever runs. This used to also
191    /// sync `targets.*` itself via the now-retired `Component::relevant_target_keys()` (hand-
192    /// maintained tag/`run_target` inference, merged with the hand-maintained `NODE_TARGETS`
193    /// table) - that was always redundant with the template-driven write that already ran first,
194    /// and removing it is what let this repo drop the second, drift-prone copy of node placement
195    /// entirely (see `mecha10_cli_core::component_catalog::types` module history).
196    async fn add_node_to_config(
197        &self,
198        project_root: &Path,
199        component_name: &str,
200        is_monorepo_node: bool,
201    ) -> Result<()> {
202        use crate::services::ConfigService;
203
204        let config_path = project_root.join(paths::PROJECT_CONFIG);
205        let mut config = ConfigService::load_from(&config_path).await?;
206
207        let (node_identifier, version) = if is_monorepo_node {
208            (
209                format!("@mecha10/{}", component_name),
210                env!("CARGO_PKG_VERSION").to_string(),
211            )
212        } else {
213            (component_name.to_string(), "0.1.0".to_string())
214        };
215
216        config.nodes.add_node(&node_identifier, &version);
217
218        let config_json = serde_json::to_string_pretty(&config)?;
219        tokio::fs::write(&config_path, config_json).await?;
220
221        Ok(())
222    }
223}
224
225impl Default for InitService {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231#[cfg(test)]
232mod add_node_to_config_tests {
233    use super::*;
234    use mecha10_cli_core::services::ConfigService;
235
236    /// Minimal valid mecha10.json content for a freshly-initialized project.
237    fn minimal_project_config_json() -> &'static str {
238        r#"{
239            "name": "test-project",
240            "version": "0.1.0",
241            "robot_id": "test-robot"
242        }"#
243    }
244
245    #[tokio::test]
246    async fn mints_project_nodes_with_plain_identifier() {
247        let temp_dir = tempfile::tempdir().expect("create temp dir");
248        let project_root = temp_dir.path();
249        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
250            .await
251            .expect("write mecha10.json");
252
253        let service = InitService::new();
254
255        service
256            .add_node_to_config(project_root, "my-custom", false)
257            .await
258            .expect("add_node_to_config should succeed");
259
260        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
261            .await
262            .expect("reload config");
263
264        assert!(
265            config.nodes.identifiers().contains(&"my-custom"),
266            "expected plain identifier 'my-custom' in nodes, got: {:?}",
267            config.nodes.identifiers()
268        );
269        assert!(
270            !config.nodes.identifiers().contains(&"@local/my-custom"),
271            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
272            config.nodes.identifiers()
273        );
274    }
275
276    /// Regression coverage (LAB-4057): `mecha10 init` writes `template_version` into
277    /// `mecha10.json` to record which published templates release the project was scaffolded
278    /// from (`create_mecha10_json_from_template`), but `add_node_to_config` runs for every
279    /// default/selected node on every `mecha10 init` and - until `ProjectConfig` declared
280    /// `template_version` as a real field - silently dropped it on this very first save, since
281    /// `serde_json::to_string_pretty` can only re-emit fields the struct it's serializing
282    /// actually has.
283    #[tokio::test]
284    async fn preserves_template_version_written_by_mecha10_init() {
285        let temp_dir = tempfile::tempdir().expect("create temp dir");
286        let project_root = temp_dir.path();
287        tokio::fs::write(
288            project_root.join(paths::PROJECT_CONFIG),
289            r#"{
290                "name": "test-project",
291                "version": "0.6.5",
292                "template_version": "0.6.5",
293                "robot_id": "test-robot"
294            }"#,
295        )
296        .await
297        .expect("write mecha10.json");
298
299        let service = InitService::new();
300
301        service
302            .add_node_to_config(project_root, "speaker", true)
303            .await
304            .expect("add_node_to_config should succeed");
305
306        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
307            .await
308            .expect("reload config");
309
310        assert_eq!(
311            config.template_version,
312            Some("0.6.5".to_string()),
313            "template_version written at `mecha10 init` time must survive add_node_to_config's save"
314        );
315    }
316
317    #[tokio::test]
318    async fn mints_framework_nodes_with_mecha10_scope() {
319        let temp_dir = tempfile::tempdir().expect("create temp dir");
320        let project_root = temp_dir.path();
321        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
322            .await
323            .expect("write mecha10.json");
324
325        let service = InitService::new();
326
327        service
328            .add_node_to_config(project_root, "speaker", true)
329            .await
330            .expect("add_node_to_config should succeed");
331
332        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
333            .await
334            .expect("reload config");
335
336        assert!(
337            config.nodes.identifiers().contains(&"@mecha10/speaker"),
338            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
339            config.nodes.identifiers()
340        );
341    }
342
343    /// Regression coverage for the node->target(s) placement migration: `add_node_to_config`
344    /// used to also sync `targets.*.nodes` itself (via the now-retired
345    /// `Component::relevant_target_keys()`), redundantly with the template-driven placement
346    /// `handlers/init.rs`'s `build_node_placement` already writes before this ever runs. It must
347    /// not touch `targets` at all anymore - doing so here again could only ever re-introduce a
348    /// second, independently-drifting copy of the exact bug this migration removes.
349    #[tokio::test]
350    async fn does_not_touch_targets_at_all() {
351        let temp_dir = tempfile::tempdir().expect("create temp dir");
352        let project_root = temp_dir.path();
353        let config_with_targets = r#"{
354            "name": "test-project",
355            "version": "0.1.0",
356            "robot_id": "test-robot",
357            "targets": {
358                "robot": { "nodes": ["@mecha10/camera"] }
359            }
360        }"#;
361        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
362            .await
363            .expect("write mecha10.json");
364
365        let service = InitService::new();
366
367        service
368            .add_node_to_config(project_root, "image-classifier", true)
369            .await
370            .expect("add_node_to_config should succeed");
371
372        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
373            .await
374            .expect("reload config");
375
376        assert!(
377            config.targets.simulation.is_none(),
378            "add_node_to_config must not synthesize any targets.* entry anymore"
379        );
380        let robot = config.targets.robot.expect("pre-existing targets.robot must be preserved");
381        assert_eq!(
382            robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
383            vec!["@mecha10/camera".to_string()],
384            "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
385            robot.nodes
386        );
387    }
388}