use std::fmt;
use std::io::Write;
use anyhow::Result;
use indexmap::{IndexMap, IndexSet};
use crate::markdown::{service_is_visible, visible_endpoints};
use crate::models::{ApiDocumentation, DocConfig, Endpoint};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EndpointRef {
pub method: String,
pub path: String,
}
impl From<&Endpoint> for EndpointRef {
fn from(endpoint: &Endpoint) -> Self {
Self {
method: endpoint.method.to_uppercase(),
path: endpoint.path.clone(),
}
}
}
impl fmt::Display for EndpointRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.method, self.path)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DuplicateOperationId {
pub operation_id: String,
pub endpoints: Vec<EndpointRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UndescribedParameter {
pub endpoint: EndpointRef,
pub name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct HygieneReport {
pub endpoint_count: usize,
pub service_count: usize,
pub missing_description: Vec<EndpointRef>,
pub missing_operation_id: Vec<EndpointRef>,
pub no_responses: Vec<EndpointRef>,
pub deprecated: Vec<EndpointRef>,
pub untagged: Vec<EndpointRef>,
pub duplicate_operation_ids: Vec<DuplicateOperationId>,
pub undescribed_parameters: Vec<UndescribedParameter>,
}
impl HygieneReport {
pub fn counts(&self) -> [(&'static str, usize); 7] {
[
("Missing description", self.missing_description.len()),
("Missing operationId", self.missing_operation_id.len()),
("No responses documented", self.no_responses.len()),
("Deprecated", self.deprecated.len()),
("Untagged (no service tag)", self.untagged.len()),
("Duplicate operationIds", self.duplicate_operation_ids.len()),
(
"Parameters without description",
self.undescribed_parameters.len(),
),
]
}
}
fn is_blank(text: Option<&String>) -> bool {
text.is_none_or(|t| t.trim().is_empty())
}
pub(crate) fn analyze(doc: &ApiDocumentation, config: &DocConfig) -> HygieneReport {
let endpoints = visible_endpoints(doc, config);
let mut report = HygieneReport {
endpoint_count: endpoints.len(),
..HygieneReport::default()
};
let mut services: IndexSet<&str> = IndexSet::new();
let mut by_operation_id: IndexMap<&str, Vec<EndpointRef>> = IndexMap::new();
for endpoint in endpoints {
let reference = EndpointRef::from(endpoint);
for service in &endpoint.services {
if service_is_visible(service, config) {
services.insert(service);
}
}
if is_blank(endpoint.summary.as_ref()) && is_blank(endpoint.description.as_ref()) {
report.missing_description.push(reference.clone());
}
match &endpoint.operation_id {
None => report.missing_operation_id.push(reference.clone()),
Some(id) => by_operation_id
.entry(id.as_str())
.or_default()
.push(reference.clone()),
}
if endpoint.responses.is_empty() {
report.no_responses.push(reference.clone());
}
if endpoint.deprecated {
report.deprecated.push(reference.clone());
}
if endpoint.untagged {
report.untagged.push(reference.clone());
}
let mut seen_body = false;
for parameter in &endpoint.parameters {
if parameter.parameter_in == "body" {
if seen_body {
continue;
}
seen_body = true;
}
if is_blank(parameter.description.as_ref()) {
report.undescribed_parameters.push(UndescribedParameter {
endpoint: reference.clone(),
name: parameter.name.clone(),
});
}
}
}
report.service_count = services.len();
let mut duplicates: Vec<DuplicateOperationId> = by_operation_id
.into_iter()
.filter(|(_, endpoints)| endpoints.len() > 1)
.map(|(operation_id, endpoints)| DuplicateOperationId {
operation_id: operation_id.to_string(),
endpoints,
})
.collect();
duplicates.sort_by(|a, b| a.operation_id.cmp(&b.operation_id));
report.duplicate_operation_ids = duplicates;
report
}
pub(crate) fn write_report<W: Write>(writer: &mut W, report: &HygieneReport) -> Result<()> {
writeln!(writer, "\n---\n")?;
writeln!(writer, "## Spec Hygiene Report\n")?;
writeln!(
writer,
"**{}** across **{}**\n",
pluralize(report.endpoint_count, "endpoint"),
pluralize(report.service_count, "service"),
)?;
writeln!(writer, "| Check | Count |")?;
writeln!(writer, "|-------|------:|")?;
for (label, count) in report.counts() {
writeln!(writer, "| {label} | {count} |")?;
}
write_endpoint_list(writer, "Missing description", &report.missing_description)?;
write_endpoint_list(writer, "Missing operationId", &report.missing_operation_id)?;
write_endpoint_list(writer, "No responses documented", &report.no_responses)?;
write_endpoint_list(writer, "Deprecated", &report.deprecated)?;
write_endpoint_list(writer, "Untagged (no service tag)", &report.untagged)?;
if !report.duplicate_operation_ids.is_empty() {
write_detail_heading(
writer,
"Duplicate operationIds",
report.duplicate_operation_ids.len(),
)?;
for duplicate in &report.duplicate_operation_ids {
writeln!(writer, "- `{}`", duplicate.operation_id)?;
for endpoint in &duplicate.endpoints {
writeln!(writer, " - `{endpoint}`")?;
}
}
}
if !report.undescribed_parameters.is_empty() {
write_detail_heading(
writer,
"Parameters without description",
report.undescribed_parameters.len(),
)?;
for parameter in &report.undescribed_parameters {
writeln!(writer, "- `{}` — `{}`", parameter.endpoint, parameter.name)?;
}
}
Ok(())
}
fn write_endpoint_list<W: Write>(
writer: &mut W,
label: &str,
endpoints: &[EndpointRef],
) -> Result<()> {
if endpoints.is_empty() {
return Ok(());
}
write_detail_heading(writer, label, endpoints.len())?;
for endpoint in endpoints {
writeln!(writer, "- `{endpoint}`")?;
}
Ok(())
}
fn write_detail_heading<W: Write>(writer: &mut W, label: &str, count: usize) -> Result<()> {
writeln!(writer, "\n### {label} ({count})")?;
Ok(())
}
fn pluralize(count: usize, noun: &str) -> String {
if count == 1 {
format!("{count} {noun}")
} else {
format!("{count} {noun}s")
}
}