Skip to main content

mecha10_cli/services/
catalog_client.rs

1//! Live template/node catalog client for `mecha10-api`.
2//!
3//! Fetches both catalogs over HTTP, with no compiled-in fallback:
4//!
5//! - `GET {mecha10_api_base_url}/templates/catalog` -> `[{ name, description, nodes }, ...]`
6//! - `GET {mecha10_api_base_url}/nodes/catalog` -> `[{ name, recommended, targets }, ...]`
7//!
8//! `targets` is each node's live `targets.<dev|simulation|robot|remote>` placement, sourced
9//! from `mecha10-nodes`' own `Cargo.toml` `[package.metadata.mecha10] targets = [...]` and
10//! published to `mecha10-api`'s catalog on every `mecha10-nodes` CI push - the single source of
11//! truth this CLI reads directly instead of maintaining its own hand-written `node id ->
12//! targets` table.
13//!
14//! Both are public, unauthenticated endpoints with no bundled/compiled-in fallback: a project
15//! with no network access to `mecha10-api` can't resolve either catalog at all.
16
17use anyhow::{Context, Result};
18use mecha10_core::node::Capability;
19use serde::{Deserialize, Serialize};
20use std::time::Duration;
21
22const CATALOG_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TemplateCatalogEntry {
26    pub name: String,
27    pub description: String,
28    #[serde(default)]
29    pub nodes: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct NodeCatalogEntry {
34    pub name: String,
35    #[serde(default)]
36    pub recommended: bool,
37    /// Which `mecha10.json` `targets` entries (`dev`/`simulation`/`robot`/`remote`) this node
38    /// should be placed into - the live replacement for the retired
39    /// `mecha10_cli_core::component_catalog::types::NODE_TARGETS` table. `serde(default)` so a
40    /// response from a `mecha10-api` build that predates this field (or any other
41    /// differently-shaped response) still deserializes instead of hard-failing - the field is
42    /// expected to always be present on a current `mecha10-api`, this is just defensive.
43    #[serde(default)]
44    pub targets: Vec<String>,
45    /// This node's own latest published version, resolved live by `mecha10-api`. `None` if
46    /// nothing has been published yet.
47    #[serde(default)]
48    pub version: Option<String>,
49    /// This node's declared [`Capability`] set, sourced from its own `configs/config.json`'s
50    /// `"capabilities"` field in `mecha10-nodes` and republished here by `mecha10-api`.
51    /// `serde(default)` so a response from a `mecha10-api` build that predates this field still
52    /// deserializes instead of hard-failing, same rationale as `targets`. This is the write
53    /// path's source of capabilities for `mecha10 init`/`mecha10 init --update`
54    /// (`handlers::init::create_mecha10_json_from_template`/`handle_update_existing`), the same
55    /// way `targets` already is - not a read-back of a locally pulled `config.json`, which is
56    /// best-effort and never guaranteed to exist or be fresh. `mecha10 add` still reads the
57    /// locally pulled `config.json` directly instead (see
58    /// `handlers::add::add_node_to_json_config`); that caller is out of scope for this field's
59    /// write-path migration.
60    #[serde(default)]
61    pub capabilities: Vec<Capability>,
62}
63
64/// Fetches the live catalog of robot project templates. No caching: every call is a fresh
65/// HTTP round-trip.
66pub async fn fetch_template_catalog() -> Result<Vec<TemplateCatalogEntry>> {
67    let url = mecha10_cli_core::paths::urls::mecha10_api_path("/templates/catalog");
68
69    let client = mecha10_cli_core::utils::build_client()?;
70    let response = client
71        .get(&url)
72        .timeout(CATALOG_FETCH_TIMEOUT)
73        .send()
74        .await
75        .context(format!("Failed to fetch template catalog from {url}"))?
76        .error_for_status()
77        .context(format!("Template catalog request to {url} failed"))?;
78
79    response
80        .json::<Vec<TemplateCatalogEntry>>()
81        .await
82        .context(format!("Malformed template catalog response from {url}"))
83}
84
85/// Fetches the live catalog of bundled framework nodes. No caching: every call is a fresh
86/// HTTP round-trip.
87pub async fn fetch_node_catalog() -> Result<Vec<NodeCatalogEntry>> {
88    let url = mecha10_cli_core::paths::urls::mecha10_api_path("/nodes/catalog");
89
90    let client = mecha10_cli_core::utils::build_client()?;
91    let response = client
92        .get(&url)
93        .timeout(CATALOG_FETCH_TIMEOUT)
94        .send()
95        .await
96        .context(format!("Failed to fetch node catalog from {url}"))?
97        .error_for_status()
98        .context(format!("Node catalog request to {url} failed"))?;
99
100    response
101        .json::<Vec<NodeCatalogEntry>>()
102        .await
103        .context(format!("Malformed node catalog response from {url}"))
104}
105
106const FALLBACK_BASIC_NODES: &[&str] = &[
107    "speaker",
108    "listener",
109    "motor",
110    "imu",
111    "teleop",
112    "diagnostics",
113    "websocket-bridge",
114    "image-classifier",
115    "object-detector",
116    "llm-command",
117    "behavior-executor",
118    "simulator",
119    "rl",
120];
121
122/// Computes which of `available_ids` should be pre-checked in `mecha10 init`'s node-selection
123/// step, based on the named template's node list. Defaults to `"rover"` when `template_id` is
124/// omitted; falls back to [`FALLBACK_BASIC_NODES`] for an unrecognized `template_id`.
125pub fn template_node_defaults(
126    templates: &[TemplateCatalogEntry],
127    template_id: &Option<String>,
128    available_ids: &[String],
129) -> Vec<bool> {
130    let template_id_str = template_id.as_deref().unwrap_or("rover");
131
132    match templates.iter().find(|t| t.name == template_id_str) {
133        Some(template) => available_ids
134            .iter()
135            .map(|id| template.nodes.iter().any(|n| n == id))
136            .collect(),
137        None => available_ids
138            .iter()
139            .map(|id| FALLBACK_BASIC_NODES.contains(&id.as_str()))
140            .collect(),
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::io::{Read, Write};
148    use std::net::TcpListener;
149
150    /// Spins up a minimal local HTTP server that answers every request with a fixed JSON body,
151    /// for driving these fetchers against a controlled response instead of the real `mecha10-api`.
152    fn spawn_fake_catalog_server(body: String) -> std::net::SocketAddr {
153        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
154        let addr = listener.local_addr().unwrap();
155
156        std::thread::spawn(move || {
157            for stream in listener.incoming() {
158                let mut stream = match stream {
159                    Ok(s) => s,
160                    Err(_) => continue,
161                };
162                let mut buf = [0u8; 4096];
163                let _ = stream.read(&mut buf);
164                let header = format!(
165                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
166                    body.len()
167                );
168                let _ = stream.write_all(header.as_bytes());
169                let _ = stream.write_all(body.as_bytes());
170                let _ = stream.flush();
171            }
172        });
173
174        addr
175    }
176
177    struct FakeCatalogServer;
178
179    impl Drop for FakeCatalogServer {
180        fn drop(&mut self) {
181            std::env::remove_var("MECHA10_API_URL");
182        }
183    }
184
185    fn start_fake_catalog_server(body: String) -> FakeCatalogServer {
186        let addr = spawn_fake_catalog_server(body);
187        std::env::set_var("MECHA10_API_URL", format!("http://{}/api", addr));
188        FakeCatalogServer
189    }
190
191    #[tokio::test]
192    #[serial_test::serial(mecha10_api_url_env_var)]
193    async fn fetch_template_catalog_parses_entries_from_the_live_endpoint() {
194        let body = serde_json::json!([
195            {"name": "rover", "description": "Mobile robot", "nodes": ["speaker", "motor"]},
196            {"name": "drone", "description": "Flying robot", "nodes": []},
197        ])
198        .to_string();
199        let _server = start_fake_catalog_server(body);
200
201        let entries = fetch_template_catalog().await.unwrap();
202
203        assert_eq!(entries.len(), 2);
204        assert_eq!(entries[0].name, "rover");
205        assert_eq!(entries[0].nodes, vec!["speaker".to_string(), "motor".to_string()]);
206        assert_eq!(entries[1].name, "drone");
207        assert!(entries[1].nodes.is_empty());
208    }
209
210    #[tokio::test]
211    #[serial_test::serial(mecha10_api_url_env_var)]
212    async fn fetch_node_catalog_parses_entries_from_the_live_endpoint() {
213        let body = serde_json::json!([
214            {"name": "camera", "recommended": true, "targets": ["robot"]},
215            {"name": "motor", "recommended": true, "targets": ["robot"]},
216        ])
217        .to_string();
218        let _server = start_fake_catalog_server(body);
219
220        let entries = fetch_node_catalog().await.unwrap();
221
222        assert_eq!(entries.len(), 2);
223        assert_eq!(entries[0].name, "camera");
224        assert!(entries[0].recommended);
225        assert_eq!(entries[0].targets, vec!["robot".to_string()]);
226        assert_eq!(entries[1].name, "motor");
227    }
228
229    /// LAB-5021: `capabilities` parses off the live catalog response, mirroring `targets`.
230    #[tokio::test]
231    #[serial_test::serial(mecha10_api_url_env_var)]
232    async fn fetch_node_catalog_parses_capabilities_from_the_live_endpoint() {
233        let body = serde_json::json!([
234            {"name": "motor", "recommended": true, "targets": ["robot"], "capabilities": ["locomotion", "joints"]},
235        ])
236        .to_string();
237        let _server = start_fake_catalog_server(body);
238
239        let entries = fetch_node_catalog().await.unwrap();
240
241        assert_eq!(entries.len(), 1);
242        assert_eq!(
243            entries[0].capabilities,
244            vec![Capability::Locomotion, Capability::Joints]
245        );
246    }
247
248    /// Regression coverage for the node->target(s) placement migration: an older/differently
249    /// shaped `mecha10-api` response with no `targets` field at all must still deserialize
250    /// (`#[serde(default)]`), rather than hard-failing every `mecha10 init`/`mecha10 add`.
251    #[tokio::test]
252    #[serial_test::serial(mecha10_api_url_env_var)]
253    async fn fetch_node_catalog_defaults_targets_to_empty_when_absent_from_the_response() {
254        let body = serde_json::json!([{"name": "camera", "recommended": true}]).to_string();
255        let _server = start_fake_catalog_server(body);
256
257        let entries = fetch_node_catalog().await.unwrap();
258
259        assert_eq!(entries.len(), 1);
260        assert!(entries[0].targets.is_empty());
261        assert!(
262            entries[0].capabilities.is_empty(),
263            "an older/no-capabilities mecha10-api response must still deserialize, same as targets"
264        );
265    }
266
267    #[tokio::test]
268    #[serial_test::serial(mecha10_api_url_env_var)]
269    async fn fetch_node_catalog_fails_hard_when_mecha10_api_is_unreachable() {
270        std::env::set_var("MECHA10_API_URL", "http://127.0.0.1:1/api");
271
272        let result = fetch_node_catalog().await;
273
274        std::env::remove_var("MECHA10_API_URL");
275
276        let err = result.unwrap_err();
277        assert!(err.to_string().contains("Failed to fetch node catalog"), "got: {}", err);
278    }
279
280    fn template_entry(name: &str, nodes: &[&str]) -> TemplateCatalogEntry {
281        TemplateCatalogEntry {
282            name: name.to_string(),
283            description: String::new(),
284            nodes: nodes.iter().map(|n| n.to_string()).collect(),
285        }
286    }
287
288    #[test]
289    fn template_node_defaults_selects_the_named_templates_nodes() {
290        let templates = vec![
291            template_entry("rover", &["speaker", "listener", "motor", "imu", "camera"]),
292            template_entry("drone", &[]),
293        ];
294        let available = vec![
295            "speaker".to_string(),
296            "listener".to_string(),
297            "motor".to_string(),
298            "imu".to_string(),
299            "camera".to_string(),
300        ];
301
302        let defaults = template_node_defaults(&templates, &Some("rover".to_string()), &available);
303
304        assert_eq!(defaults, vec![true, true, true, true, true]);
305    }
306
307    #[test]
308    fn template_node_defaults_defaults_to_rover_when_template_id_is_omitted() {
309        let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
310        let available = vec!["speaker".to_string(), "camera".to_string()];
311
312        let defaults = template_node_defaults(&templates, &None, &available);
313
314        assert_eq!(defaults, vec![true, false]);
315    }
316
317    #[test]
318    fn template_node_defaults_placeholder_template_has_no_defaults() {
319        let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
320        let available = vec!["speaker".to_string(), "listener".to_string(), "motor".to_string()];
321
322        let defaults = template_node_defaults(&templates, &Some("drone".to_string()), &available);
323
324        assert_eq!(defaults, vec![false, false, false]);
325    }
326
327    #[test]
328    fn template_node_defaults_falls_back_to_basic_nodes_for_an_unrecognized_template() {
329        let templates = vec![template_entry("rover", &["camera"])];
330        let available = vec!["speaker".to_string(), "camera".to_string(), "rl".to_string()];
331
332        let defaults = template_node_defaults(&templates, &Some("not-a-real-template".to_string()), &available);
333
334        assert_eq!(defaults, vec![true, false, true]);
335    }
336}