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