//! Auto-generated tool reference documentation.
//!
//! Builds a stable Markdown / JSON description of every built-in tool
//! straight from `base_tool_definitions()` and a small static catalog
//! of known providers, so the published reference can never drift away
//! from the running code.
//!
//! Used by `devboy tools docs` (CLI).

use std::collections::BTreeSet;
use std::fmt::Write as _;

use devboy_core::{PropertySchema, ToolCategory};
use serde_json::{Value, json};

use crate::tools::{McpOnlyTool, ToolDefinition, base_tool_definitions, mcp_only_tools};

/// Provider entry in the support matrix.
///
/// `default_categories` are advertised whenever the provider is configured.
/// `conditional_categories` require additional setup (e.g. plugins) and are
/// rendered with a footnote so users know they may not be available.
#[derive(Debug, Clone)]
pub struct ProviderInfo {
    pub display_name: &'static str,
    pub key: &'static str,
    pub default_categories: &'static [ToolCategory],
    pub conditional_categories: &'static [ConditionalCategory],
}

/// A category that a provider only supports under specific conditions.
#[derive(Debug, Clone, Copy)]
pub struct ConditionalCategory {
    pub category: ToolCategory,
    /// Short note rendered next to the matrix cell.
    pub note: &'static str,
}

/// Static catalog of known providers and the categories they advertise.
///
/// Adding a new provider is a single entry here. Tests in
/// `tests::catalog_matches_runtime_enrichers` keep this in sync with
/// the actual `ToolEnricher::supported_categories()` implementations.
pub fn known_providers() -> Vec<ProviderInfo> {
    vec![
        ProviderInfo {
            display_name: "GitHub",
            key: "github",
            default_categories: &[ToolCategory::IssueTracker, ToolCategory::GitRepository],
            conditional_categories: &[],
        },
        ProviderInfo {
            display_name: "GitLab",
            key: "gitlab",
            default_categories: &[ToolCategory::IssueTracker, ToolCategory::GitRepository],
            conditional_categories: &[],
        },
        ProviderInfo {
            display_name: "ClickUp",
            key: "clickup",
            default_categories: &[ToolCategory::IssueTracker, ToolCategory::Epics],
            conditional_categories: &[],
        },
        ProviderInfo {
            display_name: "Jira",
            key: "jira",
            default_categories: &[ToolCategory::IssueTracker],
            conditional_categories: &[ConditionalCategory {
                category: ToolCategory::JiraStructure,
                note: "requires the Structure plugin to be installed and accessible",
            }],
        },
        ProviderInfo {
            display_name: "Confluence",
            key: "confluence",
            default_categories: &[ToolCategory::KnowledgeBase],
            conditional_categories: &[],
        },
        ProviderInfo {
            display_name: "Fireflies",
            key: "fireflies",
            default_categories: &[ToolCategory::MeetingNotes],
            conditional_categories: &[],
        },
        ProviderInfo {
            display_name: "Slack",
            key: "slack",
            default_categories: &[ToolCategory::Messenger],
            conditional_categories: &[],
        },
        ProviderInfo {
            display_name: "Telegram",
            key: "telegram",
            default_categories: &[ToolCategory::Messenger],
            conditional_categories: &[],
        },
    ]
}

/// Output format for the docs renderer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocsFormat {
    Markdown,
    Json,
}

/// Render the reference document in the requested format.
pub fn render(format: DocsFormat) -> String {
    match format {
        DocsFormat::Markdown => render_markdown(),
        DocsFormat::Json => {
            // The Value tree is built from owned strings and primitives — it
            // cannot fail to serialize. Panic loudly if that ever changes,
            // rather than emitting an empty doc that would silently pass
            // the CI drift check.
            serde_json::to_string_pretty(&render_json())
                .expect("tool_docs::render_json() should produce a serializable Value")
        }
    }
}

