ferrous_forge/
doc_coverage.rs1use crate::{Error, Result};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct DocCoverage {
14 pub total_items: usize,
16 pub documented_items: usize,
18 pub coverage_percent: f32,
20 pub missing: Vec<String>,
22}
23
24impl DocCoverage {
25 pub fn meets_threshold(&self, min_coverage: f32) -> bool {
27 self.coverage_percent >= min_coverage
28 }
29
30 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
65pub 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
86async 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
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 root = project_path.to_path_buf();
141
142 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
169fn 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 let mut in_raw_string = false;
180
181 for (i, line) in lines.iter().enumerate() {
182 if !in_raw_string && line.contains("r#\"") {
184 in_raw_string = true;
185 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 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 continue;
214 } else {
215 break;
217 }
218 }
219
220 if has_docs {
221 documented += 1;
222 }
223 }
224 }
225
226 Ok((total, documented))
227}
228
229fn extract_item_name(message: &str) -> Option<String> {
231 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 if let Some(pos) = message.find("for ") {
242 Some(message[pos + 4..].trim().to_string())
243 } else {
244 None
245 }
246}
247
248pub 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}