use std::collections::BTreeSet;
use std::fmt::Write as _;
use devboy_core::{PropertySchema, ToolCategory};
use serde_json::{Value, json};
use crate::tools::{McpOnlyTool, ToolDefinition, base_tool_definitions, mcp_only_tools};
#[derive(Debug, Clone)]
pub struct ProviderInfo {
pub display_name: &'static str,
pub key: &'static str,
pub default_categories: &'static [ToolCategory],
pub conditional_categories: &'static [ConditionalCategory],
}
#[derive(Debug, Clone, Copy)]
pub struct ConditionalCategory {
pub category: ToolCategory,
pub note: &'static str,
}
pub fn known_providers() -> Vec<ProviderInfo> {
vec![
ProviderInfo {
display_name: "GitHub",
key: "github",
default_categories: &[ToolCategory::IssueTracker, ToolCategory::GitRepository],
conditional_categories: &[],
},
ProviderInfo {
display_name: "GitLab",
key: "gitlab",
default_categories: &[ToolCategory::IssueTracker, ToolCategory::GitRepository],
conditional_categories: &[],
},
ProviderInfo {
display_name: "ClickUp",
key: "clickup",
default_categories: &[ToolCategory::IssueTracker, ToolCategory::Epics],
conditional_categories: &[],
},
ProviderInfo {
display_name: "Jira",
key: "jira",
default_categories: &[ToolCategory::IssueTracker],
conditional_categories: &[ConditionalCategory {
category: ToolCategory::JiraStructure,
note: "requires the Structure plugin to be installed and accessible",
}],
},
ProviderInfo {
display_name: "Confluence",
key: "confluence",
default_categories: &[ToolCategory::KnowledgeBase],
conditional_categories: &[],
},
ProviderInfo {
display_name: "Fireflies",
key: "fireflies",
default_categories: &[ToolCategory::MeetingNotes],
conditional_categories: &[],
},
ProviderInfo {
display_name: "Slack",
key: "slack",
default_categories: &[ToolCategory::Messenger],
conditional_categories: &[],
},
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocsFormat {
Markdown,
Json,
}
pub fn render(format: DocsFormat) -> String {
match format {
DocsFormat::Markdown => render_markdown(),
DocsFormat::Json => {
serde_json::to_string_pretty(&render_json())
.expect("tool_docs::render_json() should produce a serializable Value")
}
}
}
pub fn render_markdown() -> String {
let providers = known_providers();
let tools = base_tool_definitions();
let context_tools = mcp_only_tools();
let mut categories: BTreeSet<ToolCategory> = tools.iter().map(|t| t.category).collect();
for p in &providers {
categories.extend(p.default_categories.iter().copied());
categories.extend(p.conditional_categories.iter().map(|c| c.category));
}
let categories: Vec<ToolCategory> = categories.into_iter().collect();
let mut out = String::new();
let _ = writeln!(out, "# DevBoy Tools Reference");
out.push('\n');
let _ = writeln!(
out,
"> Auto-generated by `devboy tools docs` from `base_tool_definitions()` and the static \
provider catalog. Do not edit by hand — re-run the command to refresh."
);
out.push('\n');
let _ = writeln!(
out,
"DevBoy Tools v{} ships {} provider-backed tools across {} categories, {} always-on context tools, and {} providers.",
env!("CARGO_PKG_VERSION"),
tools.len(),
categories.len(),
context_tools.len(),
providers.len(),
);
out.push('\n');
render_provider_matrix(&mut out, &categories, &providers);
out.push('\n');
render_tool_sections(&mut out, &categories, &providers, &tools);
render_context_section(&mut out, &context_tools);
out
}
pub fn render_json() -> Value {
let providers = known_providers();
let tools = base_tool_definitions();
let providers_json: Vec<Value> = providers
.iter()
.map(|p| {
json!({
"key": p.key,
"displayName": p.display_name,
"defaultCategories": p.default_categories.iter().map(|c| c.key()).collect::<Vec<_>>(),
"conditionalCategories": p.conditional_categories.iter().map(|c| json!({
"category": c.category.key(),
"note": c.note,
})).collect::<Vec<_>>(),
})
})
.collect();
let tools_json: Vec<Value> = sorted_tools(&tools).into_iter().map(tool_to_json).collect();
let context_tools_json: Vec<Value> =
mcp_only_tools().iter().map(mcp_only_tool_to_json).collect();
json!({
"version": env!("CARGO_PKG_VERSION"),
"providers": providers_json,
"tools": tools_json,
"contextTools": context_tools_json,
})
}
fn render_provider_matrix(
out: &mut String,
categories: &[ToolCategory],
providers: &[ProviderInfo],
) {
let _ = writeln!(out, "## Provider Support Matrix");
out.push('\n');
out.push_str("| Provider |");
for cat in categories {
let _ = write!(out, " {} |", cat.display_name());
}
out.push('\n');
out.push_str("|---|");
for _ in categories {
out.push_str(":---:|");
}
out.push('\n');
let mut footnotes: Vec<String> = Vec::new();
for provider in providers {
let _ = write!(out, "| **{}** |", provider.display_name);
for cat in categories {
let cell = matrix_cell(provider, *cat, &mut footnotes);
let _ = write!(out, " {} |", cell);
}
out.push('\n');
}
out.push('\n');
out.push_str("Legend: `✅` supported · `⚠️` conditional (see notes) · `—` not applicable.\n");
if !footnotes.is_empty() {
out.push('\n');
let _ = writeln!(out, "### Conditional support");
out.push('\n');
for note in footnotes {
let _ = writeln!(out, "- {}", note);
}
}
}
fn matrix_cell(provider: &ProviderInfo, cat: ToolCategory, footnotes: &mut Vec<String>) -> String {
if provider.default_categories.contains(&cat) {
return "✅".into();
}
if let Some(cond) = provider
.conditional_categories
.iter()
.find(|c| c.category == cat)
{
footnotes.push(format!(
"**{} → {}**: {}.",
provider.display_name,
cat.display_name(),
cond.note
));
return "⚠️".into();
}
"—".into()
}
fn render_tool_sections(
out: &mut String,
categories: &[ToolCategory],
providers: &[ProviderInfo],
tools: &[ToolDefinition],
) {
for cat in categories {
let mut in_cat: Vec<&ToolDefinition> =
tools.iter().filter(|t| t.category == *cat).collect();
if in_cat.is_empty() {
continue;
}
in_cat.sort_by(|a, b| a.name.cmp(&b.name));
let _ = writeln!(out, "## {} Tools", cat.display_name());
out.push('\n');
let provider_names = providers_for_category(*cat, providers);
if !provider_names.is_empty() {
let _ = writeln!(out, "Providers: {}.", provider_names.join(", "));
out.push('\n');
}
for tool in in_cat {
render_tool(out, tool);
}
}
}
fn providers_for_category(cat: ToolCategory, providers: &[ProviderInfo]) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for p in providers {
if p.default_categories.contains(&cat) {
names.push(p.display_name.to_string());
} else if p.conditional_categories.iter().any(|c| c.category == cat) {
names.push(format!("{} (conditional)", p.display_name));
}
}
names
}
fn render_tool(out: &mut String, tool: &ToolDefinition) {
render_tool_entry(out, &tool.name, &tool.description, &tool.input_schema);
}
fn render_context_section(out: &mut String, tools: &[McpOnlyTool]) {
if tools.is_empty() {
return;
}
let _ = writeln!(out, "## Context Management Tools");
out.push('\n');
let _ = writeln!(
out,
"Always-on tools attached to every `tools/list` response, independent of which providers \
are configured. They let the agent inspect or switch the active context."
);
out.push('\n');
for tool in tools {
render_tool_entry(out, &tool.name, &tool.description, &tool.input_schema);
}
}
fn render_tool_entry(
out: &mut String,
name: &str,
description: &str,
schema: &devboy_core::ToolSchema,
) {
let _ = writeln!(out, "### `{}`", name);
out.push('\n');
let _ = writeln!(out, "{}", description);
out.push('\n');
if schema.properties.is_empty() {
out.push_str("_No parameters._\n\n");
return;
}
out.push_str("| Parameter | Type | Required | Description |\n");
out.push_str("|---|---|:---:|---|\n");
let mut names: Vec<&String> = schema.properties.keys().collect();
names.sort_by(|a, b| {
let a_req = schema.required.contains(a);
let b_req = schema.required.contains(b);
b_req.cmp(&a_req).then_with(|| a.cmp(b))
});
for name in names {
let prop = &schema.properties[name];
let required = if schema.required.contains(name) {
"✅"
} else {
"—"
};
let type_label = format_type(prop);
let description = format_description(prop);
let _ = writeln!(
out,
"| `{}` | {} | {} | {} |",
escape_pipe(name),
type_label,
required,
description
);
}
out.push('\n');
}
fn format_type(prop: &PropertySchema) -> String {
if let Some(variants) = &prop.any_of {
let inner = variants
.iter()
.map(format_type)
.collect::<Vec<_>>()
.join(" \\| ");
return inner;
}
match prop.schema_type.as_str() {
"array" => {
let inner = prop
.items
.as_deref()
.map(|i| i.schema_type.clone())
.unwrap_or_else(|| "any".into());
format!("array<{}>", inner)
}
"" => "any".into(),
other => other.to_string(),
}
}
fn format_description(prop: &PropertySchema) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(desc) = prop.description.as_deref()
&& !desc.is_empty()
{
parts.push(escape_pipe(desc));
}
if let Some(values) = &prop.enum_values
&& !values.is_empty()
{
let joined = values
.iter()
.map(|v| format!("`{}`", v))
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("Allowed values: {}", joined));
}
if let (Some(min), Some(max)) = (prop.minimum, prop.maximum) {
parts.push(format!("Range: {} – {}", trim_float(min), trim_float(max)));
} else if let Some(min) = prop.minimum {
parts.push(format!("Min: {}", trim_float(min)));
} else if let Some(max) = prop.maximum {
parts.push(format!("Max: {}", trim_float(max)));
}
if let Some(default) = &prop.default {
parts.push(format!("Default: `{}`", default));
}
if parts.is_empty() {
"—".into()
} else {
parts.join(". ")
}
}
fn trim_float(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
format!("{}", value)
}
}
fn escape_pipe(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
fn sorted_tools(tools: &[ToolDefinition]) -> Vec<&ToolDefinition> {
let mut sorted: Vec<&ToolDefinition> = tools.iter().collect();
sorted.sort_by(|a, b| {
a.category
.cmp(&b.category)
.then_with(|| a.name.cmp(&b.name))
});
sorted
}
fn tool_to_json(tool: &ToolDefinition) -> Value {
json!({
"name": tool.name,
"category": tool.category.key(),
"description": tool.description,
"parameters": parameters_to_json(&tool.input_schema),
})
}
fn mcp_only_tool_to_json(tool: &McpOnlyTool) -> Value {
json!({
"name": tool.name,
"description": tool.description,
"parameters": parameters_to_json(&tool.input_schema),
})
}
fn parameters_to_json(schema: &devboy_core::ToolSchema) -> Vec<Value> {
let mut names: Vec<&String> = schema.properties.keys().collect();
names.sort_by(|a, b| {
let a_req = schema.required.contains(a);
let b_req = schema.required.contains(b);
b_req.cmp(&a_req).then_with(|| a.cmp(b))
});
names
.into_iter()
.map(|name| {
let prop = &schema.properties[name];
let mut entry = if prop.schema_type.is_empty() {
json!({
"name": name,
"required": schema.required.contains(name),
})
} else {
json!({
"name": name,
"type": prop.schema_type,
"required": schema.required.contains(name),
})
};
if let Some(desc) = &prop.description {
entry["description"] = Value::String(desc.clone());
}
if let Some(values) = &prop.enum_values {
entry["enum"] = json!(values);
}
if let Some(variants) = &prop.any_of {
entry["anyOf"] =
serde_json::to_value(variants).unwrap_or_else(|_| Value::Array(vec![]));
}
if let Some(min) = prop.minimum {
entry["minimum"] = json!(min);
}
if let Some(max) = prop.maximum {
entry["maximum"] = json!(max);
}
if let Some(default) = &prop.default {
entry["default"] = default.clone();
}
if let Some(items) = &prop.items {
entry["items"] = json!({ "type": items.schema_type });
}
entry
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::{
ClickUpScope, ConfluenceAuthConfig, ConfluenceScope, GitHubScope, GitLabScope, JiraScope,
ProviderConfig, SlackScope,
};
use devboy_core::ToolEnricher;
use std::collections::HashMap;
#[test]
fn markdown_contains_header_and_matrix() {
let md = render_markdown();
assert!(md.starts_with("# DevBoy Tools Reference"));
assert!(md.contains("## Provider Support Matrix"));
assert!(md.contains("| **GitHub** |"));
assert!(md.contains("| **Slack** |"));
}
#[test]
fn markdown_lists_every_tool() {
let md = render_markdown();
for tool in base_tool_definitions() {
let heading = format!("### `{}`", tool.name);
assert!(
md.contains(&heading),
"tool `{}` missing from rendered docs",
tool.name
);
}
}
#[test]
fn markdown_marks_jira_structure_as_conditional() {
let md = render_markdown();
assert!(md.contains("⚠️"), "expected conditional marker in matrix");
assert!(md.contains("requires the Structure plugin"));
}
#[test]
fn markdown_groups_categories_in_canonical_order() {
let md = render_markdown();
let order = [
"## Issue Tracker Tools",
"## Git Repository Tools",
"## Epics Tools",
"## Meeting Notes Tools",
"## Messenger Tools",
"## Jira Structure Tools",
];
let mut last = 0usize;
for heading in order {
let pos = md
.find(heading)
.unwrap_or_else(|| panic!("missing heading {}", heading));
assert!(
pos >= last,
"headings out of order: {} appeared before previous heading",
heading
);
last = pos;
}
}
#[test]
fn json_render_has_expected_top_level_keys() {
let value = render_json();
assert!(value.get("version").is_some());
let providers = value.get("providers").and_then(|v| v.as_array()).unwrap();
let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
assert_eq!(providers.len(), known_providers().len());
assert_eq!(tools.len(), base_tool_definitions().len());
}
#[test]
fn json_marks_required_parameters() {
let value = render_json();
let tools = value.get("tools").and_then(|v| v.as_array()).unwrap();
let create_issue = tools
.iter()
.find(|t| t["name"] == "create_issue")
.expect("create_issue must be present");
let title = create_issue["parameters"]
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "title")
.expect("title parameter must be present");
assert_eq!(title["required"], Value::Bool(true));
}
#[test]
fn provider_keys_are_unique() {
let mut keys: Vec<&str> = known_providers().iter().map(|p| p.key).collect();
keys.sort_unstable();
let original_len = keys.len();
keys.dedup();
assert_eq!(keys.len(), original_len, "provider keys must be unique");
}
#[test]
fn markdown_renders_context_management_section() {
let md = render_markdown();
assert!(md.contains("## Context Management Tools"));
for tool in mcp_only_tools() {
let heading = format!("### `{}`", tool.name);
assert!(
md.contains(&heading),
"context tool `{}` missing from rendered docs",
tool.name
);
}
}
#[test]
fn json_includes_context_tools_array() {
let value = render_json();
let context = value
.get("contextTools")
.and_then(|v| v.as_array())
.expect("contextTools must be present in JSON output");
assert_eq!(context.len(), mcp_only_tools().len());
let names: Vec<&str> = context
.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"list_contexts"));
assert!(names.contains(&"use_context"));
assert!(names.contains(&"get_current_context"));
}
#[test]
fn every_factory_provider_is_in_catalog() {
use std::collections::HashSet;
let samples: Vec<ProviderConfig> = vec![
ProviderConfig::GitLab {
base_url: "https://gitlab.com".into(),
access_token: "x".into(),
scope: GitLabScope::Project { id: "1".into() },
extra: HashMap::new(),
},
ProviderConfig::GitHub {
base_url: "https://api.github.com".into(),
access_token: "x".into(),
scope: GitHubScope::Repository {
owner: "o".into(),
repo: "r".into(),
},
extra: HashMap::new(),
},
ProviderConfig::ClickUp {
access_token: "x".into(),
scope: ClickUpScope::List {
id: "1".into(),
team_id: None,
},
extra: HashMap::new(),
},
ProviderConfig::Jira {
base_url: "https://x.atlassian.net".into(),
access_token: "x".into(),
email: "x@x".into(),
scope: JiraScope::Project { key: "X".into() },
flavor: None,
extra: HashMap::new(),
},
ProviderConfig::Confluence {
base_url: "https://wiki.example.com".into(),
auth: ConfluenceAuthConfig::BearerToken { token: "x".into() },
scope: ConfluenceScope::Space {
key: Some("ENG".into()),
},
api_version: Some("v1".into()),
extra: HashMap::new(),
},
ProviderConfig::Fireflies {
api_key: "x".into(),
extra: HashMap::new(),
},
ProviderConfig::Slack {
base_url: "https://slack.com/api".into(),
access_token: "x".into(),
scope: SlackScope::Workspace { team_id: None },
required_scopes: Vec::new(),
extra: HashMap::new(),
},
ProviderConfig::Custom {
name: "custom".into(),
config: HashMap::new(),
},
];
fn expected_catalog_key(config: &ProviderConfig) -> Option<&'static str> {
match config {
ProviderConfig::GitLab { .. } => Some("gitlab"),
ProviderConfig::GitHub { .. } => Some("github"),
ProviderConfig::ClickUp { .. } => Some("clickup"),
ProviderConfig::Jira { .. } => Some("jira"),
ProviderConfig::Confluence { .. } => Some("confluence"),
ProviderConfig::Fireflies { .. } => Some("fireflies"),
ProviderConfig::Slack { .. } => Some("slack"),
ProviderConfig::Custom { .. } => None,
}
}
let catalog_keys: HashSet<&str> = known_providers().iter().map(|p| p.key).collect();
let mut required_keys: HashSet<&str> = HashSet::new();
for cfg in &samples {
if let Some(expected) = expected_catalog_key(cfg) {
assert_eq!(
cfg.provider_name(),
expected,
"ProviderConfig::{:?}.provider_name() drifted from the catalog key",
expected
);
required_keys.insert(expected);
}
}
let missing: Vec<&&str> = required_keys.difference(&catalog_keys).collect();
assert!(
missing.is_empty(),
"factory dispatches on providers {:?} but tool_docs::known_providers() does not list them — \
update the catalog or remove the variant",
missing
);
let extras: Vec<&&str> = catalog_keys.difference(&required_keys).collect();
assert!(
extras.is_empty(),
"tool_docs::known_providers() advertises {:?} but factory has no matching variant — \
remove the catalog entry or add a factory dispatch arm",
extras
);
}
#[test]
fn catalog_matches_runtime_enrichers() {
use std::collections::HashSet;
fn assert_subset<E: ToolEnricher>(provider_key: &str, enricher: &E) {
let runtime: HashSet<ToolCategory> =
enricher.supported_categories().iter().copied().collect();
let entry = known_providers()
.into_iter()
.find(|p| p.key == provider_key)
.unwrap_or_else(|| panic!("provider `{}` missing from catalog", provider_key));
for cat in entry.default_categories {
assert!(
runtime.contains(cat),
"{} catalog claims category {:?} but the runtime enricher does not",
provider_key,
cat
);
}
}
assert_subset("github", &devboy_github::GitHubSchemaEnricher);
assert_subset("gitlab", &devboy_gitlab::GitLabSchemaEnricher);
assert_subset("fireflies", &devboy_fireflies::FirefliesSchemaEnricher);
}
}