/// Render the reference document as Markdown.
pub fn render_markdown() -> String {
    let providers = known_providers();
    let tools = base_tool_definitions();
    let context_tools = mcp_only_tools();

    // Categories that actually show up in the matrix: any category that
    // a tool uses or any provider claims (default or conditional).
    let mut categories: BTreeSet<ToolCategory> = tools.iter().map(|t| t.category).collect();
    for p in &providers {
        categories.extend(p.default_categories.iter().copied());
        categories.extend(p.conditional_categories.iter().map(|c| c.category));
    }
    let categories: Vec<ToolCategory> = categories.into_iter().collect();

    let mut out = String::new();
    let _ = writeln!(out, "# DevBoy Tools Reference");
    out.push('\n');
    let _ = writeln!(
        out,
        "> Auto-generated by `devboy tools docs` from `base_tool_definitions()` and the static \
         provider catalog. Do not edit by hand — re-run the command to refresh."
    );
    out.push('\n');
    let _ = writeln!(
        out,
        "DevBoy Tools v{} ships {} provider-backed tools across {} categories, {} always-on context tools, and {} providers.",
        env!("CARGO_PKG_VERSION"),
        tools.len(),
        categories.len(),
        context_tools.len(),
        providers.len(),
    );
    out.push('\n');

    render_provider_matrix(&mut out, &categories, &providers);
    out.push('\n');
    render_tool_sections(&mut out, &categories, &providers, &tools);
    render_context_section(&mut out, &context_tools);

    out
}

/// Render the reference document as a structured JSON value.
pub fn render_json() -> Value {
    let providers = known_providers();
    let tools = base_tool_definitions();

    let providers_json: Vec<Value> = providers
        .iter()
        .map(|p| {
            json!({
                "key": p.key,
                "displayName": p.display_name,
                "defaultCategories": p.default_categories.iter().map(|c| c.key()).collect::<Vec<_>>(),
                "conditionalCategories": p.conditional_categories.iter().map(|c| json!({
                    "category": c.category.key(),
                    "note": c.note,
                })).collect::<Vec<_>>(),
            })
        })
        .collect();

    let tools_json: Vec<Value> = sorted_tools(&tools).into_iter().map(tool_to_json).collect();

    let context_tools_json: Vec<Value> =
        mcp_only_tools().iter().map(mcp_only_tool_to_json).collect();

    json!({
        "version": env!("CARGO_PKG_VERSION"),
        "providers": providers_json,
        "tools": tools_json,
        "contextTools": context_tools_json,
    })
}

// =============================================================================
// Markdown helpers
// =============================================================================

fn render_provider_matrix(
    out: &mut String,
    categories: &[ToolCategory],
    providers: &[ProviderInfo],
) {
    let _ = writeln!(out, "## Provider Support Matrix");
    out.push('\n');

    // Header
    out.push_str("| Provider |");
    for cat in categories {
        let _ = write!(out, " {} |", cat.display_name());
    }
    out.push('\n');

    out.push_str("|---|");
    for _ in categories {
        out.push_str(":---:|");
    }
    out.push('\n');

    // Rows
    let mut footnotes: Vec<String> = Vec::new();
    for provider in providers {
        let _ = write!(out, "| **{}** |", provider.display_name);
        for cat in categories {
            let cell = matrix_cell(provider, *cat, &mut footnotes);
            let _ = write!(out, " {} |", cell);
        }
        out.push('\n');
    }

    out.push('\n');
    out.push_str("Legend: `✅` supported · `⚠️` conditional (see notes) · `—` not applicable.\n");

    if !footnotes.is_empty() {
        out.push('\n');
        let _ = writeln!(out, "### Conditional support");
        out.push('\n');
        for note in footnotes {
            let _ = writeln!(out, "- {}", note);
        }
    }
}

fn matrix_cell(provider: &ProviderInfo, cat: ToolCategory, footnotes: &mut Vec<String>) -> String {
    if provider.default_categories.contains(&cat) {
        return "✅".into();
    }
    if let Some(cond) = provider
        .conditional_categories
        .iter()
        .find(|c| c.category == cat)
    {
        footnotes.push(format!(
            "**{} → {}**: {}.",
            provider.display_name,
            cat.display_name(),
            cond.note
        ));
        return "⚠️".into();
    }
    "—".into()
}

