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::{Context, Result};
7use mecha10_cli_core::types::project::NodeEntry;
8use mecha10_cli_core::utils::build_client;
9use mecha10_core::node::Capability;
10use std::path::Path;
11
12/// crates.io requests a descriptive User-Agent identifying the caller and a contact point
13/// (https://crates.io/data-access) - this mirrors the one `scripts/release/publish-crates.sh`
14/// already uses for its own crates.io API calls.
15const CRATES_IO_USER_AGENT: &str = "mecha10-cli (+https://github.com/mecha10/mecha10; contact: releases@mecha10.dev)";
16
17/// Body of a successful `GET https://crates.io/api/v1/crates/{name}` response - only the one
18/// field this module needs out of the much larger payload.
19#[derive(serde::Deserialize)]
20struct CratesIoCrateResponse {
21    #[serde(rename = "crate")]
22    krate: CratesIoCrateMeta,
23}
24
25#[derive(serde::Deserialize)]
26struct CratesIoCrateMeta {
27    max_version: String,
28}
29
30/// Look up the latest published version of `package_name` on crates.io.
31///
32/// This is the same source of truth `scripts/release/publish-crates.sh::get_published_version`
33/// queries when publishing, so `mecha10 add <component>` never writes a node crate version into
34/// a project's `Cargo.toml` that can silently go stale (unlike a hand-maintained literal in the
35/// component catalog).
36async fn latest_published_crate_version(package_name: &str) -> Result<String> {
37    let url = format!("https://crates.io/api/v1/crates/{}", package_name);
38
39    let client = build_client()?;
40    let response = client
41        .get(&url)
42        .header("User-Agent", CRATES_IO_USER_AGENT)
43        .send()
44        .await
45        .with_context(|| format!("Failed to query crates.io for latest version of {}", package_name))?;
46
47    if !response.status().is_success() {
48        anyhow::bail!(
49            "Failed to look up latest published version of {} on crates.io: HTTP {}\nURL: {}",
50            package_name,
51            response.status(),
52            url
53        );
54    }
55
56    let body = response
57        .text()
58        .await
59        .with_context(|| format!("Failed to read crates.io response body for {}", package_name))?;
60
61    parse_max_version(&body, package_name)
62}
63
64/// Pull `.crate.max_version` out of a `GET https://crates.io/api/v1/crates/{name}` response
65/// body, split out from [`latest_published_crate_version`] so the parsing logic can be unit
66/// tested without a live network call.
67fn parse_max_version(body: &str, package_name: &str) -> Result<String> {
68    let parsed: CratesIoCrateResponse = serde_json::from_str(body)
69        .with_context(|| format!("Failed to parse crates.io response for {}", package_name))?;
70
71    Ok(parsed.krate.max_version)
72}
73
74pub struct InitService;
75
76impl InitService {
77    /// Create a new InitService
78    pub fn new() -> Self {
79        Self
80    }
81
82    /// Create all the necessary directories for a new Mecha10 project.
83    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
84        let dirs = vec![
85            paths::project::NODES_DIR,
86            paths::project::DRIVERS_DIR,
87            paths::project::TYPES_DIR,
88            paths::project::BEHAVIORS_DIR,
89            paths::project::LOGS_DIR,
90            paths::project::MODELS_DIR,
91            paths::project::MODELS_CATALOG_DIR,
92            paths::project::SIMULATION_DIR,
93            paths::project::SIMULATION_MODELS_DIR,
94            paths::project::SIMULATION_ENVIRONMENTS_DIR,
95            paths::project::SIMULATION_IMAGES_DIR,
96            paths::project::RL_TASKS_DIR,
97            paths::config::NODES_DIR,
98        ];
99
100        for dir in dirs {
101            let dir_path = path.join(dir);
102            tokio::fs::create_dir_all(&dir_path).await?;
103        }
104
105        Ok(())
106    }
107
108    /// Add an example node from the catalog to the project, updating Cargo.toml and
109    /// mecha10.json as needed.
110    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
111        use crate::component_catalog::ComponentCatalog;
112
113        let catalog = ComponentCatalog::new();
114        let components = catalog.search(component_name);
115
116        if components.is_empty() {
117            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
118        }
119
120        let component = &components[0];
121
122        for file_template in &component.files {
123            let file_path = project_root.join(&file_template.path);
124
125            if let Some(parent) = file_path.parent() {
126                tokio::fs::create_dir_all(parent).await?;
127            }
128
129            tokio::fs::write(&file_path, &file_template.content).await?;
130        }
131
132        if !component.files.is_empty() {
133            self.add_workspace_member(project_root, component_name).await?;
134        }
135
136        let is_monorepo_node = component.files.is_empty();
137        if is_monorepo_node {
138            self.add_dependency(project_root, component).await?;
139        }
140
141        self.add_node_to_config(project_root, component_name, is_monorepo_node)
142            .await?;
143
144        Ok(())
145    }
146
147    /// Add a workspace member to Cargo.toml
148    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
149        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
150        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
151
152        let new_member = format!("nodes/{}", component_name);
153        let updated_content = if content.contains("members = []") {
154            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
155        } else if let Some(start) = content.find("members = [") {
156            if let Some(end) = content[start..].find(']') {
157                let insert_pos = start + end;
158                let members_section = &content[start..insert_pos];
159                if members_section.contains('\"') {
160                    format!(
161                        "{}\n    \"{}\",{}",
162                        &content[..insert_pos],
163                        new_member,
164                        &content[insert_pos..]
165                    )
166                } else {
167                    format!(
168                        "{}\n    \"{}\",\n{}",
169                        &content[..insert_pos],
170                        new_member,
171                        &content[insert_pos..]
172                    )
173                }
174            } else {
175                content
176            }
177        } else {
178            content
179        };
180
181        tokio::fs::write(&cargo_toml_path, updated_content).await?;
182        Ok(())
183    }
184
185    /// The crate names `add_dependency` must resolve/add for a component.
186    ///
187    /// Walks every entry in `component.cargo_dependencies`, not just the first one - driver
188    /// components (see `component_catalog/drivers.rs`) list their local `mecha10-core` path
189    /// dependency first and their real third-party crate (e.g. `realsense-rust`, `serialport`)
190    /// second, so stopping at `.first()` silently resolved to `mecha10-core` - which is always
191    /// already present in a scaffolded Cargo.toml - and skipped adding the driver's actual
192    /// crate dependency entirely. Local workspace path dependencies (`dep.path.is_some()`, e.g.
193    /// `mecha10-core`) are already present via the project's own scaffolding and must not be
194    /// touched here - only real external crates need resolving/adding.
195    fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
196        if component.cargo_dependencies.is_empty() {
197            return vec![format!("mecha10-nodes-{}", component.id)];
198        }
199
200        component
201            .cargo_dependencies
202            .iter()
203            .filter(|dep| dep.path.is_none())
204            .map(|dep| dep.name.clone())
205            .collect()
206    }
207
208    /// Insert `package_name = "version"` into `content` right after the
209    /// `mecha10-core = "0.1"` anchor line that every scaffolded project's Cargo.toml has.
210    /// Returns `content` unchanged if the anchor line isn't found.
211    fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
212        let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
213            return content.to_string();
214        };
215        let Some(newline_pos) = content[pos..].find('\n') else {
216            return content.to_string();
217        };
218        let insert_pos = pos + newline_pos + 1;
219        format!(
220            "{}{} = \"{}\"\n{}",
221            &content[..insert_pos],
222            package_name,
223            version,
224            &content[insert_pos..]
225        )
226    }
227
228    /// Add a dependency to Cargo.toml
229    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
230        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
231        let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
232
233        let mut changed = false;
234
235        for package_name in Self::external_dependency_names(component) {
236            if content.contains(&package_name) {
237                continue;
238            }
239
240            let version = latest_published_crate_version(&package_name).await?;
241            content = Self::insert_dependency_version(&content, &package_name, &version);
242            changed = true;
243        }
244
245        if changed {
246            tokio::fs::write(&cargo_toml_path, content).await?;
247        }
248
249        Ok(())
250    }
251
252    /// Add node entry to mecha10.json. Only mints the `nodes` map entry; `targets` placement
253    /// is handled separately by `handlers/init.rs`.
254    ///
255    /// Writes the expanded [`NodeEntry::Detailed`] form (LAB-5021) rather than a bare
256    /// pinned-version string, with `capabilities` read off whatever local `config.json`
257    /// [`Self::read_local_node_capabilities`] finds for this node (best-effort: empty if none
258    /// has been pulled yet, or it has no `capabilities` field) - otherwise
259    /// `NodesConfig::aggregated_capabilities()` has nothing to see for a node added this way.
260    async fn add_node_to_config(
261        &self,
262        project_root: &Path,
263        component_name: &str,
264        is_monorepo_node: bool,
265    ) -> Result<()> {
266        use crate::services::ConfigService;
267
268        let config_path = project_root.join(paths::PROJECT_CONFIG);
269        let mut config = ConfigService::load_from(&config_path).await?;
270
271        let (node_identifier, version) = if is_monorepo_node {
272            (
273                format!("@mecha10/{}", component_name),
274                env!("CARGO_PKG_VERSION").to_string(),
275            )
276        } else {
277            (component_name.to_string(), "0.1.0".to_string())
278        };
279
280        if config.nodes.get_version(&node_identifier).is_none() {
281            let capabilities = Self::read_local_node_capabilities(project_root, component_name, is_monorepo_node);
282            config.nodes.0.insert(
283                node_identifier,
284                NodeEntry::Detailed {
285                    version,
286                    enabled: true,
287                    capabilities,
288                },
289            );
290
291            let config_json = serde_json::to_string_pretty(&config)?;
292            tokio::fs::write(&config_path, config_json).await?;
293        }
294
295        Ok(())
296    }
297
298    /// Read the `capabilities` field off a node's already-pulled local `config.json`, if any -
299    /// `configs/nodes/@mecha10/<name>/config.json` for a framework node,
300    /// `configs/nodes/<name>/config.json` for a project-local one. Best-effort like
301    /// `mecha10_robot_config::pull_node_catalog_configs` itself: a missing file, missing field,
302    /// or malformed value all just yield an empty `Vec` rather than failing the add.
303    fn read_local_node_capabilities(
304        project_root: &Path,
305        component_name: &str,
306        is_monorepo_node: bool,
307    ) -> Vec<Capability> {
308        let config_path = project_root.join(if is_monorepo_node {
309            paths::config::framework_node(component_name)
310        } else {
311            paths::config::project_node(component_name)
312        });
313
314        let Ok(content) = std::fs::read_to_string(&config_path) else {
315            return Vec::new();
316        };
317
318        let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
319            return Vec::new();
320        };
321
322        value
323            .get("capabilities")
324            .cloned()
325            .and_then(|v| serde_json::from_value::<Vec<Capability>>(v).ok())
326            .unwrap_or_default()
327    }
328}
329
330impl Default for InitService {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336#[cfg(test)]
337mod parse_max_version_tests {
338    use super::*;
339
340    #[test]
341    fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
342        let body = r#"{
343            "crate": {
344                "id": "mecha10-nodes-behavior-executor",
345                "max_version": "0.4.2",
346                "max_stable_version": "0.4.2"
347            },
348            "versions": []
349        }"#;
350
351        let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
352        assert_eq!(version, "0.4.2");
353    }
354
355    #[test]
356    fn errors_instead_of_silently_falling_back_on_malformed_body() {
357        let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
358        assert!(result.is_err());
359    }
360}
361
362#[cfg(test)]
363mod add_dependency_tests {
364    use super::*;
365
366    /// Minimal workspace `Cargo.toml` shaped like what `mecha10 init` scaffolds, with the
367    /// `mecha10-core = "0.1"` anchor line `add_dependency` inserts after.
368    fn minimal_cargo_toml() -> &'static str {
369        "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
370    }
371
372    #[tokio::test]
373    async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
374        // If the package name is already in Cargo.toml, `add_dependency` must return early
375        // before ever resolving a version - this must be able to run with no network access.
376        let temp_dir = tempfile::tempdir().expect("create temp dir");
377        let project_root = temp_dir.path();
378        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
379
380        let existing = format!("{}mecha10-nodes-behavior-executor = \"0.3.0\"\n", minimal_cargo_toml());
381        tokio::fs::write(&cargo_toml_path, &existing)
382            .await
383            .expect("write Cargo.toml");
384
385        let catalog = crate::component_catalog::ComponentCatalog::new();
386        let component = catalog
387            .get("behavior-executor")
388            .expect("behavior-executor is in the catalog")
389            .clone();
390
391        let service = InitService::new();
392        service
393            .add_dependency(project_root, &component)
394            .await
395            .expect("add_dependency should no-op without touching the network");
396
397        let updated = tokio::fs::read_to_string(&cargo_toml_path)
398            .await
399            .expect("read Cargo.toml");
400        assert_eq!(
401            updated, existing,
402            "an already-present dependency must be left untouched"
403        );
404    }
405
406    #[tokio::test]
407    async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
408        // Same no-network guarantee as the Package-type test above, but for a Driver-type
409        // component whose `cargo_dependencies` has two entries (local `mecha10-core` path dep
410        // first, real third-party crate second) - both already present must mean no network
411        // calls and no changes at all.
412        let temp_dir = tempfile::tempdir().expect("create temp dir");
413        let project_root = temp_dir.path();
414        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
415
416        let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
417        tokio::fs::write(&cargo_toml_path, &existing)
418            .await
419            .expect("write Cargo.toml");
420
421        let catalog = crate::component_catalog::ComponentCatalog::new();
422        let component = catalog
423            .get("realsense-camera")
424            .expect("realsense-camera is in the catalog")
425            .clone();
426
427        let service = InitService::new();
428        service
429            .add_dependency(project_root, &component)
430            .await
431            .expect("add_dependency should no-op without touching the network");
432
433        let updated = tokio::fs::read_to_string(&cargo_toml_path)
434            .await
435            .expect("read Cargo.toml");
436        assert_eq!(
437            updated, existing,
438            "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
439        );
440    }
441
442    /// `external_dependency_names` is the pure piece of `add_dependency` that decides which
443    /// crate names need resolving/adding - covering it directly avoids a live crates.io call in
444    /// this test suite while still proving the fix for the underlying bug.
445    mod external_dependency_names_tests {
446        use super::*;
447
448        #[test]
449        fn resolves_only_the_single_external_crate_for_a_package_component() {
450            // Package-type catalog entries (behavior-executor, camera, servo, ...) have exactly
451            // one `cargo_dependencies` entry for their own `mecha10-nodes-<id>` crate - this
452            // must be unaffected by the driver fix.
453            let catalog = crate::component_catalog::ComponentCatalog::new();
454            let component = catalog
455                .get("behavior-executor")
456                .expect("behavior-executor is in the catalog");
457
458            let names = InitService::external_dependency_names(component);
459
460            assert_eq!(
461                names,
462                vec!["mecha10-nodes-behavior-executor".to_string()],
463                "a Package component must resolve exactly its own node crate"
464            );
465        }
466
467        #[test]
468        fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
469            // This is the bug: `realsense-camera` lists `mecha10-core` (a local path dep) first
470            // and `realsense-rust` (its real third-party crate) second. The fixed logic must
471            // skip the path dependency and resolve only the real crate - not stop at `.first()`
472            // and silently resolve to `mecha10-core`, which is always already present.
473            let catalog = crate::component_catalog::ComponentCatalog::new();
474            let component = catalog
475                .get("realsense-camera")
476                .expect("realsense-camera is in the catalog");
477
478            let names = InitService::external_dependency_names(component);
479
480            assert_eq!(
481                names,
482                vec!["realsense-rust".to_string()],
483                "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
484            );
485        }
486    }
487
488    mod insert_dependency_version_tests {
489        use super::*;
490
491        #[test]
492        fn inserts_right_after_the_mecha10_core_anchor_line() {
493            let content = minimal_cargo_toml();
494
495            let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
496
497            assert_eq!(
498                updated,
499                "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
500            );
501        }
502    }
503
504    #[tokio::test]
505    async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
506        // End-to-end (minus the network hop) check that `add_dependency`'s loop, when the
507        // driver's real crate is missing but its local mecha10-core dep is already present,
508        // adds the real crate and does not duplicate/touch mecha10-core.
509        let temp_dir = tempfile::tempdir().expect("create temp dir");
510        let project_root = temp_dir.path();
511        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
512        tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
513            .await
514            .expect("write Cargo.toml");
515
516        let catalog = crate::component_catalog::ComponentCatalog::new();
517        let component = catalog
518            .get("realsense-camera")
519            .expect("realsense-camera is in the catalog")
520            .clone();
521
522        // Exercise the same package-name resolution `add_dependency` uses, but substitute a
523        // fixed version in place of the live crates.io lookup so this test has no network
524        // dependency.
525        let names = InitService::external_dependency_names(&component);
526        assert_eq!(names, vec!["realsense-rust".to_string()]);
527
528        let content = tokio::fs::read_to_string(&cargo_toml_path)
529            .await
530            .expect("read Cargo.toml");
531        let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
532        tokio::fs::write(&cargo_toml_path, &updated)
533            .await
534            .expect("write Cargo.toml");
535
536        let final_content = tokio::fs::read_to_string(&cargo_toml_path)
537            .await
538            .expect("read Cargo.toml");
539        assert!(
540            final_content.contains("realsense-rust = \"0.6.1\""),
541            "the driver's real third-party crate must be added, got: {}",
542            final_content
543        );
544        assert_eq!(
545            final_content.matches("mecha10-core").count(),
546            1,
547            "mecha10-core must not be duplicated, got: {}",
548            final_content
549        );
550    }
551}
552
553#[cfg(test)]
554mod add_node_to_config_tests {
555    use super::*;
556    use mecha10_cli_core::services::ConfigService;
557
558    /// Minimal valid mecha10.json content for a freshly-initialized project.
559    fn minimal_project_config_json() -> &'static str {
560        r#"{
561            "name": "test-project",
562            "version": "0.1.0",
563            "robot_id": "test-robot"
564        }"#
565    }
566
567    #[tokio::test]
568    async fn mints_project_nodes_with_plain_identifier() {
569        let temp_dir = tempfile::tempdir().expect("create temp dir");
570        let project_root = temp_dir.path();
571        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
572            .await
573            .expect("write mecha10.json");
574
575        let service = InitService::new();
576
577        service
578            .add_node_to_config(project_root, "my-custom", false)
579            .await
580            .expect("add_node_to_config should succeed");
581
582        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
583            .await
584            .expect("reload config");
585
586        assert!(
587            config.nodes.identifiers().contains(&"my-custom"),
588            "expected plain identifier 'my-custom' in nodes, got: {:?}",
589            config.nodes.identifiers()
590        );
591        assert!(
592            !config.nodes.identifiers().contains(&"@local/my-custom"),
593            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
594            config.nodes.identifiers()
595        );
596    }
597
598    /// `add_node_to_config` must preserve `template_version` across its save, since it runs for
599    /// every default/selected node on every `mecha10 init`.
600    #[tokio::test]
601    async fn preserves_template_version_written_by_mecha10_init() {
602        let temp_dir = tempfile::tempdir().expect("create temp dir");
603        let project_root = temp_dir.path();
604        tokio::fs::write(
605            project_root.join(paths::PROJECT_CONFIG),
606            r#"{
607                "name": "test-project",
608                "version": "0.6.5",
609                "template_version": "0.6.5",
610                "robot_id": "test-robot"
611            }"#,
612        )
613        .await
614        .expect("write mecha10.json");
615
616        let service = InitService::new();
617
618        service
619            .add_node_to_config(project_root, "speaker", true)
620            .await
621            .expect("add_node_to_config should succeed");
622
623        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
624            .await
625            .expect("reload config");
626
627        assert_eq!(
628            config.template_version,
629            Some("0.6.5".to_string()),
630            "template_version written at `mecha10 init` time must survive add_node_to_config's save"
631        );
632    }
633
634    #[tokio::test]
635    async fn mints_framework_nodes_with_mecha10_scope() {
636        let temp_dir = tempfile::tempdir().expect("create temp dir");
637        let project_root = temp_dir.path();
638        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
639            .await
640            .expect("write mecha10.json");
641
642        let service = InitService::new();
643
644        service
645            .add_node_to_config(project_root, "speaker", true)
646            .await
647            .expect("add_node_to_config should succeed");
648
649        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
650            .await
651            .expect("reload config");
652
653        assert!(
654            config.nodes.identifiers().contains(&"@mecha10/speaker"),
655            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
656            config.nodes.identifiers()
657        );
658    }
659
660    /// LAB-5021: when a framework node's local `config.json` (what
661    /// `mecha10_robot_config::pull_node_catalog_configs` would have downloaded) carries a
662    /// `"capabilities"` field, `add_node_to_config` must thread it into the expanded
663    /// `NodeEntry::Detailed` form rather than minting a bare pinned-version string.
664    #[tokio::test]
665    async fn mints_detailed_entry_with_capabilities_from_local_config_json() {
666        let temp_dir = tempfile::tempdir().expect("create temp dir");
667        let project_root = temp_dir.path();
668        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
669            .await
670            .expect("write mecha10.json");
671
672        let node_config_dir = project_root.join("configs/nodes/@mecha10/speaker");
673        tokio::fs::create_dir_all(&node_config_dir)
674            .await
675            .expect("create node config dir");
676        tokio::fs::write(
677            node_config_dir.join("config.json"),
678            serde_json::json!({ "run_target": "robot", "capabilities": ["audio"] }).to_string(),
679        )
680        .await
681        .expect("write node config.json");
682
683        let service = InitService::new();
684
685        service
686            .add_node_to_config(project_root, "speaker", true)
687            .await
688            .expect("add_node_to_config should succeed");
689
690        let raw = tokio::fs::read_to_string(project_root.join(paths::PROJECT_CONFIG))
691            .await
692            .expect("read mecha10.json");
693        let raw: serde_json::Value = serde_json::from_str(&raw).expect("parse mecha10.json");
694
695        assert_eq!(
696            raw["nodes"]["@mecha10/speaker"],
697            serde_json::json!({
698                "version": env!("CARGO_PKG_VERSION"),
699                "enabled": true,
700                "capabilities": ["audio"],
701            })
702        );
703    }
704
705    /// `handlers/init.rs`'s `build_node_placement` runs before this and already pins a
706    /// selected framework node to its own live-catalog version. `add_node_to_config` must not
707    /// clobber that back to this crate's own `CARGO_PKG_VERSION`.
708    #[tokio::test]
709    async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
710        let temp_dir = tempfile::tempdir().expect("create temp dir");
711        let project_root = temp_dir.path();
712        let config_with_pinned_node = r#"{
713            "name": "test-project",
714            "version": "0.1.0",
715            "robot_id": "test-robot",
716            "nodes": { "@mecha10/speaker": "0.7.0" }
717        }"#;
718        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
719            .await
720            .expect("write mecha10.json");
721
722        let service = InitService::new();
723
724        service
725            .add_node_to_config(project_root, "speaker", true)
726            .await
727            .expect("add_node_to_config should succeed");
728
729        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
730            .await
731            .expect("reload config");
732
733        assert_eq!(
734            config.nodes.get_version("@mecha10/speaker"),
735            Some("0.7.0"),
736            "the live-catalog-pinned version must survive add_node_to_config's save, not be \
737             reset to this crate's own CARGO_PKG_VERSION"
738        );
739    }
740
741    /// `add_node_to_config` must not touch `targets` at all - that placement is already
742    /// written by `handlers/init.rs`'s `build_node_placement` before this ever runs.
743    #[tokio::test]
744    async fn does_not_touch_targets_at_all() {
745        let temp_dir = tempfile::tempdir().expect("create temp dir");
746        let project_root = temp_dir.path();
747        let config_with_targets = r#"{
748            "name": "test-project",
749            "version": "0.1.0",
750            "robot_id": "test-robot",
751            "targets": {
752                "robot": { "nodes": ["@mecha10/camera"] }
753            }
754        }"#;
755        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
756            .await
757            .expect("write mecha10.json");
758
759        let service = InitService::new();
760
761        service
762            .add_node_to_config(project_root, "image-classifier", true)
763            .await
764            .expect("add_node_to_config should succeed");
765
766        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
767            .await
768            .expect("reload config");
769
770        assert!(
771            config.targets.simulation.is_none(),
772            "add_node_to_config must not synthesize any targets.* entry anymore"
773        );
774        let robot = config
775            .targets
776            .robot
777            .expect("pre-existing targets.robot must be preserved");
778        assert_eq!(
779            robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
780            vec!["@mecha10/camera".to_string()],
781            "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
782            robot.nodes
783        );
784    }
785}