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