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