fn render_tool_sections(
    out: &mut String,
    categories: &[ToolCategory],
    providers: &[ProviderInfo],
    tools: &[ToolDefinition],
) {
    for cat in categories {
        let mut in_cat: Vec<&ToolDefinition> =
            tools.iter().filter(|t| t.category == *cat).collect();
        if in_cat.is_empty() {
            continue;
        }
        in_cat.sort_by(|a, b| a.name.cmp(&b.name));

        let _ = writeln!(out, "## {} Tools", cat.display_name());
        out.push('\n');

        let provider_names = providers_for_category(*cat, providers);
        if !provider_names.is_empty() {
            let _ = writeln!(out, "Providers: {}.", provider_names.join(", "));
            out.push('\n');
        }

        for tool in in_cat {
            render_tool(out, tool);
        }
    }
}

fn providers_for_category(cat: ToolCategory, providers: &[ProviderInfo]) -> Vec<String> {
    let mut names: Vec<String> = Vec::new();
    for p in providers {
        if p.default_categories.contains(&cat) {
            names.push(p.display_name.to_string());
        } else if p.conditional_categories.iter().any(|c| c.category == cat) {
            names.push(format!("{} (conditional)", p.display_name));
        }
    }
    names
}

fn render_tool(out: &mut String, tool: &ToolDefinition) {
    render_tool_entry(out, &tool.name, &tool.description, &tool.input_schema);
}

fn render_context_section(out: &mut String, tools: &[McpOnlyTool]) {
    if tools.is_empty() {
        return;
    }
    let _ = writeln!(out, "## Context Management Tools");
    out.push('\n');
    let _ = writeln!(
        out,
        "Always-on tools attached to every `tools/list` response, independent of which providers \
         are configured. They let the agent inspect or switch the active context."
    );
    out.push('\n');
    for tool in tools {
        render_tool_entry(out, &tool.name, &tool.description, &tool.input_schema);
    }
}

fn render_tool_entry(
    out: &mut String,
    name: &str,
    description: &str,
    schema: &devboy_core::ToolSchema,
) {
    let _ = writeln!(out, "### `{}`", name);
    out.push('\n');
    let _ = writeln!(out, "{}", description);
    out.push('\n');

    if schema.properties.is_empty() {
        out.push_str("_No parameters._\n\n");
        return;
    }

    out.push_str("| Parameter | Type | Required | Description |\n");
    out.push_str("|---|---|:---:|---|\n");

    let mut names: Vec<&String> = schema.properties.keys().collect();
    names.sort_by(|a, b| {
        let a_req = schema.required.contains(a);
        let b_req = schema.required.contains(b);
        b_req.cmp(&a_req).then_with(|| a.cmp(b))
    });

    for name in names {
        let prop = &schema.properties[name];
        let required = if schema.required.contains(name) {
            "✅"
        } else {
            "—"
        };
        let type_label = format_type(prop);
        let description = format_description(prop);
        let _ = writeln!(
            out,
            "| `{}` | {} | {} | {} |",
            escape_pipe(name),
            type_label,
            required,
            description
        );
    }
    out.push('\n');
}

fn format_type(prop: &PropertySchema) -> String {
    if let Some(variants) = &prop.any_of {
        // Render `anyOf` as a `|`-joined list of variant types.
        // Pipes are escaped for markdown table cells (`\|`).
        let inner = variants
            .iter()
            .map(format_type)
            .collect::<Vec<_>>()
            .join(" \\| ");
        return inner;
    }
    match prop.schema_type.as_str() {
        "array" => {
            let inner = prop
                .items
                .as_deref()
                .map(|i| i.schema_type.clone())
                .unwrap_or_else(|| "any".into());
            format!("array&lt;{}&gt;", inner)
        }
        "" => "any".into(),
        other => other.to_string(),
    }
}

fn format_description(prop: &PropertySchema) -> String {
    let mut parts: Vec<String> = Vec::new();
    if let Some(desc) = prop.description.as_deref()
        && !desc.is_empty()
    {
        parts.push(escape_pipe(desc));
    }
    if let Some(values) = &prop.enum_values
        && !values.is_empty()
    {
        let joined = values
            .iter()
            .map(|v| format!("`{}`", v))
            .collect::<Vec<_>>()
            .join(", ");
        parts.push(format!("Allowed values: {}", joined));
    }
    if let (Some(min), Some(max)) = (prop.minimum, prop.maximum) {
        parts.push(format!("Range: {} – {}", trim_float(min), trim_float(max)));
    } else if let Some(min) = prop.minimum {
        parts.push(format!("Min: {}", trim_float(min)));
    } else if let Some(max) = prop.maximum {
        parts.push(format!("Max: {}", trim_float(max)));
    }
    if let Some(default) = &prop.default {
        parts.push(format!("Default: `{}`", default));
    }
    if parts.is_empty() {
        "—".into()
    } else {
        parts.join(". ")
    }
}

