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