pmat 3.15.0

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
//! KAIZEN-0178: Build-time generated MCP tool schema metadata.
//!
//! # Why this module exists
//!
//! Prior to KAIZEN-0178, every `ToolHandler` in `src/mcp_pmcp/` had to
//! hand-roll a `metadata()` override to forward `inputSchema` into
//! `pmcp::types::ToolInfo`. The pmcp default impl returns `None`, producing
//! empty schemas at runtime (D82-adjacent silent failure). PRs #351 and #356
//! fixed 20 tools each the hard way and left the defect class intact: adding
//! a new handler without `metadata()` silently regresses.
//!
//! # How the generator closes the defect class
//!
//! * Source of truth: `mcp_tool_schemas/<tool_name>.json`
//!   (one file per tool, validated at build time).
//! * `build.rs::generate_mcp_tool_schemas` walks that directory, validates
//!   each JSON object has `{name, description, inputSchema: { type: "object" }}`,
//!   and emits `$OUT_DIR/mcp_tool_schemas_gen.rs` with a
//!   `MCP_TOOL_SCHEMAS: &[(&str, &str)]` slice where every entry is an
//!   `include_str!()` of the JSON file.
//! * Deleting or misnaming a schema JSON → `include_str!` fails at compile
//!   time. There is no path to ship an empty schema.
//! * [`tool_metadata!`] is the only public entry point handlers should use.
//!
//! # Future handler migration pattern
//!
//! ```ignore
//! # use async_trait::async_trait;
//! # use pmcp::{RequestHandlerExtra, Result, ToolHandler, types::ToolInfo};
//! # use serde_json::Value;
//! use crate::tool_metadata;
//!
//! pub struct MyTool;
//!
//! #[async_trait]
//! impl ToolHandler for MyTool {
//!     async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> {
//!         /* ... */
//! #       Ok(Value::Null)
//!     }
//!
//!     fn metadata(&self) -> Option<ToolInfo> {
//!         Some(tool_metadata!("my_tool"))
//!     }
//! }
//! ```
//!
//! The `tool_metadata!` macro resolves the tool name against
//! [`MCP_TOOL_SCHEMAS`] at compile time: an unknown tool name panics in
//! a `const` context, turning "missing schema" into a build-time failure.

include!(concat!(env!("OUT_DIR"), "/mcp_tool_schemas_gen.rs"));

use pmcp::types::ToolInfo;
use serde_json::Value;

/// Look up a tool schema by name, panicking if it is absent.
///
/// Runs in `O(n)` over the sorted schema slice. Called once per handler
/// registration — never on the hot path.
///
/// # Panics
///
/// Panics if `name` has no corresponding entry in [`MCP_TOOL_SCHEMAS`].
/// This is deliberate: the `tool_metadata!` macro surfaces the panic at
/// handler construction, which is effectively build-time for the typical
/// unit/integration test harness.
#[must_use]
pub fn lookup_raw_schema(name: &str) -> &'static str {
    MCP_TOOL_SCHEMAS
        .iter()
        .find_map(|(n, json)| if *n == name { Some(*json) } else { None })
        .unwrap_or_else(|| {
            panic!(
                "KAIZEN-0178: no MCP tool schema registered for `{name}`. \
                 Add `mcp_tool_schemas/{name}.json` and rebuild."
            )
        })
}

/// Parse a raw tool schema JSON string into a `pmcp::types::ToolInfo`.
///
/// Separated from [`lookup_raw_schema`] so tests can exercise the JSON →
/// `ToolInfo` conversion without recompiling the schema directory.
///
/// # Panics
///
/// Panics if the schema JSON is malformed. `build.rs` validates the same
/// invariants, so this should only ever fire during development.
#[must_use]
pub fn raw_schema_to_tool_info(raw: &str) -> ToolInfo {
    let parsed: Value =
        serde_json::from_str(raw).expect("KAIZEN-0178: generated schema is valid JSON");
    let name = parsed
        .get("name")
        .and_then(Value::as_str)
        .expect("KAIZEN-0178: schema has name")
        .to_string();
    let description = parsed
        .get("description")
        .and_then(Value::as_str)
        .map(String::from);
    let input_schema = parsed
        .get("inputSchema")
        .cloned()
        .expect("KAIZEN-0178: schema has inputSchema");
    ToolInfo::new(name, description, input_schema)
}

/// Build a `pmcp::types::ToolInfo` from the generated schema registry.
///
/// Prefer the [`tool_metadata!`] macro in handlers; this function is the
/// reusable runtime core it expands to.
///
/// # Panics
///
/// See [`lookup_raw_schema`] and [`raw_schema_to_tool_info`].
#[must_use]
pub fn tool_info_for(name: &str) -> ToolInfo {
    raw_schema_to_tool_info(lookup_raw_schema(name))
}

