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 plain [`NodeEntry::Version`] pinned-version string, matching every other
303    /// template-rendered entry in `mecha10.json` - a freshly-minted node is always enabled, so
304    /// there's nothing the expanded [`NodeEntry::Detailed`] form would add here. If an entry
305    /// already exists for this node identifier (in either form), it's left completely untouched:
306    /// this is a pure insert-if-absent, no upsert rules needed now that there's no per-node field
307    ///   left to backfill.
308    async fn add_node_to_config(
309        &self,
310        project_root: &Path,
311        component_name: &str,
312        is_monorepo_node: bool,
313    ) -> Result<()> {
314        use crate::services::ConfigService;
315
316        let config_path = project_root.join(paths::PROJECT_CONFIG);
317        let mut config = ConfigService::load_from(&config_path).await?;
318
319        let (node_identifier, version) = if is_monorepo_node {
320            (
321                format!("@mecha10/{}", component_name),
322                env!("CARGO_PKG_VERSION").to_string(),
323            )
324        } else {
325            (component_name.to_string(), "0.1.0".to_string())
326        };
327
328        if config.nodes.0.contains_key(&node_identifier) {
329            return Ok(());
330        }
331
332        config.nodes.0.insert(node_identifier, NodeEntry::Version(version));
333
334        let config_json = serde_json::to_string_pretty(&config)?;
335        tokio::fs::write(&config_path, config_json).await?;
336
337        Ok(())
338    }
339}
340
341impl Default for InitService {
342    fn default() -> Self {
343        Self::new()
344    }
345}
346
347#[cfg(test)]
348mod parse_max_version_tests {
349    use super::*;
350
351    #[test]
352    fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
353        let body = r#"{
354            "crate": {
355                "id": "mecha10-nodes-behavior-executor",
356                "max_version": "0.4.2",
357                "max_stable_version": "0.4.2"
358            },
359            "versions": []
360        }"#;
361
362        let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
363        assert_eq!(version, "0.4.2");
364    }
365
366    #[test]
367    fn errors_instead_of_silently_falling_back_on_malformed_body() {
368        let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
369        assert!(result.is_err());
370    }
371}
372
373#[cfg(test)]
374mod add_dependency_tests {
375    use super::*;
376
377    /// Minimal workspace `Cargo.toml` shaped like what `mecha10 init` scaffolds, with the
378    /// `mecha10-core = "0.1"` anchor line `add_dependency` inserts after.
379    fn minimal_cargo_toml() -> &'static str {
380        "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
381    }
382
383    #[tokio::test]
384    async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
385        // If the package name is already in Cargo.toml, `add_dependency` must return early
386        // before ever resolving a version - this must be able to run with no network access.
387        let temp_dir = tempfile::tempdir().expect("create temp dir");
388        let project_root = temp_dir.path();
389        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
390
391        let existing = format!("{}realsense-rust = \"0.3.0\"\n", minimal_cargo_toml());
392        tokio::fs::write(&cargo_toml_path, &existing)
393            .await
394            .expect("write Cargo.toml");
395
396        let catalog = crate::component_catalog::ComponentCatalog::new();
397        let component = catalog
398            .get("realsense-camera")
399            .expect("realsense-camera is in the catalog")
400            .clone();
401
402        let service = InitService::new();
403        service
404            .add_dependency(project_root, &component)
405            .await
406            .expect("add_dependency should no-op without touching the network");
407
408        let updated = tokio::fs::read_to_string(&cargo_toml_path)
409            .await
410            .expect("read Cargo.toml");
411        assert_eq!(
412            updated, existing,
413            "an already-present dependency must be left untouched"
414        );
415    }
416
417    #[tokio::test]
418    async fn is_a_complete_no_op_for_a_package_node_regardless_of_cargo_toml_content() {
419        // Package-type nodes (behavior-executor, camera, servo, pan-tilt, ...) list only their
420        // own `mecha10-nodes-<id>` crate in `cargo_dependencies`, which `external_dependency_names`
421        // now always excludes - so `add_dependency`'s loop has nothing to iterate. This must be
422        // provable without any network access: no crates.io call, no Cargo.toml write, even
423        // though the dependency name is nowhere in the file.
424        let temp_dir = tempfile::tempdir().expect("create temp dir");
425        let project_root = temp_dir.path();
426        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
427
428        let existing = minimal_cargo_toml().to_string();
429        tokio::fs::write(&cargo_toml_path, &existing)
430            .await
431            .expect("write Cargo.toml");
432
433        let catalog = crate::component_catalog::ComponentCatalog::new();
434        let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog").clone();
435
436        let service = InitService::new();
437        service
438            .add_dependency(project_root, &component)
439            .await
440            .expect("add_dependency should no-op without touching the network");
441
442        let updated = tokio::fs::read_to_string(&cargo_toml_path)
443            .await
444            .expect("read Cargo.toml");
445        assert_eq!(
446            updated, existing,
447            "a Package node's own mecha10-nodes-<id> crate must never be added to Cargo.toml"
448        );
449    }
450
451    #[tokio::test]
452    async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
453        // Same no-network guarantee as the Package-type test above, but for a Driver-type
454        // component whose `cargo_dependencies` has two entries (local `mecha10-core` path dep
455        // first, real third-party crate second) - both already present must mean no network
456        // calls and no changes at all.
457        let temp_dir = tempfile::tempdir().expect("create temp dir");
458        let project_root = temp_dir.path();
459        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
460
461        let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
462        tokio::fs::write(&cargo_toml_path, &existing)
463            .await
464            .expect("write Cargo.toml");
465
466        let catalog = crate::component_catalog::ComponentCatalog::new();
467        let component = catalog
468            .get("realsense-camera")
469            .expect("realsense-camera is in the catalog")
470            .clone();
471
472        let service = InitService::new();
473        service
474            .add_dependency(project_root, &component)
475            .await
476            .expect("add_dependency should no-op without touching the network");
477
478        let updated = tokio::fs::read_to_string(&cargo_toml_path)
479            .await
480            .expect("read Cargo.toml");
481        assert_eq!(
482            updated, existing,
483            "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
484        );
485    }
486
487    /// `external_dependency_names` is the pure piece of `add_dependency` that decides which
488    /// crate names need resolving/adding - covering it directly avoids a live crates.io call in
489    /// this test suite while still proving the fix for the underlying bug.
490    mod external_dependency_names_tests {
491        use super::*;
492
493        #[test]
494        fn excludes_a_package_components_own_node_crate() {
495            // Package-type catalog entries (behavior-executor, camera, servo, pan-tilt, ...)
496            // have exactly one `cargo_dependencies` entry, for their own `mecha10-nodes-<id>`
497            // crate. That crate is never published to crates.io - it's distributed as a
498            // prebuilt binary via mecha10-api, keyed by the version already in `mecha10.json` -
499            // so resolving/adding it here must never happen. This is the actual bug fix: before
500            // it, this returned `vec!["mecha10-nodes-pan-tilt"]`, which 404'd against crates.io
501            // and aborted `add_example_node` before the node ever landed in `mecha10.json`.
502            let catalog = crate::component_catalog::ComponentCatalog::new();
503            let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog");
504
505            let names = InitService::external_dependency_names(component);
506
507            assert_eq!(
508                names,
509                Vec::<String>::new(),
510                "a Package component must never resolve its own mecha10-nodes-<id> crate"
511            );
512        }
513
514        #[test]
515        fn excludes_every_package_components_own_node_crate() {
516            // Belt-and-suspenders: assert the exclusion holds across the whole catalog, not just
517            // one hand-picked component - every Package-type component must resolve to an empty
518            // Vec, never its own `mecha10-nodes-<id>` name.
519            let catalog = crate::component_catalog::ComponentCatalog::new();
520
521            for component in catalog.get_all() {
522                let own_node_crate = format!("mecha10-nodes-{}", component.id);
523                let names = InitService::external_dependency_names(component);
524                assert!(
525                    !names.contains(&own_node_crate),
526                    "{} must never resolve its own node crate {}, got {:?}",
527                    component.id,
528                    own_node_crate,
529                    names
530                );
531            }
532        }
533
534        #[test]
535        fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
536            // This is the bug: `realsense-camera` lists `mecha10-core` (a local path dep) first
537            // and `realsense-rust` (its real third-party crate) second. The fixed logic must
538            // skip the path dependency and resolve only the real crate - not stop at `.first()`
539            // and silently resolve to `mecha10-core`, which is always already present.
540            let catalog = crate::component_catalog::ComponentCatalog::new();
541            let component = catalog
542                .get("realsense-camera")
543                .expect("realsense-camera is in the catalog");
544
545            let names = InitService::external_dependency_names(component);
546
547            assert_eq!(
548                names,
549                vec!["realsense-rust".to_string()],
550                "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
551            );
552        }
553    }
554
555    mod insert_dependency_version_tests {
556        use super::*;
557
558        #[test]
559        fn inserts_right_after_the_mecha10_core_anchor_line() {
560            let content = minimal_cargo_toml();
561
562            let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
563
564            assert_eq!(
565                updated,
566                "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
567            );
568        }
569    }
570
571    #[tokio::test]
572    async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
573        // End-to-end (minus the network hop) check that `add_dependency`'s loop, when the
574        // driver's real crate is missing but its local mecha10-core dep is already present,
575        // adds the real crate and does not duplicate/touch mecha10-core.
576        let temp_dir = tempfile::tempdir().expect("create temp dir");
577        let project_root = temp_dir.path();
578        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
579        tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
580            .await
581            .expect("write Cargo.toml");
582
583        let catalog = crate::component_catalog::ComponentCatalog::new();
584        let component = catalog
585            .get("realsense-camera")
586            .expect("realsense-camera is in the catalog")
587            .clone();
588
589        // Exercise the same package-name resolution `add_dependency` uses, but substitute a
590        // fixed version in place of the live crates.io lookup so this test has no network
591        // dependency.
592        let names = InitService::external_dependency_names(&component);
593        assert_eq!(names, vec!["realsense-rust".to_string()]);
594
595        let content = tokio::fs::read_to_string(&cargo_toml_path)
596            .await
597            .expect("read Cargo.toml");
598        let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
599        tokio::fs::write(&cargo_toml_path, &updated)
600            .await
601            .expect("write Cargo.toml");
602
603        let final_content = tokio::fs::read_to_string(&cargo_toml_path)
604            .await
605            .expect("read Cargo.toml");
606        assert!(
607            final_content.contains("realsense-rust = \"0.6.1\""),
608            "the driver's real third-party crate must be added, got: {}",
609            final_content
610        );
611        assert_eq!(
612            final_content.matches("mecha10-core").count(),
613            1,
614            "mecha10-core must not be duplicated, got: {}",
615            final_content
616        );
617    }
618}
619
620#[cfg(test)]
621mod add_node_to_config_tests {
622    use super::*;
623    use mecha10_cli_core::services::ConfigService;
624
625    /// Minimal valid mecha10.json content for a freshly-initialized project.
626    fn minimal_project_config_json() -> &'static str {
627        r#"{
628            "name": "test-project",
629            "version": "0.1.0",
630            "robot_id": "test-robot"
631        }"#
632    }
633
634    #[tokio::test]
635    async fn mints_project_nodes_with_plain_identifier() {
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, "my-custom", false)
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(&"my-custom"),
655            "expected plain identifier 'my-custom' in nodes, got: {:?}",
656            config.nodes.identifiers()
657        );
658        assert!(
659            !config.nodes.identifiers().contains(&"@local/my-custom"),
660            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
661            config.nodes.identifiers()
662        );
663    }
664
665    /// `add_node_to_config` must preserve `template_version` across its save, since it runs for
666    /// every default/selected node on every `mecha10 init`.
667    #[tokio::test]
668    async fn preserves_template_version_written_by_mecha10_init() {
669        let temp_dir = tempfile::tempdir().expect("create temp dir");
670        let project_root = temp_dir.path();
671        tokio::fs::write(
672            project_root.join(paths::PROJECT_CONFIG),
673            r#"{
674                "name": "test-project",
675                "version": "0.6.5",
676                "template_version": "0.6.5",
677                "robot_id": "test-robot"
678            }"#,
679        )
680        .await
681        .expect("write mecha10.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 config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
691            .await
692            .expect("reload config");
693
694        assert_eq!(
695            config.template_version,
696            Some("0.6.5".to_string()),
697            "template_version written at `mecha10 init` time must survive add_node_to_config's save"
698        );
699    }
700
701    #[tokio::test]
702    async fn mints_framework_nodes_with_mecha10_scope() {
703        let temp_dir = tempfile::tempdir().expect("create temp dir");
704        let project_root = temp_dir.path();
705        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
706            .await
707            .expect("write mecha10.json");
708
709        let service = InitService::new();
710
711        service
712            .add_node_to_config(project_root, "speaker", true)
713            .await
714            .expect("add_node_to_config should succeed");
715
716        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
717            .await
718            .expect("reload config");
719
720        assert!(
721            config.nodes.identifiers().contains(&"@mecha10/speaker"),
722            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
723            config.nodes.identifiers()
724        );
725    }
726
727    /// Capabilities no longer live per-node, and a freshly-minted node is always enabled - so
728    /// `add_node_to_config` mints a plain pinned-version string, matching every other
729    /// template-rendered entry, not the expanded `Detailed` object form.
730    #[tokio::test]
731    async fn mints_plain_version_string() {
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!(env!("CARGO_PKG_VERSION"))
753        );
754    }
755
756    /// `handlers/init.rs`'s `build_node_placement` runs before this and already pins a
757    /// selected framework node to its own live-catalog version. `add_node_to_config` must not
758    /// clobber that back to this crate's own `CARGO_PKG_VERSION`.
759    #[tokio::test]
760    async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
761        let temp_dir = tempfile::tempdir().expect("create temp dir");
762        let project_root = temp_dir.path();
763        let config_with_pinned_node = r#"{
764            "name": "test-project",
765            "version": "0.1.0",
766            "robot_id": "test-robot",
767            "nodes": { "@mecha10/speaker": "0.7.0" }
768        }"#;
769        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
770            .await
771            .expect("write mecha10.json");
772
773        let service = InitService::new();
774
775        service
776            .add_node_to_config(project_root, "speaker", true)
777            .await
778            .expect("add_node_to_config should succeed");
779
780        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
781            .await
782            .expect("reload config");
783
784        assert_eq!(
785            config.nodes.get_version("@mecha10/speaker"),
786            Some("0.7.0"),
787            "the live-catalog-pinned version must survive add_node_to_config's save, not be \
788             reset to this crate's own CARGO_PKG_VERSION"
789        );
790    }
791
792    /// An entry that already exists (in either form) must be left completely untouched - no
793    /// upsert rules apply now that there's no per-node field left to backfill.
794    #[tokio::test]
795    async fn does_not_touch_an_already_present_entry() {
796        let temp_dir = tempfile::tempdir().expect("create temp dir");
797        let project_root = temp_dir.path();
798        let config_with_existing_entry = r#"{
799            "name": "test-project",
800            "version": "0.1.0",
801            "robot_id": "test-robot",
802            "nodes": { "@mecha10/speaker": { "version": "0.7.0", "enabled": false } }
803        }"#;
804        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_existing_entry)
805            .await
806            .expect("write mecha10.json");
807
808        let service = InitService::new();
809
810        service
811            .add_node_to_config(project_root, "speaker", true)
812            .await
813            .expect("add_node_to_config should succeed");
814
815        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
816            .await
817            .expect("reload config");
818
819        let entry = config.nodes.0.get("@mecha10/speaker").expect("entry must still exist");
820        assert_eq!(entry.version(), "0.7.0", "version must be preserved");
821        assert!(!entry.enabled(), "enabled: false must be preserved, not clobbered");
822    }
823
824    /// `add_node_to_config` must not touch `targets` at all - that placement is already
825    /// written by `handlers/init.rs`'s `build_node_placement` before this ever runs.
826    #[tokio::test]
827    async fn does_not_touch_targets_at_all() {
828        let temp_dir = tempfile::tempdir().expect("create temp dir");
829        let project_root = temp_dir.path();
830        let config_with_targets = r#"{
831            "name": "test-project",
832            "version": "0.1.0",
833            "robot_id": "test-robot",
834            "targets": {
835                "robot": { "nodes": ["@mecha10/camera"] }
836            }
837        }"#;
838        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
839            .await
840            .expect("write mecha10.json");
841
842        let service = InitService::new();
843
844        service
845            .add_node_to_config(project_root, "image-classifier", true)
846            .await
847            .expect("add_node_to_config should succeed");
848
849        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
850            .await
851            .expect("reload config");
852
853        assert!(
854            config.targets.simulation.is_none(),
855            "add_node_to_config must not synthesize any targets.* entry anymore"
856        );
857        let robot = config
858            .targets
859            .robot
860            .expect("pre-existing targets.robot must be preserved");
861        assert_eq!(
862            robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
863            vec!["@mecha10/camera".to_string()],
864            "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
865            robot.nodes
866        );
867    }
868}