ferrous_forge/
doc_coverage.rs1use crate::{Error, Result};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use std::process::Command;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DocCoverage {
15 pub total_items: usize,
17 pub documented_items: usize,
19 pub coverage_percent: f32,
21 pub missing: Vec<String>,
23}
24
25impl DocCoverage {
26 pub fn meets_threshold(&self, min_coverage: f32) -> bool {
28 self.coverage_percent >= min_coverage
29 }
30
31 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
66pub 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
82fn 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
96fn 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 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 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
126fn 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
135async fn count_documentation_items(project_path: &Path) -> Result<(usize, usize)> {
137 let mut total = 0;
138 let mut documented = 0;
139
140 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
159fn 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 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
185fn extract_item_name(message: &str) -> Option<String> {
187 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 if let Some(pos) = message.find("for ") {
198 Some(message[pos + 4..].trim().to_string())
199 } else {
200 None
201 }
202}
203
204pub 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}