mecha10_templates/services/
init_service.rs1use crate::paths;
8use anyhow::Result;
9use std::path::Path;
10
11pub struct InitService;
12
13impl InitService {
14 pub fn new() -> Self {
16 Self
17 }
18
19 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 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 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 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 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 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 #[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 #[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}