1use 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 #[serde(default)]
44 pub targets: Vec<String>,
45 #[serde(default)]
48 pub version: Option<String>,
49 #[serde(default)]
61 pub capabilities: Vec<Capability>,
62}
63
64pub 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
85pub 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
122pub 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 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 #[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 #[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}