//! Runtime catalog of every matcher name the YAML schema accepts.
//!
//! Why: agents authoring YAML kept re-reading the schema (or a very long
//! prompt) just to recall a matcher name. `mcptest matchers` prints this
//! catalog instead, and the loader's did-you-mean hints suggest from the
//! same vocabulary. The accepted names are owned by `schemas/v1.json`
//! (`$defs/Matcher/properties` plus the budget and header keys on
//! `$defs/Expectation`); this module adds the descriptions and examples.
//! `mcptest-config` derives its name list from its embedded schema copy,
//! and a drift test in the `mcptest` binary crate (which sees both this
//! catalog and that loader) pins the two lists together, so neither side
//! can drift silently.

/// Where in the YAML `expect:` grammar a catalog entry is written.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
    /// A key inside an assertion's `matcher:` object
    /// (`$defs/Matcher` in the schema).
    Matcher,
    /// A budget or header check written directly on the long-form
    /// `expect:` block (`$defs/Expectation` in the schema).
    Expectation,
}

/// One catalog row: a name the schema accepts, what it does, and the
/// smallest YAML that uses it.
#[derive(Debug, Clone, Copy)]
pub struct CatalogEntry {
    /// Name exactly as the YAML schema accepts it.
    pub name: &'static str,
    /// Where the name is written in the `expect:` grammar.
    pub kind: EntryKind,
    /// One-line human description.
    pub description: &'static str,
    /// Minimal YAML example. Starts at `expect:` so it can be pasted
    /// under any tool test (and so tests can validate it mechanically).
    pub example: &'static str,
}

