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