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