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