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