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 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<{}>", 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
430fn 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#[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 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 #[test]
645 fn every_factory_provider_is_in_catalog() {
646 use std::collections::HashSet;
647
648 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 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 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 #[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 assert_subset("github", &devboy_github::GitHubSchemaEnricher);
783 assert_subset("gitlab", &devboy_gitlab::GitLabSchemaEnricher);
784 assert_subset("fireflies", &devboy_fireflies::FirefliesSchemaEnricher);
785 }
788}