ferrous_forge/
doc_coverage.rs

1//! Documentation coverage checking module
2//!
3//! This module provides functionality to check documentation coverage
4//! for Rust projects, ensuring all public APIs are properly documented.
5
6use crate::{Error, Result};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use std::process::Command;
11
12/// Documentation coverage report
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DocCoverage {
15    /// Total number of documentable items
16    pub total_items: usize,
17    /// Number of documented items
18    pub documented_items: usize,
19    /// Coverage percentage
20    pub coverage_percent: f32,
21    /// List of items missing documentation
22    pub missing: Vec<String>,
23}
24
25impl DocCoverage {
26    /// Check if coverage meets minimum threshold
27    pub fn meets_threshold(&self, min_coverage: f32) -> bool {
28        self.coverage_percent >= min_coverage
29    }
30
31    /// Generate a human-readable report
32    pub fn report(&self) -> String {
33        let mut report = String::new();
34
35        if self.coverage_percent >= 100.0 {
36            report.push_str("✅ Documentation coverage: 100% - All items documented!\n");
37        } else if self.coverage_percent >= 80.0 {
38            report.push_str(&format!(
39                "📝 Documentation coverage: {:.1}% - Good coverage\n",
40                self.coverage_percent
41            ));
42        } else {
43            report.push_str(&format!(
44                "⚠️ Documentation coverage: {:.1}% - Needs improvement\n",
45                self.coverage_percent
46            ));
47        }
48
49        if !self.missing.is_empty() {
50            report.push_str("\nMissing documentation for:\n");
51            for (i, item) in self.missing.iter().take(10).enumerate() {
52                report.push_str(&format!("  {}. {}\n", i + 1, item));
53            }
54            if self.missing.len() > 10 {
55                report.push_str(&format!(
56                    "  ... and {} more items\n",
57                    self.missing.len() - 10
58                ));
59            }
60        }
61
62        report
63    }
64}
65
66/// Check documentation coverage for a Rust project
67pub async fn check_documentation_coverage(project_path: &Path) -> Result<DocCoverage> {
68    let output = run_cargo_doc(project_path)?;
69    let missing = find_missing_docs(&output)?;
70    let (total, documented) = count_documentation_items(project_path).await?;
71
72    let coverage_percent = calculate_coverage_percent(documented, total);
73
74    Ok(DocCoverage {
75        total_items: total,
76        documented_items: documented,
77        coverage_percent,
78        missing,
79    })
80}
81
82/// Run cargo doc and get output
83fn run_cargo_doc(project_path: &Path) -> Result<std::process::Output> {
84    Command::new("cargo")
85        .args(&[
86            "doc",
87            "--no-deps",
88            "--document-private-items",
89            "--message-format=json",
90        ])
91        .current_dir(project_path)
92        .output()
93        .map_err(|e| Error::process(format!("Failed to run cargo doc: {}", e)))
94}
95
96/// Find missing documentation items from cargo doc output
97fn find_missing_docs(output: &std::process::Output) -> Result<Vec<String>> {
98    let stderr = String::from_utf8_lossy(&output.stderr);
99    let stdout = String::from_utf8_lossy(&output.stdout);
100    let mut missing = Vec::new();
101
102    // Parse JSON messages for missing docs warnings
103    for line in stdout.lines() {
104        if line.contains("missing_docs") {
105            if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
106                if let Some(message) = json["message"]["rendered"].as_str() {
107                    if let Some(item_match) = extract_item_name(message) {
108                        missing.push(item_match);
109                    }
110                }
111            }
112        }
113    }
114
115    // Also check stderr for traditional warnings
116    let warning_re = Regex::new(r"warning: missing documentation for (.+)")
117        .map_err(|e| Error::validation(format!("Invalid regex: {}", e)))?;
118
119    for cap in warning_re.captures_iter(&stderr) {
120        missing.push(cap[1].to_string());
121    }
122
123    Ok(missing)
124}
125
126/// Calculate coverage percentage
127fn calculate_coverage_percent(documented: usize, total: usize) -> f32 {
128    if total > 0 {
129        (documented as f32 / total as f32) * 100.0
130    } else {
131        100.0
132    }
133}
134
135/// Count documentation items in the project
136async fn count_documentation_items(project_path: &Path) -> Result<(usize, usize)> {
137    let mut total = 0;
138    let mut documented = 0;
139
140    // Walk through all Rust files
141    let walker = walkdir::WalkDir::new(project_path)
142        .into_iter()
143        .filter_map(|e| e.ok())
144        .filter(|e| {
145            e.path().extension().is_some_and(|ext| ext == "rs")
146                && !e.path().to_string_lossy().contains("target")
147        });
148
149    for entry in walker {
150        let content = tokio::fs::read_to_string(entry.path()).await?;
151        let (file_total, file_documented) = count_items_in_file(&content)?;
152        total += file_total;
153        documented += file_documented;
154    }
155
156    Ok((total, documented))
157}
158
159/// Count documentation items in a single file
160fn count_items_in_file(content: &str) -> Result<(usize, usize)> {
161    let mut total = 0;
162    let mut documented = 0;
163    let lines: Vec<&str> = content.lines().collect();
164
165    let pub_item_re = Regex::new(r"^\s*pub\s+(fn|struct|enum|trait|type|const|static|mod)\s+")
166        .map_err(|e| Error::validation(format!("Failed to compile regex: {}", e)))?;
167
168    for (i, line) in lines.iter().enumerate() {
169        if pub_item_re.is_match(line) {
170            total += 1;
171
172            // Check if there's documentation above this line
173            if i > 0
174                && (lines[i - 1].trim().starts_with("///")
175                    || lines[i - 1].trim().starts_with("//!"))
176            {
177                documented += 1;
178            }
179        }
180    }
181
182    Ok((total, documented))
183}
184
185/// Extract item name from error message
186fn extract_item_name(message: &str) -> Option<String> {
187    // Try to extract the item name from messages like:
188    // "missing documentation for a struct"
189    // "missing documentation for function `foo`"
190    if let Some(start) = message.find('`') {
191        if let Some(end) = message[start + 1..].find('`') {
192            return Some(message[start + 1..start + 1 + end].to_string());
193        }
194    }
195
196    // Fallback: extract type after "for"
197    if let Some(pos) = message.find("for ") {
198        Some(message[pos + 4..].trim().to_string())
199    } else {
200        None
201    }
202}
203
204/// Suggest documentation for missing items
205pub fn suggest_documentation(item_type: &str, item_name: &str) -> String {
206    match item_type {
207        "fn" | "function" => format!(
208            "/// TODO: Document function `{}`.\n\
209             ///\n\
210             /// # Arguments\n\
211             ///\n\
212             /// # Returns\n\
213             ///\n\
214             /// # Examples\n\
215             /// ```\n\
216             /// // Example usage\n\
217             /// ```",
218            item_name
219        ),
220        "struct" => format!(
221            "/// TODO: Document struct `{}`.\n\
222             ///\n\
223             /// # Fields\n\
224             ///\n\
225             /// # Examples\n\
226             /// ```\n\
227             /// // Example usage\n\
228             /// ```",
229            item_name
230        ),
231        "enum" => format!(
232            "/// TODO: Document enum `{}`.\n\
233             ///\n\
234             /// # Variants\n\
235             ///\n\
236             /// # Examples\n\
237             /// ```\n\
238             /// // Example usage\n\
239             /// ```",
240            item_name
241        ),
242        _ => format!("/// TODO: Document `{}`.", item_name),
243    }
244}
245
246#[cfg(test)]
247#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_count_items_in_file() {
253        let content = r"
254/// Documented function
255pub fn documented() {}
256
257pub fn undocumented() {}
258
259/// Documented struct
260pub struct DocStruct {}
261
262pub struct UndocStruct {}
263";
264        let (total, documented) = count_items_in_file(content).unwrap();
265        assert_eq!(total, 4);
266        assert_eq!(documented, 2);
267    }
268
269    #[test]
270    fn test_coverage_calculation() {
271        let coverage = DocCoverage {
272            total_items: 10,
273            documented_items: 8,
274            coverage_percent: 80.0,
275            missing: vec![],
276        };
277
278        assert!(coverage.meets_threshold(75.0));
279        assert!(!coverage.meets_threshold(85.0));
280    }
281}