1use 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#[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#[derive(Debug, Clone, Copy)]
33pub struct ConditionalCategory {
34 pub category: ToolCategory,
35 pub note: &'static str,
37}
38
39pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum DocsFormat {
115 Markdown,
116 Json,
117}
118
119pub fn render(format: DocsFormat) -> String {
121 match format {
122 DocsFormat::Markdown => render_markdown(),
123 DocsFormat::Json => {
124 serde_json::to_string_pretty(&render_json())
129 .expect("tool_docs::render_json() should produce a serializable Value")
130 }
131 }
132}
133
134pub fn render_markdown() -> String {
136 let providers = known_providers();
137 let tools = base_tool_definitions();
138 let context_tools = mcp_only_tools();
139
140 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
177pub 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
210fn 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 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 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 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<{}>", 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
459fn 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 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#[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 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 #[test]
687 fn every_factory_provider_is_in_catalog() {
688 use std::collections::HashSet;
689
690 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 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 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 #[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 assert_subset("github", &devboy_github::GitHubSchemaEnricher);
851 assert_subset("gitlab", &devboy_gitlab::GitLabSchemaEnricher);
852 assert_subset("fireflies", &devboy_fireflies::FirefliesSchemaEnricher);
853 }
856}