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.
4
5use crate::paths;
6use anyhow::Result;
7use std::path::Path;
8
9pub struct InitService;
10
11impl InitService {
12    /// Create a new InitService
13    pub fn new() -> Self {
14        Self
15    }
16
17    /// Create all the necessary directories for a new Mecha10 project.
18    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
19        let dirs = vec![
20            paths::project::NODES_DIR,
21            paths::project::DRIVERS_DIR,
22            paths::project::TYPES_DIR,
23            paths::project::BEHAVIORS_DIR,
24            paths::project::LOGS_DIR,
25            paths::project::MODELS_DIR,
26            paths::project::MODELS_CATALOG_DIR,
27            paths::project::SIMULATION_DIR,
28            paths::project::SIMULATION_MODELS_DIR,
29            paths::project::SIMULATION_ENVIRONMENTS_DIR,
30            paths::project::SIMULATION_IMAGES_DIR,
31            paths::project::RL_TASKS_DIR,
32            paths::config::NODES_DIR,
33        ];
34
35        for dir in dirs {
36            let dir_path = path.join(dir);
37            tokio::fs::create_dir_all(&dir_path).await?;
38        }
39
40        Ok(())
41    }
42
43    /// Add an example node from the catalog to the project, updating Cargo.toml and
44    /// mecha10.json as needed.
45    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
46        use crate::component_catalog::ComponentCatalog;
47
48        let catalog = ComponentCatalog::new();
49        let components = catalog.search(component_name);
50
51        if components.is_empty() {
52            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
53        }
54
55        let component = &components[0];
56
57        for file_template in &component.files {
58            let file_path = project_root.join(&file_template.path);
59
60            if let Some(parent) = file_path.parent() {
61                tokio::fs::create_dir_all(parent).await?;
62            }
63
64            tokio::fs::write(&file_path, &file_template.content).await?;
65        }
66
67        if !component.files.is_empty() {
68            self.add_workspace_member(project_root, component_name).await?;
69        }
70
71        let is_monorepo_node = component.files.is_empty();
72        if is_monorepo_node {
73            self.add_dependency(project_root, component).await?;
74        }
75
76        self.add_node_to_config(project_root, component_name, is_monorepo_node)
77            .await?;
78
79        Ok(())
80    }
81
82    /// Add a workspace member to Cargo.toml
83    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
84        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
85        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
86
87        let new_member = format!("nodes/{}", component_name);
88        let updated_content = if content.contains("members = []") {
89            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
90        } else if let Some(start) = content.find("members = [") {
91            if let Some(end) = content[start..].find(']') {
92                let insert_pos = start + end;
93                let members_section = &content[start..insert_pos];
94                if members_section.contains('\"') {
95                    format!(
96                        "{}\n    \"{}\",{}",
97                        &content[..insert_pos],
98                        new_member,
99                        &content[insert_pos..]
100                    )
101                } else {
102                    format!(
103                        "{}\n    \"{}\",\n{}",
104                        &content[..insert_pos],
105                        new_member,
106                        &content[insert_pos..]
107                    )
108                }
109            } else {
110                content
111            }
112        } else {
113            content
114        };
115
116        tokio::fs::write(&cargo_toml_path, updated_content).await?;
117        Ok(())
118    }
119
120    /// Add a dependency to Cargo.toml
121    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
122        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
123        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
124
125        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
126            dep.name.clone()
127        } else {
128            format!("mecha10-nodes-{}", component.id)
129        };
130
131        if !content.contains(&package_name) {
132            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
133                if let Some(newline_pos) = content[pos..].find('\n') {
134                    let insert_pos = pos + newline_pos + 1;
135                    format!(
136                        "{}{} = \"0.1\"\n{}",
137                        &content[..insert_pos],
138                        package_name,
139                        &content[insert_pos..]
140                    )
141                } else {
142                    content
143                }
144            } else {
145                content
146            };
147
148            tokio::fs::write(&cargo_toml_path, updated_content).await?;
149        }
150
151        Ok(())
152    }
153
154    /// Add node entry to mecha10.json. Only mints the `nodes` map entry; `targets` placement
155    /// is handled separately by `handlers/init.rs`.
156    async fn add_node_to_config(
157        &self,
158        project_root: &Path,
159        component_name: &str,
160        is_monorepo_node: bool,
161    ) -> Result<()> {
162        use crate::services::ConfigService;
163
164        let config_path = project_root.join(paths::PROJECT_CONFIG);
165        let mut config = ConfigService::load_from(&config_path).await?;
166
167        let (node_identifier, version) = if is_monorepo_node {
168            (
169                format!("@mecha10/{}", component_name),
170                env!("CARGO_PKG_VERSION").to_string(),
171            )
172        } else {
173            (component_name.to_string(), "0.1.0".to_string())
174        };
175
176        // `handlers/init.rs`'s `build_node_placement` already mints every selected framework
177        // node into `nodes`, pinned to that node's own live-catalog version (`mecha10-nodes`
178        // releases each node independently) - don't clobber that back to this crate's own
179        // build version for a node that's already present. Only mint an entry that's actually
180        // missing (e.g. `mecha10 init --update`/`mecha10 add`, which don't go through
181        // `build_node_placement` first).
182        if config.nodes.get_version(&node_identifier).is_none() {
183            config.nodes.add_node(&node_identifier, &version);
184
185            let config_json = serde_json::to_string_pretty(&config)?;
186            tokio::fs::write(&config_path, config_json).await?;
187        }
188
189        Ok(())
190    }
191}
192
193impl Default for InitService {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199#[cfg(test)]
200mod add_node_to_config_tests {
201    use super::*;
202    use mecha10_cli_core::services::ConfigService;
203
204    /// Minimal valid mecha10.json content for a freshly-initialized project.
205    fn minimal_project_config_json() -> &'static str {
206        r#"{
207            "name": "test-project",
208            "version": "0.1.0",
209            "robot_id": "test-robot"
210        }"#
211    }
212
213    #[tokio::test]
214    async fn mints_project_nodes_with_plain_identifier() {
215        let temp_dir = tempfile::tempdir().expect("create temp dir");
216        let project_root = temp_dir.path();
217        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
218            .await
219            .expect("write mecha10.json");
220
221        let service = InitService::new();
222
223        service
224            .add_node_to_config(project_root, "my-custom", false)
225            .await
226            .expect("add_node_to_config should succeed");
227
228        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
229            .await
230            .expect("reload config");
231
232        assert!(
233            config.nodes.identifiers().contains(&"my-custom"),
234            "expected plain identifier 'my-custom' in nodes, got: {:?}",
235            config.nodes.identifiers()
236        );
237        assert!(
238            !config.nodes.identifiers().contains(&"@local/my-custom"),
239            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
240            config.nodes.identifiers()
241        );
242    }
243
244    /// `add_node_to_config` must preserve `template_version` across its save, since it runs for
245    /// every default/selected node on every `mecha10 init`.
246    #[tokio::test]
247    async fn preserves_template_version_written_by_mecha10_init() {
248        let temp_dir = tempfile::tempdir().expect("create temp dir");
249        let project_root = temp_dir.path();
250        tokio::fs::write(
251            project_root.join(paths::PROJECT_CONFIG),
252            r#"{
253                "name": "test-project",
254                "version": "0.6.5",
255                "template_version": "0.6.5",
256                "robot_id": "test-robot"
257            }"#,
258        )
259        .await
260        .expect("write mecha10.json");
261
262        let service = InitService::new();
263
264        service
265            .add_node_to_config(project_root, "speaker", true)
266            .await
267            .expect("add_node_to_config should succeed");
268
269        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
270            .await
271            .expect("reload config");
272
273        assert_eq!(
274            config.template_version,
275            Some("0.6.5".to_string()),
276            "template_version written at `mecha10 init` time must survive add_node_to_config's save"
277        );
278    }
279
280    #[tokio::test]
281    async fn mints_framework_nodes_with_mecha10_scope() {
282        let temp_dir = tempfile::tempdir().expect("create temp dir");
283        let project_root = temp_dir.path();
284        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
285            .await
286            .expect("write mecha10.json");
287
288        let service = InitService::new();
289
290        service
291            .add_node_to_config(project_root, "speaker", true)
292            .await
293            .expect("add_node_to_config should succeed");
294
295        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
296            .await
297            .expect("reload config");
298
299        assert!(
300            config.nodes.identifiers().contains(&"@mecha10/speaker"),
301            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
302            config.nodes.identifiers()
303        );
304    }
305
306    /// `handlers/init.rs`'s `build_node_placement` runs before this and already pins a
307    /// selected framework node to its own live-catalog version. `add_node_to_config` must not
308    /// clobber that back to this crate's own `CARGO_PKG_VERSION`.
309    #[tokio::test]
310    async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
311        let temp_dir = tempfile::tempdir().expect("create temp dir");
312        let project_root = temp_dir.path();
313        let config_with_pinned_node = r#"{
314            "name": "test-project",
315            "version": "0.1.0",
316            "robot_id": "test-robot",
317            "nodes": { "@mecha10/speaker": "0.7.0" }
318        }"#;
319        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
320            .await
321            .expect("write mecha10.json");
322
323        let service = InitService::new();
324
325        service
326            .add_node_to_config(project_root, "speaker", true)
327            .await
328            .expect("add_node_to_config should succeed");
329
330        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
331            .await
332            .expect("reload config");
333
334        assert_eq!(
335            config.nodes.get_version("@mecha10/speaker"),
336            Some("0.7.0"),
337            "the live-catalog-pinned version must survive add_node_to_config's save, not be \
338             reset to this crate's own CARGO_PKG_VERSION"
339        );
340    }
341
342    /// `add_node_to_config` must not touch `targets` at all - that placement is already
343    /// written by `handlers/init.rs`'s `build_node_placement` before this ever runs.
344    #[tokio::test]
345    async fn does_not_touch_targets_at_all() {
346        let temp_dir = tempfile::tempdir().expect("create temp dir");
347        let project_root = temp_dir.path();
348        let config_with_targets = r#"{
349            "name": "test-project",
350            "version": "0.1.0",
351            "robot_id": "test-robot",
352            "targets": {
353                "robot": { "nodes": ["@mecha10/camera"] }
354            }
355        }"#;
356        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
357            .await
358            .expect("write mecha10.json");
359
360        let service = InitService::new();
361
362        service
363            .add_node_to_config(project_root, "image-classifier", true)
364            .await
365            .expect("add_node_to_config should succeed");
366
367        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
368            .await
369            .expect("reload config");
370
371        assert!(
372            config.targets.simulation.is_none(),
373            "add_node_to_config must not synthesize any targets.* entry anymore"
374        );
375        let robot = config
376            .targets
377            .robot
378            .expect("pre-existing targets.robot must be preserved");
379        assert_eq!(
380            robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
381            vec!["@mecha10/camera".to_string()],
382            "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
383            robot.nodes
384        );
385    }
386}