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