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    match prop.schema_type.as_str() {
372        "array" => {
373            let inner = prop
374                .items
375                .as_deref()
376                .map(|i| i.schema_type.clone())
377                .unwrap_or_else(|| "any".into());
378            format!("array&lt;{}&gt;", inner)
379        }
380        other => other.to_string(),
381    }
382}
383
384fn format_description(prop: &PropertySchema) -> String {
385    let mut parts: Vec<String> = Vec::new();
386    if let Some(desc) = prop.description.as_deref()
387        && !desc.is_empty()
388    {
389        parts.push(escape_pipe(desc));
390    }
391    if let Some(values) = &prop.enum_values
392        && !values.is_empty()
393    {
394        let joined = values
395            .iter()
396            .map(|v| format!("`{}`", v))
397            .collect::<Vec<_>>()
398            .join(", ");
399        parts.push(format!("Allowed values: {}", joined));
400    }
401    if let (Some(min), Some(max)) = (prop.minimum, prop.maximum) {
402        parts.push(format!("Range: {} – {}", trim_float(min), trim_float(max)));
403    } else if let Some(min) = prop.minimum {
404        parts.push(format!("Min: {}", trim_float(min)));
405    } else if let Some(max) = prop.maximum {
406        parts.push(format!("Max: {}", trim_float(max)));
407    }
408    if let Some(default) = &prop.default {
409        parts.push(format!("Default: `{}`", default));
410    }
411    if parts.is_empty() {
412        "—".into()
413    } else {
414        parts.join(". ")
415    }
416}
417
418fn trim_float(value: f64) -> String {
419    if value.fract() == 0.0 {
420        format!("{}", value as i64)
421    } else {
422        format!("{}", value)
423    }
424}
425
426fn escape_pipe(s: &str) -> String {
427    s.replace('|', "\\|").replace('\n', " ")
428}
429
430// =============================================================================
431// JSON helpers
432// =============================================================================
433
434fn sorted_tools(tools: &[ToolDefinition]) -> Vec<&ToolDefinition> {
435    let mut sorted: Vec<&ToolDefinition> = tools.iter().collect();
436    sorted.sort_by(|a, b| {
437        a.category
438            .cmp(&b.category)
439            .then_with(|| a.name.cmp(&b.name))
440    });
441    sorted
442}
443
444fn tool_to_json(tool: &ToolDefinition) -> Value {
445    json!({
446        "name": tool.name,
447        "category": tool.category.key(),
448        "description": tool.description,
449        "parameters": parameters_to_json(&tool.input_schema),
450    })
451}
452
453fn mcp_only_tool_to_json(tool: &McpOnlyTool) -> Value {
454    json!({
455        "name": tool.name,
456        "description": tool.description,
457        "parameters": parameters_to_json(&tool.input_schema),
458    })
459}
460
461fn parameters_to_json(schema: &devboy_core::ToolSchema) -> Vec<Value> {
462    let mut names: Vec<&String> = schema.properties.keys().collect();
463    names.sort_by(|a, b| {
464        let a_req = schema.required.contains(a);
465        let b_req = schema.required.contains(b);
466        b_req.cmp(&a_req).then_with(|| a.cmp(b))
467    });
468
469    names
470        .into_iter()
471        .map(|name| {
472            let prop = &schema.properties[name];
473            let mut entry = json!({
474                "name": name,
475                "type": prop.schema_type,
476                "required": schema.required.contains(name),
477            });
478            if let Some(desc) = &prop.description {
479                entry["description"] = Value::String(desc.clone());
480            }
481            if let Some(values) = &prop.enum_values {
482                entry["enum"] = json!(values);
483            }
484            if let Some(min) = prop.minimum {
485                entry["minimum"] = json!(min);
486            }
487            if let Some(max) = prop.maximum {
488                entry["maximum"] = json!(max);
489            }
490            if let Some(default) = &prop.default {
491                entry["default"] = default.clone();
492            }
493            if let Some(items) = &prop.items {
494                entry["items"] = json!({ "type": items.schema_type });
495            }
496            entry
497        })
498        .collect()
499}
500
501// =============================================================================
502// Tests
503// =============================================================================
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::context::{
509        ClickUpScope, ConfluenceAuthConfig, ConfluenceScope, GitHubScope, GitLabScope, JiraScope,
510        ProviderConfig, SlackScope,
511    };
512    use devboy_core::ToolEnricher;
513    use std::collections::HashMap;
514
515    #[test]
516    fn markdown_contains_header_and_matrix() {
517        let md = render_markdown();
518        assert!(md.starts_with("# DevBoy Tools Reference"));
519        assert!(md.contains("## Provider Support Matrix"));
520        assert!(md.contains("| **GitHub** |"));
521        assert!(md.contains("| **Slack** |"));
522    }
523
524    #[test]
525    fn markdown_lists_every_tool() {
526        let md = render_markdown();
527        for tool in base_tool_definitions() {
528            let heading = format!("### `{}`", tool.name);
529            assert!(
530                md.contains(&heading),
531                "tool `{}` missing from rendered docs",
532                tool.name
533            );
534        }
535    }
536
537    #[test]
538    fn markdown_marks_jira_structure_as_conditional() {
539        let md = render_markdown();
540        // Conditional cell present
541        assert!(md.contains("⚠️"), "expected conditional marker in matrix");
542        assert!(md.contains("requires the Structure plugin"));
543    }
544
545    #[test]
546    fn markdown_groups_categories_in_canonical_order() {
547        let md = render_markdown();
548        let order = [
549            "## Issue Tracker Tools",
550            "## Git Repository Tools",
551            "## Epics Tools",
552            "## Meeting Notes Tools",
553            "## Messenger Tools",
554            "## Jira Structure Tools",
555        ];
556        let mut last = 0usize;
557        for heading in order {
558            let pos = md
559                .find(heading)
560                .unwrap_or_else(|| panic!("missing heading {}", heading));
561            assert!(
562                pos >= last,
563                "headings out of order: {} appeared before previous heading",
564                heading
565            );
566            last = pos;
567        }
568    }
569
570    #[test]
571    fn json_render_has_expected_top_level_keys() {
572        let value = render_json();
573        assert!(value.get("version").is_some());
574        let providers = value.get("providers").and_then(|v| v.as_array()).unwrap();
575        let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
576        assert_eq!(providers.len(), known_providers().len());
577        assert_eq!(tools.len(), base_tool_definitions().len());
578    }
579
580    #[test]
581    fn json_marks_required_parameters() {
582        let value = render_json();
583        let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
584        let create_issue = tools
585            .iter()
586            .find(|t| t["name"] == "create_issue")
587            .expect("create_issue must be present");
588        let title = create_issue["parameters"]
589            .as_array()
590            .unwrap()
591            .iter()
592            .find(|p| p["name"] == "title")
593            .expect("title parameter must be present");
594        assert_eq!(title["required"], Value::Bool(true));
595    }
596
597    #[test]
598    fn provider_keys_are_unique() {
599        let mut keys: Vec<&str> = known_providers().iter().map(|p| p.key).collect();
600        keys.sort_unstable();
601        let original_len = keys.len();
602        keys.dedup();
603        assert_eq!(keys.len(), original_len, "provider keys must be unique");
604    }
605
606    #[test]
607    fn markdown_renders_context_management_section() {
608        let md = render_markdown();
609        assert!(md.contains("## Context Management Tools"));
610        for tool in mcp_only_tools() {
611            let heading = format!("### `{}`", tool.name);
612            assert!(
613                md.contains(&heading),
614                "context tool `{}` missing from rendered docs",
615                tool.name
616            );
617        }
618    }
619
620    #[test]
621    fn json_includes_context_tools_array() {
622        let value = render_json();
623        let context = value
624            .get("contextTools")
625            .and_then(|v| v.as_array())
626            .expect("contextTools must be present in JSON output");
627        assert_eq!(context.len(), mcp_only_tools().len());
628        let names: Vec<&str> = context
629            .iter()
630            .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
631            .collect();
632        assert!(names.contains(&"list_contexts"));
633        assert!(names.contains(&"use_context"));
634        assert!(names.contains(&"get_current_context"));
635    }
636
637    /// Compile-time guardrail: every variant of `ProviderConfig` (i.e. every
638    /// provider the factory dispatches on) must either be present in
639    /// `known_providers()` or be the explicit `Custom` escape hatch.
640    ///
641    /// The exhaustive `match` makes it impossible to add a new variant
642    /// without updating this mapping; the runtime assertion then forces
643    /// the catalog update too.
644    #[test]
645    fn every_factory_provider_is_in_catalog() {
646        use std::collections::HashSet;
647
648        // Sample config per variant. Only `provider_name()` is used —
649        // tokens, scopes and base URLs are placeholders.
650        let samples: Vec<ProviderConfig> = vec![
651            ProviderConfig::GitLab {
652                base_url: "https://gitlab.com".into(),
653                access_token: "x".into(),
654                scope: GitLabScope::Project { id: "1".into() },
655                extra: HashMap::new(),
656            },
657            ProviderConfig::GitHub {
658                base_url: "https://api.github.com".into(),
659                access_token: "x".into(),
660                scope: GitHubScope::Repository {
661                    owner: "o".into(),
662                    repo: "r".into(),
663                },
664                extra: HashMap::new(),
665            },
666            ProviderConfig::ClickUp {
667                access_token: "x".into(),
668                scope: ClickUpScope::List {
669                    id: "1".into(),
670                    team_id: None,
671                },
672                extra: HashMap::new(),
673            },
674            ProviderConfig::Jira {
675                base_url: "https://x.atlassian.net".into(),
676                access_token: "x".into(),
677                email: "x@x".into(),
678                scope: JiraScope::Project { key: "X".into() },
679                flavor: None,
680                extra: HashMap::new(),
681            },
682            ProviderConfig::Confluence {
683                base_url: "https://wiki.example.com".into(),
684                auth: ConfluenceAuthConfig::BearerToken { token: "x".into() },
685                scope: ConfluenceScope::Space {
686                    key: Some("ENG".into()),
687                },
688                api_version: Some("v1".into()),
689                extra: HashMap::new(),
690            },
691            ProviderConfig::Fireflies {
692                api_key: "x".into(),
693                extra: HashMap::new(),
694            },
695            ProviderConfig::Slack {
696                base_url: "https://slack.com/api".into(),
697                access_token: "x".into(),
698                scope: SlackScope::Workspace { team_id: None },
699                required_scopes: Vec::new(),
700                extra: HashMap::new(),
701            },
702            ProviderConfig::Custom {
703                name: "custom".into(),
704                config: HashMap::new(),
705            },
706        ];
707
708        // Compile-time exhaustive match — adding a new ProviderConfig
709        // variant breaks this and forces the maintainer to either map it
710        // to a catalog key or explicitly skip it (like Custom).
711        fn expected_catalog_key(config: &ProviderConfig) -> Option<&'static str> {
712            match config {
713                ProviderConfig::GitLab { .. } => Some("gitlab"),
714                ProviderConfig::GitHub { .. } => Some("github"),
715                ProviderConfig::ClickUp { .. } => Some("clickup"),
716                ProviderConfig::Jira { .. } => Some("jira"),
717                ProviderConfig::Confluence { .. } => Some("confluence"),
718                ProviderConfig::Fireflies { .. } => Some("fireflies"),
719                ProviderConfig::Slack { .. } => Some("slack"),
720                ProviderConfig::Custom { .. } => None,
721            }
722        }
723
724        let catalog_keys: HashSet<&str> = known_providers().iter().map(|p| p.key).collect();
725        let mut required_keys: HashSet<&str> = HashSet::new();
726        for cfg in &samples {
727            // Sanity: provider_name() and our expected key agree —
728            // catches accidental rename of the runtime identifier.
729            if let Some(expected) = expected_catalog_key(cfg) {
730                assert_eq!(
731                    cfg.provider_name(),
732                    expected,
733                    "ProviderConfig::{:?}.provider_name() drifted from the catalog key",
734                    expected
735                );
736                required_keys.insert(expected);
737            }
738        }
739
740        let missing: Vec<&&str> = required_keys.difference(&catalog_keys).collect();
741        assert!(
742            missing.is_empty(),
743            "factory dispatches on providers {:?} but tool_docs::known_providers() does not list them — \
744             update the catalog or remove the variant",
745            missing
746        );
747
748        let extras: Vec<&&str> = catalog_keys.difference(&required_keys).collect();
749        assert!(
750            extras.is_empty(),
751            "tool_docs::known_providers() advertises {:?} but factory has no matching variant — \
752             remove the catalog entry or add a factory dispatch arm",
753            extras
754        );
755    }
756
757    /// Catalog claims must remain a subset of what each enricher actually
758    /// reports at runtime — protects against quietly drifting docs when a
759    /// provider drops or adds a category.
760    #[test]
761    fn catalog_matches_runtime_enrichers() {
762        use std::collections::HashSet;
763
764        fn assert_subset<E: ToolEnricher>(provider_key: &str, enricher: &E) {
765            let runtime: HashSet<ToolCategory> =
766                enricher.supported_categories().iter().copied().collect();
767            let entry = known_providers()
768                .into_iter()
769                .find(|p| p.key == provider_key)
770                .unwrap_or_else(|| panic!("provider `{}` missing from catalog", provider_key));
771            for cat in entry.default_categories {
772                assert!(
773                    runtime.contains(cat),
774                    "{} catalog claims category {:?} but the runtime enricher does not",
775                    provider_key,
776                    cat
777                );
778            }
779        }
780
781        // Constructible without runtime data:
782        assert_subset("github", &devboy_github::GitHubSchemaEnricher);
783        assert_subset("gitlab", &devboy_gitlab::GitLabSchemaEnricher);
784        assert_subset("fireflies", &devboy_fireflies::FirefliesSchemaEnricher);
785        // ClickUp / Jira need metadata; their runtime claims are exercised by
786        // each crate's own enricher tests, which guard the same invariant.
787    }
788}