use crate::{Error, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocCoverage {
pub total_items: usize,
pub documented_items: usize,
pub coverage_percent: f32,
pub missing: Vec<String>,
}
impl DocCoverage {
pub fn meets_threshold(&self, min_coverage: f32) -> bool {
self.coverage_percent >= min_coverage
}
pub fn report(&self) -> String {
let mut report = String::new();
if self.coverage_percent >= 100.0 {
report.push_str("✅ Documentation coverage: 100% - All items documented!\n");
} else if self.coverage_percent >= 80.0 {
report.push_str(&format!(
"📝 Documentation coverage: {:.1}% - Good coverage\n",
self.coverage_percent
));
} else {
report.push_str(&format!(
"⚠️ Documentation coverage: {:.1}% - Needs improvement\n",
self.coverage_percent
));
}
if !self.missing.is_empty() {
report.push_str("\nMissing documentation for:\n");
for (i, item) in self.missing.iter().take(10).enumerate() {
report.push_str(&format!(" {}. {}\n", i + 1, item));
}
if self.missing.len() > 10 {
report.push_str(&format!(
" ... and {} more items\n",
self.missing.len() - 10
));
}
}
report
}
}
pub async fn check_documentation_coverage(project_path: &Path) -> Result<DocCoverage> {
let output = run_cargo_doc(project_path).await?;
let missing = find_missing_docs(&output)?;
let (total, documented) = count_documentation_items(project_path).await?;
let coverage_percent = calculate_coverage_percent(documented, total);
Ok(DocCoverage {
total_items: total,
documented_items: documented,
coverage_percent,
missing,
})
}
async fn run_cargo_doc(project_path: &Path) -> Result<std::process::Output> {
tokio::process::Command::new("cargo")
.args(&[
"doc",
"--no-deps",
"--document-private-items",
"--message-format=json",
])
.current_dir(project_path)
.output()
.await
.map_err(|e| Error::process(format!("Failed to run cargo doc: {}", e)))
}
fn find_missing_docs(output: &std::process::Output) -> Result<Vec<String>> {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let mut missing = Vec::new();
for line in stdout.lines() {
if line.contains("missing_docs")
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
&& let Some(message) = json["message"]["rendered"].as_str()
&& let Some(item_match) = extract_item_name(message)
{
missing.push(item_match);
}
}
let warning_re = Regex::new(r"warning: missing documentation for (.+)")
.map_err(|e| Error::validation(format!("Invalid regex: {}", e)))?;
for cap in warning_re.captures_iter(&stderr) {
missing.push(cap[1].to_string());
}
Ok(missing)
}
fn calculate_coverage_percent(documented: usize, total: usize) -> f32 {
if total > 0 {
(documented as f32 / total as f32) * 100.0
} else {
100.0
}
}
async fn count_documentation_items(project_path: &Path) -> Result<(usize, usize)> {
let root = project_path.to_path_buf();
let paths: Vec<PathBuf> = tokio::task::spawn_blocking(move || {
walkdir::WalkDir::new(&root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "rs")
&& !e.path().to_string_lossy().contains("target")
})
.map(|e| e.path().to_path_buf())
.collect::<Vec<_>>()
})
.await
.map_err(|e| Error::process(format!("Task join error: {}", e)))?;
let mut total = 0;
let mut documented = 0;
for path in paths {
let content = tokio::fs::read_to_string(&path).await?;
let (file_total, file_documented) = count_items_in_file(&content)?;
total += file_total;
documented += file_documented;
}
Ok((total, documented))
}
fn count_items_in_file(content: &str) -> Result<(usize, usize)> {
let mut total = 0;
let mut documented = 0;
let lines: Vec<&str> = content.lines().collect();
let pub_item_re = Regex::new(r"^\s*pub\s+(fn|struct|enum|trait|type|const|static|mod)\s+")
.map_err(|e| Error::validation(format!("Failed to compile regex: {}", e)))?;
let mut in_raw_string = false;
for (i, line) in lines.iter().enumerate() {
if !in_raw_string && line.contains("r#\"") {
in_raw_string = true;
if line.contains("\"#")
&& line.rfind("\"#").unwrap_or(0) > line.find("r#\"").unwrap_or(0)
{
in_raw_string = false;
}
continue;
}
if in_raw_string {
if line.contains("\"#") {
in_raw_string = false;
}
continue;
}
if pub_item_re.is_match(line) {
total += 1;
let mut has_docs = false;
for j in (i.saturating_sub(10)..i).rev() {
let prev = lines[j].trim();
if prev.starts_with("///") || prev.starts_with("//!") {
has_docs = true;
break;
} else if prev.starts_with("#[") || prev.is_empty() || prev.starts_with("//") {
continue;
} else {
break;
}
}
if has_docs {
documented += 1;
}
}
}
Ok((total, documented))
}
fn extract_item_name(message: &str) -> Option<String> {
if let Some(start) = message.find('`')
&& let Some(end) = message[start + 1..].find('`')
{
return Some(message[start + 1..start + 1 + end].to_string());
}
if let Some(pos) = message.find("for ") {
Some(message[pos + 4..].trim().to_string())
} else {
None
}
}
pub fn suggest_documentation(item_type: &str, item_name: &str) -> String {
match item_type {
"fn" | "function" => format!(
"/// TODO: Document function `{}`.\n\
///\n\
/// # Arguments\n\
///\n\
/// # Returns\n\
///\n\
/// # Examples\n\
/// ```\n\
/// // Example usage\n\
/// ```",
item_name
),
"struct" => format!(
"/// TODO: Document struct `{}`.\n\
///\n\
/// # Fields\n\
///\n\
/// # Examples\n\
/// ```\n\
/// // Example usage\n\
/// ```",
item_name
),
"enum" => format!(
"/// TODO: Document enum `{}`.\n\
///\n\
/// # Variants\n\
///\n\
/// # Examples\n\
/// ```\n\
/// // Example usage\n\
/// ```",
item_name
),
_ => format!("/// TODO: Document `{}`.", item_name),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn test_count_items_in_file() {
let content = r"
/// Documented function
pub fn documented() {}
pub fn undocumented() {}
/// Documented struct
pub struct DocStruct {}
pub struct UndocStruct {}
";
let (total, documented) = count_items_in_file(content).unwrap();
assert_eq!(total, 4);
assert_eq!(documented, 2);
}
#[test]
fn test_coverage_calculation() {
let coverage = DocCoverage {
total_items: 10,
documented_items: 8,
coverage_percent: 80.0,
missing: vec![],
};
assert!(coverage.meets_threshold(75.0));
assert!(!coverage.meets_threshold(85.0));
}
}