Skip to main content

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
67///
68/// # Errors
69///
70/// Returns [`Error::Process`] if `cargo doc` fails to execute.
71/// Returns [`Error::Io`] if project source files cannot be read.
72pub async fn check_documentation_coverage(project_path: &Path) -> Result<DocCoverage> {
73    let output = run_cargo_doc(project_path)?;
74    let missing = find_missing_docs(&output)?;
75    let (total, documented) = count_documentation_items(project_path).await?;
76
77    let coverage_percent = calculate_coverage_percent(documented, total);
78
79    Ok(DocCoverage {
80        total_items: total,
81        documented_items: documented,
82        coverage_percent,
83        missing,
84    })
85}
86
87/// Run cargo doc and get output
88fn run_cargo_doc(project_path: &Path) -> Result<std::process::Output> {
89    Command::new("cargo")
90        .args(&[
91            "doc",
92            "--no-deps",
93            "--document-private-items",
94            "--message-format=json",
95        ])
96        .current_dir(project_path)
97        .output()
98        .map_err(|e| Error::process(format!("Failed to run cargo doc: {}", e)))
99}
100
101/// Find missing documentation items from cargo doc output
102fn find_missing_docs(output: &std::process::Output) -> Result<Vec<String>> {
103    let stderr = String::from_utf8_lossy(&output.stderr);
104    let stdout = String::from_utf8_lossy(&output.stdout);
105    let mut missing = Vec::new();
106
107    // Parse JSON messages for missing docs warnings
108    for line in stdout.lines() {
109        if line.contains("missing_docs")
110            && let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
111            && let Some(message) = json["message"]["rendered"].as_str()
112            && let Some(item_match) = extract_item_name(message)
113        {
114            missing.push(item_match);
115        }
116    }
117
118    // Also check stderr for traditional warnings
119    let warning_re = Regex::new(r"warning: missing documentation for (.+)")
120        .map_err(|e| Error::validation(format!("Invalid regex: {}", e)))?;
121
122    for cap in warning_re.captures_iter(&stderr) {
123        missing.push(cap[1].to_string());
124    }
125
126    Ok(missing)
127}
128
129/// Calculate coverage percentage
130fn calculate_coverage_percent(documented: usize, total: usize) -> f32 {
131    if total > 0 {
132        (documented as f32 / total as f32) * 100.0
133    } else {
134        100.0
135    }
136}
137
138/// Count documentation items in the project
139async fn count_documentation_items(project_path: &Path) -> Result<(usize, usize)> {
140    let mut total = 0;
141    let mut documented = 0;
142
143    // Walk through all Rust files
144    let walker = walkdir::WalkDir::new(project_path)
145        .into_iter()
146        .filter_map(|e| e.ok())
147        .filter(|e| {
148            e.path().extension().is_some_and(|ext| ext == "rs")
149                && !e.path().to_string_lossy().contains("target")
150        });
151
152    for entry in walker {
153        let content = tokio::fs::read_to_string(entry.path()).await?;
154        let (file_total, file_documented) = count_items_in_file(&content)?;
155        total += file_total;
156        documented += file_documented;
157    }
158
159    Ok((total, documented))
160}
161
162/// Count documentation items in a single file
163fn count_items_in_file(content: &str) -> Result<(usize, usize)> {
164    let mut total = 0;
165    let mut documented = 0;
166    let lines: Vec<&str> = content.lines().collect();
167
168    let pub_item_re = Regex::new(r"^\s*pub\s+(fn|struct|enum|trait|type|const|static|mod)\s+")
169        .map_err(|e| Error::validation(format!("Failed to compile regex: {}", e)))?;
170
171    // Track whether we're inside a raw string literal
172    let mut in_raw_string = false;
173
174    for (i, line) in lines.iter().enumerate() {
175        // Simple raw string tracking: toggle on r#" and "#
176        if !in_raw_string && line.contains("r#\"") {
177            in_raw_string = true;
178            // If this same line also closes the raw string, we're not in one
179            if line.contains("\"#")
180                && line.rfind("\"#").unwrap_or(0) > line.find("r#\"").unwrap_or(0)
181            {
182                in_raw_string = false;
183            }
184            continue;
185        }
186        if in_raw_string {
187            if line.contains("\"#") {
188                in_raw_string = false;
189            }
190            continue;
191        }
192
193        if pub_item_re.is_match(line) {
194            total += 1;
195
196            // Look backward past attributes, blank lines, and regular comments
197            // to find doc comments (/// or //!)
198            let mut has_docs = false;
199            for j in (i.saturating_sub(10)..i).rev() {
200                let prev = lines[j].trim();
201                if prev.starts_with("///") || prev.starts_with("//!") {
202                    has_docs = true;
203                    break;
204                } else if prev.starts_with("#[") || prev.is_empty() || prev.starts_with("//") {
205                    // Skip attributes, blank lines, and regular comments
206                    continue;
207                } else {
208                    // Hit actual code — no doc comment found
209                    break;
210                }
211            }
212
213            if has_docs {
214                documented += 1;
215            }
216        }
217    }
218
219    Ok((total, documented))
220}
221
222/// Extract item name from error message
223fn extract_item_name(message: &str) -> Option<String> {
224    // Try to extract the item name from messages like:
225    // "missing documentation for a struct"
226    // "missing documentation for function `foo`"
227    if let Some(start) = message.find('`')
228        && let Some(end) = message[start + 1..].find('`')
229    {
230        return Some(message[start + 1..start + 1 + end].to_string());
231    }
232
233    // Fallback: extract type after "for"
234    if let Some(pos) = message.find("for ") {
235        Some(message[pos + 4..].trim().to_string())
236    } else {
237        None
238    }
239}
240
241/// Suggest documentation for missing items
242pub fn suggest_documentation(item_type: &str, item_name: &str) -> String {
243    match item_type {
244        "fn" | "function" => format!(
245            "/// TODO: Document function `{}`.\n\
246             ///\n\
247             /// # Arguments\n\
248             ///\n\
249             /// # Returns\n\
250             ///\n\
251             /// # Examples\n\
252             /// ```\n\
253             /// // Example usage\n\
254             /// ```",
255            item_name
256        ),
257        "struct" => format!(
258            "/// TODO: Document struct `{}`.\n\
259             ///\n\
260             /// # Fields\n\
261             ///\n\
262             /// # Examples\n\
263             /// ```\n\
264             /// // Example usage\n\
265             /// ```",
266            item_name
267        ),
268        "enum" => format!(
269            "/// TODO: Document enum `{}`.\n\
270             ///\n\
271             /// # Variants\n\
272             ///\n\
273             /// # Examples\n\
274             /// ```\n\
275             /// // Example usage\n\
276             /// ```",
277            item_name
278        ),
279        _ => format!("/// TODO: Document `{}`.", item_name),
280    }
281}
282
283#[cfg(test)]
284#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_count_items_in_file() {
290        let content = r"
291/// Documented function
292pub fn documented() {}
293
294pub fn undocumented() {}
295
296/// Documented struct
297pub struct DocStruct {}
298
299pub struct UndocStruct {}
300";
301        let (total, documented) = count_items_in_file(content).unwrap();
302        assert_eq!(total, 4);
303        assert_eq!(documented, 2);
304    }
305
306    #[test]
307    fn test_coverage_calculation() {
308        let coverage = DocCoverage {
309            total_items: 10,
310            documented_items: 8,
311            coverage_percent: 80.0,
312            missing: vec![],
313        };
314
315        assert!(coverage.meets_threshold(75.0));
316        assert!(!coverage.meets_threshold(85.0));
317    }
318}