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