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