robotrt-cli 0.1.0-beta.1

RobotRT modular robotics runtime and middleware components.
use introspection_core::{ActionStatus, NodeStatus, StatusSnapshot};

use crate::helpers::{find_topic_status, first_positional, has_flag, utilization_percent};
use crate::status_api::{ACTION_INFO_API_VERSION, NODE_INFO_API_VERSION, TOPIC_INFO_API_VERSION};

use super::data_source::load_status_snapshot;

pub(super) fn node_info(args: &[String]) -> Result<(), String> {
    let json = has_flag(args, "--json");
    let node_name = first_positional(args).ok_or_else(|| String::from("missing node name"))?;
    let (snapshot, source) = load_status_snapshot(args)?;
    let node = snapshot
        .nodes
        .iter()
        .find(|item| item.name == node_name)
        .ok_or_else(|| format!("node not found: {node_name}"))?;

    if json {
        let payload = stable_node_info_json(&snapshot, &source.json, &node_name, node);
        println!(
            "{}",
            serde_json::to_string_pretty(&payload)
                .map_err(|err| format!("serialize node info failed: {err}"))?
        );
    } else {
        println!("RobotRT Node Info");
        println!("source: {}", source.label);
        println!("name: {}", node.name);
        println!("namespace: {}", node.namespace);
        println!("capabilities: {}", node.capabilities.join(", "));
    }

    Ok(())
}

pub(super) fn topic_info(args: &[String]) -> Result<(), String> {
    let json = has_flag(args, "--json");
    let topic_name = first_positional(args).ok_or_else(|| String::from("missing topic name"))?;
    let (snapshot, source) = load_status_snapshot(args)?;
    let topic = find_topic_status(&snapshot, &topic_name)
        .ok_or_else(|| format!("topic not found: {topic_name}"))?;

    if json {
        let payload = stable_topic_info_json(&snapshot, &source.json, &topic_name, topic);
        println!(
            "{}",
            serde_json::to_string_pretty(&payload)
                .map_err(|err| format!("serialize topic info failed: {err}"))?
        );
    } else {
        println!("RobotRT Topic Info");
        println!("source: {}", source.label);
        println!("name: {}", topic.name);
        println!("schema: {}", topic.schema);
        println!("qos: reliable={} depth={}", topic.reliable, topic.depth);
        println!(
            "load: pending={} max_depth={} utilization={:.2}%",
            topic.pending,
            topic.max_depth,
            utilization_percent(topic.pending, topic.max_depth),
        );
        println!(
            "endpoints: publishers={} subscribers={}",
            topic.publishers, topic.subscribers
        );
    }

    Ok(())
}

pub(super) fn action_info(args: &[String]) -> Result<(), String> {
    let json = has_flag(args, "--json");
    let action_name = first_positional(args).ok_or_else(|| String::from("missing action name"))?;
    let (snapshot, source) = load_status_snapshot(args)?;
    let action = find_action_status(&snapshot, &action_name)
        .ok_or_else(|| format!("action not found: {action_name}"))?;

    if json {
        let payload = stable_action_info_json(&snapshot, &source.json, &action_name, action);
        println!(
            "{}",
            serde_json::to_string_pretty(&payload)
                .map_err(|err| format!("serialize action info failed: {err}"))?
        );
    } else {
        println!("RobotRT Action Info");
        println!("source: {}", source.label);
        println!("name: {}", action.name);
        println!(
            "endpoints: clients={} servers={}",
            action.clients, action.servers
        );
        println!(
            "health: state={} current_state={} active_goals={} heartbeat_timeout_ms={}",
            action.health_state.as_deref().unwrap_or("unknown"),
            action.current_state.as_deref().unwrap_or("unknown"),
            action
                .active_goals
                .map(|value| value.to_string())
                .unwrap_or_else(|| "-".to_string()),
            action
                .heartbeat_timeout_ms
                .map(|value| value.to_string())
                .unwrap_or_else(|| "-".to_string()),
        );
    }

    Ok(())
}

pub(super) fn plugin_list(args: &[String]) -> Result<(), String> {
    let json = has_flag(args, "--json");
    let loaded_only = has_flag(args, "--loaded");
    let (snapshot, source) = load_status_snapshot(args)?;
    let captured_at_unix_nanos = snapshot.captured_at_unix_nanos;

    let plugins = snapshot
        .plugins
        .into_iter()
        .filter(|plugin| !loaded_only || plugin.loaded)
        .collect::<Vec<_>>();

    if json {
        let payload = serde_json::json!({
            "api_version": "robotrt.plugin.list.v1",
            "kind": "plugin_list",
            "captured_at_unix_nanos": captured_at_unix_nanos,
            "source": source.json,
            "query": {
                "loaded_only": loaded_only,
            },
            "result": {
                "plugins": plugins,
            },
        });
        println!(
            "{}",
            serde_json::to_string_pretty(&payload)
                .map_err(|err| format!("serialize plugin list failed: {err}"))?
        );
    } else {
        println!("RobotRT Plugin List");
        println!("source: {}", source.label);
        println!("loaded_only: {}", loaded_only);
        for plugin in &plugins {
            println!(
                "- {} kind={} loaded={}",
                plugin.name, plugin.kind, plugin.loaded
            );
        }
    }

    Ok(())
}

