use crate::config::Config;
use crate::validation::{Severity, Violation, ViolationType};
use crate::{Error, Result};
use regex::Regex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionFormat {
SemVer,
CalVer,
}
#[derive(Debug, Clone)]
pub struct ChangelogRequirements {
pub enforce_keep_a_changelog: bool,
pub require_version_entry: bool,
pub check_on_tag: bool,
pub required_sections: Vec<String>,
}
impl Default for ChangelogRequirements {
fn default() -> Self {
Self {
enforce_keep_a_changelog: true,
require_version_entry: true,
check_on_tag: true,
required_sections: vec![
"Added".to_string(),
"Changed".to_string(),
"Fixed".to_string(),
],
}
}
}
pub struct VersionConsistencyValidator {
project_root: PathBuf,
source_version: String,
version_format: VersionFormat,
version_regex: Regex,
exclusions: HashSet<PathBuf>,
config: Config,
changelog_requirements: ChangelogRequirements,
}
#[derive(Debug, Clone)]
pub struct VersionValidationResult {
pub passed: bool,
pub violations: Vec<Violation>,
pub source_version: String,
pub version_format: VersionFormat,
pub changelog_status: ChangelogStatus,
}
#[derive(Debug, Clone)]
pub struct ChangelogStatus {
pub exists: bool,
pub version_documented: bool,
pub follows_keep_a_changelog: bool,
pub missing_sections: Vec<String>,
}
impl VersionConsistencyValidator {
pub fn new(project_root: PathBuf, config: Config) -> Result<Self> {
let source_version = Self::extract_version_from_cargo(&project_root)?;
let version_format = Self::detect_version_format(&source_version);
let version_pattern = match version_format {
VersionFormat::SemVer => r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)",
VersionFormat::CalVer => r"(\d{4}(?:\.\d{1,2}){1,2}(?:-[a-zA-Z0-9.-]+)?)",
};
let version_regex = Regex::new(&format!(
r#"(?i)(?:version\s*[=:]\s*["']?|const\s+VERSION\s*[=:]\s*["']?|static\s+VERSION\s*[=:]\s*["']?){}"#,
version_pattern
)).map_err(|e| Error::validation(format!("Failed to compile version regex: {}", e)))?;
let mut exclusions = HashSet::new();
exclusions.insert(project_root.join("Cargo.toml"));
exclusions.insert(project_root.join("Cargo.lock"));
exclusions.insert(project_root.join("CHANGELOG.md"));
exclusions.insert(project_root.join("README.md"));
exclusions.insert(project_root.join("CHANGELOG"));
exclusions.insert(project_root.join("docs"));
exclusions.insert(project_root.join(".github"));
exclusions.insert(project_root.join("packaging"));
exclusions.insert(project_root.join("target"));
exclusions.insert(project_root.join("node_modules"));
exclusions.insert(project_root.join(".git"));
exclusions.insert(project_root.join(".claude"));
exclusions.insert(project_root.join("dist"));
exclusions.insert(project_root.join("build"));
exclusions.insert(project_root.join(".next"));
exclusions.insert(project_root.join(".turbo"));
exclusions.insert(project_root.join("vendor"));
exclusions.insert(project_root.join("__pycache__"));
exclusions.insert(project_root.join(".venv"));
if let Some(user_exclusions) = config.validation.version_check_exclusions.as_ref() {
for exclusion in user_exclusions {
exclusions.insert(project_root.join(exclusion));
}
}
let changelog_requirements = ChangelogRequirements {
enforce_keep_a_changelog: config.validation.enforce_keep_a_changelog.unwrap_or(true),
require_version_entry: config.validation.require_changelog_entry.unwrap_or(true),
check_on_tag: config.validation.check_changelog_on_tag.unwrap_or(true),
required_sections: config
.validation
.changelog_required_sections
.clone()
.unwrap_or_else(|| {
vec![
"Added".to_string(),
"Changed".to_string(),
"Fixed".to_string(),
]
}),
};
Ok(Self {
project_root,
source_version,
version_format,
version_regex,
exclusions,
config,
changelog_requirements,
})
}
fn detect_version_format(version: &str) -> VersionFormat {
if Regex::new(r"^\d{4}\.")
.ok()
.is_some_and(|re| re.is_match(version))
{
VersionFormat::CalVer
} else {
VersionFormat::SemVer
}
}
fn extract_version_from_cargo(project_root: &Path) -> Result<String> {
let cargo_path = project_root.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_path)
.map_err(|e| Error::io(format!("Failed to read Cargo.toml: {}", e)))?;
let parsed: toml::Value = toml::from_str(&content)
.map_err(|e| Error::validation(format!("Failed to parse Cargo.toml: {}", e)))?;
if let Some(version) = parsed.get("package").and_then(|p| p.get("version")) {
if let Some(s) = version.as_str()
&& Self::is_valid_version(s)
{
return Ok(s.to_string());
}
let is_inherited = version
.as_table()
.and_then(|t| t.get("workspace"))
.and_then(toml::Value::as_bool)
== Some(true);
if is_inherited {
return Self::resolve_workspace_version(project_root);
}
}
if let Some(s) = parsed
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(toml::Value::as_str)
&& Self::is_valid_version(s)
{
return Ok(s.to_string());
}
Err(Error::validation(
"Could not parse version from Cargo.toml".to_string(),
))
}
fn resolve_workspace_version(start: &Path) -> Result<String> {
let mut current: Option<&Path> = Some(start);
while let Some(dir) = current {
let cargo = dir.join("Cargo.toml");
if cargo.exists()
&& let Ok(content) = std::fs::read_to_string(&cargo)
&& let Ok(parsed) = toml::from_str::<toml::Value>(&content)
&& let Some(s) = parsed
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(toml::Value::as_str)
&& Self::is_valid_version(s)
{
return Ok(s.to_string());
}
current = dir.parent();
}
Err(Error::validation(
"Could not resolve workspace-inherited version: no workspace root with [workspace.package].version found".to_string(),
))
}
fn is_valid_version(version: &str) -> bool {
let semver_ok = Regex::new(r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$")
.ok()
.is_some_and(|re| re.is_match(version));
let calver_ok = Regex::new(r"^\d{4}(?:\.\d{1,2}){1,2}(?:-[a-zA-Z0-9.-]+)?$")
.ok()
.is_some_and(|re| re.is_match(version));
semver_ok || calver_ok
}
pub async fn validate(&self) -> Result<VersionValidationResult> {
let mut violations = Vec::new();
if !self
.config
.validation
.check_version_consistency
.unwrap_or(true)
{
return Ok(self.empty_result());
}
self.check_hardcoded_versions(&mut violations).await?;
let changelog_status = self.validate_changelog().await?;
if self.changelog_requirements.require_version_entry && !changelog_status.version_documented
{
violations.push(Violation {
violation_type: ViolationType::MissingChangelogEntry,
file: self.project_root.join("CHANGELOG.md"),
line: 1,
message: format!(
"Version {} is not documented in CHANGELOG.md. Add entry following Keep a Changelog format.",
self.source_version
),
severity: Severity::Error,
});
}
if self.changelog_requirements.enforce_keep_a_changelog
&& !changelog_status.follows_keep_a_changelog
{
violations.push(Violation {
violation_type: ViolationType::InvalidChangelogFormat,
file: self.project_root.join("CHANGELOG.md"),
line: 1,
message: "CHANGELOG.md does not follow Keep a Changelog format. See https://keepachangelog.com/".to_string(),
severity: Severity::Warning,
});
}
if self.is_tagging_scenario().await?
&& self.changelog_requirements.check_on_tag
&& !changelog_status.version_documented
{
violations.push(Violation {
violation_type: ViolationType::MissingChangelogEntry,
file: self.project_root.join("CHANGELOG.md"),
line: 1,
message: format!(
"Cannot create tag for version {}: No changelog entry found. Document changes before tagging.",
self.source_version
),
severity: Severity::Error,
});
}
Ok(VersionValidationResult {
passed: violations.is_empty(),
violations,
source_version: self.source_version.clone(),
version_format: self.version_format,
changelog_status,
})
}
async fn check_hardcoded_versions(&self, violations: &mut Vec<Violation>) -> Result<()> {
let root = self.project_root.clone();
let exclusions = self.exclusions.clone();
const WALK_SKIP_DIRS: &[&str] = &[
"target",
"node_modules",
".git",
".claude",
".next",
"dist",
"build",
".turbo",
".pnpm",
".yarn",
"__pycache__",
".venv",
"vendor",
];
let rs_paths: Vec<PathBuf> = tokio::task::spawn_blocking(move || {
let walker = WalkDir::new(&root).into_iter();
let mut paths = Vec::new();
for entry in walker.filter_entry(|e| {
if e.file_type().is_dir()
&& e.file_name()
.to_str()
.is_some_and(|name| WALK_SKIP_DIRS.contains(&name))
{
return false;
}
true
}) {
let Ok(e) = entry else { continue };
let p = e.path();
if exclusions.iter().any(|ex| p.starts_with(ex)) {
continue;
}
let s = p.to_string_lossy();
if s.contains("/tests/")
|| s.contains("/test/")
|| s.contains("/fixtures/")
|| s.contains("/examples/")
{
continue;
}
if p.extension().is_some_and(|ext| ext == "rs") {
paths.push(p.to_path_buf());
}
}
paths
})
.await
.map_err(|e| Error::process(format!("Task join error: {}", e)))?;
for path in rs_paths {
self.check_file(&path, violations).await?;
}
Ok(())
}
async fn check_file(&self, path: &Path, violations: &mut Vec<Violation>) -> Result<()> {
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| Error::io(format!("Failed to read {}: {}", path.display(), e)))?;
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") && !trimmed.starts_with("///") {
continue;
}
if let Some(captures) = self.version_regex.captures(line)
&& let Some(version_match) = captures.get(1)
{
let found_version = version_match.as_str();
if found_version == self.source_version {
if !line.contains("env!(\"CARGO_PKG_VERSION\")")
&& !line.contains("CARGO_PKG_VERSION")
&& !line.contains("clap::crate_version!")
{
violations.push(Violation {
violation_type: ViolationType::HardcodedVersion,
file: path.to_path_buf(),
line: line_num + 1,
message: format!(
"Hardcoded version '{}' found. Use env!(\"CARGO_PKG_VERSION\") or clap::crate_version!() for SSoT.",
found_version
),
severity: Severity::Error,
});
}
}
}
}
Ok(())
}
async fn validate_changelog(&self) -> Result<ChangelogStatus> {
let changelog_path = self.project_root.join("CHANGELOG.md");
if !changelog_path.exists() {
return Ok(ChangelogStatus {
exists: false,
version_documented: false,
follows_keep_a_changelog: false,
missing_sections: vec![],
});
}
let content = tokio::fs::read_to_string(&changelog_path)
.await
.map_err(|e| Error::io(format!("Failed to read CHANGELOG.md: {}", e)))?;
let content_lower = content.to_lowercase();
let has_keep_a_changelog_format = content.contains("## [Unreleased]")
|| content_lower.contains("all notable changes")
|| content.contains("Keep a Changelog");
let version_documented = content.contains(&format!("[{}]", self.source_version))
|| content.contains(&format!("## {}", self.source_version));
let mut missing_sections = Vec::new();
for section in &self.changelog_requirements.required_sections {
let section_lower = section.to_lowercase();
if !content_lower.contains(&format!("### {}", section_lower))
&& !content_lower.contains(&format!("## {}", section_lower))
{
missing_sections.push(section.clone());
}
}
Ok(ChangelogStatus {
exists: true,
version_documented,
follows_keep_a_changelog: has_keep_a_changelog_format,
missing_sections,
})
}
async fn is_tagging_scenario(&self) -> Result<bool> {
if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
return Ok(true);
}
let output = tokio::process::Command::new("git")
.args(["describe", "--exact-match", "--tags", "HEAD"])
.current_dir(&self.project_root)
.output()
.await;
if let Ok(output) = output
&& output.status.success()
{
return Ok(true);
}
Ok(false)
}
fn empty_result(&self) -> VersionValidationResult {
VersionValidationResult {
passed: true,
violations: vec![],
source_version: self.source_version.clone(),
version_format: self.version_format,
changelog_status: ChangelogStatus {
exists: false,
version_documented: false,
follows_keep_a_changelog: false,
missing_sections: vec![],
},
}
}
pub fn source_version(&self) -> &str {
&self.source_version
}
pub fn version_format(&self) -> VersionFormat {
self.version_format
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::fs;
#[tokio::test]
async fn test_detects_semver() {
assert_eq!(
VersionConsistencyValidator::detect_version_format("1.2.3"),
VersionFormat::SemVer
);
assert_eq!(
VersionConsistencyValidator::detect_version_format("1.2.3-alpha"),
VersionFormat::SemVer
);
}
#[tokio::test]
async fn test_detects_calver() {
assert_eq!(
VersionConsistencyValidator::detect_version_format("2025.03.21"),
VersionFormat::CalVer
);
assert_eq!(
VersionConsistencyValidator::detect_version_format("2025.3"),
VersionFormat::CalVer
);
}
#[tokio::test]
async fn test_validates_changelog_format() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let cargo_toml = r#"
[package]
name = "test-project"
version = "1.2.3"
edition = "2021"
"#;
fs::write(project_root.join("Cargo.toml"), cargo_toml)
.await
.unwrap();
let changelog = r#"# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.2.3] - 2025-03-21
### Added
- New feature X
### Fixed
- Bug Y
"#;
fs::write(project_root.join("CHANGELOG.md"), changelog)
.await
.unwrap();
let config = Config::default();
let validator =
VersionConsistencyValidator::new(project_root.to_path_buf(), config).unwrap();
let result = validator.validate().await.unwrap();
assert!(result.changelog_status.follows_keep_a_changelog);
assert!(result.changelog_status.version_documented);
}
#[tokio::test]
async fn test_extract_version_with_workspace_inheritance_dotted() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("Cargo.toml"),
r#"
[workspace]
members = ["child"]
[workspace.package]
version = "1.2.3"
edition = "2024"
"#,
)
.await
.unwrap();
let child = root.join("child");
fs::create_dir_all(&child).await.unwrap();
fs::write(
child.join("Cargo.toml"),
r#"
[package]
name = "child"
version.workspace = true
edition.workspace = true
"#,
)
.await
.unwrap();
let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
assert_eq!(version, "1.2.3");
}
#[tokio::test]
async fn test_extract_version_with_workspace_inheritance_inline() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("Cargo.toml"),
r#"
[workspace]
members = ["child"]
[workspace.package]
version = "2.0.0"
"#,
)
.await
.unwrap();
let child = root.join("child");
fs::create_dir_all(&child).await.unwrap();
fs::write(
child.join("Cargo.toml"),
r#"
[package]
name = "child"
version = { workspace = true }
"#,
)
.await
.unwrap();
let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
assert_eq!(version, "2.0.0");
}
#[tokio::test]
async fn test_extract_version_from_virtual_workspace_root() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(
root.join("Cargo.toml"),
r#"
[workspace]
members = ["a", "b"]
[workspace.package]
version = "0.9.0"
edition = "2024"
"#,
)
.await
.unwrap();
let version = VersionConsistencyValidator::extract_version_from_cargo(root).unwrap();
assert_eq!(version, "0.9.0");
}
}