Skip to main content

devboy_executor/
tool_docs.rs

1//! Auto-generated tool reference documentation.
2//!
3//! Builds a stable Markdown / JSON description of every built-in tool
4//! straight from `base_tool_definitions()` and a small static catalog
5//! of known providers, so the published reference can never drift away
6//! from the running code.
7//!
8//! Used by `devboy tools docs` (CLI).
9
10use std::collections::BTreeSet;
11use std::fmt::Write as _;
12
13use devboy_core::{PropertySchema, ToolCategory};
14use serde_json::{Value, json};
15
16use crate::tools::{McpOnlyTool, ToolDefinition, base_tool_definitions, mcp_only_tools};
17
18/// Provider entry in the support matrix.
19///
20/// `default_categories` are advertised whenever the provider is configured.
21/// `conditional_categories` require additional setup (e.g. plugins) and are
22/// rendered with a footnote so users know they may not be available.
23#[derive(Debug, Clone)]
24pub struct ProviderInfo {
25    pub display_name: &'static str,
26    pub key: &'static str,
27    pub default_categories: &'static [ToolCategory],
28    pub conditional_categories: &'static [ConditionalCategory],
29}
30
31/// A category that a provider only supports under specific conditions.
32#[derive(Debug, Clone, Copy)]
33pub struct ConditionalCategory {
34    pub category: ToolCategory,
35    /// Short note rendered next to the matrix cell.
36    pub note: &'static str,
37}
38
39/// Static catalog of known providers and the categories they advertise.
40///
41/// Adding a new provider is a single entry here. Tests in
42/// `tests::catalog_matches_runtime_enrichers` keep this in sync with
43/// the actual `ToolEnricher::supported_categories()` implementations.
44pub fn known_providers() -> Vec<ProviderInfo> {
45    vec![
46        ProviderInfo {
47            display_name: "GitHub",
48            key: "github",
49            default_categories: &[ToolCategory::IssueTracker, ToolCategory::GitRepository],
50            conditional_categories: &[],
51        },
52        ProviderInfo {
53            display_name: "GitLab",
54            key: "gitlab",
55            default_categories: &[ToolCategory::IssueTracker, ToolCategory::GitRepository],
56            conditional_categories: &[],
57        },
58        ProviderInfo {
59            display_name: "ClickUp",
60            key: "clickup",
61            default_categories: &[ToolCategory::IssueTracker, ToolCategory::Epics],
62            conditional_categories: &[],
63        },
64        ProviderInfo {
65            display_name: "Jira",
66            key: "jira",
67            default_categories: &[ToolCategory::IssueTracker],
68            conditional_categories: &[ConditionalCategory {
69                category: ToolCategory::JiraStructure,
70                note: "requires the Structure plugin to be installed and accessible",
71            }],
72        },
73        ProviderInfo {
74            display_name: "Confluence",
75            key: "confluence",
76            default_categories: &[ToolCategory::KnowledgeBase],
77            conditional_categories: &[],
78        },
79        ProviderInfo {
80            display_name: "Fireflies",
81            key: "fireflies",
82            default_categories: &[ToolCategory::MeetingNotes],
83            conditional_categories: &[],
84        },
85        ProviderInfo {
86            display_name: "Slack",
87            key: "slack",
88            default_categories: &[ToolCategory::Messenger],
89            conditional_categories: &[],
90        },
91        ProviderInfo {
92            display_name: "Telegram",
93            key: "telegram",
94            default_categories: &[ToolCategory::Messenger],
95            conditional_categories: &[],
96        },
97    ]
98}
99
100/// Output format for the docs renderer.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum DocsFormat {
103    Markdown,
104    Json,
105}
106
107/// Render the reference document in the requested format.
108pub fn render(format: DocsFormat) -> String {
109    match format {
110        DocsFormat::Markdown => render_markdown(),
111        DocsFormat::Json => {
112            // The Value tree is built from owned strings and primitives — it
113            // cannot fail to serialize. Panic loudly if that ever changes,
114            // rather than emitting an empty doc that would silently pass
115            // the CI drift check.
116            serde_json::to_string_pretty(&render_json())
117                .expect("tool_docs::render_json() should produce a serializable Value")
118        }
119    }
120}
121
122/// Render the reference document as Markdown.
123pub fn render_markdown() -> String {
124    let providers = known_providers();
125    let tools = base_tool_definitions();
126    let context_tools = mcp_only_tools();
127
128    // Categories that actually show up in the matrix: any category that
129    // a tool uses or any provider claims (default or conditional).
130    let mut categories: BTreeSet<ToolCategory> = tools.iter().map(|t| t.category).collect();
131    for p in &providers {
132        categories.extend(p.default_categories.iter().copied());
133        categories.extend(p.conditional_categories.iter().map(|c| c.category));
134    }
135    let categories: Vec<ToolCategory> = categories.into_iter().collect();
136
137    let mut out = String::new();
138    let _ = writeln!(out, "# DevBoy Tools Reference");
139    out.push('\n');
140    let _ = writeln!(
141        out,
142        "> Auto-generated by `devboy tools docs` from `base_tool_definitions()` and the static \
143         provider catalog. Do not edit by hand — re-run the command to refresh."
144    );
145    out.push('\n');
146    let _ = writeln!(
147        out,
148        "DevBoy Tools v{} ships {} provider-backed tools across {} categories, {} always-on context tools, and {} providers.",
149        env!("CARGO_PKG_VERSION"),
150        tools.len(),
151        categories.len(),
152        context_tools.len(),
153        providers.len(),
154    );
155    out.push('\n');
156
157    render_provider_matrix(&mut out, &categories, &providers);
158    out.push('\n');
159    render_tool_sections(&mut out, &categories, &providers, &tools);
160    render_context_section(&mut out, &context_tools);
161
162    out
163}
164
165/// Render the reference document as a structured JSON value.
166pub fn render_json() -> Value {
167    let providers = known_providers();
168    let tools = base_tool_definitions();
169
170    let providers_json: Vec<Value> = providers
171        .iter()
172        .map(|p| {
173            json!({
174                "key": p.key,
175                "displayName": p.display_name,
176                "defaultCategories": p.default_categories.iter().map(|c| c.key()).collect::<Vec<_>>(),
177                "conditionalCategories": p.conditional_categories.iter().map(|c| json!({
178                    "category": c.category.key(),
179                    "note": c.note,
180                })).collect::<Vec<_>>(),
181            })
182        })
183        .collect();
184
185    let tools_json: Vec<Value> = sorted_tools(&tools).into_iter().map(tool_to_json).collect();
186
187    let context_tools_json: Vec<Value> =
188        mcp_only_tools().iter().map(mcp_only_tool_to_json).collect();
189
190    json!({
191        "version": env!("CARGO_PKG_VERSION"),
192        "providers": providers_json,
193        "tools": tools_json,
194        "contextTools": context_tools_json,
195    })
196}
197
198// =============================================================================
199// Markdown helpers
200// =============================================================================
201
202fn render_provider_matrix(
203    out: &mut String,
204    categories: &[ToolCategory],
205    providers: &[ProviderInfo],
206) {
207    let _ = writeln!(out, "## Provider Support Matrix");
208    out.push('\n');
209
210    // Header
211    out.push_str("| Provider |");
212    for cat in categories {
213        let _ = write!(out, " {} |", cat.display_name());
214    }
215    out.push('\n');
216
217    out.push_str("|---|");
218    for _ in categories {
219        out.push_str(":---:|");
220    }
221    out.push('\n');
222
223    // Rows
224    let mut footnotes: Vec<String> = Vec::new();
225    for provider in providers {
226        let _ = write!(out, "| **{}** |", provider.display_name);
227        for cat in categories {
228            let cell = matrix_cell(provider, *cat, &mut footnotes);
229            let _ = write!(out, " {} |", cell);
230        }
231        out.push('\n');
232    }
233
234    out.push('\n');
235    out.push_str("Legend: `✅` supported · `⚠️` conditional (see notes) · `—` not applicable.\n");
236
237    if !footnotes.is_empty() {
238        out.push('\n');
239        let _ = writeln!(out, "### Conditional support");
240        out.push('\n');
241        for note in footnotes {
242            let _ = writeln!(out, "- {}", note);
243        }
244    }
245}
246
247fn matrix_cell(provider: &ProviderInfo, cat: ToolCategory, footnotes: &mut Vec<String>) -> String {
248    if provider.default_categories.contains(&cat) {
249        return "✅".into();
250    }
251    if let Some(cond) = provider
252        .conditional_categories
253        .iter()
254        .find(|c| c.category == cat)
255    {
256        footnotes.push(format!(
257            "**{} → {}**: {}.",
258            provider.display_name,
259            cat.display_name(),
260            cond.note
261        ));
262        return "⚠️".into();
263    }
264    "—".into()
265}
266
267fn render_tool_sections(
268    out: &mut String,
269    categories: &[ToolCategory],
270    providers: &[ProviderInfo],
271    tools: &[ToolDefinition],
272) {
273    for cat in categories {
274        let mut in_cat: Vec<&ToolDefinition> =
275            tools.iter().filter(|t| t.category == *cat).collect();
276        if in_cat.is_empty() {
277            continue;
278        }
279        in_cat.sort_by(|a, b| a.name.cmp(&b.name));
280
281        let _ = writeln!(out, "## {} Tools", cat.display_name());
282        out.push('\n');
283
284        let provider_names = providers_for_category(*cat, providers);
285        if !provider_names.is_empty() {
286            let _ = writeln!(out, "Providers: {}.", provider_names.join(", "));
287            out.push('\n');
288        }
289
290        for tool in in_cat {
291            render_tool(out, tool);
292        }
293    }
294}
295
296fn providers_for_category(cat: ToolCategory, providers: &[ProviderInfo]) -> Vec<String> {
297    let mut names: Vec<String> = Vec::new();
298    for p in providers {
299        if p.default_categories.contains(&cat) {
300            names.push(p.display_name.to_string());
301        } else if p.conditional_categories.iter().any(|c| c.category == cat) {
302            names.push(format!("{} (conditional)", p.display_name));
303        }
304    }
305    names
306}
307
308fn render_tool(out: &mut String, tool: &ToolDefinition) {
309    render_tool_entry(out, &tool.name, &tool.description, &tool.input_schema);
310}
311
312fn render_context_section(out: &mut String, tools: &[McpOnlyTool]) {
313    if tools.is_empty() {
314        return;
315    }
316    let _ = writeln!(out, "## Context Management Tools");
317    out.push('\n');
318    let _ = writeln!(
319        out,
320        "Always-on tools attached to every `tools/list` response, independent of which providers \
321         are configured. They let the agent inspect or switch the active context."
322    );
323    out.push('\n');
324    for tool in tools {
325        render_tool_entry(out, &tool.name, &tool.description, &tool.input_schema);
326    }
327}
328
329fn render_tool_entry(
330    out: &mut String,
331    name: &str,
332    description: &str,
333    schema: &devboy_core::ToolSchema,
334) {
335    let _ = writeln!(out, "### `{}`", name);
336    out.push('\n');
337    let _ = writeln!(out, "{}", description);
338    out.push('\n');
339
340    if schema.properties.is_empty() {
341        out.push_str("_No parameters._\n\n");
342        return;
343    }
344
345    out.push_str("| Parameter | Type | Required | Description |\n");
346    out.push_str("|---|---|:---:|---|\n");
347
348    let mut names: Vec<&String> = schema.properties.keys().collect();
349    names.sort_by(|a, b| {
350        let a_req = schema.required.contains(a);
351        let b_req = schema.required.contains(b);
352        b_req.cmp(&a_req).then_with(|| a.cmp(b))
353    });
354
355    for name in names {
356        let prop = &schema.properties[name];
357        let required = if schema.required.contains(name) {
358            "✅"
359        } else {
360            "—"
361        };
362        let type_label = format_type(prop);
363        let description = format_description(prop);
364        let _ = writeln!(
365            out,
366            "| `{}` | {} | {} | {} |",
367            escape_pipe(name),
368            type_label,
369            required,
370            description
371        );
372    }
373    out.push('\n');
374}
375
376fn format_type(prop: &PropertySchema) -> String {
377    if let Some(variants) = &prop.any_of {
378        // Render `anyOf` as a `|`-joined list of variant types.
379        // Pipes are escaped for markdown table cells (`\|`).
380        let inner = variants
381            .iter()
382            .map(format_type)
383            .collect::<Vec<_>>()
384            .join(" \\| ");
385        return inner;
386    }
387    match prop.schema_type.as_str() {
388        "array" => {
389            let inner = prop
390                .items
391                .as_deref()
392                .map(|i| i.schema_type.clone())
393                .unwrap_or_else(|| "any".into());
394            format!("array&lt;{}&gt;", inner)
395        }
396        "" => "any".into(),
397        other => other.to_string(),
398    }
399}
400
401fn format_description(prop: &PropertySchema) -> String {
402    let mut parts: Vec<String> = Vec::new();
403    if let Some(desc) = prop.description.as_deref()
404        && !desc.is_empty()
405    {
406        parts.push(escape_pipe(desc));
407    }
408    if let Some(values) = &prop.enum_values
409        && !values.is_empty()
410    {
411        let joined = values
412            .iter()
413            .map(|v| format!("`{}`", v))
414            .collect::<Vec<_>>()
415            .join(", ");
416        parts.push(format!("Allowed values: {}", joined));
417    }
418    if let (Some(min), Some(max)) = (prop.minimum, prop.maximum) {
419        parts.push(format!("Range: {} – {}", trim_float(min), trim_float(max)));
420    } else if let Some(min) = prop.minimum {
421        parts.push(format!("Min: {}", trim_float(min)));
422    } else if let Some(max) = prop.maximum {
423        parts.push(format!("Max: {}", trim_float(max)));
424    }
425    if let Some(default) = &prop.default {
426        parts.push(format!("Default: `{}`", default));
427    }
428    if parts.is_empty() {
429        "—".into()
430    } else {
431        parts.join(". ")
432    }
433}
434
435fn trim_float(value: f64) -> String {
436    if value.fract() == 0.0 {
437        format!("{}", value as i64)
438    } else {
439        format!("{}", value)
440    }
441}
442
443fn escape_pipe(s: &str) -> String {
444    s.replace('|', "\\|").replace('\n', " ")
445}
446
447// =============================================================================
448// JSON helpers
449// =============================================================================
450
451fn sorted_tools(tools: &[ToolDefinition]) -> Vec<&ToolDefinition> {
452    let mut sorted: Vec<&ToolDefinition> = tools.iter().collect();
453    sorted.sort_by(|a, b| {
454        a.category
455            .cmp(&b.category)
456            .then_with(|| a.name.cmp(&b.name))
457    });
458    sorted
459}
460
461fn tool_to_json(tool: &ToolDefinition) -> Value {
462    json!({
463        "name": tool.name,
464        "category": tool.category.key(),
465        "description": tool.description,
466        "parameters": parameters_to_json(&tool.input_schema),
467    })
468}
469
470fn mcp_only_tool_to_json(tool: &McpOnlyTool) -> Value {
471    json!({
472        "name": tool.name,
473        "description": tool.description,
474        "parameters": parameters_to_json(&tool.input_schema),
475    })
476}
477
478fn parameters_to_json(schema: &devboy_core::ToolSchema) -> Vec<Value> {
479    let mut names: Vec<&String> = schema.properties.keys().collect();
480    names.sort_by(|a, b| {
481        let a_req = schema.required.contains(a);
482        let b_req = schema.required.contains(b);
483        b_req.cmp(&a_req).then_with(|| a.cmp(b))
484    });
485
486    names
487        .into_iter()
488        .map(|name| {
489            let prop = &schema.properties[name];
490            let mut entry = if prop.schema_type.is_empty() {
491                // `anyOf` schemas have no top-level `type`.
492                json!({
493                    "name": name,
494                    "required": schema.required.contains(name),
495                })
496            } else {
497                json!({
498                    "name": name,
499                    "type": prop.schema_type,
500                    "required": schema.required.contains(name),
501                })
502            };
503            if let Some(desc) = &prop.description {
504                entry["description"] = Value::String(desc.clone());
505            }
506            if let Some(values) = &prop.enum_values {
507                entry["enum"] = json!(values);
508            }
509            if let Some(variants) = &prop.any_of {
510                entry["anyOf"] =
511                    serde_json::to_value(variants).unwrap_or_else(|_| Value::Array(vec![]));
512            }
513            if let Some(min) = prop.minimum {
514                entry["minimum"] = json!(min);
515            }
516            if let Some(max) = prop.maximum {
517                entry["maximum"] = json!(max);
518            }
519            if let Some(default) = &prop.default {
520                entry["default"] = default.clone();
521            }
522            if let Some(items) = &prop.items {
523                entry["items"] = json!({ "type": items.schema_type });
524            }
525            entry
526        })
527        .collect()
528}
529
530// =============================================================================
531// Tests
532// =============================================================================
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::context::{
538        ClickUpScope, ConfluenceAuthConfig, ConfluenceScope, GitHubScope, GitLabScope, JiraScope,
539        ProviderConfig, SlackScope, TelegramScope,
540    };
541    use devboy_core::ToolEnricher;
542    use std::collections::HashMap;
543
544    #[test]
545    fn markdown_contains_header_and_matrix() {
546        let md = render_markdown();
547        assert!(md.starts_with("# DevBoy Tools Reference"));
548        assert!(md.contains("## Provider Support Matrix"));
549        assert!(md.contains("| **GitHub** |"));
550        assert!(md.contains("| **Slack** |"));
551        assert!(md.contains("| **Telegram** |"));
552    }
553
554    #[test]
555    fn markdown_lists_every_tool() {
556        let md = render_markdown();
557        for tool in base_tool_definitions() {
558            let heading = format!("### `{}`", tool.name);
559            assert!(
560                md.contains(&heading),
561                "tool `{}` missing from rendered docs",
562                tool.name
563            );
564        }
565    }
566
567    #[test]
568    fn markdown_marks_jira_structure_as_conditional() {
569        let md = render_markdown();
570        // Conditional cell present
571        assert!(md.contains("⚠️"), "expected conditional marker in matrix");
572        assert!(md.contains("requires the Structure plugin"));
573    }
574
575    #[test]
576    fn markdown_groups_categories_in_canonical_order() {
577        let md = render_markdown();
578        let order = [
579            "## Issue Tracker Tools",
580            "## Git Repository Tools",
581            "## Epics Tools",
582            "## Meeting Notes Tools",
583            "## Messenger Tools",
584            "## Jira Structure Tools",
585        ];
586        let mut last = 0usize;
587        for heading in order {
588            let pos = md
589                .find(heading)
590                .unwrap_or_else(|| panic!("missing heading {}", heading));
591            assert!(
592                pos >= last,
593                "headings out of order: {} appeared before previous heading",
594                heading
595            );
596            last = pos;
597        }
598    }
599
600    #[test]
601    fn json_render_has_expected_top_level_keys() {
602        let value = render_json();
603        assert!(value.get("version").is_some());
604        let providers = value.get("providers").and_then(|v| v.as_array()).unwrap();
605        let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
606        assert_eq!(providers.len(), known_providers().len());
607        assert_eq!(tools.len(), base_tool_definitions().len());
608    }
609
610    #[test]
611    fn json_marks_required_parameters() {
612        let value = render_json();
613        let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
614        let create_issue = tools
615            .iter()
616            .find(|t| t["name"] == "create_issue")
617            .expect("create_issue must be present");
618        let title = create_issue["parameters"]
619            .as_array()
620            .unwrap()
621            .iter()
622            .find(|p| p["name"] == "title")
623            .expect("title parameter must be present");
624        assert_eq!(title["required"], Value::Bool(true));
625    }
626
627    #[test]
628    fn provider_keys_are_unique() {
629        let mut keys: Vec<&str> = known_providers().iter().map(|p| p.key).collect();
630        keys.sort_unstable();
631        let original_len = keys.len();
632        keys.dedup();
633        assert_eq!(keys.len(), original_len, "provider keys must be unique");
634    }
635
636    #[test]
637    fn markdown_renders_context_management_section() {
638        let md = render_markdown();
639        assert!(md.contains("## Context Management Tools"));
640        for tool in mcp_only_tools() {
641            let heading = format!("### `{}`", tool.name);
642            assert!(
643                md.contains(&heading),
644                "context tool `{}` missing from rendered docs",
645                tool.name
646            );
647        }
648    }
649
650    #[test]
651    fn json_includes_context_tools_array() {
652        let value = render_json();
653        let context = value
654            .get("contextTools")
655            .and_then(|v| v.as_array())
656            .expect("contextTools must be present in JSON output");
657        assert_eq!(context.len(), mcp_only_tools().len());
658        let names: Vec<&str> = context
659            .iter()
660            .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
661            .collect();
662        assert!(names.contains(&"list_contexts"));
663        assert!(names.contains(&"use_context"));
664        assert!(names.contains(&"get_current_context"));
665    }
666
667    /// Compile-time guardrail: every variant of `ProviderConfig` (i.e. every
668    /// provider the factory dispatches on) must either be present in
669    /// `known_providers()` or be the explicit `Custom` escape hatch.
670    ///
671    /// The exhaustive `match` makes it impossible to add a new variant
672    /// without updating this mapping; the runtime assertion then forces
673    /// the catalog update too.
674    #[test]
675    fn every_factory_provider_is_in_catalog() {
676        use std::collections::HashSet;
677
678        // Sample config per variant. Only `provider_name()` is used —
679        // tokens, scopes and base URLs are placeholders.
680        let samples: Vec<ProviderConfig> = vec![
681            ProviderConfig::GitLab {
682                base_url: "https://gitlab.com".into(),
683                access_token: "x".into(),
684                scope: GitLabScope::Project { id: "1".into() },
685                extra: HashMap::new(),
686            },
687            ProviderConfig::GitHub {
688                base_url: "https://api.github.com".into(),
689                access_token: "x".into(),
690                scope: GitHubScope::Repository {
691                    owner: "o".into(),
692                    repo: "r".into(),
693                },
694                extra: HashMap::new(),
695            },
696            ProviderConfig::ClickUp {
697                access_token: "x".into(),
698                scope: ClickUpScope::List {
699                    id: "1".into(),
700                    team_id: None,
701                },
702                extra: HashMap::new(),
703            },
704            ProviderConfig::Jira {
705                base_url: "https://x.atlassian.net".into(),
706                access_token: "x".into(),
707                email: "x@x".into(),
708                scope: JiraScope::Project { key: "X".into() },
709                flavor: None,
710                extra: HashMap::new(),
711            },
712            ProviderConfig::Confluence {
713                base_url: "https://wiki.example.com".into(),
714                auth: ConfluenceAuthConfig::BearerToken { token: "x".into() },
715                scope: ConfluenceScope::Space {
716                    key: Some("ENG".into()),
717                },
718                api_version: Some("v1".into()),
719                extra: HashMap::new(),
720            },
721            ProviderConfig::Fireflies {
722                api_key: "x".into(),
723                extra: HashMap::new(),
724            },
725            ProviderConfig::Slack {
726                base_url: "https://slack.com/api".into(),
727                access_token: "x".into(),
728                scope: SlackScope::Workspace { team_id: None },
729                required_scopes: Vec::new(),
730                extra: HashMap::new(),
731            },
732            ProviderConfig::Telegram {
733                base_url: "https://api.telegram.org".into(),
734                access_token: "x".into(),
735                scope: TelegramScope::Bot { bot_username: None },
736                extra: HashMap::new(),
737            },
738            ProviderConfig::Custom {
739                name: "custom".into(),
740                config: HashMap::new(),
741            },
742        ];
743
744        // Compile-time exhaustive match — adding a new ProviderConfig
745        // variant breaks this and forces the maintainer to either map it
746        // to a catalog key or explicitly skip it (like Custom).
747        fn expected_catalog_key(config: &ProviderConfig) -> Option<&'static str> {
748            match config {
749                ProviderConfig::GitLab { .. } => Some("gitlab"),
750                ProviderConfig::GitHub { .. } => Some("github"),
751                ProviderConfig::ClickUp { .. } => Some("clickup"),
752                ProviderConfig::Jira { .. } => Some("jira"),
753                ProviderConfig::Confluence { .. } => Some("confluence"),
754                ProviderConfig::Fireflies { .. } => Some("fireflies"),
755                ProviderConfig::Slack { .. } => Some("slack"),
756                ProviderConfig::Telegram { .. } => Some("telegram"),
757                ProviderConfig::Custom { .. } => None,
758            }
759        }
760
761        let catalog_keys: HashSet<&str> = known_providers().iter().map(|p| p.key).collect();
762        let mut required_keys: HashSet<&str> = HashSet::new();
763        for cfg in &samples {
764            // Sanity: provider_name() and our expected key agree —
765            // catches accidental rename of the runtime identifier.
766            if let Some(expected) = expected_catalog_key(cfg) {
767                assert_eq!(
768                    cfg.provider_name(),
769                    expected,
770                    "ProviderConfig::{:?}.provider_name() drifted from the catalog key",
771                    expected
772                );
773                required_keys.insert(expected);
774            }
775        }
776
777        let missing: Vec<&&str> = required_keys.difference(&catalog_keys).collect();
778        assert!(
779            missing.is_empty(),
780            "factory dispatches on providers {:?} but tool_docs::known_providers() does not list them — \
781             update the catalog or remove the variant",
782            missing
783        );
784
785        let extras: Vec<&&str> = catalog_keys.difference(&required_keys).collect();
786        assert!(
787            extras.is_empty(),
788            "tool_docs::known_providers() advertises {:?} but factory has no matching variant — \
789             remove the catalog entry or add a factory dispatch arm",
790            extras
791        );
792    }
793
794    /// Catalog claims must remain a subset of what each enricher actually
795    /// reports at runtime — protects against quietly drifting docs when a
796    /// provider drops or adds a category.
797    #[test]
798    fn catalog_matches_runtime_enrichers() {
799        use std::collections::HashSet;
800
801        fn assert_subset<E: ToolEnricher>(provider_key: &str, enricher: &E) {
802            let runtime: HashSet<ToolCategory> =
803                enricher.supported_categories().iter().copied().collect();
804            let entry = known_providers()
805                .into_iter()
806                .find(|p| p.key == provider_key)
807                .unwrap_or_else(|| panic!("provider `{}` missing from catalog", provider_key));
808            for cat in entry.default_categories {
809                assert!(
810                    runtime.contains(cat),
811                    "{} catalog claims category {:?} but the runtime enricher does not",
812                    provider_key,
813                    cat
814                );
815            }
816        }
817
818        // Constructible without runtime data:
819        assert_subset("github", &devboy_github::GitHubSchemaEnricher);
820        assert_subset("gitlab", &devboy_gitlab::GitLabSchemaEnricher);
821        assert_subset("fireflies", &devboy_fireflies::FirefliesSchemaEnricher);
822        // ClickUp / Jira need metadata; their runtime claims are exercised by
823        // each crate's own enricher tests, which guard the same invariant.
824    }
825}