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> {
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
87fn 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
101fn 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 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 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
129fn 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
138async fn count_documentation_items(project_path: &Path) -> Result<(usize, usize)> {
140 let mut total = 0;
141 let mut documented = 0;
142
143 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
162fn 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 let mut in_raw_string = false;
173
174 for (i, line) in lines.iter().enumerate() {
175 if !in_raw_string && line.contains("r#\"") {
177 in_raw_string = true;
178 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 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 continue;
207 } else {
208 break;
210 }
211 }
212
213 if has_docs {
214 documented += 1;
215 }
216 }
217 }
218
219 Ok((total, documented))
220}
221
222fn extract_item_name(message: &str) -> Option<String> {
224 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 if let Some(pos) = message.find("for ") {
235 Some(message[pos + 4..].trim().to_string())
236 } else {
237 None
238 }
239}
240
241pub 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}