//! Rust documentation coverage adapter. //! //! Checks for: //! - A `//!` module-level doc comment somewhere in every `.rs` file. //! - A `///` doc comment immediately before every `pub` item (`fn`, `struct`, //! `enum`, `trait`, `type`, `const`, `static`, `mod`). use std::fs; use std::path::Path; use crate::{CheckFailure, CheckResult, LanguageAdapter}; /// Rust documentation coverage adapter. pub struct RustAdapter; impl RustAdapter { fn check_file(&self, path: &Path) -> Vec { let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return vec![], }; let lines: Vec<&str> = content.lines().collect(); let mut failures = Vec::new(); // Module-level doc comment (//!) if !lines.iter().any(|l| l.trim_start().starts_with("//!")) { failures.push(CheckFailure { file_path: path.to_path_buf(), line: 1, item_kind: "module".to_string(), item_name: module_name(path), }); } // Public items missing /// doc comments for (i, &line) in lines.iter().enumerate() { if let Some((kind, name)) = parse_pub_item(line) && !has_doc_before(&lines, i) { failures.push(CheckFailure { file_path: path.to_path_buf(), line: i + 1, item_kind: kind, item_name: name, }); } } failures } /// Extract public item signatures from a Rust file as `"kind name"` strings. pub(crate) fn extract_items(path: &Path) -> Vec { let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return vec![], }; content .lines() .filter_map(|line| { let (kind, name) = parse_pub_item(line)?; Some(format!("{kind} {name}")) }) .collect() } } impl LanguageAdapter for RustAdapter { fn check(&self, files: &[&Path]) -> CheckResult { let failures: Vec = files.iter().flat_map(|&f| self.check_file(f)).collect(); if failures.is_empty() { CheckResult::Ok } else { CheckResult::Failures(failures) } } fn update_source_map( &self, passing_files: &[&Path], source_map_path: &Path, ) -> Result<(), String> { let mut map = crate::read_map(source_map_path)?; for &file in passing_files { let key = file.to_string_lossy().to_string(); let items: Vec = Self::extract_items(file) .into_iter() .map(serde_json::Value::String) .collect(); map.insert(key, serde_json::Value::Array(items)); } crate::write_map(source_map_path, map) } } fn module_name(path: &Path) -> String { path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string() } /// Parse a line as a public Rust item declaration. /// /// Returns `(kind, name)` if the line declares a public item, `None` otherwise. fn parse_pub_item(line: &str) -> Option<(String, String)> { let trimmed = line.trim(); // Strip visibility: "pub(…)" or "pub " let rest = if let Some(r) = trimmed.strip_prefix("pub(") { let end = r.find(')')?; r[end + 1..].trim_start() } else if let Some(r) = trimmed.strip_prefix("pub ") { r.trim_start() } else { return None; }; // Handle "async fn" let rest = if let Some(r) = rest.strip_prefix("async ") { r.trim_start() } else { rest }; // Match item keyword and extract name part let (kind, name_part) = if let Some(r) = rest.strip_prefix("fn ") { ("fn", r.trim_start()) } else if let Some(r) = rest.strip_prefix("struct ") { ("struct", r.trim_start()) } else if let Some(r) = rest.strip_prefix("enum ") { ("enum", r.trim_start()) } else if let Some(r) = rest.strip_prefix("trait ") { ("trait", r.trim_start()) } else if let Some(r) = rest.strip_prefix("type ") { ("type", r.trim_start()) } else if let Some(r) = rest.strip_prefix("const ") { ("const", r.trim_start()) } else if let Some(r) = rest.strip_prefix("static ") { ("static", r.trim_start()) } else if let Some(r) = rest.strip_prefix("mod ") { ("mod", r.trim_start()) } else { return None; }; let name: String = name_part .chars() .take_while(|&c| c.is_alphanumeric() || c == '_') .collect(); if name.is_empty() { return None; } Some((kind.to_string(), name)) } /// Return `true` if a `///` doc comment appears before the item at `item_idx`. /// /// Scans backward from `item_idx`, skipping blank lines and `#[…]` attribute /// lines. Returns `true` if the first substantive line is a `///` comment. fn has_doc_before(lines: &[&str], item_idx: usize) -> bool { let mut i = item_idx; while i > 0 { i -= 1; let line = lines[i].trim(); if line.starts_with("///") { return true; } if line.starts_with("#[") || line.starts_with("#![") || line.is_empty() { continue; } break; } false } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn write_rs(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { let path = dir.join(name); std::fs::write(&path, content).unwrap(); path } #[test] fn check_fully_documented_file_returns_ok() { let tmp = TempDir::new().unwrap(); let path = write_rs( tmp.path(), "lib.rs", "//! Module doc.\n\n/// A function.\npub fn hello() {}\n\n/// A struct.\npub struct Foo;\n", ); let adapter = RustAdapter; assert_eq!(adapter.check(&[&path]), CheckResult::Ok); } #[test] fn check_detects_missing_module_doc() { let tmp = TempDir::new().unwrap(); let path = write_rs(tmp.path(), "lib.rs", "/// A function.\npub fn hello() {}\n"); let adapter = RustAdapter; let result = adapter.check(&[&path]); assert!( matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")), "expected module failure, got {result:?}" ); } #[test] fn check_detects_missing_fn_doc_with_correct_fields() { let tmp = TempDir::new().unwrap(); let path = write_rs(tmp.path(), "bar.rs", "//! Module.\n\npub fn no_doc() {}\n"); let adapter = RustAdapter; let result = adapter.check(&[&path]); if let CheckResult::Failures(failures) = result { let f = failures.iter().find(|f| f.item_kind == "fn").unwrap(); assert_eq!(f.item_name, "no_doc"); assert_eq!(f.line, 3); assert_eq!(f.file_path, path); } else { panic!("expected failures"); } } #[test] fn check_passes_item_with_attribute_before_doc() { let tmp = TempDir::new().unwrap(); // Attribute between doc and item is fine; doc between attribute and item is fine too let path = write_rs( tmp.path(), "lib.rs", "//! Module.\n\n/// Doc.\n#[derive(Debug)]\npub struct Foo;\n", ); let adapter = RustAdapter; assert_eq!(adapter.check(&[&path]), CheckResult::Ok); } #[test] fn parse_pub_item_recognises_various_kinds() { assert_eq!( parse_pub_item("pub fn foo()"), Some(("fn".into(), "foo".into())) ); assert_eq!( parse_pub_item(" pub async fn bar()"), Some(("fn".into(), "bar".into())) ); assert_eq!( parse_pub_item("pub struct Baz"), Some(("struct".into(), "Baz".into())) ); assert_eq!( parse_pub_item("pub enum Qux"), Some(("enum".into(), "Qux".into())) ); assert_eq!( parse_pub_item("pub trait MyTrait"), Some(("trait".into(), "MyTrait".into())) ); assert_eq!( parse_pub_item("pub(crate) fn inner()"), Some(("fn".into(), "inner".into())) ); assert_eq!(parse_pub_item("fn private()"), None); assert_eq!(parse_pub_item("let x = 1;"), None); } }