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