/// Expand to `pmcp::types::ToolInfo` for `$name`, with a schema lookup that
/// **cannot** silently fall back to an empty schema.
///
/// The macro expansion references `__kaizen0178_schema_const!($name)`, which
/// is generated by `build.rs` with one match arm per schema JSON file. If
/// `mcp_tool_schemas/<name>.json` is deleted or misnamed, the arm disappears
/// and `cargo build` fails with `no rules expected the token <name>` —
/// turning a former silent-failure defect class into a hard build error.
#[macro_export]
macro_rules! tool_metadata {
    // NOTE: `$name:tt` (not `:literal`) is intentional — rustc cannot forward a
    // `:literal` fragment into another macro's literal-arm matcher, so we take
    // the token tree verbatim and let `__kaizen0178_schema_const!` match it.
    //
    // `__kaizen0178_schema_const!` is invoked bare (no `$crate::` prefix)
    // because it is a `#[macro_export]` macro generated by build.rs inside an
    // `include!`'d file; absolute-path access to such macros is forbidden by
    // rust-lang/rust#52234.
    ($name:tt) => {{
        // Compile-time schema assertion: if `$name.json` does not exist,
        // `__kaizen0178_schema_const!` has no matching arm and the build fails.
        let raw: &'static str = __kaizen0178_schema_const!($name);
        $crate::mcp_pmcp::tool_schemas_generated::raw_schema_to_tool_info(raw)
    }};
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn registry_is_not_empty() {
        assert!(
            !MCP_TOOL_SCHEMAS.is_empty(),
            "at least the 2 PoC schemas (pmat_query_code, quality_gate) must be generated"
        );
    }

    #[test]
    fn registry_is_sorted_and_unique() {
        for pair in MCP_TOOL_SCHEMAS.windows(2) {
            assert!(
                pair[0].0 < pair[1].0,
                "tool schemas must be sorted and unique: {} vs {}",
                pair[0].0,
                pair[1].0
            );
        }
    }

    #[test]
    fn every_schema_parses_and_has_object_input() {
        for (name, raw) in MCP_TOOL_SCHEMAS {
            let info = raw_schema_to_tool_info(raw);
            assert_eq!(info.name, *name, "ToolInfo.name must match registry key");
            assert!(
                info.description.as_ref().is_some_and(|s| !s.is_empty()),
                "{name}: description must be present and non-empty"
            );
            assert_eq!(
                info.input_schema.get("type").and_then(Value::as_str),
                Some("object"),
                "{name}: inputSchema.type must be \"object\""
            );
        }
    }

    #[test]
    fn poc_pmat_tool_present() {
        let info = tool_info_for("pmat_query_code");
        assert_eq!(info.name, "pmat_query_code");
        let required = info
            .input_schema
            .get("required")
            .and_then(Value::as_array)
            .expect("pmat_query_code has required array");
        assert!(
            required.iter().any(|v| v.as_str() == Some("query")),
            "`query` must remain a required property"
        );
    }

    #[test]
    fn poc_core_tool_present() {
        let info = tool_info_for("quality_gate");
        assert_eq!(info.name, "quality_gate");
        assert!(
            info.input_schema
                .get("properties")
                .and_then(|p| p.get("paths"))
                .is_some(),
            "quality_gate must expose `paths` property"
        );
    }

    #[test]
    #[should_panic(expected = "KAIZEN-0178: no MCP tool schema registered")]
    fn unknown_tool_panics() {
        let _ = tool_info_for("does_not_exist");
    }

    #[test]
    fn macro_expands_to_tool_info() {
        let info = tool_metadata!("quality_gate");
        assert_eq!(info.name, "quality_gate");
    }

    // NOTE: The compile-error-on-missing-schema constraint is exercised by
    // temporarily uncommenting the line below. It MUST fail to compile
    // ("no rules expected the token `zzz_not_a_tool`"). This is how KAIZEN-0178
    // proves the defect class is closed: there is no handler path to an empty
    // inputSchema, because missing schemas can't even reach runtime.
    //
    // Uncomment to verify the KAIZEN-0178 compile-error constraint locally:
    //
    // #[test]
    // fn missing_schema_fails_to_compile() {
    //     let _ = tool_metadata!("zzz_not_a_tool");
    // }
    //
    // Expected diagnostic: `error: no rules expected "zzz_not_a_tool"`.
}