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