pub(super) fn health_cmd(args: &[String]) -> Result<(), String> {
    let json = has_flag(args, "--json");
    let (snapshot, source) = load_status_snapshot(args)?;

    if json {
        let payload = serde_json::json!({
            "api_version": "robotrt.health.v1",
            "kind": "health",
            "captured_at_unix_nanos": snapshot.captured_at_unix_nanos,
            "source": source.json,
            "result": {
                "health": snapshot.health,
            },
        });
        println!(
            "{}",
            serde_json::to_string_pretty(&payload)
                .map_err(|err| format!("serialize health failed: {err}"))?
        );
    } else {
        println!("RobotRT Health");
        println!("source: {}", source.label);
        for item in &snapshot.health {
            println!(
                "- component={} status={} reason={}",
                item.component,
                item.status,
                item.reason.as_deref().unwrap_or("-")
            );
        }
    }

    Ok(())
}

pub(super) fn graph_cmd(args: &[String]) -> Result<(), String> {
    let json = has_flag(args, "--json");
    let (snapshot, source) = load_status_snapshot(args)?;

    if json {
        let payload = serde_json::json!({
            "api_version": "robotrt.graph.v1",
            "kind": "graph",
            "captured_at_unix_nanos": snapshot.captured_at_unix_nanos,
            "source": source.json,
            "result": {
                "edges": snapshot.edges,
            },
        });
        println!(
            "{}",
            serde_json::to_string_pretty(&payload)
                .map_err(|err| format!("serialize graph failed: {err}"))?
        );
    } else {
        println!("RobotRT Graph");
        println!("source: {}", source.label);
        for edge in &snapshot.edges {
            println!("{} -[{}]-> {}", edge.from, edge.relation, edge.to);
        }
    }

    Ok(())
}

fn stable_node_info_json(
    snapshot: &StatusSnapshot,
    source: &serde_json::Value,
    query_name: &str,
    node: &NodeStatus,
) -> serde_json::Value {
    serde_json::json!({
        "api_version": NODE_INFO_API_VERSION,
        "kind": "node_info",
        "captured_at_unix_nanos": snapshot.captured_at_unix_nanos,
        "source": source,
        "query": {
            "node_name": query_name,
        },
        "result": {
            "name": node.name,
            "namespace": node.namespace,
            "capabilities": node.capabilities,
        },
    })
}

fn stable_topic_info_json(
    snapshot: &StatusSnapshot,
    source: &serde_json::Value,
    query_name: &str,
    topic: &introspection_core::TopicStatus,
) -> serde_json::Value {
    serde_json::json!({
        "api_version": TOPIC_INFO_API_VERSION,
        "kind": "topic_info",
        "captured_at_unix_nanos": snapshot.captured_at_unix_nanos,
        "source": source,
        "query": {
            "topic_name": query_name,
        },
        "result": {
            "name": topic.name,
            "schema": topic.schema,
            "qos": {
                "reliable": topic.reliable,
                "depth": topic.depth,
            },
            "load": {
                "pending": topic.pending,
                "max_depth": topic.max_depth,
                "utilization_percent": utilization_percent(topic.pending, topic.max_depth),
            },
            "endpoints": {
                "publishers": topic.publishers,
                "subscribers": topic.subscribers,
            },
        },
    })
}

fn stable_action_info_json(
    snapshot: &StatusSnapshot,
    source: &serde_json::Value,
    query_name: &str,
    action: &ActionStatus,
) -> serde_json::Value {
    serde_json::json!({
        "api_version": ACTION_INFO_API_VERSION,
        "kind": "action_info",
        "captured_at_unix_nanos": snapshot.captured_at_unix_nanos,
        "source": source,
        "query": {
            "action_name": query_name,
        },
        "result": {
            "name": action.name,
            "endpoints": {
                "clients": action.clients,
                "servers": action.servers,
            },
            "health": {
                "current_state": action.current_state,
                "active_goals": action.active_goals,
                "health_state": action.health_state,
                "heartbeat_timeout_ms": action.heartbeat_timeout_ms,
                "last_heartbeat_at_unix_nanos": action.last_heartbeat_at_unix_nanos,
                "last_feedback_at_unix_nanos": action.last_feedback_at_unix_nanos,
                "last_result_at_unix_nanos": action.last_result_at_unix_nanos,
            },
        },
    })
}

fn find_action_status<'a>(snapshot: &'a StatusSnapshot, name: &str) -> Option<&'a ActionStatus> {
    snapshot
        .actions
        .iter()
        .find(|action| action_matches(&action.name, name))
}

pub(super) fn action_matches(candidate: &str, input: &str) -> bool {
    candidate == input || candidate.ends_with(&format!("/{input}"))
}