arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! `inspect_route` tool — a single named route's details.
//!
//! Reuses [`crate::inspection::find_routes_by_name`] (the shared extractor
//! consumed by `arc inspect route <name>` too) so there is one route-lookup
//! implementation, not two (master Reservation #6). Takes a single
//! `route_name` string argument, bounded by [`MAX_STRING_ARG`] (§29).

use crate::commands::mcp::capability::CapabilitySet;
use crate::commands::mcp::error::McpError;
use crate::commands::mcp::registry::ToolContext;
use crate::commands::mcp::tools::parse_bounded_string;
use crate::inspection::find_routes_by_name;
use serde::Serialize;
use serde_json::Value;

#[derive(Serialize)]
struct InspectRouteResult<'a> {
    route_name: String,
    routes: Vec<&'a arcature_build::uag::schema::RouteEntry>,
}

pub(crate) fn call(
    arguments: &Value,
    _capabilities: &CapabilitySet,
    context: &ToolContext,
) -> Result<Value, McpError> {
    let route_name = parse_bounded_string(arguments, "route_name")?;
    let routes = find_routes_by_name(&context.uag, &route_name);
    let result = InspectRouteResult { route_name, routes };
    serde_json::to_value(result).map_err(McpError::from)
}

#[cfg(test)]
mod tests {
    use super::*;
    use arcature_build::uag::Uag;
    use arcature_build::uag::schema::RouteEntry;
    use std::collections::BTreeMap;

    fn route(name: &str, method: &str, path: &str) -> RouteEntry {
        RouteEntry {
            method: method.into(),
            path: path.into(),
            name: name.into(),
            handler: format!("H::{name}"),
            pages: vec![],
            action_fields: vec![],
            action_type: String::new(),
            query_fields: vec![],
            query_type: String::new(),
            query_array: false,
            query_string_fields: vec![],
            query_string_type: String::new(),
        }
    }

    fn ctx_with(routes: Vec<RouteEntry>) -> ToolContext {
        ToolContext {
            uag: Uag {
                schema_version: 1,
                application: "App".into(),
                framework_version: "2026.1.0".into(),
                modules: BTreeMap::new(),
                routes,
                services: vec![],
                pages: vec![],
            },
        }
    }

    #[test]
    fn finds_a_named_route() {
        let ctx = ctx_with(vec![
            route("links.index", "get", "/links"),
            route("links.show", "get", "/links/{link}"),
        ]);
        let args = serde_json::json!({"route_name": "links.show"});
        let value = call(
            &args,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect("ok");
        assert_eq!(value["route_name"], "links.show");
        let arr = value["routes"].as_array().expect("array");
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["path"], "/links/{link}");
    }

    #[test]
    fn finds_multiple_routes_sharing_a_name() {
        let ctx = ctx_with(vec![route("dup", "get", "/a"), route("dup", "post", "/a")]);
        let args = serde_json::json!({"route_name": "dup"});
        let value = call(
            &args,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect("ok");
        assert_eq!(value["routes"].as_array().expect("array").len(), 2);
    }

    #[test]
    fn unknown_route_returns_empty_not_an_error() {
        let ctx = ctx_with(vec![route("links.index", "get", "/links")]);
        let args = serde_json::json!({"route_name": "nope"});
        let value = call(
            &args,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect("ok");
        assert_eq!(value["route_name"], "nope");
        assert!(value["routes"].as_array().expect("array").is_empty());
    }

    #[test]
    fn missing_argument_is_a_typed_error() {
        let ctx = ctx_with(vec![]);
        let args = serde_json::json!({});
        let err = call(
            &args,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect_err("missing");
        assert!(matches!(err, McpError::InvalidArgument { .. }));
    }

    #[test]
    fn non_string_argument_is_a_typed_error() {
        let ctx = ctx_with(vec![]);
        let args = serde_json::json!({"route_name": 42});
        let err = call(
            &args,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect_err("non-string");
        assert!(matches!(err, McpError::InvalidArgument { .. }));
    }

    #[test]
    fn oversized_route_name_is_a_typed_error() {
        let ctx = ctx_with(vec![]);
        let huge = "x".repeat(crate::commands::mcp::tools::parse::MAX_STRING_ARG + 1);
        let args = serde_json::json!({"route_name": huge});
        let err = call(
            &args,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect_err("oversized");
        assert!(matches!(
            err,
            McpError::ArgumentTooLarge {
                field: "route_name",
                ..
            }
        ));
    }
}