arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `docs_search` tool — version-aware docs retrieval.
//!
//! Returns the certified stack components, their pinned versions, and the
//! cross-stack protocol coordinates for the installed stack — read from the
//! embedded Certified Stack Contract (`crate::stack::contract::load`) that
//! `arc doctor` also uses. Version-aware: the docs are the versions actually
//! certified for the installed Arcature, not "latest" (AGENTS.md §24: no
//! `latest` claims). Takes an optional `query` string to filter components
//! by name/role (case-insensitive substring); when absent, returns all.
//!
//! The cross-stack protocol coordinate is the framework version the UAG
//! records (`framework_version`), distinct from a Rust crate YBF version,
//! the Platform version, and the npm package version (ADR-0006 §2; AP2.1
//! "Cross-stack protocol version is separate"). The tool surfaces it from
//! the loaded UAG so a client can negotiate compatibility.

use crate::commands::mcp::capability::CapabilitySet;
use crate::commands::mcp::error::McpError;
use crate::commands::mcp::registry::ToolContext;
use crate::commands::mcp::tools::parse_optional_bounded_string;
use crate::stack::load as load_contract;
use serde::Serialize;
use serde_json::Value;

#[derive(Serialize)]
struct DocsSearchResult {
    /// The Arcature engine version the certified stack is pinned against.
    arcature_version: String,
    /// The certified `@arcature/client` npm version (placeholder until
    /// separately-secured publication — see the contract).
    arcature_client_version: String,
    /// The cross-stack protocol coordinate: the framework version the UAG
    /// records for the loaded application graph. Distinct from a crate YBF
    /// version, the Platform version, and the npm version.
    cross_stack_protocol_version: String,
    /// The UAG schema version the CLI consumes.
    uag_schema_version: u32,
    /// The matching certified-stack components (filtered by the query).
    components: Vec<ComponentDoc>,
}

#[derive(Serialize)]
struct ComponentDoc {
    name: String,
    version: String,
    role: String,
}

pub(crate) fn call(
    arguments: &Value,
    _capabilities: &CapabilitySet,
    context: &ToolContext,
) -> Result<Value, McpError> {
    let query = parse_optional_bounded_string(arguments, "query")?;
    let contract = load_contract();
    let q = query.as_deref().map(str::to_lowercase);
    let mut components = Vec::new();
    for (name, component) in &contract.components {
        let role = component.role.clone();
        let needle = q.as_deref();
        let matches = match needle {
            None => true,
            Some(q) => name.to_lowercase().contains(q) || role.to_lowercase().contains(q),
        };
        if matches {
            components.push(ComponentDoc {
                name: name.clone(),
                version: component.version.clone(),
                role,
            });
        }
    }
    let result = DocsSearchResult {
        arcature_version: contract.snapshot.arcature_version.clone(),
        arcature_client_version: contract.snapshot.arcature_client_version.clone(),
        cross_stack_protocol_version: context.uag.framework_version.clone(),
        uag_schema_version: context.uag.schema_version,
        components,
    };
    serde_json::to_value(result).map_err(McpError::from)
}

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

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

    fn caps() -> CapabilitySet {
        CapabilitySet::from_options(&crate::cli::McpOptions::default())
    }

    #[test]
    fn returns_arcature_version_and_components_without_query() {
        let value = call(&Value::Null, &caps(), &ctx()).expect("ok");
        let components = value["components"].as_array().expect("array");
        assert!(!components.is_empty(), "the certified stack has components");
        assert!(value["arcature_version"].as_str().is_some());
        // The contract is loaded at compile time; the version is a known constant.
        assert_eq!(value["arcature_version"], "2026.1.0");
    }

    #[test]
    fn cross_stack_protocol_version_comes_from_uag() {
        let value = call(&Value::Null, &caps(), &ctx()).expect("ok");
        assert_eq!(value["cross_stack_protocol_version"], "2026.1.0");
        assert_eq!(value["uag_schema_version"], 1);
    }

    #[test]
    fn query_filters_components_case_insensitively() {
        // Search for the HTTP server role.
        let args = serde_json::json!({"query": "HTTP"});
        let value = call(&args, &caps(), &ctx()).expect("ok");
        let components = value["components"].as_array().expect("array");
        assert!(!components.is_empty());
        // Every returned component should mention "http" in name or role.
        for c in components {
            let role = c["role"].as_str().expect("role");
            let name = c["name"].as_str().expect("name");
            let hits = role.to_lowercase().contains("http") || name.to_lowercase().contains("http");
            assert!(hits, "non-matching component returned: {name} / {role}");
        }
    }

    #[test]
    fn query_by_component_name() {
        let args = serde_json::json!({"query": "axum"});
        let value = call(&args, &caps(), &ctx()).expect("ok");
        let components = value["components"].as_array().expect("array");
        // "axum" matches the `axum` component by name AND any component whose
        // role mentions axum (e.g. hyper's "HTTP engine (transitive via
        // axum)"). The role-substring match is intentional (searching "HTTP"
        // must find all HTTP-role components). Assert the named component is
        // present with its certified version, not that it is the sole match —
        // the exclusivity claim was wrong against the real contract.
        let axum = components
            .iter()
            .find(|c| c["name"] == "axum")
            .expect("axum component matched by name");
        assert_eq!(axum["version"], "0.8.9");
        assert!(!components.is_empty());
    }

    #[test]
    fn no_match_query_returns_empty_components() {
        let args = serde_json::json!({"query": "zzznope"});
        let value = call(&args, &caps(), &ctx()).expect("ok");
        assert!(value["components"].as_array().expect("array").is_empty());
    }

    #[test]
    fn oversized_query_is_a_typed_error() {
        let huge = "x".repeat(crate::commands::mcp::tools::parse::MAX_STRING_ARG + 1);
        let args = serde_json::json!({"query": huge});
        let err = call(&args, &caps(), &ctx()).expect_err("oversized");
        assert!(matches!(
            err,
            McpError::ArgumentTooLarge { field: "query", .. }
        ));
    }

    #[test]
    fn non_string_query_is_a_typed_error() {
        let args = serde_json::json!({"query": 42});
        let err = call(&args, &caps(), &ctx()).expect_err("non-string");
        assert!(matches!(err, McpError::InvalidArgument { .. }));
    }

    #[test]
    fn client_version_is_reported_honestly() {
        let value = call(&Value::Null, &caps(), &ctx()).expect("ok");
        // The contract carries the placeholder until the separately-secured
        // npm lifecycle replaces it. The tool surfaces it verbatim, not
        // claiming a real published version it does not have.
        assert!(value["arcature_client_version"].as_str().is_some());
    }
}