use crate::error::RepoLensError;
use crate::config::Config;
use crate::rules::engine::RuleCategory;
use crate::rules::results::{Finding, Severity};
use crate::scanner::Scanner;
pub struct DocsRules;
#[async_trait::async_trait]
impl RuleCategory for DocsRules {
fn name(&self) -> &'static str {
"docs"
}
async fn run(&self, scanner: &Scanner, config: &Config) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if config.is_rule_enabled("docs/readme") {
findings.extend(check_readme(scanner).await?);
}
if config.is_rule_enabled("docs/license") {
findings.extend(check_license(scanner, config).await?);
}
if config.is_rule_enabled("docs/contributing") {
findings.extend(check_contributing(scanner).await?);
}
if config.is_rule_enabled("docs/code-of-conduct") {
findings.extend(check_code_of_conduct(scanner).await?);
}
if config.is_rule_enabled("docs/security") {
findings.extend(check_security(scanner).await?);
}
if config.is_rule_enabled("docs/changelog") {
findings.extend(check_changelog(scanner).await?);
}
if config.is_rule_enabled("docs/changelog-format") {
findings.extend(check_changelog_format(scanner).await?);
}
if config.is_rule_enabled("docs/changelog-unreleased") {
findings.extend(check_changelog_unreleased(scanner).await?);
}
Ok(findings)
}
}
async fn check_readme(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
let readme_files = ["README.md", "README", "README.txt", "README.rst"];
let has_readme = readme_files.iter().any(|f| scanner.file_exists(f));
if !has_readme {
findings.push(
Finding::new(
"DOC001",
"docs",
Severity::Warning,
"README file is missing",
)
.with_description(
"A README file is essential for explaining what the project does and how to use it.",
)
.with_remediation(
"Create a README.md file with project description, installation instructions, and usage examples.",
),
);
return Ok(findings);
}
if let Ok(content) = scanner.read_file("README.md") {
let line_count = content.lines().count();
if line_count < 10 {
findings.push(
Finding::new(
"DOC002",
"docs",
Severity::Warning,
format!("README is too short ({} lines)", line_count),
)
.with_description(
"A comprehensive README should include sections for description, installation, usage, and contribution guidelines.",
),
);
}
let sections = [
("installation", "Installation instructions"),
("usage", "Usage examples"),
("license", "License information"),
];
for (keyword, description) in sections {
if !content.to_lowercase().contains(keyword) {
findings.push(Finding::new(
"DOC003",
"docs",
Severity::Info,
format!("README missing section: {}", description),
));
}
}
}
Ok(findings)
}
async fn check_license(scanner: &Scanner, config: &Config) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
let license_files = [
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"COPYING",
"LICENSE-MIT",
"LICENSE-APACHE",
"LICENCE",
];
let has_license = license_files.iter().any(|f| scanner.file_exists(f));
if config.preset == "enterprise" && !has_license {
return Ok(findings);
}
if !has_license {
findings.push(
Finding::new(
"DOC004",
"docs",
Severity::Critical,
"LICENSE file is missing",
)
.with_description(
"A LICENSE file is required for open source projects to define how others can use your code.",
)
.with_remediation(
"Add a LICENSE file with an appropriate open source license (MIT, Apache-2.0, GPL-3.0, etc.).",
),
);
}
Ok(findings)
}
async fn check_contributing(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
let contributing_files = ["CONTRIBUTING.md", "CONTRIBUTING", ".github/CONTRIBUTING.md"];
let has_contributing = contributing_files.iter().any(|f| scanner.file_exists(f));
if !has_contributing {
findings.push(
Finding::new(
"DOC005",
"docs",
Severity::Warning,
"CONTRIBUTING file is missing",
)
.with_description(
"A CONTRIBUTING file helps potential contributors understand how to participate in your project.",
)
.with_remediation(
"Create a CONTRIBUTING.md file with contribution guidelines, code style, and pull request process.",
),
);
}
Ok(findings)
}
async fn check_code_of_conduct(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
let coc_files = [
"CODE_OF_CONDUCT.md",
"CODE_OF_CONDUCT",
".github/CODE_OF_CONDUCT.md",
];
let has_coc = coc_files.iter().any(|f| scanner.file_exists(f));
if !has_coc {
findings.push(
Finding::new(
"DOC006",
"docs",
Severity::Warning,
"CODE_OF_CONDUCT file is missing",
)
.with_description(
"A Code of Conduct establishes expectations for behavior and helps create a welcoming community.",
)
.with_remediation(
"Add a CODE_OF_CONDUCT.md file. Consider using the Contributor Covenant as a starting point.",
),
);
}
Ok(findings)
}
async fn check_security(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
let security_files = ["SECURITY.md", ".github/SECURITY.md"];
let has_security = security_files.iter().any(|f| scanner.file_exists(f));
if !has_security {
findings.push(
Finding::new(
"DOC007",
"docs",
Severity::Warning,
"SECURITY policy file is missing",
)
.with_description(
"A SECURITY.md file tells users how to report security vulnerabilities responsibly.",
)
.with_remediation(
"Create a SECURITY.md file with instructions for reporting security issues.",
),
);
}
Ok(findings)
}
fn find_changelog(scanner: &Scanner) -> Option<&'static str> {
let changelog_files = [
"CHANGELOG.md",
"CHANGELOG",
"CHANGELOG.txt",
"HISTORY.md",
"CHANGES.md",
];
changelog_files.into_iter().find(|f| scanner.file_exists(f))
}
async fn check_changelog(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if find_changelog(scanner).is_none() {
findings.push(
Finding::new(
"DOC008",
"docs",
Severity::Warning,
"CHANGELOG file is missing",
)
.with_description(
"A CHANGELOG file helps users and contributors track notable changes between releases.",
)
.with_remediation(
"Create a CHANGELOG.md file. Consider following the Keep a Changelog format (https://keepachangelog.com).",
),
);
}
Ok(findings)
}
async fn check_changelog_format(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if let Some(changelog_path) = find_changelog(scanner) {
if let Ok(content) = scanner.read_file(changelog_path) {
let has_semver_headers = content.lines().any(|line| {
let trimmed = line.trim();
trimmed.starts_with("## [")
&& trimmed.contains('.')
&& (trimmed.contains(']') || trimmed.ends_with(']'))
});
if !has_semver_headers {
findings.push(
Finding::new(
"DOC009",
"docs",
Severity::Info,
"CHANGELOG does not follow Keep a Changelog format",
)
.with_location(changelog_path)
.with_description(
"The Keep a Changelog format uses '## [x.y.z]' headers for each version, making it easier to parse and read.",
)
.with_remediation(
"Consider reformatting your CHANGELOG to follow Keep a Changelog (https://keepachangelog.com).",
),
);
}
}
}
Ok(findings)
}
async fn check_changelog_unreleased(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if let Some(changelog_path) = find_changelog(scanner) {
if let Ok(content) = scanner.read_file(changelog_path) {
let lines: Vec<&str> = content.lines().collect();
let unreleased_idx = lines.iter().position(|line| {
let trimmed = line.trim().to_lowercase();
trimmed.starts_with("## [unreleased]") || trimmed == "## [unreleased]"
});
if let Some(idx) = unreleased_idx {
let next_section_idx = lines
.iter()
.skip(idx + 1)
.position(|line| line.trim().starts_with("## ["))
.map(|pos| pos + idx + 1);
let end = next_section_idx.unwrap_or(lines.len());
let has_content = lines[idx + 1..end]
.iter()
.any(|line| !line.trim().is_empty());
if !has_content {
findings.push(
Finding::new(
"DOC010",
"docs",
Severity::Info,
"CHANGELOG has empty Unreleased section",
)
.with_location(changelog_path)
.with_description(
"The [Unreleased] section in the CHANGELOG is empty. Consider adding pending changes or removing the section until there are changes to document.",
)
.with_remediation(
"Add pending changes to the [Unreleased] section or remove it until there are changes to document.",
),
);
}
}
}
}
Ok(findings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::scanner::Scanner;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_check_readme_missing() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_readme(&scanner).await.unwrap();
assert!(!findings.is_empty());
assert!(findings.iter().any(|f| f.rule_id == "DOC001"));
}
#[tokio::test]
async fn test_check_readme_too_short() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let readme = root.join("README.md");
fs::write(&readme, "# Test\n\nShort.").unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_readme(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "DOC002"));
}
#[tokio::test]
async fn test_check_readme_missing_sections() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let readme = root.join("README.md");
fs::write(&readme, "# Project\n\nDescription here.\n\nMore content.\n\nEven more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.").unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_readme(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "DOC003"));
}
#[tokio::test]
async fn test_check_license_missing() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let config = Config::default();
let findings = check_license(&scanner, &config).await.unwrap();
assert!(!findings.is_empty());
assert!(findings.iter().any(|f| f.rule_id == "DOC004"));
}
#[tokio::test]
async fn test_check_license_enterprise_optional() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let config = Config {
preset: "enterprise".to_string(),
..Default::default()
};
let findings = check_license(&scanner, &config).await.unwrap();
assert!(findings.is_empty());
}
#[tokio::test]
async fn test_check_contributing_missing() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_contributing(&scanner).await.unwrap();
assert!(!findings.is_empty());
assert!(findings.iter().any(|f| f.rule_id == "DOC005"));
}
#[tokio::test]
async fn test_check_code_of_conduct_missing() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_code_of_conduct(&scanner).await.unwrap();
assert!(!findings.is_empty());
assert!(findings.iter().any(|f| f.rule_id == "DOC006"));
}
#[tokio::test]
async fn test_check_security_missing() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_security(&scanner).await.unwrap();
assert!(!findings.is_empty());
assert!(findings.iter().any(|f| f.rule_id == "DOC007"));
}
#[tokio::test]
async fn test_check_changelog_missing() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog(&scanner).await.unwrap();
assert!(!findings.is_empty());
assert!(findings.iter().any(|f| f.rule_id == "DOC008"));
}
#[tokio::test]
async fn test_check_changelog_present() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [1.0.0]\n\n- Initial release",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC008"));
}
#[tokio::test]
async fn test_check_changelog_present_as_history() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("HISTORY.md"),
"# History\n\n## 1.0.0\n\n- First release",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC008"));
}
#[tokio::test]
async fn test_check_changelog_present_as_changes() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(root.join("CHANGES.md"), "# Changes").unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC008"));
}
#[tokio::test]
async fn test_check_changelog_format_valid() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2024-01-01\n\n### Added\n- Initial release\n",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_format(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC009"));
}
#[tokio::test]
async fn test_check_changelog_format_invalid() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\nJust some text about what changed.\nNo structured headers here.\n",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_format(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "DOC009"));
}
#[tokio::test]
async fn test_check_changelog_format_no_changelog() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_format(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC009"));
}
#[tokio::test]
async fn test_check_changelog_unreleased_with_content() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n### Added\n- New feature\n\n## [1.0.0] - 2024-01-01\n\n- Initial release\n",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_unreleased(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC010"));
}
#[tokio::test]
async fn test_check_changelog_unreleased_empty() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2024-01-01\n\n- Initial release\n",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_unreleased(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "DOC010"));
}
#[tokio::test]
async fn test_check_changelog_unreleased_no_unreleased_section() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [1.0.0] - 2024-01-01\n\n- Initial release\n",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_unreleased(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC010"));
}
#[tokio::test]
async fn test_check_changelog_unreleased_no_changelog() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_unreleased(&scanner).await.unwrap();
assert!(findings.iter().all(|f| f.rule_id != "DOC010"));
}
#[tokio::test]
async fn test_check_changelog_unreleased_at_end_of_file_empty() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [1.0.0] - 2024-01-01\n\n- Initial release\n\n## [Unreleased]\n",
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_changelog_unreleased(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "DOC010"));
}
}