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