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    /// Backfill `capabilities` onto a node that's already fully scaffolded on disk (component
162    /// files already written, Cargo.toml/workspace member already present), without re-running
163    /// any of [`Self::add_example_node`]'s file-writing steps.
164    ///
165    /// `mecha10 init --update` needs this: re-running the full `add_example_node` scaffold for
166    /// a node that already has a local `config.json` would needlessly rewrite its component
167    /// files (clobbering anything the project author customized) just to upsert one field in
168    /// `mecha10.json`. This only touches the `nodes` map entry, via
169    /// [`Self::add_node_to_config`]'s upsert rules.
170    pub async fn sync_node_capabilities(
171        &self,
172        project_root: &Path,
173        component_name: &str,
174        is_monorepo_node: bool,
175        capabilities: Vec<Capability>,
176    ) -> Result<()> {
177        self.add_node_to_config(project_root, component_name, is_monorepo_node, capabilities)
178            .await
179    }
180
181    /// Add a workspace member to Cargo.toml
182    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
183        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
184        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
185
186        let new_member = format!("nodes/{}", component_name);
187        let updated_content = if content.contains("members = []") {
188            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
189        } else if let Some(start) = content.find("members = [") {
190            if let Some(end) = content[start..].find(']') {
191                let insert_pos = start + end;
192                let members_section = &content[start..insert_pos];
193                if members_section.contains('\"') {
194                    format!(
195                        "{}\n    \"{}\",{}",
196                        &content[..insert_pos],
197                        new_member,
198                        &content[insert_pos..]
199                    )
200                } else {
201                    format!(
202                        "{}\n    \"{}\",\n{}",
203                        &content[..insert_pos],
204                        new_member,
205                        &content[insert_pos..]
206                    )
207                }
208            } else {
209                content
210            }
211        } else {
212            content
213        };
214
215        tokio::fs::write(&cargo_toml_path, updated_content).await?;
216        Ok(())
217    }
218
219    /// The crate names `add_dependency` must resolve/add for a component.
220    ///
221    /// Walks every entry in `component.cargo_dependencies`, not just the first one - driver
222    /// components (see `component_catalog/drivers.rs`) list their local `mecha10-core` path
223    /// dependency first and their real third-party crate (e.g. `realsense-rust`, `serialport`)
224    /// second, so stopping at `.first()` silently resolved to `mecha10-core` - which is always
225    /// already present in a scaffolded Cargo.toml - and skipped adding the driver's actual
226    /// crate dependency entirely. Local workspace path dependencies (`dep.path.is_some()`, e.g.
227    /// `mecha10-core`) are already present via the project's own scaffolding and must not be
228    /// touched here - only real external crates need resolving/adding.
229    ///
230    /// Also always excludes a component's own `mecha10-nodes-<id>` crate (e.g.
231    /// `mecha10-nodes-pan-tilt`), which every `Package`-type component (see
232    /// `component_catalog/packages.rs`) lists as its sole `cargo_dependencies` entry. These
233    /// crates are deliberately never published to crates.io - `mecha10-nodes` builds and
234    /// uploads prebuilt binaries to mecha10-api, and nodes are downloaded as prebuilt binaries
235    /// at runtime (`mecha10-deploy/src/services/binary_manager.rs`) keyed by the version already
236    /// stored in `mecha10.json` (see PR #1384/#1488's `node_versions`/`build_node_placement`
237    /// wiring, which is the actual source of truth for that version - never Cargo.toml).
238    /// Resolving/adding this name against crates.io either 404s (for most nodes) or resolves a
239    /// stale orphaned version (for a handful of leftovers), and either way the resulting error
240    /// used to abort `add_example_node` before `add_node_to_config` ever ran, so no `Package`
241    /// node ever landed in `mecha10.json` at all. Nothing in the build or runtime path reads
242    /// this Cargo.toml entry back.
243    fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
244        if component.cargo_dependencies.is_empty() {
245            return vec![];
246        }
247
248        let own_node_crate = format!("mecha10-nodes-{}", component.id);
249
250        component
251            .cargo_dependencies
252            .iter()
253            .filter(|dep| dep.path.is_none())
254            .map(|dep| dep.name.clone())
255            .filter(|name| *name != own_node_crate)
256            .collect()
257    }
258
259    /// Insert `package_name = "version"` into `content` right after the
260    /// `mecha10-core = "0.1"` anchor line that every scaffolded project's Cargo.toml has.
261    /// Returns `content` unchanged if the anchor line isn't found.
262    fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
263        let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
264            return content.to_string();
265        };
266        let Some(newline_pos) = content[pos..].find('\n') else {
267            return content.to_string();
268        };
269        let insert_pos = pos + newline_pos + 1;
270        format!(
271            "{}{} = \"{}\"\n{}",
272            &content[..insert_pos],
273            package_name,
274            version,
275            &content[insert_pos..]
276        )
277    }
278
279    /// Add a dependency to Cargo.toml.
280    ///
281    /// Only ever resolves/adds the names `external_dependency_names` returns, which already
282    /// excludes a component's own `mecha10-nodes-<id>` binary-distributed crate - so for every
283    /// `Package`-type node this is a guaranteed no-op (empty `Vec` to iterate: no network call,
284    /// no Cargo.toml write). Driver components' genuine third-party crates (e.g.
285    /// `realsense-rust`, `serialport`) still resolve against crates.io exactly as before.
286    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
287        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
288        let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
289
290        let mut changed = false;
291
292        for package_name in Self::external_dependency_names(component) {
293            if content.contains(&package_name) {
294                continue;
295            }
296
297            let version = latest_published_crate_version(&package_name).await?;
298            content = Self::insert_dependency_version(&content, &package_name, &version);
299            changed = true;
300        }
301
302        if changed {
303            tokio::fs::write(&cargo_toml_path, content).await?;
304        }
305
306        Ok(())
307    }
308
309    /// Mint or upsert this node's entry in mecha10.json's `nodes` map; `targets` placement is
310    /// handled separately by `handlers/init.rs`.
311    ///
312    /// Writes the expanded [`NodeEntry::Detailed`] form (LAB-5021) rather than a bare
313    /// pinned-version string, with `capabilities` exactly as given by the caller (the live
314    /// node-catalog response's `NodeCatalogEntry::capabilities`, resolved by `handlers/init.rs`
315    /// before this runs) - otherwise `NodesConfig::aggregated_capabilities()` has nothing to see
316    /// for a node added this way.
317    ///
318    /// Three cases, in order:
319    /// - No entry yet for this node identifier -> mint a fresh `NodeEntry::Detailed`.
320    /// - An entry already exists but declares no capabilities (either the plain
321    ///   `NodeEntry::Version` form, or a `Detailed` entry with an empty `capabilities` list) and
322    ///   the caller has a non-empty `capabilities` to give -> backfill capabilities onto the
323    ///   existing entry in place, preserving its pinned version (and, for an existing `Detailed`
324    ///   entry, its `enabled` value too). This is what lets `mecha10 init --update` backfill
325    ///   capabilities onto a node that was added before the live-catalog capability wiring
326    ///   existed, without clobbering a deliberate `enabled: false`.
327    /// - Otherwise (the entry already has capabilities, or the caller has none to give) -> leave
328    ///   the entry untouched.
329    async fn add_node_to_config(
330        &self,
331        project_root: &Path,
332        component_name: &str,
333        is_monorepo_node: bool,
334        capabilities: Vec<Capability>,
335    ) -> Result<()> {
336        use crate::services::ConfigService;
337
338        let config_path = project_root.join(paths::PROJECT_CONFIG);
339        let mut config = ConfigService::load_from(&config_path).await?;
340
341        let (node_identifier, version) = if is_monorepo_node {
342            (
343                format!("@mecha10/{}", component_name),
344                env!("CARGO_PKG_VERSION").to_string(),
345            )
346        } else {
347            (component_name.to_string(), "0.1.0".to_string())
348        };
349
350        let new_entry = match config.nodes.0.get(&node_identifier) {
351            None => Some(NodeEntry::Detailed {
352                version,
353                enabled: true,
354                capabilities,
355            }),
356            Some(NodeEntry::Version(existing_version)) if !capabilities.is_empty() => Some(NodeEntry::Detailed {
357                version: existing_version.clone(),
358                enabled: true,
359                capabilities,
360            }),
361            Some(NodeEntry::Detailed {
362                version: existing_version,
363                enabled: existing_enabled,
364                capabilities: existing_capabilities,
365            }) if existing_capabilities.is_empty() && !capabilities.is_empty() => Some(NodeEntry::Detailed {
366                version: existing_version.clone(),
367                enabled: *existing_enabled,
368                capabilities,
369            }),
370            _ => None,
371        };
372
373        if let Some(entry) = new_entry {
374            config.nodes.0.insert(node_identifier, entry);
375
376            let config_json = serde_json::to_string_pretty(&config)?;
377            tokio::fs::write(&config_path, config_json).await?;
378        }
379
380        Ok(())
381    }
382}
383
384impl Default for InitService {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390#[cfg(test)]
391mod parse_max_version_tests {
392    use super::*;
393
394    #[test]
395    fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
396        let body = r#"{
397            "crate": {
398                "id": "mecha10-nodes-behavior-executor",
399                "max_version": "0.4.2",
400                "max_stable_version": "0.4.2"
401            },
402            "versions": []
403        }"#;
404
405        let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
406        assert_eq!(version, "0.4.2");
407    }
408
409    #[test]
410    fn errors_instead_of_silently_falling_back_on_malformed_body() {
411        let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
412        assert!(result.is_err());
413    }
414}
415
416#[cfg(test)]
417mod add_dependency_tests {
418    use super::*;
419
420    /// Minimal workspace `Cargo.toml` shaped like what `mecha10 init` scaffolds, with the
421    /// `mecha10-core = "0.1"` anchor line `add_dependency` inserts after.
422    fn minimal_cargo_toml() -> &'static str {
423        "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
424    }
425
426    #[tokio::test]
427    async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
428        // If the package name is already in Cargo.toml, `add_dependency` must return early
429        // before ever resolving a version - this must be able to run with no network access.
430        let temp_dir = tempfile::tempdir().expect("create temp dir");
431        let project_root = temp_dir.path();
432        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
433
434        let existing = format!("{}realsense-rust = \"0.3.0\"\n", minimal_cargo_toml());
435        tokio::fs::write(&cargo_toml_path, &existing)
436            .await
437            .expect("write Cargo.toml");
438
439        let catalog = crate::component_catalog::ComponentCatalog::new();
440        let component = catalog
441            .get("realsense-camera")
442            .expect("realsense-camera is in the catalog")
443            .clone();
444
445        let service = InitService::new();
446        service
447            .add_dependency(project_root, &component)
448            .await
449            .expect("add_dependency should no-op without touching the network");
450
451        let updated = tokio::fs::read_to_string(&cargo_toml_path)
452            .await
453            .expect("read Cargo.toml");
454        assert_eq!(
455            updated, existing,
456            "an already-present dependency must be left untouched"
457        );
458    }
459
460    #[tokio::test]
461    async fn is_a_complete_no_op_for_a_package_node_regardless_of_cargo_toml_content() {
462        // Package-type nodes (behavior-executor, camera, servo, pan-tilt, ...) list only their
463        // own `mecha10-nodes-<id>` crate in `cargo_dependencies`, which `external_dependency_names`
464        // now always excludes - so `add_dependency`'s loop has nothing to iterate. This must be
465        // provable without any network access: no crates.io call, no Cargo.toml write, even
466        // though the dependency name is nowhere in the file.
467        let temp_dir = tempfile::tempdir().expect("create temp dir");
468        let project_root = temp_dir.path();
469        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
470
471        let existing = minimal_cargo_toml().to_string();
472        tokio::fs::write(&cargo_toml_path, &existing)
473            .await
474            .expect("write Cargo.toml");
475
476        let catalog = crate::component_catalog::ComponentCatalog::new();
477        let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog").clone();
478
479        let service = InitService::new();
480        service
481            .add_dependency(project_root, &component)
482            .await
483            .expect("add_dependency should no-op without touching the network");
484
485        let updated = tokio::fs::read_to_string(&cargo_toml_path)
486            .await
487            .expect("read Cargo.toml");
488        assert_eq!(
489            updated, existing,
490            "a Package node's own mecha10-nodes-<id> crate must never be added to Cargo.toml"
491        );
492    }
493
494    #[tokio::test]
495    async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
496        // Same no-network guarantee as the Package-type test above, but for a Driver-type
497        // component whose `cargo_dependencies` has two entries (local `mecha10-core` path dep
498        // first, real third-party crate second) - both already present must mean no network
499        // calls and no changes at all.
500        let temp_dir = tempfile::tempdir().expect("create temp dir");
501        let project_root = temp_dir.path();
502        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
503
504        let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
505        tokio::fs::write(&cargo_toml_path, &existing)
506            .await
507            .expect("write Cargo.toml");
508
509        let catalog = crate::component_catalog::ComponentCatalog::new();
510        let component = catalog
511            .get("realsense-camera")
512            .expect("realsense-camera is in the catalog")
513            .clone();
514
515        let service = InitService::new();
516        service
517            .add_dependency(project_root, &component)
518            .await
519            .expect("add_dependency should no-op without touching the network");
520
521        let updated = tokio::fs::read_to_string(&cargo_toml_path)
522            .await
523            .expect("read Cargo.toml");
524        assert_eq!(
525            updated, existing,
526            "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
527        );
528    }
529
530    /// `external_dependency_names` is the pure piece of `add_dependency` that decides which
531    /// crate names need resolving/adding - covering it directly avoids a live crates.io call in
532    /// this test suite while still proving the fix for the underlying bug.
533    mod external_dependency_names_tests {
534        use super::*;
535
536        #[test]
537        fn excludes_a_package_components_own_node_crate() {
538            // Package-type catalog entries (behavior-executor, camera, servo, pan-tilt, ...)
539            // have exactly one `cargo_dependencies` entry, for their own `mecha10-nodes-<id>`
540            // crate. That crate is never published to crates.io - it's distributed as a
541            // prebuilt binary via mecha10-api, keyed by the version already in `mecha10.json` -
542            // so resolving/adding it here must never happen. This is the actual bug fix: before
543            // it, this returned `vec!["mecha10-nodes-pan-tilt"]`, which 404'd against crates.io
544            // and aborted `add_example_node` before the node ever landed in `mecha10.json`.
545            let catalog = crate::component_catalog::ComponentCatalog::new();
546            let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog");
547
548            let names = InitService::external_dependency_names(component);
549
550            assert_eq!(
551                names,
552                Vec::<String>::new(),
553                "a Package component must never resolve its own mecha10-nodes-<id> crate"
554            );
555        }
556
557        #[test]
558        fn excludes_every_package_components_own_node_crate() {
559            // Belt-and-suspenders: assert the exclusion holds across the whole catalog, not just
560            // one hand-picked component - every Package-type component must resolve to an empty
561            // Vec, never its own `mecha10-nodes-<id>` name.
562            let catalog = crate::component_catalog::ComponentCatalog::new();
563
564            for component in catalog.get_all() {
565                let own_node_crate = format!("mecha10-nodes-{}", component.id);
566                let names = InitService::external_dependency_names(component);
567                assert!(
568                    !names.contains(&own_node_crate),
569                    "{} must never resolve its own node crate {}, got {:?}",
570                    component.id,
571                    own_node_crate,
572                    names
573                );
574            }
575        }
576
577        #[test]
578        fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
579            // This is the bug: `realsense-camera` lists `mecha10-core` (a local path dep) first
580            // and `realsense-rust` (its real third-party crate) second. The fixed logic must
581            // skip the path dependency and resolve only the real crate - not stop at `.first()`
582            // and silently resolve to `mecha10-core`, which is always already present.
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
588            let names = InitService::external_dependency_names(component);
589
590            assert_eq!(
591                names,
592                vec!["realsense-rust".to_string()],
593                "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
594            );
595        }
596    }
597
598    mod insert_dependency_version_tests {
599        use super::*;
600
601        #[test]
602        fn inserts_right_after_the_mecha10_core_anchor_line() {
603            let content = minimal_cargo_toml();
604
605            let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
606
607            assert_eq!(
608                updated,
609                "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
610            );
611        }
612    }
613
614    #[tokio::test]
615    async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
616        // End-to-end (minus the network hop) check that `add_dependency`'s loop, when the
617        // driver's real crate is missing but its local mecha10-core dep is already present,
618        // adds the real crate and does not duplicate/touch mecha10-core.
619        let temp_dir = tempfile::tempdir().expect("create temp dir");
620        let project_root = temp_dir.path();
621        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
622        tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
623            .await
624            .expect("write Cargo.toml");
625
626        let catalog = crate::component_catalog::ComponentCatalog::new();
627        let component = catalog
628            .get("realsense-camera")
629            .expect("realsense-camera is in the catalog")
630            .clone();
631
632        // Exercise the same package-name resolution `add_dependency` uses, but substitute a
633        // fixed version in place of the live crates.io lookup so this test has no network
634        // dependency.
635        let names = InitService::external_dependency_names(&component);
636        assert_eq!(names, vec!["realsense-rust".to_string()]);
637
638        let content = tokio::fs::read_to_string(&cargo_toml_path)
639            .await
640            .expect("read Cargo.toml");
641        let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
642        tokio::fs::write(&cargo_toml_path, &updated)
643            .await
644            .expect("write Cargo.toml");
645
646        let final_content = tokio::fs::read_to_string(&cargo_toml_path)
647            .await
648            .expect("read Cargo.toml");
649        assert!(
650            final_content.contains("realsense-rust = \"0.6.1\""),
651            "the driver's real third-party crate must be added, got: {}",
652            final_content
653        );
654        assert_eq!(
655            final_content.matches("mecha10-core").count(),
656            1,
657            "mecha10-core must not be duplicated, got: {}",
658            final_content
659        );
660    }
661}
662
663#[cfg(test)]
664mod add_node_to_config_tests {
665    use super::*;
666    use mecha10_cli_core::services::ConfigService;
667
668    /// Minimal valid mecha10.json content for a freshly-initialized project.
669    fn minimal_project_config_json() -> &'static str {
670        r#"{
671            "name": "test-project",
672            "version": "0.1.0",
673            "robot_id": "test-robot"
674        }"#
675    }
676
677    #[tokio::test]
678    async fn mints_project_nodes_with_plain_identifier() {
679        let temp_dir = tempfile::tempdir().expect("create temp dir");
680        let project_root = temp_dir.path();
681        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
682            .await
683            .expect("write mecha10.json");
684
685        let service = InitService::new();
686
687        service
688            .add_node_to_config(project_root, "my-custom", false, Vec::new())
689            .await
690            .expect("add_node_to_config should succeed");
691
692        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
693            .await
694            .expect("reload config");
695
696        assert!(
697            config.nodes.identifiers().contains(&"my-custom"),
698            "expected plain identifier 'my-custom' in nodes, got: {:?}",
699            config.nodes.identifiers()
700        );
701        assert!(
702            !config.nodes.identifiers().contains(&"@local/my-custom"),
703            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
704            config.nodes.identifiers()
705        );
706    }
707
708    /// `add_node_to_config` must preserve `template_version` across its save, since it runs for
709    /// every default/selected node on every `mecha10 init`.
710    #[tokio::test]
711    async fn preserves_template_version_written_by_mecha10_init() {
712        let temp_dir = tempfile::tempdir().expect("create temp dir");
713        let project_root = temp_dir.path();
714        tokio::fs::write(
715            project_root.join(paths::PROJECT_CONFIG),
716            r#"{
717                "name": "test-project",
718                "version": "0.6.5",
719                "template_version": "0.6.5",
720                "robot_id": "test-robot"
721            }"#,
722        )
723        .await
724        .expect("write mecha10.json");
725
726        let service = InitService::new();
727
728        service
729            .add_node_to_config(project_root, "speaker", true, Vec::new())
730            .await
731            .expect("add_node_to_config should succeed");
732
733        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
734            .await
735            .expect("reload config");
736
737        assert_eq!(
738            config.template_version,
739            Some("0.6.5".to_string()),
740            "template_version written at `mecha10 init` time must survive add_node_to_config's save"
741        );
742    }
743
744    #[tokio::test]
745    async fn mints_framework_nodes_with_mecha10_scope() {
746        let temp_dir = tempfile::tempdir().expect("create temp dir");
747        let project_root = temp_dir.path();
748        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
749            .await
750            .expect("write mecha10.json");
751
752        let service = InitService::new();
753
754        service
755            .add_node_to_config(project_root, "speaker", true, Vec::new())
756            .await
757            .expect("add_node_to_config should succeed");
758
759        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
760            .await
761            .expect("reload config");
762
763        assert!(
764            config.nodes.identifiers().contains(&"@mecha10/speaker"),
765            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
766            config.nodes.identifiers()
767        );
768    }
769
770    /// Follow-up to LAB-5021: `capabilities` must be written exactly as the caller passes it in
771    /// (resolved by `handlers/init.rs` from the live node-catalog response) into the expanded
772    /// `NodeEntry::Detailed` form, rather than minting a bare pinned-version string or reading
773    /// any local `config.json` back off disk.
774    #[tokio::test]
775    async fn mints_detailed_entry_with_capabilities_given_by_the_caller() {
776        let temp_dir = tempfile::tempdir().expect("create temp dir");
777        let project_root = temp_dir.path();
778        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
779            .await
780            .expect("write mecha10.json");
781
782        let service = InitService::new();
783
784        service
785            .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
786            .await
787            .expect("add_node_to_config should succeed");
788
789        let raw = tokio::fs::read_to_string(project_root.join(paths::PROJECT_CONFIG))
790            .await
791            .expect("read mecha10.json");
792        let raw: serde_json::Value = serde_json::from_str(&raw).expect("parse mecha10.json");
793
794        assert_eq!(
795            raw["nodes"]["@mecha10/speaker"],
796            serde_json::json!({
797                "version": env!("CARGO_PKG_VERSION"),
798                "enabled": true,
799                "capabilities": ["audio"],
800            })
801        );
802    }
803
804    /// No local `config.json` exists for this node anywhere - `add_node_to_config` must still
805    /// write the caller-given `capabilities`, proving it no longer reads any file off disk to
806    /// resolve them (the old `read_local_node_capabilities` behavior this superseded).
807    #[tokio::test]
808    async fn mints_detailed_entry_with_capabilities_even_with_no_local_config_json_on_disk() {
809        let temp_dir = tempfile::tempdir().expect("create temp dir");
810        let project_root = temp_dir.path();
811        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
812            .await
813            .expect("write mecha10.json");
814
815        assert!(
816            !project_root.join("configs/nodes/@mecha10/speaker/config.json").exists(),
817            "precondition: no local config.json for this node"
818        );
819
820        let service = InitService::new();
821
822        service
823            .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
824            .await
825            .expect("add_node_to_config should succeed");
826
827        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
828            .await
829            .expect("reload config");
830
831        assert_eq!(
832            config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
833            Some([Capability::Audio].as_slice()),
834            "capabilities must come from the caller-given value, not a local file read-back"
835        );
836    }
837
838    /// `handlers/init.rs`'s `build_node_placement` runs before this and already pins a
839    /// selected framework node to its own live-catalog version. `add_node_to_config` must not
840    /// clobber that back to this crate's own `CARGO_PKG_VERSION`.
841    #[tokio::test]
842    async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
843        let temp_dir = tempfile::tempdir().expect("create temp dir");
844        let project_root = temp_dir.path();
845        let config_with_pinned_node = r#"{
846            "name": "test-project",
847            "version": "0.1.0",
848            "robot_id": "test-robot",
849            "nodes": { "@mecha10/speaker": "0.7.0" }
850        }"#;
851        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
852            .await
853            .expect("write mecha10.json");
854
855        let service = InitService::new();
856
857        service
858            .add_node_to_config(project_root, "speaker", true, Vec::new())
859            .await
860            .expect("add_node_to_config should succeed");
861
862        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
863            .await
864            .expect("reload config");
865
866        assert_eq!(
867            config.nodes.get_version("@mecha10/speaker"),
868            Some("0.7.0"),
869            "the live-catalog-pinned version must survive add_node_to_config's save, not be \
870             reset to this crate's own CARGO_PKG_VERSION"
871        );
872    }
873
874    /// `mecha10 init --update` backfill case: the node already exists as a bare pinned-version
875    /// string (implicitly no capabilities) and the caller now has a non-empty capabilities list
876    /// for it (resolved off the live catalog) - `add_node_to_config` must upgrade the entry to
877    /// the expanded `Detailed` form in place, preserving the existing pinned version.
878    #[tokio::test]
879    async fn upgrades_a_bare_version_entry_to_detailed_when_backfilling_capabilities() {
880        let temp_dir = tempfile::tempdir().expect("create temp dir");
881        let project_root = temp_dir.path();
882        let config_with_pinned_node = r#"{
883            "name": "test-project",
884            "version": "0.1.0",
885            "robot_id": "test-robot",
886            "nodes": { "@mecha10/speaker": "0.7.0" }
887        }"#;
888        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
889            .await
890            .expect("write mecha10.json");
891
892        let service = InitService::new();
893
894        service
895            .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
896            .await
897            .expect("add_node_to_config should succeed");
898
899        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
900            .await
901            .expect("reload config");
902
903        assert_eq!(
904            config.nodes.get_version("@mecha10/speaker"),
905            Some("0.7.0"),
906            "existing pinned version must be preserved when upgrading to the Detailed form"
907        );
908        assert_eq!(
909            config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
910            Some([Capability::Audio].as_slice()),
911            "capabilities must be backfilled onto the upgraded entry"
912        );
913    }
914
915    /// `mecha10 init --update` backfill case: the node already exists as a `Detailed` entry
916    /// but with an empty `capabilities` list (e.g. added before live-catalog capability
917    /// wiring existed) - `add_node_to_config` must backfill just the `capabilities` field,
918    /// leaving `version`/`enabled` untouched.
919    #[tokio::test]
920    async fn backfills_capabilities_onto_a_detailed_entry_with_empty_capabilities() {
921        let temp_dir = tempfile::tempdir().expect("create temp dir");
922        let project_root = temp_dir.path();
923        let config_with_empty_capabilities = r#"{
924            "name": "test-project",
925            "version": "0.1.0",
926            "robot_id": "test-robot",
927            "nodes": { "@mecha10/speaker": { "version": "0.7.0", "enabled": false, "capabilities": [] } }
928        }"#;
929        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_empty_capabilities)
930            .await
931            .expect("write mecha10.json");
932
933        let service = InitService::new();
934
935        service
936            .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
937            .await
938            .expect("add_node_to_config should succeed");
939
940        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
941            .await
942            .expect("reload config");
943
944        let entry = config.nodes.0.get("@mecha10/speaker").expect("entry must still exist");
945        assert_eq!(entry.version(), "0.7.0", "version must be preserved");
946        assert!(!entry.enabled(), "enabled: false must be preserved, not clobbered");
947        assert_eq!(
948            entry.capabilities(),
949            &[Capability::Audio],
950            "capabilities must be backfilled"
951        );
952    }
953
954    /// If the node already has a non-empty `capabilities` list, `add_node_to_config` must not
955    /// clobber it with a different caller-given list - the existing, presumably
956    /// deliberately-declared value wins.
957    #[tokio::test]
958    async fn does_not_clobber_an_existing_non_empty_capabilities_list() {
959        let temp_dir = tempfile::tempdir().expect("create temp dir");
960        let project_root = temp_dir.path();
961        let config_with_existing_capabilities = r#"{
962            "name": "test-project",
963            "version": "0.1.0",
964            "robot_id": "test-robot",
965            "nodes": { "@mecha10/speaker": { "version": "0.7.0", "capabilities": ["vision"] } }
966        }"#;
967        tokio::fs::write(
968            project_root.join(paths::PROJECT_CONFIG),
969            config_with_existing_capabilities,
970        )
971        .await
972        .expect("write mecha10.json");
973
974        let service = InitService::new();
975
976        service
977            .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
978            .await
979            .expect("add_node_to_config should succeed");
980
981        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
982            .await
983            .expect("reload config");
984
985        assert_eq!(
986            config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
987            Some([Capability::Vision].as_slice()),
988            "an already-populated capabilities list must not be overwritten by a later call"
989        );
990    }
991
992    /// `add_node_to_config` must not touch `targets` at all - that placement is already
993    /// written by `handlers/init.rs`'s `build_node_placement` before this ever runs.
994    #[tokio::test]
995    async fn does_not_touch_targets_at_all() {
996        let temp_dir = tempfile::tempdir().expect("create temp dir");
997        let project_root = temp_dir.path();
998        let config_with_targets = r#"{
999            "name": "test-project",
1000            "version": "0.1.0",
1001            "robot_id": "test-robot",
1002            "targets": {
1003                "robot": { "nodes": ["@mecha10/camera"] }
1004            }
1005        }"#;
1006        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
1007            .await
1008            .expect("write mecha10.json");
1009
1010        let service = InitService::new();
1011
1012        service
1013            .add_node_to_config(project_root, "image-classifier", true, Vec::new())
1014            .await
1015            .expect("add_node_to_config should succeed");
1016
1017        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
1018            .await
1019            .expect("reload config");
1020
1021        assert!(
1022            config.targets.simulation.is_none(),
1023            "add_node_to_config must not synthesize any targets.* entry anymore"
1024        );
1025        let robot = config
1026            .targets
1027            .robot
1028            .expect("pre-existing targets.robot must be preserved");
1029        assert_eq!(
1030            robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
1031            vec!["@mecha10/camera".to_string()],
1032            "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
1033            robot.nodes
1034        );
1035    }
1036}