use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
const CATALOG_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateCatalogEntry {
pub name: String,
pub description: String,
#[serde(default)]
pub nodes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeCatalogEntry {
pub name: String,
#[serde(default)]
pub recommended: bool,
#[serde(default)]
pub targets: Vec<String>,
}
pub async fn fetch_template_catalog() -> Result<Vec<TemplateCatalogEntry>> {
let url = mecha10_cli_core::paths::urls::mecha10_api_path("/templates/catalog");
let client = mecha10_cli_core::utils::build_client()?;
let response = client
.get(&url)
.timeout(CATALOG_FETCH_TIMEOUT)
.send()
.await
.context(format!("Failed to fetch template catalog from {url}"))?
.error_for_status()
.context(format!("Template catalog request to {url} failed"))?;
response
.json::<Vec<TemplateCatalogEntry>>()
.await
.context(format!("Malformed template catalog response from {url}"))
}
pub async fn fetch_node_catalog() -> Result<Vec<NodeCatalogEntry>> {
let url = mecha10_cli_core::paths::urls::mecha10_api_path("/nodes/catalog");
let client = mecha10_cli_core::utils::build_client()?;
let response = client
.get(&url)
.timeout(CATALOG_FETCH_TIMEOUT)
.send()
.await
.context(format!("Failed to fetch node catalog from {url}"))?
.error_for_status()
.context(format!("Node catalog request to {url} failed"))?;
response
.json::<Vec<NodeCatalogEntry>>()
.await
.context(format!("Malformed node catalog response from {url}"))
}
const FALLBACK_BASIC_NODES: &[&str] = &[
"speaker",
"listener",
"motor",
"imu",
"teleop",
"diagnostics",
"websocket-bridge",
"image-classifier",
"object-detector",
"llm-command",
"behavior-executor",
"simulator",
"rl",
];
pub fn template_node_defaults(
templates: &[TemplateCatalogEntry],
template_id: &Option<String>,
available_ids: &[String],
) -> Vec<bool> {
let template_id_str = template_id.as_deref().unwrap_or("rover");
match templates.iter().find(|t| t.name == template_id_str) {
Some(template) => available_ids
.iter()
.map(|id| template.nodes.iter().any(|n| n == id))
.collect(),
None => available_ids
.iter()
.map(|id| FALLBACK_BASIC_NODES.contains(&id.as_str()))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn spawn_fake_catalog_server(body: String) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(body.as_bytes());
let _ = stream.flush();
}
});
addr
}
struct FakeCatalogServer;
impl Drop for FakeCatalogServer {
fn drop(&mut self) {
std::env::remove_var("MECHA10_API_URL");
}
}
fn start_fake_catalog_server(body: String) -> FakeCatalogServer {
let addr = spawn_fake_catalog_server(body);
std::env::set_var("MECHA10_API_URL", format!("http://{}/api", addr));
FakeCatalogServer
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn fetch_template_catalog_parses_entries_from_the_live_endpoint() {
let body = serde_json::json!([
{"name": "rover", "description": "Mobile robot", "nodes": ["speaker", "motor"]},
{"name": "drone", "description": "Flying robot", "nodes": []},
])
.to_string();
let _server = start_fake_catalog_server(body);
let entries = fetch_template_catalog().await.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "rover");
assert_eq!(entries[0].nodes, vec!["speaker".to_string(), "motor".to_string()]);
assert_eq!(entries[1].name, "drone");
assert!(entries[1].nodes.is_empty());
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn fetch_node_catalog_parses_entries_from_the_live_endpoint() {
let body = serde_json::json!([
{"name": "camera", "recommended": true, "targets": ["robot"]},
{"name": "motor", "recommended": true, "targets": ["robot"]},
])
.to_string();
let _server = start_fake_catalog_server(body);
let entries = fetch_node_catalog().await.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "camera");
assert!(entries[0].recommended);
assert_eq!(entries[0].targets, vec!["robot".to_string()]);
assert_eq!(entries[1].name, "motor");
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn fetch_node_catalog_defaults_targets_to_empty_when_absent_from_the_response() {
let body = serde_json::json!([{"name": "camera", "recommended": true}]).to_string();
let _server = start_fake_catalog_server(body);
let entries = fetch_node_catalog().await.unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].targets.is_empty());
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn fetch_node_catalog_fails_hard_when_mecha10_api_is_unreachable() {
std::env::set_var("MECHA10_API_URL", "http://127.0.0.1:1/api");
let result = fetch_node_catalog().await;
std::env::remove_var("MECHA10_API_URL");
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to fetch node catalog"), "got: {}", err);
}
fn template_entry(name: &str, nodes: &[&str]) -> TemplateCatalogEntry {
TemplateCatalogEntry {
name: name.to_string(),
description: String::new(),
nodes: nodes.iter().map(|n| n.to_string()).collect(),
}
}
#[test]
fn template_node_defaults_selects_the_named_templates_nodes() {
let templates = vec![
template_entry("rover", &["speaker", "listener", "motor", "imu", "camera"]),
template_entry("drone", &[]),
];
let available = vec![
"speaker".to_string(),
"listener".to_string(),
"motor".to_string(),
"imu".to_string(),
"camera".to_string(),
];
let defaults = template_node_defaults(&templates, &Some("rover".to_string()), &available);
assert_eq!(defaults, vec![true, true, true, true, true]);
}
#[test]
fn template_node_defaults_defaults_to_rover_when_template_id_is_omitted() {
let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
let available = vec!["speaker".to_string(), "camera".to_string()];
let defaults = template_node_defaults(&templates, &None, &available);
assert_eq!(defaults, vec![true, false]);
}
#[test]
fn template_node_defaults_placeholder_template_has_no_defaults() {
let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
let available = vec!["speaker".to_string(), "listener".to_string(), "motor".to_string()];
let defaults = template_node_defaults(&templates, &Some("drone".to_string()), &available);
assert_eq!(defaults, vec![false, false, false]);
}
#[test]
fn template_node_defaults_falls_back_to_basic_nodes_for_an_unrecognized_template() {
let templates = vec![template_entry("rover", &["camera"])];
let available = vec!["speaker".to_string(), "camera".to_string(), "rl".to_string()];
let defaults = template_node_defaults(&templates, &Some("not-a-real-template".to_string()), &available);
assert_eq!(defaults, vec![true, false, true]);
}
}