fn trim_float(value: f64) -> String {
    if value.fract() == 0.0 {
        format!("{}", value as i64)
    } else {
        format!("{}", value)
    }
}

fn escape_pipe(s: &str) -> String {
    s.replace('|', "\\|").replace('\n', " ")
}

// =============================================================================
// JSON helpers
// =============================================================================

fn sorted_tools(tools: &[ToolDefinition]) -> Vec<&ToolDefinition> {
    let mut sorted: Vec<&ToolDefinition> = tools.iter().collect();
    sorted.sort_by(|a, b| {
        a.category
            .cmp(&b.category)
            .then_with(|| a.name.cmp(&b.name))
    });
    sorted
}

fn tool_to_json(tool: &ToolDefinition) -> Value {
    json!({
        "name": tool.name,
        "category": tool.category.key(),
        "description": tool.description,
        "parameters": parameters_to_json(&tool.input_schema),
    })
}

fn mcp_only_tool_to_json(tool: &McpOnlyTool) -> Value {
    json!({
        "name": tool.name,
        "description": tool.description,
        "parameters": parameters_to_json(&tool.input_schema),
    })
}

fn parameters_to_json(schema: &devboy_core::ToolSchema) -> Vec<Value> {
    let mut names: Vec<&String> = schema.properties.keys().collect();
    names.sort_by(|a, b| {
        let a_req = schema.required.contains(a);
        let b_req = schema.required.contains(b);
        b_req.cmp(&a_req).then_with(|| a.cmp(b))
    });

    names
        .into_iter()
        .map(|name| {
            let prop = &schema.properties[name];
            let mut entry = if prop.schema_type.is_empty() {
                // `anyOf` schemas have no top-level `type`.
                json!({
                    "name": name,
                    "required": schema.required.contains(name),
                })
            } else {
                json!({
                    "name": name,
                    "type": prop.schema_type,
                    "required": schema.required.contains(name),
                })
            };
            if let Some(desc) = &prop.description {
                entry["description"] = Value::String(desc.clone());
            }
            if let Some(values) = &prop.enum_values {
                entry["enum"] = json!(values);
            }
            if let Some(variants) = &prop.any_of {
                entry["anyOf"] =
                    serde_json::to_value(variants).unwrap_or_else(|_| Value::Array(vec![]));
            }
            if let Some(min) = prop.minimum {
                entry["minimum"] = json!(min);
            }
            if let Some(max) = prop.maximum {
                entry["maximum"] = json!(max);
            }
            if let Some(default) = &prop.default {
                entry["default"] = default.clone();
            }
            if let Some(items) = &prop.items {
                entry["items"] = json!({ "type": items.schema_type });
            }
            entry
        })
        .collect()
}

