ferrous-forge 1.9.6

System-wide Rust development standards enforcer
Documentation
//! Documentation presence validation
//!
//! Validates documentation standards that clippy cannot catch when lints are not yet configured:
//! - lib.rs and mod.rs files must have //! module-level documentation
//! - Cargo.toml should have [lints.rustdoc] section configured
//!
//! These checks prompt users toward running `ferrous-forge init --project` to properly
//! configure rustdoc lints, after which clippy takes ownership of enforcement.

use crate::Result;
use crate::validation::{Severity, Violation, ViolationType};
use std::path::Path;

/// Check that module root files (lib.rs, mod.rs) have //! documentation
pub fn validate_doc_presence(
    rust_file: &Path,
    lines: &[&str],
    violations: &mut Vec<Violation>,
) -> Result<()> {
    let filename = rust_file.file_name().and_then(|n| n.to_str()).unwrap_or("");

    // Only check module root files
    if filename != "lib.rs" && filename != "mod.rs" {
        return Ok(());
    }

    // Skip generated files — cargo fmt strips //! docs from @generated files,
    // creating an impossible loop (see GitHub issue #17)
    let is_generated = lines
        .iter()
        .any(|line| line.trim() == "// @generated" || line.trim().starts_with("// @generated "));
    if is_generated {
        return Ok(());
    }

    let has_module_doc = lines
        .iter()
        .any(|line| line.trim_start().starts_with("//!"));

    if !has_module_doc {
        violations.push(Violation {
            violation_type: ViolationType::MissingModuleDoc,
            file: rust_file.to_path_buf(),
            line: 1,
            message: format!(
                "FERROUS FORGE [DOC STANDARD] — Missing Module Documentation\n  \
                 File: {}\n  \
                 Module root missing //! documentation. Add at minimum:\n  \
                   //! Brief description of what this module does.",
                rust_file.display()
            ),
            severity: Severity::Warning,
        });
    }

    Ok(())
}

/// Check that Cargo.toml has a [lints.rustdoc] section configured
pub fn validate_cargo_doc_config(
    cargo_file: &Path,
    content: &str,
    violations: &mut Vec<Violation>,
) -> Result<()> {
    if !content.contains("[lints.rustdoc]") && !content.contains("[lints]") {
        violations.push(Violation {
            violation_type: ViolationType::MissingDocConfig,
            file: cargo_file.to_path_buf(),
            line: 0,
            message: "FERROUS FORGE [DOC STANDARD] — Missing rustdoc lint configuration\n  \
                     Cargo.toml is missing [lints.rustdoc] section.\n  \
                     Run 'ferrous-forge init --project' to inject the full rustdoc lint block."
                .to_string(),
            severity: Severity::Warning,
        });
    }

    Ok(())
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn run_doc_check(filename: &str, source: &str) -> Vec<Violation> {
        let path = PathBuf::from(format!("crate/src/{filename}"));
        let lines: Vec<&str> = source.lines().collect();
        let mut violations = Vec::new();
        validate_doc_presence(&path, &lines, &mut violations).unwrap();
        violations
    }

    #[test]
    fn mod_rs_with_doc_passes() {
        let v = run_doc_check("mod.rs", "//! Module docs\npub mod foo;");
        assert!(v.is_empty());
    }

    #[test]
    fn mod_rs_without_doc_fails() {
        let v = run_doc_check("mod.rs", "pub mod foo;");
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].violation_type, ViolationType::MissingModuleDoc);
    }

    #[test]
    fn lib_rs_without_doc_fails() {
        let v = run_doc_check("lib.rs", "pub fn hello() {}");
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].violation_type, ViolationType::MissingModuleDoc);
    }

    #[test]
    fn regular_file_skipped() {
        let v = run_doc_check("main.rs", "fn main() {}");
        assert!(v.is_empty());
    }

    #[test]
    fn generated_mod_rs_skipped() {
        let source = "// @generated\n\npub mod events;";
        let v = run_doc_check("mod.rs", source);
        assert!(v.is_empty(), "should skip @generated files");
    }

    #[test]
    fn generated_with_reason_skipped() {
        let source = "// @generated by build.rs\n\npub mod events;";
        let v = run_doc_check("mod.rs", source);
        assert!(v.is_empty(), "should skip @generated with reason");
    }

    #[test]
    fn generated_lib_rs_skipped() {
        let source = "// @generated\n\npub fn init() {}";
        let v = run_doc_check("lib.rs", source);
        assert!(v.is_empty(), "should skip @generated lib.rs");
    }

    #[test]
    fn generated_with_doc_still_passes() {
        let source = "//! Module docs\n// @generated\npub mod events;";
        let v = run_doc_check("mod.rs", source);
        assert!(v.is_empty());
    }

    #[test]
    fn at_generated_in_normal_comment_not_matched() {
        // "// this has @generated in it" should NOT trigger the skip
        let source = "// this has @generated in it\npub mod foo;";
        let v = run_doc_check("mod.rs", source);
        assert_eq!(v.len(), 1, "partial match should not skip validation");
    }
}