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, PathBuf};
10
11/// Documentation coverage report
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct DocCoverage {
14    /// Total number of documentable items
15    pub total_items: usize,
16    /// Number of documented items
17    pub documented_items: usize,
18    /// Coverage percentage
19    pub coverage_percent: f32,
20    /// List of items missing documentation
21    pub missing: Vec<String>,
22}
23
24impl DocCoverage {
25    /// Check if coverage meets minimum threshold
26    pub fn meets_threshold(&self, min_coverage: f32) -> bool {
27        self.coverage_percent >= min_coverage
28    }
29
30    /// Generate a human-readable report
31    pub fn report(&self) -> String {
32        let mut report = String::new();
33
34        if self.coverage_percent >= 100.0 {
35            report.push_str("✅ Documentation coverage: 100% - All items documented!\n");
36        } else if self.coverage_percent >= 80.0 {
37            report.push_str(&format!(
38                "📝 Documentation coverage: {:.1}% - Good coverage\n",
39                self.coverage_percent
40            ));
41        } else {
42            report.push_str(&format!(
43                "⚠️ Documentation coverage: {:.1}% - Needs improvement\n",
44                self.coverage_percent
45            ));
46        }
47
48        if !self.missing.is_empty() {
49            report.push_str("\nMissing documentation for:\n");
50            for (i, item) in self.missing.iter().take(10).enumerate() {
51                report.push_str(&format!("  {}. {}\n", i + 1, item));
52            }
53            if self.missing.len() > 10 {
54                report.push_str(&format!(
55                    "  ... and {} more items\n",
56                    self.missing.len() - 10
57                ));
58            }
59        }
60
61        report
62    }
63}
64
65/// Check documentation coverage for a Rust project
66///
67/// # Errors
68///
69/// Returns [`Error::Process`] if `cargo doc` fails to execute.
70/// Returns [`Error::Io`] if project source files cannot be read.
71pub async fn check_documentation_coverage(project_path: &Path) -> Result<DocCoverage> {
72    let output = run_cargo_doc(project_path).await?;
73    let missing = find_missing_docs(&output)?;
74    let (total, documented) = count_documentation_items(project_path).await?;
75
76    let coverage_percent = calculate_coverage_percent(documented, total);
77
78    Ok(DocCoverage {
79        total_items: total,
80        documented_items: documented,
81        coverage_percent,
82        missing,
83    })
84}
85
86/// Run cargo doc and get output
87async fn run_cargo_doc(project_path: &Path) -> Result<std::process::Output> {
88    tokio::process::Command::new("cargo")
89        .args(&[
90            "doc",
91            "--no-deps",
92            "--document-private-items",
93            "--message-format=json",
94        ])
95        .current_dir(project_path)
96        .output()
97        .await
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 root = project_path.to_path_buf();
141
142    // Collect file paths in a blocking context (WalkDir is synchronous)
143    let paths: Vec<PathBuf> = tokio::task::spawn_blocking(move || {
144        walkdir::WalkDir::new(&root)
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            .map(|e| e.path().to_path_buf())
152            .collect::<Vec<_>>()
153    })
154    .await
155    .map_err(|e| Error::process(format!("Task join error: {}", e)))?;
156
157    let mut total = 0;
158    let mut documented = 0;
159    for path in paths {
160        let content = tokio::fs::read_to_string(&path).await?;
161        let (file_total, file_documented) = count_items_in_file(&content)?;
162        total += file_total;
163        documented += file_documented;
164    }
165
166    Ok((total, documented))
167}
168
169/// Count documentation items in a single file
170fn count_items_in_file(content: &str) -> Result<(usize, usize)> {
171    let mut total = 0;
172    let mut documented = 0;
173    let lines: Vec<&str> = content.lines().collect();
174
175    let pub_item_re = Regex::new(r"^\s*pub\s+(fn|struct|enum|trait|type|const|static|mod)\s+")
176        .map_err(|e| Error::validation(format!("Failed to compile regex: {}", e)))?;
177
178    // Track whether we're inside a raw string literal
179    let mut in_raw_string = false;
180
181    for (i, line) in lines.iter().enumerate() {
182        // Simple raw string tracking: toggle on r#" and "#
183        if !in_raw_string && line.contains("r#\"") {
184            in_raw_string = true;
185            // If this same line also closes the raw string, we're not in one
186            if line.contains("\"#")
187                && line.rfind("\"#").unwrap_or(0) > line.find("r#\"").unwrap_or(0)
188            {
189                in_raw_string = false;
190            }
191            continue;
192        }
193        if in_raw_string {
194            if line.contains("\"#") {
195                in_raw_string = false;
196            }
197            continue;
198        }
199
200        if pub_item_re.is_match(line) {
201            total += 1;
202
203            // Look backward past attributes, blank lines, and regular comments
204            // to find doc comments (/// or //!)
205            let mut has_docs = false;
206            for j in (i.saturating_sub(10)..i).rev() {
207                let prev = lines[j].trim();
208                if prev.starts_with("///") || prev.starts_with("//!") {
209                    has_docs = true;
210                    break;
211                } else if prev.starts_with("#[") || prev.is_empty() || prev.starts_with("//") {
212                    // Skip attributes, blank lines, and regular comments
213                    continue;
214                } else {
215                    // Hit actual code — no doc comment found
216                    break;
217                }
218            }
219
220            if has_docs {
221                documented += 1;
222            }
223        }
224    }
225
226    Ok((total, documented))
227}
228
229/// Extract item name from error message
230fn extract_item_name(message: &str) -> Option<String> {
231    // Try to extract the item name from messages like:
232    // "missing documentation for a struct"
233    // "missing documentation for function `foo`"
234    if let Some(start) = message.find('`')
235        && let Some(end) = message[start + 1..].find('`')
236    {
237        return Some(message[start + 1..start + 1 + end].to_string());
238    }
239
240    // Fallback: extract type after "for"
241    if let Some(pos) = message.find("for ") {
242        Some(message[pos + 4..].trim().to_string())
243    } else {
244        None
245    }
246}
247
248/// Suggest documentation for missing items
249pub fn suggest_documentation(item_type: &str, item_name: &str) -> String {
250    match item_type {
251        "fn" | "function" => format!(
252            "/// TODO: Document function `{}`.\n\
253             ///\n\
254             /// # Arguments\n\
255             ///\n\
256             /// # Returns\n\
257             ///\n\
258             /// # Examples\n\
259             /// ```\n\
260             /// // Example usage\n\
261             /// ```",
262            item_name
263        ),
264        "struct" => format!(
265            "/// TODO: Document struct `{}`.\n\
266             ///\n\
267             /// # Fields\n\
268             ///\n\
269             /// # Examples\n\
270             /// ```\n\
271             /// // Example usage\n\
272             /// ```",
273            item_name
274        ),
275        "enum" => format!(
276            "/// TODO: Document enum `{}`.\n\
277             ///\n\
278             /// # Variants\n\
279             ///\n\
280             /// # Examples\n\
281             /// ```\n\
282             /// // Example usage\n\
283             /// ```",
284            item_name
285        ),
286        _ => format!("/// TODO: Document `{}`.", item_name),
287    }
288}
289
290#[cfg(test)]
291#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_count_items_in_file() {
297        let content = r"
298/// Documented function
299pub fn documented() {}
300
301pub fn undocumented() {}
302
303/// Documented struct
304pub struct DocStruct {}
305
306pub struct UndocStruct {}
307";
308        let (total, documented) = count_items_in_file(content).unwrap();
309        assert_eq!(total, 4);
310        assert_eq!(documented, 2);
311    }
312
313    #[test]
314    fn test_coverage_calculation() {
315        let coverage = DocCoverage {
316            total_items: 10,
317            documented_items: 8,
318            coverage_percent: 80.0,
319            missing: vec![],
320        };
321
322        assert!(coverage.meets_threshold(75.0));
323        assert!(!coverage.meets_threshold(85.0));
324    }
325}