use std::fmt;
use std::path::{Path, PathBuf};
use serde_json::Value;
pub const UPDATE_ENV: &str = "UPDATE_DOCS";
const SUMMARY_CAP: usize = 140;
const ABBREVIATIONS: &[&str] = &["e.g.", "i.e.", "etc.", "vs.", "approx.", "cf.", "Fig."];
pub fn begin_marker(id: &str) -> String {
format!("<!-- BEGIN GENERATED: {id} -->")
}
pub fn end_marker(id: &str) -> String {
format!("<!-- END GENERATED: {id} -->")
}
#[derive(Debug)]
pub enum DocGenError {
Io {
path: PathBuf,
source: std::io::Error,
},
MissingMarker {
path: PathBuf,
id: String,
marker: String,
},
DuplicateMarker {
path: PathBuf,
id: String,
marker: String,
},
InvertedMarkers { path: PathBuf, id: String },
MalformedDescriptors(String),
}
impl fmt::Display for DocGenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::MissingMarker { path, id, marker } => write!(
f,
"{}: no generated region `{id}` — expected the line `{marker}`. \
A file that loses its markers is no longer checked, so this is a failure, \
not a skip.",
path.display()
),
Self::DuplicateMarker { path, id, marker } => write!(
f,
"{}: `{marker}` appears more than once, so region `{id}` is ambiguous",
path.display()
),
Self::InvertedMarkers { path, id } => write!(
f,
"{}: the END marker for region `{id}` precedes its BEGIN marker",
path.display()
),
Self::MalformedDescriptors(msg) => write!(f, "malformed tool descriptors: {msg}"),
}
}
}
impl std::error::Error for DocGenError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolRow {
pub name: String,
pub arguments: String,
pub summary: String,
pub availability: Option<String>,
}
pub fn tool_rows(descriptors: &Value) -> Result<Vec<ToolRow>, DocGenError> {
let array = descriptors
.as_array()
.or_else(|| descriptors.get("tools").and_then(Value::as_array))
.ok_or_else(|| {
DocGenError::MalformedDescriptors(
"expected an array, or an object with a `tools` array".to_string(),
)
})?;
array
.iter()
.map(|tool| {
let name = tool
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| {
DocGenError::MalformedDescriptors(format!("descriptor without `name`: {tool}"))
})?
.to_string();
let summary = first_sentence(
tool.get("description")
.and_then(Value::as_str)
.unwrap_or_default(),
);
let arguments = arguments(tool.get("inputSchema"));
Ok(ToolRow {
name,
arguments,
summary,
availability: None,
})
})
.collect()
}
fn arguments(schema: Option<&Value>) -> String {
let Some(schema) = schema else {
return "—".to_string();
};
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let mut optional: Vec<&str> = schema
.get("properties")
.and_then(Value::as_object)
.map(|p| {
p.keys()
.map(String::as_str)
.filter(|k| !required.contains(k))
.collect()
})
.unwrap_or_default();
optional.sort_unstable();
let mut parts: Vec<String> = required.iter().map(|s| format!("`{s}`")).collect();
parts.extend(optional.iter().map(|s| format!("`{s}?`")));
if parts.is_empty() {
"—".to_string()
} else {
parts.join(", ")
}
}
#[must_use]
pub fn labelled(rows: Vec<ToolRow>, label: &str) -> Vec<ToolRow> {
rows.into_iter()
.map(|row| ToolRow {
availability: Some(label.to_string()),
..row
})
.collect()
}
#[must_use]
pub fn render_tool_section(source: &str, count_note: &str, rows: &[ToolRow]) -> String {
let mut rows: Vec<&ToolRow> = rows.iter().collect();
rows.sort_by(|a, b| a.name.cmp(&b.name));
if let Some(dup) = rows.windows(2).find(|w| w[0].name == w[1].name) {
panic!("duplicate tool name in descriptors: {}", dup[0].name);
}
let with_availability = rows.iter().any(|r| r.availability.is_some());
let mut out = String::new();
out.push_str(&format!(
"The MCP server registers {count_note}. Authoritative source: `{source}` —\n\
this table is generated from it, not maintained by hand.\n\n"
));
if with_availability {
out.push_str("| Tool | Available | Arguments | Summary |\n|---|---|---|---|\n");
} else {
out.push_str("| Tool | Arguments | Summary |\n|---|---|---|\n");
}
for row in rows {
let summary = escape_cell(&row.summary);
let arguments = escape_cell(&row.arguments);
if with_availability {
let availability = escape_cell(row.availability.as_deref().unwrap_or("—"));
out.push_str(&format!(
"| `{}` | {availability} | {arguments} | {summary} |\n",
row.name
));
} else {
out.push_str(&format!("| `{}` | {arguments} | {summary} |\n", row.name));
}
}
out
}
#[must_use]
pub fn count_note(counts: &[(&str, usize)]) -> String {
counts
.iter()
.map(|(label, n)| {
if label.is_empty() {
format!("**{n} tools**")
} else {
format!("**{n} tools** {label}")
}
})
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
UpToDate,
Rewritten,
Stale {
diff: String,
},
}
pub fn sync_region(path: &Path, id: &str, body: &str) -> Result<Outcome, DocGenError> {
sync_region_mode(path, id, body, update_requested())
}
fn sync_region_mode(
path: &Path,
id: &str,
body: &str,
update: bool,
) -> Result<Outcome, DocGenError> {
let text = std::fs::read_to_string(path).map_err(|source| DocGenError::Io {
path: path.to_path_buf(),
source,
})?;
let (start, end) = region_bounds(&text, path, id)?;
let current = &text[start..end];
let desired = format!("\n{}\n", body.trim_end());
if current == desired {
return Ok(Outcome::UpToDate);
}
if !update {
return Ok(Outcome::Stale {
diff: line_diff(current, &desired),
});
}
let mut rewritten = String::with_capacity(text.len() + desired.len());
rewritten.push_str(&text[..start]);
rewritten.push_str(&desired);
rewritten.push_str(&text[end..]);
std::fs::write(path, rewritten).map_err(|source| DocGenError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(Outcome::Rewritten)
}
pub fn assert_region(path: &Path, id: &str, body: &str, regen_cmd: &str) {
match sync_region(path, id, body) {
Ok(Outcome::UpToDate) => {}
Ok(Outcome::Rewritten) => {
eprintln!("docgen: rewrote region `{id}` in {}", path.display());
}
Ok(Outcome::Stale { diff }) => panic!(
"\ngenerated region `{id}` in {} is stale.\n\n{diff}\n\
Do not hand-edit inside the markers — the source of truth is the code.\n\
Regenerate with:\n\n {regen_cmd}\n",
path.display()
),
Err(e) => panic!("\ndocgen failed for region `{id}`: {e}\n"),
}
}
fn region_bounds(text: &str, path: &Path, id: &str) -> Result<(usize, usize), DocGenError> {
let begin = locate(text, path, id, &begin_marker(id))?;
let end = locate(text, path, id, &end_marker(id))?;
let body_start = begin.1;
let body_end = end.0;
if body_end < body_start {
return Err(DocGenError::InvertedMarkers {
path: path.to_path_buf(),
id: id.to_string(),
});
}
Ok((body_start, body_end))
}
fn locate(text: &str, path: &Path, id: &str, marker: &str) -> Result<(usize, usize), DocGenError> {
let mut hits = text.match_indices(marker);
let (at, _) = hits.next().ok_or_else(|| DocGenError::MissingMarker {
path: path.to_path_buf(),
id: id.to_string(),
marker: marker.to_string(),
})?;
if hits.next().is_some() {
return Err(DocGenError::DuplicateMarker {
path: path.to_path_buf(),
id: id.to_string(),
marker: marker.to_string(),
});
}
Ok((at, at + marker.len()))
}
fn update_requested() -> bool {
match std::env::var(UPDATE_ENV) {
Ok(v) => !v.is_empty() && v != "0",
Err(_) => false,
}
}
fn line_diff(checked_in: &str, generated: &str) -> String {
let old: Vec<&str> = checked_in.lines().collect();
let new: Vec<&str> = generated.lines().collect();
let mut out = String::from("--- checked in\n+++ generated from source\n");
let mut shown = 0usize;
for line in old.iter().filter(|l| !new.contains(*l)) {
if shown == 40 {
out.push_str("… (diff truncated)\n");
return out;
}
out.push_str(&format!("-{line}\n"));
shown += 1;
}
for line in new.iter().filter(|l| !old.contains(*l)) {
if shown == 40 {
out.push_str("… (diff truncated)\n");
return out;
}
out.push_str(&format!("+{line}\n"));
shown += 1;
}
out
}
fn first_sentence(description: &str) -> String {
let collapsed = description.split_whitespace().collect::<Vec<_>>().join(" ");
let mut sentence = collapsed.as_str();
let mut from = 0usize;
while let Some(rel) = collapsed[from..].find(". ") {
let at = from + rel;
let head = &collapsed[..=at];
if ABBREVIATIONS.iter().any(|abbr| head.ends_with(abbr)) {
from = at + 2;
continue;
}
sentence = &collapsed[..=at];
break;
}
let sentence = sentence.trim();
if sentence.chars().count() <= SUMMARY_CAP {
return sentence.to_string();
}
let truncated: String = sentence.chars().take(SUMMARY_CAP).collect();
let cut = truncated.rfind(' ').unwrap_or(truncated.len());
format!("{}…", truncated[..cut].trim_end())
}
fn escape_cell(text: &str) -> String {
text.replace('|', "\\|")
}
#[must_use]
pub fn normalise_path(stringified: &str) -> String {
stringified.split_whitespace().collect()
}
#[macro_export]
macro_rules! descriptor_source {
($path:path) => {{
fn _resolves<T>(_descriptor_fn: fn() -> T) {}
_resolves($path);
$crate::docgen::normalise_path(stringify!($path))
}};
}
#[cfg(test)]
mod tests;