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`. Not currently the
53    /// write path's source of capabilities for `mecha10 add` (that reads the locally pulled
54    /// `config.json` directly instead, see `handlers::add::add_node_to_json_config`) - this is
55    /// catalog-listing metadata for callers that only have the list response, not a pulled
56    /// config file, to work with.
57    #[serde(default)]
58    pub capabilities: Vec<Capability>,
59}
60
61/// Fetches the live catalog of robot project templates. No caching: every call is a fresh
62/// HTTP round-trip.
63pub async fn fetch_template_catalog() -> Result<Vec<TemplateCatalogEntry>> {
64    let url = mecha10_cli_core::paths::urls::mecha10_api_path("/templates/catalog");
65
66    let client = mecha10_cli_core::utils::build_client()?;
67    let response = client
68        .get(&url)
69        .timeout(CATALOG_FETCH_TIMEOUT)
70        .send()
71        .await
72        .context(format!("Failed to fetch template catalog from {url}"))?
73        .error_for_status()
74        .context(format!("Template catalog request to {url} failed"))?;
75
76    response
77        .json::<Vec<TemplateCatalogEntry>>()
78        .await
79        .context(format!("Malformed template catalog response from {url}"))
80}
81
82/// Fetches the live catalog of bundled framework nodes. No caching: every call is a fresh
83/// HTTP round-trip.
84pub async fn fetch_node_catalog() -> Result<Vec<NodeCatalogEntry>> {
85    let url = mecha10_cli_core::paths::urls::mecha10_api_path("/nodes/catalog");
86
87    let client = mecha10_cli_core::utils::build_client()?;
88    let response = client
89        .get(&url)
90        .timeout(CATALOG_FETCH_TIMEOUT)
91        .send()
92        .await
93        .context(format!("Failed to fetch node catalog from {url}"))?
94        .error_for_status()
95        .context(format!("Node catalog request to {url} failed"))?;
96
97    response
98        .json::<Vec<NodeCatalogEntry>>()
99        .await
100        .context(format!("Malformed node catalog response from {url}"))
101}
102
103const FALLBACK_BASIC_NODES: &[&str] = &[
104    "speaker",
105    "listener",
106    "motor",
107    "imu",
108    "teleop",
109    "diagnostics",
110    "websocket-bridge",
111    "image-classifier",
112    "object-detector",
113    "llm-command",
114    "behavior-executor",
115    "simulator",
116    "rl",
117];
118
119/// Computes which of `available_ids` should be pre-checked in `mecha10 init`'s node-selection
120/// step, based on the named template's node list. Defaults to `"rover"` when `template_id` is
121/// omitted; falls back to [`FALLBACK_BASIC_NODES`] for an unrecognized `template_id`.
122pub fn template_node_defaults(
123    templates: &[TemplateCatalogEntry],
124    template_id: &Option<String>,
125    available_ids: &[String],
126) -> Vec<bool> {
127    let template_id_str = template_id.as_deref().unwrap_or("rover");
128
129    match templates.iter().find(|t| t.name == template_id_str) {
130        Some(template) => available_ids
131            .iter()
132            .map(|id| template.nodes.iter().any(|n| n == id))
133            .collect(),
134        None => available_ids
135            .iter()
136            .map(|id| FALLBACK_BASIC_NODES.contains(&id.as_str()))
137            .collect(),
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::io::{Read, Write};
145    use std::net::TcpListener;
146
147    /// Spins up a minimal local HTTP server that answers every request with a fixed JSON body,
148    /// for driving these fetchers against a controlled response instead of the real `mecha10-api`.
149    fn spawn_fake_catalog_server(body: String) -> std::net::SocketAddr {
150        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
151        let addr = listener.local_addr().unwrap();
152
153        std::thread::spawn(move || {
154            for stream in listener.incoming() {
155                let mut stream = match stream {
156                    Ok(s) => s,
157                    Err(_) => continue,
158                };
159                let mut buf = [0u8; 4096];
160                let _ = stream.read(&mut buf);
161                let header = format!(
162                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
163                    body.len()
164                );
165                let _ = stream.write_all(header.as_bytes());
166                let _ = stream.write_all(body.as_bytes());
167                let _ = stream.flush();
168            }
169        });
170
171        addr
172    }
173
174    struct FakeCatalogServer;
175
176    impl Drop for FakeCatalogServer {
177        fn drop(&mut self) {
178            std::env::remove_var("MECHA10_API_URL");
179        }
180    }
181
182    fn start_fake_catalog_server(body: String) -> FakeCatalogServer {
183        let addr = spawn_fake_catalog_server(body);
184        std::env::set_var("MECHA10_API_URL", format!("http://{}/api", addr));
185        FakeCatalogServer
186    }
187
188    #[tokio::test]
189    #[serial_test::serial(mecha10_api_url_env_var)]
190    async fn fetch_template_catalog_parses_entries_from_the_live_endpoint() {
191        let body = serde_json::json!([
192            {"name": "rover", "description": "Mobile robot", "nodes": ["speaker", "motor"]},
193            {"name": "drone", "description": "Flying robot", "nodes": []},
194        ])
195        .to_string();
196        let _server = start_fake_catalog_server(body);
197
198        let entries = fetch_template_catalog().await.unwrap();
199
200        assert_eq!(entries.len(), 2);
201        assert_eq!(entries[0].name, "rover");
202        assert_eq!(entries[0].nodes, vec!["speaker".to_string(), "motor".to_string()]);
203        assert_eq!(entries[1].name, "drone");
204        assert!(entries[1].nodes.is_empty());
205    }
206
207    #[tokio::test]
208    #[serial_test::serial(mecha10_api_url_env_var)]
209    async fn fetch_node_catalog_parses_entries_from_the_live_endpoint() {
210        let body = serde_json::json!([
211            {"name": "camera", "recommended": true, "targets": ["robot"]},
212            {"name": "motor", "recommended": true, "targets": ["robot"]},
213        ])
214        .to_string();
215        let _server = start_fake_catalog_server(body);
216
217        let entries = fetch_node_catalog().await.unwrap();
218
219        assert_eq!(entries.len(), 2);
220        assert_eq!(entries[0].name, "camera");
221        assert!(entries[0].recommended);
222        assert_eq!(entries[0].targets, vec!["robot".to_string()]);
223        assert_eq!(entries[1].name, "motor");
224    }
225
226    /// LAB-5021: `capabilities` parses off the live catalog response, mirroring `targets`.
227    #[tokio::test]
228    #[serial_test::serial(mecha10_api_url_env_var)]
229    async fn fetch_node_catalog_parses_capabilities_from_the_live_endpoint() {
230        let body = serde_json::json!([
231            {"name": "motor", "recommended": true, "targets": ["robot"], "capabilities": ["locomotion", "joints"]},
232        ])
233        .to_string();
234        let _server = start_fake_catalog_server(body);
235
236        let entries = fetch_node_catalog().await.unwrap();
237
238        assert_eq!(entries.len(), 1);
239        assert_eq!(
240            entries[0].capabilities,
241            vec![Capability::Locomotion, Capability::Joints]
242        );
243    }
244
245    /// Regression coverage for the node->target(s) placement migration: an older/differently
246    /// shaped `mecha10-api` response with no `targets` field at all must still deserialize
247    /// (`#[serde(default)]`), rather than hard-failing every `mecha10 init`/`mecha10 add`.
248    #[tokio::test]
249    #[serial_test::serial(mecha10_api_url_env_var)]
250    async fn fetch_node_catalog_defaults_targets_to_empty_when_absent_from_the_response() {
251        let body = serde_json::json!([{"name": "camera", "recommended": true}]).to_string();
252        let _server = start_fake_catalog_server(body);
253
254        let entries = fetch_node_catalog().await.unwrap();
255
256        assert_eq!(entries.len(), 1);
257        assert!(entries[0].targets.is_empty());
258        assert!(
259            entries[0].capabilities.is_empty(),
260            "an older/no-capabilities mecha10-api response must still deserialize, same as targets"
261        );
262    }
263
264    #[tokio::test]
265    #[serial_test::serial(mecha10_api_url_env_var)]
266    async fn fetch_node_catalog_fails_hard_when_mecha10_api_is_unreachable() {
267        std::env::set_var("MECHA10_API_URL", "http://127.0.0.1:1/api");
268
269        let result = fetch_node_catalog().await;
270
271        std::env::remove_var("MECHA10_API_URL");
272
273        let err = result.unwrap_err();
274        assert!(err.to_string().contains("Failed to fetch node catalog"), "got: {}", err);
275    }
276
277    fn template_entry(name: &str, nodes: &[&str]) -> TemplateCatalogEntry {
278        TemplateCatalogEntry {
279            name: name.to_string(),
280            description: String::new(),
281            nodes: nodes.iter().map(|n| n.to_string()).collect(),
282        }
283    }
284
285    #[test]
286    fn template_node_defaults_selects_the_named_templates_nodes() {
287        let templates = vec![
288            template_entry("rover", &["speaker", "listener", "motor", "imu", "camera"]),
289            template_entry("drone", &[]),
290        ];
291        let available = vec![
292            "speaker".to_string(),
293            "listener".to_string(),
294            "motor".to_string(),
295            "imu".to_string(),
296            "camera".to_string(),
297        ];
298
299        let defaults = template_node_defaults(&templates, &Some("rover".to_string()), &available);
300
301        assert_eq!(defaults, vec![true, true, true, true, true]);
302    }
303
304    #[test]
305    fn template_node_defaults_defaults_to_rover_when_template_id_is_omitted() {
306        let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
307        let available = vec!["speaker".to_string(), "camera".to_string()];
308
309        let defaults = template_node_defaults(&templates, &None, &available);
310
311        assert_eq!(defaults, vec![true, false]);
312    }
313
314    #[test]
315    fn template_node_defaults_placeholder_template_has_no_defaults() {
316        let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
317        let available = vec!["speaker".to_string(), "listener".to_string(), "motor".to_string()];
318
319        let defaults = template_node_defaults(&templates, &Some("drone".to_string()), &available);
320
321        assert_eq!(defaults, vec![false, false, false]);
322    }
323
324    #[test]
325    fn template_node_defaults_falls_back_to_basic_nodes_for_an_unrecognized_template() {
326        let templates = vec![template_entry("rover", &["camera"])];
327        let available = vec!["speaker".to_string(), "camera".to_string(), "rl".to_string()];
328
329        let defaults = template_node_defaults(&templates, &Some("not-a-real-template".to_string()), &available);
330
331        assert_eq!(defaults, vec![true, false, true]);
332    }
333}