ferrous_forge/validation/
version_consistency.rs1use crate::config::Config;
7use crate::validation::{Severity, Violation, ViolationType};
8use crate::{Error, Result};
9use regex::Regex;
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12use walkdir::WalkDir;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum VersionFormat {
17 SemVer,
19 CalVer,
21}
22
23#[derive(Debug, Clone)]
25pub struct ChangelogRequirements {
26 pub enforce_keep_a_changelog: bool,
28 pub require_version_entry: bool,
30 pub check_on_tag: bool,
32 pub required_sections: Vec<String>,
34}
35
36impl Default for ChangelogRequirements {
37 fn default() -> Self {
38 Self {
39 enforce_keep_a_changelog: true,
40 require_version_entry: true,
41 check_on_tag: true,
42 required_sections: vec![
43 "Added".to_string(),
44 "Changed".to_string(),
45 "Fixed".to_string(),
46 ],
47 }
48 }
49}
50
51pub struct VersionConsistencyValidator {
53 project_root: PathBuf,
55 source_version: String,
57 version_format: VersionFormat,
59 version_regex: Regex,
61 exclusions: HashSet<PathBuf>,
63 config: Config,
65 changelog_requirements: ChangelogRequirements,
67}
68
69#[derive(Debug, Clone)]
71pub struct VersionValidationResult {
72 pub passed: bool,
74 pub violations: Vec<Violation>,
76 pub source_version: String,
78 pub version_format: VersionFormat,
80 pub changelog_status: ChangelogStatus,
82}
83
84#[derive(Debug, Clone)]
86pub struct ChangelogStatus {
87 pub exists: bool,
89 pub version_documented: bool,
91 pub follows_keep_a_changelog: bool,
93 pub missing_sections: Vec<String>,
95}
96
97impl VersionConsistencyValidator {
98 pub fn new(project_root: PathBuf, config: Config) -> Result<Self> {
107 let source_version = Self::extract_version_from_cargo(&project_root)?;
108 let version_format = Self::detect_version_format(&source_version);
109
110 let version_pattern = match version_format {
112 VersionFormat::SemVer => r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)",
113 VersionFormat::CalVer => r"(\d{4}(?:\.\d{1,2}){1,2}(?:-[a-zA-Z0-9.-]+)?)",
114 };
115
116 let version_regex = Regex::new(&format!(
117 r#"(?i)(?:version\s*[=:]\s*["']?|const\s+VERSION\s*[=:]\s*["']?|static\s+VERSION\s*[=:]\s*["']?){}"#,
118 version_pattern
119 )).map_err(|e| Error::validation(format!("Failed to compile version regex: {}", e)))?;
120
121 let mut exclusions = HashSet::new();
122
123 exclusions.insert(project_root.join("Cargo.toml"));
125 exclusions.insert(project_root.join("Cargo.lock"));
126 exclusions.insert(project_root.join("CHANGELOG.md"));
127 exclusions.insert(project_root.join("README.md"));
128 exclusions.insert(project_root.join("docs"));
129 exclusions.insert(project_root.join(".github"));
130 exclusions.insert(project_root.join("packaging"));
131 exclusions.insert(project_root.join("CHANGELOG"));
132
133 if let Some(user_exclusions) = config.validation.version_check_exclusions.as_ref() {
135 for exclusion in user_exclusions {
136 exclusions.insert(project_root.join(exclusion));
137 }
138 }
139
140 let changelog_requirements = ChangelogRequirements {
142 enforce_keep_a_changelog: config.validation.enforce_keep_a_changelog.unwrap_or(true),
143 require_version_entry: config.validation.require_changelog_entry.unwrap_or(true),
144 check_on_tag: config.validation.check_changelog_on_tag.unwrap_or(true),
145 required_sections: config
146 .validation
147 .changelog_required_sections
148 .clone()
149 .unwrap_or_else(|| {
150 vec![
151 "Added".to_string(),
152 "Changed".to_string(),
153 "Fixed".to_string(),
154 ]
155 }),
156 };
157
158 Ok(Self {
159 project_root,
160 source_version,
161 version_format,
162 version_regex,
163 exclusions,
164 config,
165 changelog_requirements,
166 })
167 }
168
169 fn detect_version_format(version: &str) -> VersionFormat {
171 if Regex::new(r"^\d{4}\.")
173 .ok()
174 .is_some_and(|re| re.is_match(version))
175 {
176 VersionFormat::CalVer
177 } else {
178 VersionFormat::SemVer
179 }
180 }
181
182 fn extract_version_from_cargo(project_root: &Path) -> Result<String> {
184 let cargo_path = project_root.join("Cargo.toml");
185 let content = std::fs::read_to_string(&cargo_path)
186 .map_err(|e| Error::io(format!("Failed to read Cargo.toml: {}", e)))?;
187
188 for line in content.lines() {
190 if let Some(version) = line.trim().strip_prefix("version")
191 && let Some(eq_idx) = version.find('=')
192 {
193 let version_str = &version[eq_idx + 1..].trim();
194 let version_clean = version_str.trim_matches('"').trim_matches('\'');
196
197 if Self::is_valid_version(version_clean) {
198 return Ok(version_clean.to_string());
199 }
200 }
201 }
202
203 Err(Error::validation(
204 "Could not parse version from Cargo.toml".to_string(),
205 ))
206 }
207
208 fn is_valid_version(version: &str) -> bool {
210 let semver_ok = Regex::new(r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$")
212 .ok()
213 .is_some_and(|re| re.is_match(version));
214
215 let calver_ok = Regex::new(r"^\d{4}(?:\.\d{1,2}){1,2}(?:-[a-zA-Z0-9.-]+)?$")
217 .ok()
218 .is_some_and(|re| re.is_match(version));
219
220 semver_ok || calver_ok
221 }
222
223 pub async fn validate(&self) -> Result<VersionValidationResult> {
229 let mut violations = Vec::new();
230
231 if !self
233 .config
234 .validation
235 .check_version_consistency
236 .unwrap_or(true)
237 {
238 return Ok(self.empty_result());
239 }
240
241 self.check_hardcoded_versions(&mut violations).await?;
243
244 let changelog_status = self.validate_changelog().await?;
246
247 if self.changelog_requirements.require_version_entry && !changelog_status.version_documented
249 {
250 violations.push(Violation {
251 violation_type: ViolationType::MissingChangelogEntry,
252 file: self.project_root.join("CHANGELOG.md"),
253 line: 1,
254 message: format!(
255 "Version {} is not documented in CHANGELOG.md. Add entry following Keep a Changelog format.",
256 self.source_version
257 ),
258 severity: Severity::Error,
259 });
260 }
261
262 if self.changelog_requirements.enforce_keep_a_changelog
263 && !changelog_status.follows_keep_a_changelog
264 {
265 violations.push(Violation {
266 violation_type: ViolationType::InvalidChangelogFormat,
267 file: self.project_root.join("CHANGELOG.md"),
268 line: 1,
269 message: "CHANGELOG.md does not follow Keep a Changelog format. See https://keepachangelog.com/".to_string(),
270 severity: Severity::Warning,
271 });
272 }
273
274 if self.is_tagging_scenario().await?
276 && self.changelog_requirements.check_on_tag
277 && !changelog_status.version_documented
278 {
279 violations.push(Violation {
280 violation_type: ViolationType::MissingChangelogEntry,
281 file: self.project_root.join("CHANGELOG.md"),
282 line: 1,
283 message: format!(
284 "Cannot create tag for version {}: No changelog entry found. Document changes before tagging.",
285 self.source_version
286 ),
287 severity: Severity::Error,
288 });
289 }
290
291 Ok(VersionValidationResult {
292 passed: violations.is_empty(),
293 violations,
294 source_version: self.source_version.clone(),
295 version_format: self.version_format,
296 changelog_status,
297 })
298 }
299
300 async fn check_hardcoded_versions(&self, violations: &mut Vec<Violation>) -> Result<()> {
302 for entry in WalkDir::new(&self.project_root)
303 .into_iter()
304 .filter_map(|e| e.ok())
305 {
306 let path = entry.path();
307
308 if self.is_excluded(path) {
309 continue;
310 }
311
312 if path.extension().is_some_and(|ext| ext == "rs") {
313 self.check_file(path, violations).await?;
314 }
315 }
316 Ok(())
317 }
318
319 async fn check_file(&self, path: &Path, violations: &mut Vec<Violation>) -> Result<()> {
321 let content = tokio::fs::read_to_string(path)
322 .await
323 .map_err(|e| Error::io(format!("Failed to read {}: {}", path.display(), e)))?;
324
325 for (line_num, line) in content.lines().enumerate() {
326 let trimmed = line.trim();
328 if trimmed.starts_with("//") && !trimmed.starts_with("///") {
329 continue;
330 }
331
332 if let Some(captures) = self.version_regex.captures(line)
334 && let Some(version_match) = captures.get(1)
335 {
336 let found_version = version_match.as_str();
337
338 if found_version == self.source_version {
340 if !line.contains("env!(\"CARGO_PKG_VERSION\")")
342 && !line.contains("CARGO_PKG_VERSION")
343 && !line.contains("clap::crate_version!")
344 {
345 violations.push(Violation {
346 violation_type: ViolationType::HardcodedVersion,
347 file: path.to_path_buf(),
348 line: line_num + 1,
349 message: format!(
350 "Hardcoded version '{}' found. Use env!(\"CARGO_PKG_VERSION\") or clap::crate_version!() for SSoT.",
351 found_version
352 ),
353 severity: Severity::Error,
354 });
355 }
356 }
357 }
358 }
359
360 Ok(())
361 }
362
363 async fn validate_changelog(&self) -> Result<ChangelogStatus> {
365 let changelog_path = self.project_root.join("CHANGELOG.md");
366
367 if !changelog_path.exists() {
368 return Ok(ChangelogStatus {
369 exists: false,
370 version_documented: false,
371 follows_keep_a_changelog: false,
372 missing_sections: vec![],
373 });
374 }
375
376 let content = tokio::fs::read_to_string(&changelog_path)
377 .await
378 .map_err(|e| Error::io(format!("Failed to read CHANGELOG.md: {}", e)))?;
379
380 let content_lower = content.to_lowercase();
381
382 let has_keep_a_changelog_format = content.contains("## [Unreleased]")
384 || content_lower.contains("all notable changes")
385 || content.contains("Keep a Changelog");
386
387 let version_documented = content.contains(&format!("[{}]", self.source_version))
389 || content.contains(&format!("## {}", self.source_version));
390
391 let mut missing_sections = Vec::new();
393 for section in &self.changelog_requirements.required_sections {
394 let section_lower = section.to_lowercase();
395 if !content_lower.contains(&format!("### {}", section_lower))
396 && !content_lower.contains(&format!("## {}", section_lower))
397 {
398 missing_sections.push(section.clone());
399 }
400 }
401
402 Ok(ChangelogStatus {
403 exists: true,
404 version_documented,
405 follows_keep_a_changelog: has_keep_a_changelog_format,
406 missing_sections,
407 })
408 }
409
410 async fn is_tagging_scenario(&self) -> Result<bool> {
412 if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
414 return Ok(true);
415 }
416
417 let output = tokio::process::Command::new("git")
419 .args(["describe", "--exact-match", "--tags", "HEAD"])
420 .current_dir(&self.project_root)
421 .output()
422 .await;
423
424 if let Ok(output) = output
425 && output.status.success()
426 {
427 return Ok(true);
428 }
429
430 Ok(false)
431 }
432
433 fn is_excluded(&self, path: &Path) -> bool {
435 for exclusion in &self.exclusions {
436 if path.starts_with(exclusion) {
437 return true;
438 }
439 }
440
441 let path_str = path.to_string_lossy();
442 if path_str.contains("/tests/")
443 || path_str.contains("/test/")
444 || path_str.contains("/fixtures/")
445 || path_str.contains("/examples/")
446 {
447 return true;
448 }
449
450 false
451 }
452
453 fn empty_result(&self) -> VersionValidationResult {
455 VersionValidationResult {
456 passed: true,
457 violations: vec![],
458 source_version: self.source_version.clone(),
459 version_format: self.version_format,
460 changelog_status: ChangelogStatus {
461 exists: false,
462 version_documented: false,
463 follows_keep_a_changelog: false,
464 missing_sections: vec![],
465 },
466 }
467 }
468
469 pub fn source_version(&self) -> &str {
471 &self.source_version
472 }
473
474 pub fn version_format(&self) -> VersionFormat {
476 self.version_format
477 }
478}
479
480#[cfg(test)]
481#[allow(clippy::unwrap_used)]
482mod tests {
483 use super::*;
484 use tempfile::TempDir;
485 use tokio::fs;
486
487 #[tokio::test]
488 async fn test_detects_semver() {
489 assert_eq!(
490 VersionConsistencyValidator::detect_version_format("1.2.3"),
491 VersionFormat::SemVer
492 );
493 assert_eq!(
494 VersionConsistencyValidator::detect_version_format("1.2.3-alpha"),
495 VersionFormat::SemVer
496 );
497 }
498
499 #[tokio::test]
500 async fn test_detects_calver() {
501 assert_eq!(
502 VersionConsistencyValidator::detect_version_format("2025.03.21"),
503 VersionFormat::CalVer
504 );
505 assert_eq!(
506 VersionConsistencyValidator::detect_version_format("2025.3"),
507 VersionFormat::CalVer
508 );
509 }
510
511 #[tokio::test]
512 async fn test_validates_changelog_format() {
513 let temp_dir = TempDir::new().unwrap();
514 let project_root = temp_dir.path();
515
516 let cargo_toml = r#"
518[package]
519name = "test-project"
520version = "1.2.3"
521edition = "2021"
522"#;
523 fs::write(project_root.join("Cargo.toml"), cargo_toml)
524 .await
525 .unwrap();
526
527 let changelog = r#"# Changelog
529
530All notable changes to this project will be documented in this file.
531
532The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
533and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
534
535## [Unreleased]
536
537## [1.2.3] - 2025-03-21
538
539### Added
540- New feature X
541
542### Fixed
543- Bug Y
544"#;
545 fs::write(project_root.join("CHANGELOG.md"), changelog)
546 .await
547 .unwrap();
548
549 let config = Config::default();
550 let validator =
551 VersionConsistencyValidator::new(project_root.to_path_buf(), config).unwrap();
552
553 let result = validator.validate().await.unwrap();
554 assert!(result.changelog_status.follows_keep_a_changelog);
555 assert!(result.changelog_status.version_documented);
556 }
557}