mecha10_templates/services/
init_service.rs1use crate::paths;
6use anyhow::Result;
7use std::path::Path;
8
9pub struct InitService;
10
11impl InitService {
12 pub fn new() -> Self {
14 Self
15 }
16
17 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 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 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 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 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 if config.nodes.get_version(&node_identifier).is_none() {
177 config.nodes.add_node(&node_identifier, &version);
178
179 let config_json = serde_json::to_string_pretty(&config)?;
180 tokio::fs::write(&config_path, config_json).await?;
181 }
182
183 Ok(())
184 }
185}
186
187impl Default for InitService {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193#[cfg(test)]
194mod add_node_to_config_tests {
195 use super::*;
196 use mecha10_cli_core::services::ConfigService;
197
198 fn minimal_project_config_json() -> &'static str {
200 r#"{
201 "name": "test-project",
202 "version": "0.1.0",
203 "robot_id": "test-robot"
204 }"#
205 }
206
207 #[tokio::test]
208 async fn mints_project_nodes_with_plain_identifier() {
209 let temp_dir = tempfile::tempdir().expect("create temp dir");
210 let project_root = temp_dir.path();
211 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
212 .await
213 .expect("write mecha10.json");
214
215 let service = InitService::new();
216
217 service
218 .add_node_to_config(project_root, "my-custom", false)
219 .await
220 .expect("add_node_to_config should succeed");
221
222 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
223 .await
224 .expect("reload config");
225
226 assert!(
227 config.nodes.identifiers().contains(&"my-custom"),
228 "expected plain identifier 'my-custom' in nodes, got: {:?}",
229 config.nodes.identifiers()
230 );
231 assert!(
232 !config.nodes.identifiers().contains(&"@local/my-custom"),
233 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
234 config.nodes.identifiers()
235 );
236 }
237
238 #[tokio::test]
241 async fn preserves_template_version_written_by_mecha10_init() {
242 let temp_dir = tempfile::tempdir().expect("create temp dir");
243 let project_root = temp_dir.path();
244 tokio::fs::write(
245 project_root.join(paths::PROJECT_CONFIG),
246 r#"{
247 "name": "test-project",
248 "version": "0.6.5",
249 "template_version": "0.6.5",
250 "robot_id": "test-robot"
251 }"#,
252 )
253 .await
254 .expect("write mecha10.json");
255
256 let service = InitService::new();
257
258 service
259 .add_node_to_config(project_root, "speaker", true)
260 .await
261 .expect("add_node_to_config should succeed");
262
263 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
264 .await
265 .expect("reload config");
266
267 assert_eq!(
268 config.template_version,
269 Some("0.6.5".to_string()),
270 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
271 );
272 }
273
274 #[tokio::test]
275 async fn mints_framework_nodes_with_mecha10_scope() {
276 let temp_dir = tempfile::tempdir().expect("create temp dir");
277 let project_root = temp_dir.path();
278 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
279 .await
280 .expect("write mecha10.json");
281
282 let service = InitService::new();
283
284 service
285 .add_node_to_config(project_root, "speaker", true)
286 .await
287 .expect("add_node_to_config should succeed");
288
289 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
290 .await
291 .expect("reload config");
292
293 assert!(
294 config.nodes.identifiers().contains(&"@mecha10/speaker"),
295 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
296 config.nodes.identifiers()
297 );
298 }
299
300 #[tokio::test]
304 async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
305 let temp_dir = tempfile::tempdir().expect("create temp dir");
306 let project_root = temp_dir.path();
307 let config_with_pinned_node = r#"{
308 "name": "test-project",
309 "version": "0.1.0",
310 "robot_id": "test-robot",
311 "nodes": { "@mecha10/speaker": "0.7.0" }
312 }"#;
313 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
314 .await
315 .expect("write mecha10.json");
316
317 let service = InitService::new();
318
319 service
320 .add_node_to_config(project_root, "speaker", true)
321 .await
322 .expect("add_node_to_config should succeed");
323
324 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
325 .await
326 .expect("reload config");
327
328 assert_eq!(
329 config.nodes.get_version("@mecha10/speaker"),
330 Some("0.7.0"),
331 "the live-catalog-pinned version must survive add_node_to_config's save, not be \
332 reset to this crate's own CARGO_PKG_VERSION"
333 );
334 }
335
336 #[tokio::test]
339 async fn does_not_touch_targets_at_all() {
340 let temp_dir = tempfile::tempdir().expect("create temp dir");
341 let project_root = temp_dir.path();
342 let config_with_targets = r#"{
343 "name": "test-project",
344 "version": "0.1.0",
345 "robot_id": "test-robot",
346 "targets": {
347 "robot": { "nodes": ["@mecha10/camera"] }
348 }
349 }"#;
350 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
351 .await
352 .expect("write mecha10.json");
353
354 let service = InitService::new();
355
356 service
357 .add_node_to_config(project_root, "image-classifier", true)
358 .await
359 .expect("add_node_to_config should succeed");
360
361 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
362 .await
363 .expect("reload config");
364
365 assert!(
366 config.targets.simulation.is_none(),
367 "add_node_to_config must not synthesize any targets.* entry anymore"
368 );
369 let robot = config
370 .targets
371 .robot
372 .expect("pre-existing targets.robot must be preserved");
373 assert_eq!(
374 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
375 vec!["@mecha10/camera".to_string()],
376 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
377 robot.nodes
378 );
379 }
380}