mecha10-cli 0.9.0

Mecha10 CLI tool
Documentation
//! Live template/node catalog client for `mecha10-api`.
//!
//! Fetches both catalogs over HTTP, with no compiled-in fallback:
//!
//! - `GET {mecha10_api_base_url}/templates/catalog` -> `[{ name, description, nodes }, ...]`
//! - `GET {mecha10_api_base_url}/nodes/catalog` -> `[{ name, recommended, targets }, ...]`
//!
//! `targets` is each node's live `targets.<dev|simulation|robot|remote>` placement, sourced
//! from `mecha10-nodes`' own `Cargo.toml` `[package.metadata.mecha10] targets = [...]` and
//! published to `mecha10-api`'s catalog on every `mecha10-nodes` CI push - the single source of
//! truth this CLI reads directly instead of maintaining its own hand-written `node id ->
//! targets` table.
//!
//! Both are public, unauthenticated endpoints with no bundled/compiled-in fallback: a project
//! with no network access to `mecha10-api` can't resolve either catalog at all.

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,
    /// Which `mecha10.json` `targets` entries (`dev`/`simulation`/`robot`/`remote`) this node
    /// should be placed into - the live replacement for the retired
    /// `mecha10_cli_core::component_catalog::types::NODE_TARGETS` table. `serde(default)` so a
    /// response from a `mecha10-api` build that predates this field (or any other
    /// differently-shaped response) still deserializes instead of hard-failing - the field is
    /// expected to always be present on a current `mecha10-api`, this is just defensive.
    #[serde(default)]
    pub targets: Vec<String>,
    /// This node's own latest published version, resolved live by `mecha10-api` from its
    /// per-node Minio `latest.txt` pointer - independent of every other node's version and of
    /// this CLI's own version, since `mecha10-nodes` releases each node separately. `None` for
    /// a node with nothing published yet, or a `mecha10-api` build that predates this field -
    /// callers fall back to their own pinned version in that case.
    #[serde(default)]
    pub version: Option<String>,
}

/// Fetches the live catalog of robot project templates. No caching: every call is a fresh
/// HTTP round-trip.
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}"))
}

/// Fetches the live catalog of bundled framework nodes. No caching: every call is a fresh
/// HTTP round-trip.
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",
];

/// Computes which of `available_ids` should be pre-checked in `mecha10 init`'s node-selection
/// step, based on the named template's node list. Defaults to `"rover"` when `template_id` is
/// omitted; falls back to [`FALLBACK_BASIC_NODES`] for an unrecognized `template_id`.
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;

    /// Spins up a minimal local HTTP server that answers every request with a fixed JSON body,
    /// for driving these fetchers against a controlled response instead of the real `mecha10-api`.
    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");
    }

    /// Regression coverage for the node->target(s) placement migration: an older/differently
    /// shaped `mecha10-api` response with no `targets` field at all must still deserialize
    /// (`#[serde(default)]`), rather than hard-failing every `mecha10 init`/`mecha10 add`.
    #[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]);
    }
}