// =============================================================================
// Tests
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::{
        ClickUpScope, ConfluenceAuthConfig, ConfluenceScope, GitHubScope, GitLabScope, JiraScope,
        ProviderConfig, SlackScope, TelegramScope,
    };
    use devboy_core::ToolEnricher;
    use std::collections::HashMap;

    #[test]
    fn markdown_contains_header_and_matrix() {
        let md = render_markdown();
        assert!(md.starts_with("# DevBoy Tools Reference"));
        assert!(md.contains("## Provider Support Matrix"));
        assert!(md.contains("| **GitHub** |"));
        assert!(md.contains("| **Slack** |"));
        assert!(md.contains("| **Telegram** |"));
    }

    #[test]
    fn markdown_lists_every_tool() {
        let md = render_markdown();
        for tool in base_tool_definitions() {
            let heading = format!("### `{}`", tool.name);
            assert!(
                md.contains(&heading),
                "tool `{}` missing from rendered docs",
                tool.name
            );
        }
    }

    #[test]
    fn markdown_marks_jira_structure_as_conditional() {
        let md = render_markdown();
        // Conditional cell present
        assert!(md.contains("⚠️"), "expected conditional marker in matrix");
        assert!(md.contains("requires the Structure plugin"));
    }

    #[test]
    fn markdown_groups_categories_in_canonical_order() {
        let md = render_markdown();
        let order = [
            "## Issue Tracker Tools",
            "## Git Repository Tools",
            "## Epics Tools",
            "## Meeting Notes Tools",
            "## Messenger Tools",
            "## Jira Structure Tools",
        ];
        let mut last = 0usize;
        for heading in order {
            let pos = md
                .find(heading)
                .unwrap_or_else(|| panic!("missing heading {}", heading));
            assert!(
                pos >= last,
                "headings out of order: {} appeared before previous heading",
                heading
            );
            last = pos;
        }
    }

    #[test]
    fn json_render_has_expected_top_level_keys() {
        let value = render_json();
        assert!(value.get("version").is_some());
        let providers = value.get("providers").and_then(|v| v.as_array()).unwrap();
        let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
        assert_eq!(providers.len(), known_providers().len());
        assert_eq!(tools.len(), base_tool_definitions().len());
    }

    #[test]
    fn json_marks_required_parameters() {
        let value = render_json();
        let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
        let create_issue = tools
            .iter()
            .find(|t| t["name"] == "create_issue")
            .expect("create_issue must be present");
        let title = create_issue["parameters"]
            .as_array()
            .unwrap()
            .iter()
            .find(|p| p["name"] == "title")
            .expect("title parameter must be present");
        assert_eq!(title["required"], Value::Bool(true));
    }

    #[test]
    fn provider_keys_are_unique() {
        let mut keys: Vec<&str> = known_providers().iter().map(|p| p.key).collect();
        keys.sort_unstable();
        let original_len = keys.len();
        keys.dedup();
        assert_eq!(keys.len(), original_len, "provider keys must be unique");
    }

    #[test]
    fn markdown_renders_context_management_section() {
        let md = render_markdown();
        assert!(md.contains("## Context Management Tools"));
        for tool in mcp_only_tools() {
            let heading = format!("### `{}`", tool.name);
            assert!(
                md.contains(&heading),
                "context tool `{}` missing from rendered docs",
                tool.name
            );
        }
    }

    #[test]
    fn json_includes_context_tools_array() {
        let value = render_json();
        let context = value
            .get("contextTools")
            .and_then(|v| v.as_array())
            .expect("contextTools must be present in JSON output");
        assert_eq!(context.len(), mcp_only_tools().len());
        let names: Vec<&str> = context
            .iter()
            .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
            .collect();
        assert!(names.contains(&"list_contexts"));
        assert!(names.contains(&"use_context"));
        assert!(names.contains(&"get_current_context"));
    }

    /// Compile-time guardrail: every variant of `ProviderConfig` (i.e. every
    /// provider the factory dispatches on) must either be present in
    /// `known_providers()` or be the explicit `Custom` escape hatch.
    ///
    /// The exhaustive `match` makes it impossible to add a new variant
    /// without updating this mapping; the runtime assertion then forces
    /// the catalog update too.
    #[test]
    fn every_factory_provider_is_in_catalog() {
        use std::collections::HashSet;

        // Sample config per variant. Only `provider_name()` is used —
        // tokens, scopes and base URLs are placeholders.
        let samples: Vec<ProviderConfig> = vec![
            ProviderConfig::GitLab {
                base_url: "https://gitlab.com".into(),
                access_token: "x".into(),
                scope: GitLabScope::Project { id: "1".into() },
                extra: HashMap::new(),
            },
            ProviderConfig::GitHub {
                base_url: "https://api.github.com".into(),
                access_token: "x".into(),
                scope: GitHubScope::Repository {
                    owner: "o".into(),
                    repo: "r".into(),
                },
                extra: HashMap::new(),
            },
            ProviderConfig::ClickUp {
                access_token: "x".into(),
                scope: ClickUpScope::List {
                    id: "1".into(),
                    team_id: None,
                },
                extra: HashMap::new(),
            },
            ProviderConfig::Jira {
                base_url: "https://x.atlassian.net".into(),
                access_token: "x".into(),
                email: "x@x".into(),
                scope: JiraScope::Project { key: "X".into() },
                flavor: None,
                extra: HashMap::new(),
            },
            ProviderConfig::Confluence {
                base_url: "https://wiki.example.com".into(),
                auth: ConfluenceAuthConfig::BearerToken { token: "x".into() },
                scope: ConfluenceScope::Space {
                    key: Some("ENG".into()),
                },
                api_version: Some("v1".into()),
                extra: HashMap::new(),
            },
            ProviderConfig::Fireflies {
                api_key: "x".into(),
                extra: HashMap::new(),
            },
            ProviderConfig::Slack {
                base_url: "https://slack.com/api".into(),
                access_token: "x".into(),
                scope: SlackScope::Workspace { team_id: None },
                required_scopes: Vec::new(),
                extra: HashMap::new(),
            },
            ProviderConfig::Telegram {
                base_url: "https://api.telegram.org".into(),
                access_token: "x".into(),
                scope: TelegramScope::Bot { bot_username: None },
                extra: HashMap::new(),
            },
            ProviderConfig::Custom {
                name: "custom".into(),
                config: HashMap::new(),
            },
        ];

        // Compile-time exhaustive match — adding a new ProviderConfig
        // variant breaks this and forces the maintainer to either map it
        // to a catalog key or explicitly skip it (like Custom).
        fn expected_catalog_key(config: &ProviderConfig) -> Option<&'static str> {
            match config {
                ProviderConfig::GitLab { .. } => Some("gitlab"),
                ProviderConfig::GitHub { .. } => Some("github"),
                ProviderConfig::ClickUp { .. } => Some("clickup"),
                ProviderConfig::Jira { .. } => Some("jira"),
                ProviderConfig::Confluence { .. } => Some("confluence"),
                ProviderConfig::Fireflies { .. } => Some("fireflies"),
                ProviderConfig::Slack { .. } => Some("slack"),
                ProviderConfig::Telegram { .. } => Some("telegram"),
                ProviderConfig::Custom { .. } => None,
            }
        }

        let catalog_keys: HashSet<&str> = known_providers().iter().map(|p| p.key).collect();
        let mut required_keys: HashSet<&str> = HashSet::new();
        for cfg in &samples {
            // Sanity: provider_name() and our expected key agree —
            // catches accidental rename of the runtime identifier.
            if let Some(expected) = expected_catalog_key(cfg) {
                assert_eq!(
                    cfg.provider_name(),
                    expected,
                    "ProviderConfig::{:?}.provider_name() drifted from the catalog key",
                    expected
                );
                required_keys.insert(expected);
            }
        }

        let missing: Vec<&&str> = required_keys.difference(&catalog_keys).collect();
        assert!(
            missing.is_empty(),
            "factory dispatches on providers {:?} but tool_docs::known_providers() does not list them — \
             update the catalog or remove the variant",
            missing
        );

        let extras: Vec<&&str> = catalog_keys.difference(&required_keys).collect();
        assert!(
            extras.is_empty(),
            "tool_docs::known_providers() advertises {:?} but factory has no matching variant — \
             remove the catalog entry or add a factory dispatch arm",
            extras
        );
    }

    /// Catalog claims must remain a subset of what each enricher actually
    /// reports at runtime — protects against quietly drifting docs when a
    /// provider drops or adds a category.
    #[test]
    fn catalog_matches_runtime_enrichers() {
        use std::collections::HashSet;

        fn assert_subset<E: ToolEnricher>(provider_key: &str, enricher: &E) {
            let runtime: HashSet<ToolCategory> =
                enricher.supported_categories().iter().copied().collect();
            let entry = known_providers()
                .into_iter()
                .find(|p| p.key == provider_key)
                .unwrap_or_else(|| panic!("provider `{}` missing from catalog", provider_key));
            for cat in entry.default_categories {
                assert!(
                    runtime.contains(cat),
                    "{} catalog claims category {:?} but the runtime enricher does not",
                    provider_key,
                    cat
                );
            }
        }

        // Constructible without runtime data:
        assert_subset("github", &devboy_github::GitHubSchemaEnricher);
        assert_subset("gitlab", &devboy_gitlab::GitLabSchemaEnricher);
        assert_subset("fireflies", &devboy_fireflies::FirefliesSchemaEnricher);
        // ClickUp / Jira need metadata; their runtime claims are exercised by
        // each crate's own enricher tests, which guard the same invariant.
    }
}
