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 ]
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum DocsFormat {
97 Markdown,
98 Json,
99}
100
101pub fn render(format: DocsFormat) -> String {
103 match format {
104 DocsFormat::Markdown => render_markdown(),
105 DocsFormat::Json => {
106 serde_json::to_string_pretty(&render_json())
111 .expect("tool_docs::render_json() should produce a serializable Value")
112 }
113 }
114}
115
116pub fn render_markdown() -> String {
118 let providers = known_providers();
119 let tools = base_tool_definitions();
120 let context_tools = mcp_only_tools();
121
122 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
159pub 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
192fn 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 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 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 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<{}>", 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
441fn 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 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#[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 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 #[test]
668 fn every_factory_provider_is_in_catalog() {
669 use std::collections::HashSet;
670
671 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 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 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 #[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 assert_subset("github", &devboy_github::GitHubSchemaEnricher);
806 assert_subset("gitlab", &devboy_gitlab::GitLabSchemaEnricher);
807 assert_subset("fireflies", &devboy_fireflies::FirefliesSchemaEnricher);
808 }
811}