/// The full catalog, in the order `schemas/v1.json` declares the names.
/// Kept as a const so `mcptest matchers` costs nothing at runtime.
pub const CATALOG: &[CatalogEntry] = &[
    CatalogEntry {
        name: "exact",
        kind: EntryKind::Matcher,
        description: "Strict structural equality with the expected value.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { exact: \"hello\" }",
    },
    CatalogEntry {
        name: "contains",
        kind: EntryKind::Matcher,
        description: "Substring for strings, sub-object for objects, multiset membership for arrays.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { contains: \"status\" }",
    },
    CatalogEntry {
        name: "subset",
        kind: EntryKind::Matcher,
        description: "Recursive object subset; every expected key must be present, leaves compare equal.",
        example: "expect:\n  - target: result\n    matcher: { subset: { status: ok } }",
    },
    CatalogEntry {
        name: "regex",
        kind: EntryKind::Matcher,
        description: "Regex pattern matched against the stringified value.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { regex: \"^status:\" }",
    },
    CatalogEntry {
        name: "schema",
        kind: EntryKind::Matcher,
        description: "Inline JSON Schema the value must validate against.",
        example: "expect:\n  - target: result\n    matcher: { schema: { type: object } }",
    },
    CatalogEntry {
        name: "snapshot",
        kind: EntryKind::Matcher,
        description: "Record on first run, diff against the recording on later runs.",
        example: "expect:\n  - target: result\n    matcher: { snapshot: status-result }",
    },
    CatalogEntry {
        name: "llm-judge",
        kind: EntryKind::Matcher,
        description: "Grade the value with an LLM judge against a rubric (threshold 0.7 by default).",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { llm-judge: { rubric: \"Mentions the current status.\" } }",
    },
    CatalogEntry {
        name: "llm-jury",
        kind: EntryKind::Matcher,
        description: "Grade with an N-judge LLM jury; passes when the quorum of jurors passes.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { llm-jury: { rubric: \"Replies politely.\", jurors: [{ model: some-judge-model }] } }",
    },
    CatalogEntry {
        name: "contains-all",
        kind: EntryKind::Matcher,
        description: "The string or array value must contain every listed item.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { contains-all: [\"status\", \"ok\"] }",
    },
    CatalogEntry {
        name: "contains-any",
        kind: EntryKind::Matcher,
        description: "The string or array value must contain at least one listed item.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { contains-any: [\"ok\", \"healthy\"] }",
    },
    CatalogEntry {
        name: "icontains",
        kind: EntryKind::Matcher,
        description: "Case-insensitive substring containment for string values.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { icontains: \"OK\" }",
    },
    CatalogEntry {
        name: "starts-with",
        kind: EntryKind::Matcher,
        description: "The string value must start with the given prefix.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { starts-with: \"status:\" }",
    },
    CatalogEntry {
        name: "is-json",
        kind: EntryKind::Matcher,
        description: "The string value must parse as JSON; optionally validate the parsed doc with a schema.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { is-json: null }",
    },
    CatalogEntry {
        name: "is-valid-tools-call",
        kind: EntryKind::Matcher,
        description: "The value must be a well-formed MCP tool call (string name plus arguments object).",
        example: "expect:\n  - target: result\n    matcher: { is-valid-tools-call: null }",
    },
    CatalogEntry {
        name: "levenshtein",
        kind: EntryKind::Matcher,
        description: "Edit distance to a reference string must be at most `max`.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { levenshtein: { value: \"hello\", max: 2 } }",
    },
    CatalogEntry {
        name: "is-xml",
        kind: EntryKind::Matcher,
        description: "The string value must parse as well-formed XML; optionally assert the root element.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { is-xml: null }",
    },
    CatalogEntry {
        name: "is-sql",
        kind: EntryKind::Matcher,
        description: "The string value must parse as SQL against the generic dialect.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { is-sql: null }",
    },
    CatalogEntry {
        name: "similar",
        kind: EntryKind::Matcher,
        description: "Embedding cosine similarity to a reference string must meet the threshold.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { similar: { value: \"service is healthy\", threshold: 0.8, model: some-embedding-model } }",
    },
    CatalogEntry {
        name: "citation",
        kind: EntryKind::Matcher,
        description: "Fetch the value's cited URLs (SSRF-guarded) and judge whether each source supports the claim.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { citation: { claim: \"the library is Apache-2.0 licensed\", model: claude-sonnet-4-5 } }",
    },
    CatalogEntry {
        name: "cel",
        kind: EntryKind::Matcher,
        description: "Deterministic CEL boolean predicate; the extracted value is bound as `value`.",
        example: "expect:\n  - target: result\n    matcher: { cel: \"value.content.size() > 0\" }",
    },
    CatalogEntry {
        name: "factuality",
        kind: EntryKind::Matcher,
        description: "Model-graded check that the value is factually consistent with a reference.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { factuality: { reference: \"The capital of France is Paris.\" } }",
    },
    CatalogEntry {
        name: "answer-relevance",
        kind: EntryKind::Matcher,
        description: "Model-graded check that the value directly answers the given query.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { answer-relevance: { query: \"What is the current status?\" } }",
    },
    CatalogEntry {
        name: "context-faithfulness",
        kind: EntryKind::Matcher,
        description: "Model-graded check that every claim in the value is grounded in the given context.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { context-faithfulness: { context: \"The service is degraded.\" } }",
    },
    CatalogEntry {
        name: "not",
        kind: EntryKind::Matcher,
        description: "Universal negation; wraps another deterministic matcher and inverts it.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { not: { contains: \"error\" } }",
    },
    CatalogEntry {
        name: "oneOf",
        kind: EntryKind::Matcher,
        description: "Composition wrapper; passes when exactly one inner matcher passes.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { oneOf: [{ exact: \"a\" }, { exact: \"b\" }] }",
    },
    CatalogEntry {
        name: "anyOf",
        kind: EntryKind::Matcher,
        description: "Composition wrapper; passes when at least one inner matcher passes.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { anyOf: [{ contains: \"ok\" }, { contains: \"healthy\" }] }",
    },
    CatalogEntry {
        name: "allOf",
        kind: EntryKind::Matcher,
        description: "Composition wrapper; passes when every inner matcher passes.",
        example: "expect:\n  - target: result.content[0].text\n    matcher: { allOf: [{ contains: \"status\" }, { not: { contains: \"error\" } }] }",
    },
    CatalogEntry {
        name: "max_duration_ms",
        kind: EntryKind::Expectation,
        description: "Wall-clock budget in milliseconds; the step fails when it runs longer.",
        example: "expect:\n  max_duration_ms: 2000",
    },
    CatalogEntry {
        name: "max_response_tokens",
        kind: EntryKind::Expectation,
        description: "Token budget for the response content (integer, or an object with tokenizer options).",
        example: "expect:\n  max_response_tokens: 500",
    },
    CatalogEntry {
        name: "response_headers",
        kind: EntryKind::Expectation,
        description: "Expected HTTP response headers; values are literals or regex/contains/exists/schema checks.",
        example: "expect:\n  response_headers:\n    content-type: application/json",
    },
    CatalogEntry {
        name: "response_headers_absent",
        kind: EntryKind::Expectation,
        description: "Header names (case-insensitive) that must not appear in the response.",
        example: "expect:\n  response_headers_absent: [x-internal-debug]",
    },
];

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

    #[test]
    fn catalog_names_are_unique() {
        let mut seen: Vec<&str> = Vec::new();
        for entry in CATALOG {
            assert!(!seen.contains(&entry.name), "duplicate {}", entry.name);
            seen.push(entry.name);
        }
    }

    #[test]
    fn every_example_starts_at_the_expect_block() {
        for entry in CATALOG {
            assert!(
                entry.example.starts_with("expect:"),
                "{} example must start with `expect:` so tests can wrap it",
                entry.name
            );
        }
    }

    #[test]
    fn every_matcher_example_uses_its_own_name_as_a_key() {
        for entry in CATALOG {
            assert!(
                entry.example.contains(&format!("{}:", entry.name)),
                "{} example never uses the matcher",
                entry.name
            );
        }
    }
}
