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("CHANGELOG"));
129
130 exclusions.insert(project_root.join("docs"));
132 exclusions.insert(project_root.join(".github"));
133 exclusions.insert(project_root.join("packaging"));
134 exclusions.insert(project_root.join("target"));
135 exclusions.insert(project_root.join("node_modules"));
136 exclusions.insert(project_root.join(".git"));
137 exclusions.insert(project_root.join(".claude"));
138 exclusions.insert(project_root.join("dist"));
139 exclusions.insert(project_root.join("build"));
140 exclusions.insert(project_root.join(".next"));
141 exclusions.insert(project_root.join(".turbo"));
142 exclusions.insert(project_root.join("vendor"));
143 exclusions.insert(project_root.join("__pycache__"));
144 exclusions.insert(project_root.join(".venv"));
145
146 if let Some(user_exclusions) = config.validation.version_check_exclusions.as_ref() {
148 for exclusion in user_exclusions {
149 exclusions.insert(project_root.join(exclusion));
150 }
151 }
152
153 let changelog_requirements = ChangelogRequirements {
155 enforce_keep_a_changelog: config.validation.enforce_keep_a_changelog.unwrap_or(true),
156 require_version_entry: config.validation.require_changelog_entry.unwrap_or(true),
157 check_on_tag: config.validation.check_changelog_on_tag.unwrap_or(true),
158 required_sections: config
159 .validation
160 .changelog_required_sections
161 .clone()
162 .unwrap_or_else(|| {
163 vec![
164 "Added".to_string(),
165 "Changed".to_string(),
166 "Fixed".to_string(),
167 ]
168 }),
169 };
170
171 Ok(Self {
172 project_root,
173 source_version,
174 version_format,
175 version_regex,
176 exclusions,
177 config,
178 changelog_requirements,
179 })
180 }
181
182 fn detect_version_format(version: &str) -> VersionFormat {
184 if Regex::new(r"^\d{4}\.")
186 .ok()
187 .is_some_and(|re| re.is_match(version))
188 {
189 VersionFormat::CalVer
190 } else {
191 VersionFormat::SemVer
192 }
193 }
194
195 fn extract_version_from_cargo(project_root: &Path) -> Result<String> {
203 let cargo_path = project_root.join("Cargo.toml");
204 let content = std::fs::read_to_string(&cargo_path)
205 .map_err(|e| Error::io(format!("Failed to read Cargo.toml: {}", e)))?;
206
207 let parsed: toml::Value = toml::from_str(&content)
208 .map_err(|e| Error::validation(format!("Failed to parse Cargo.toml: {}", e)))?;
209
210 if let Some(version) = parsed.get("package").and_then(|p| p.get("version")) {
212 if let Some(s) = version.as_str()
213 && Self::is_valid_version(s)
214 {
215 return Ok(s.to_string());
216 }
217 let is_inherited = version
219 .as_table()
220 .and_then(|t| t.get("workspace"))
221 .and_then(toml::Value::as_bool)
222 == Some(true);
223 if is_inherited {
224 return Self::resolve_workspace_version(project_root);
225 }
226 }
227
228 if let Some(s) = parsed
230 .get("workspace")
231 .and_then(|w| w.get("package"))
232 .and_then(|p| p.get("version"))
233 .and_then(toml::Value::as_str)
234 && Self::is_valid_version(s)
235 {
236 return Ok(s.to_string());
237 }
238
239 Err(Error::validation(
240 "Could not parse version from Cargo.toml".to_string(),
241 ))
242 }
243
244 fn resolve_workspace_version(start: &Path) -> Result<String> {
248 let mut current: Option<&Path> = Some(start);
249 while let Some(dir) = current {
250 let cargo = dir.join("Cargo.toml");
251 if cargo.exists()
252 && let Ok(content) = std::fs::read_to_string(&cargo)
253 && let Ok(parsed) = toml::from_str::<toml::Value>(&content)
254 && let Some(s) = parsed
255 .get("workspace")
256 .and_then(|w| w.get("package"))
257 .and_then(|p| p.get("version"))
258 .and_then(toml::Value::as_str)
259 && Self::is_valid_version(s)
260 {
261 return Ok(s.to_string());
262 }
263 current = dir.parent();
264 }
265 Err(Error::validation(
266 "Could not resolve workspace-inherited version: no workspace root with [workspace.package].version found".to_string(),
267 ))
268 }
269
270 fn is_valid_version(version: &str) -> bool {
272 let semver_ok = Regex::new(r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$")
274 .ok()
275 .is_some_and(|re| re.is_match(version));
276
277 let calver_ok = Regex::new(r"^\d{4}(?:\.\d{1,2}){1,2}(?:-[a-zA-Z0-9.-]+)?$")
279 .ok()
280 .is_some_and(|re| re.is_match(version));
281
282 semver_ok || calver_ok
283 }
284
285 pub async fn validate(&self) -> Result<VersionValidationResult> {
291 let mut violations = Vec::new();
292
293 if !self
295 .config
296 .validation
297 .check_version_consistency
298 .unwrap_or(true)
299 {
300 return Ok(self.empty_result());
301 }
302
303 self.check_hardcoded_versions(&mut violations).await?;
305
306 let changelog_status = self.validate_changelog().await?;
308
309 if self.changelog_requirements.require_version_entry && !changelog_status.version_documented
311 {
312 violations.push(Violation {
313 violation_type: ViolationType::MissingChangelogEntry,
314 file: self.project_root.join("CHANGELOG.md"),
315 line: 1,
316 message: format!(
317 "Version {} is not documented in CHANGELOG.md. Add entry following Keep a Changelog format.",
318 self.source_version
319 ),
320 severity: Severity::Error,
321 });
322 }
323
324 if self.changelog_requirements.enforce_keep_a_changelog
325 && !changelog_status.follows_keep_a_changelog
326 {
327 violations.push(Violation {
328 violation_type: ViolationType::InvalidChangelogFormat,
329 file: self.project_root.join("CHANGELOG.md"),
330 line: 1,
331 message: "CHANGELOG.md does not follow Keep a Changelog format. See https://keepachangelog.com/".to_string(),
332 severity: Severity::Warning,
333 });
334 }
335
336 if self.is_tagging_scenario().await?
338 && self.changelog_requirements.check_on_tag
339 && !changelog_status.version_documented
340 {
341 violations.push(Violation {
342 violation_type: ViolationType::MissingChangelogEntry,
343 file: self.project_root.join("CHANGELOG.md"),
344 line: 1,
345 message: format!(
346 "Cannot create tag for version {}: No changelog entry found. Document changes before tagging.",
347 self.source_version
348 ),
349 severity: Severity::Error,
350 });
351 }
352
353 Ok(VersionValidationResult {
354 passed: violations.is_empty(),
355 violations,
356 source_version: self.source_version.clone(),
357 version_format: self.version_format,
358 changelog_status,
359 })
360 }
361
362 async fn check_hardcoded_versions(&self, violations: &mut Vec<Violation>) -> Result<()> {
364 let root = self.project_root.clone();
365 let exclusions = self.exclusions.clone();
366
367 const WALK_SKIP_DIRS: &[&str] = &[
371 "target",
372 "node_modules",
373 ".git",
374 ".claude",
375 ".next",
376 "dist",
377 "build",
378 ".turbo",
379 ".pnpm",
380 ".yarn",
381 "__pycache__",
382 ".venv",
383 "vendor",
384 ];
385
386 let rs_paths: Vec<PathBuf> = tokio::task::spawn_blocking(move || {
387 let walker = WalkDir::new(&root).into_iter();
388 let mut paths = Vec::new();
389 for entry in walker.filter_entry(|e| {
390 if e.file_type().is_dir()
392 && e.file_name()
393 .to_str()
394 .is_some_and(|name| WALK_SKIP_DIRS.contains(&name))
395 {
396 return false;
397 }
398 true
399 }) {
400 let Ok(e) = entry else { continue };
401 let p = e.path();
402 if exclusions.iter().any(|ex| p.starts_with(ex)) {
403 continue;
404 }
405 let s = p.to_string_lossy();
406 if s.contains("/tests/")
407 || s.contains("/test/")
408 || s.contains("/fixtures/")
409 || s.contains("/examples/")
410 {
411 continue;
412 }
413 if p.extension().is_some_and(|ext| ext == "rs") {
414 paths.push(p.to_path_buf());
415 }
416 }
417 paths
418 })
419 .await
420 .map_err(|e| Error::process(format!("Task join error: {}", e)))?;
421
422 for path in rs_paths {
423 self.check_file(&path, violations).await?;
424 }
425 Ok(())
426 }
427
428 async fn check_file(&self, path: &Path, violations: &mut Vec<Violation>) -> Result<()> {
430 let content = tokio::fs::read_to_string(path)
431 .await
432 .map_err(|e| Error::io(format!("Failed to read {}: {}", path.display(), e)))?;
433
434 for (line_num, line) in content.lines().enumerate() {
435 let trimmed = line.trim();
437 if trimmed.starts_with("//") && !trimmed.starts_with("///") {
438 continue;
439 }
440
441 if let Some(captures) = self.version_regex.captures(line)
443 && let Some(version_match) = captures.get(1)
444 {
445 let found_version = version_match.as_str();
446
447 if found_version == self.source_version {
449 if !line.contains("env!(\"CARGO_PKG_VERSION\")")
451 && !line.contains("CARGO_PKG_VERSION")
452 && !line.contains("clap::crate_version!")
453 {
454 violations.push(Violation {
455 violation_type: ViolationType::HardcodedVersion,
456 file: path.to_path_buf(),
457 line: line_num + 1,
458 message: format!(
459 "Hardcoded version '{}' found. Use env!(\"CARGO_PKG_VERSION\") or clap::crate_version!() for SSoT.",
460 found_version
461 ),
462 severity: Severity::Error,
463 });
464 }
465 }
466 }
467 }
468
469 Ok(())
470 }
471
472 async fn validate_changelog(&self) -> Result<ChangelogStatus> {
474 let changelog_path = self.project_root.join("CHANGELOG.md");
475
476 if !changelog_path.exists() {
477 return Ok(ChangelogStatus {
478 exists: false,
479 version_documented: false,
480 follows_keep_a_changelog: false,
481 missing_sections: vec![],
482 });
483 }
484
485 let content = tokio::fs::read_to_string(&changelog_path)
486 .await
487 .map_err(|e| Error::io(format!("Failed to read CHANGELOG.md: {}", e)))?;
488
489 let content_lower = content.to_lowercase();
490
491 let has_keep_a_changelog_format = content.contains("## [Unreleased]")
493 || content_lower.contains("all notable changes")
494 || content.contains("Keep a Changelog");
495
496 let version_documented = content.contains(&format!("[{}]", self.source_version))
498 || content.contains(&format!("## {}", self.source_version));
499
500 let mut missing_sections = Vec::new();
502 for section in &self.changelog_requirements.required_sections {
503 let section_lower = section.to_lowercase();
504 if !content_lower.contains(&format!("### {}", section_lower))
505 && !content_lower.contains(&format!("## {}", section_lower))
506 {
507 missing_sections.push(section.clone());
508 }
509 }
510
511 Ok(ChangelogStatus {
512 exists: true,
513 version_documented,
514 follows_keep_a_changelog: has_keep_a_changelog_format,
515 missing_sections,
516 })
517 }
518
519 async fn is_tagging_scenario(&self) -> Result<bool> {
521 if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
523 return Ok(true);
524 }
525
526 let output = tokio::process::Command::new("git")
528 .args(["describe", "--exact-match", "--tags", "HEAD"])
529 .current_dir(&self.project_root)
530 .output()
531 .await;
532
533 if let Ok(output) = output
534 && output.status.success()
535 {
536 return Ok(true);
537 }
538
539 Ok(false)
540 }
541
542 fn empty_result(&self) -> VersionValidationResult {
544 VersionValidationResult {
545 passed: true,
546 violations: vec![],
547 source_version: self.source_version.clone(),
548 version_format: self.version_format,
549 changelog_status: ChangelogStatus {
550 exists: false,
551 version_documented: false,
552 follows_keep_a_changelog: false,
553 missing_sections: vec![],
554 },
555 }
556 }
557
558 pub fn source_version(&self) -> &str {
560 &self.source_version
561 }
562
563 pub fn version_format(&self) -> VersionFormat {
565 self.version_format
566 }
567}
568
569#[cfg(test)]
570#[allow(clippy::unwrap_used)]
571mod tests {
572 use super::*;
573 use tempfile::TempDir;
574 use tokio::fs;
575
576 #[tokio::test]
577 async fn test_detects_semver() {
578 assert_eq!(
579 VersionConsistencyValidator::detect_version_format("1.2.3"),
580 VersionFormat::SemVer
581 );
582 assert_eq!(
583 VersionConsistencyValidator::detect_version_format("1.2.3-alpha"),
584 VersionFormat::SemVer
585 );
586 }
587
588 #[tokio::test]
589 async fn test_detects_calver() {
590 assert_eq!(
591 VersionConsistencyValidator::detect_version_format("2025.03.21"),
592 VersionFormat::CalVer
593 );
594 assert_eq!(
595 VersionConsistencyValidator::detect_version_format("2025.3"),
596 VersionFormat::CalVer
597 );
598 }
599
600 #[tokio::test]
601 async fn test_validates_changelog_format() {
602 let temp_dir = TempDir::new().unwrap();
603 let project_root = temp_dir.path();
604
605 let cargo_toml = r#"
607[package]
608name = "test-project"
609version = "1.2.3"
610edition = "2021"
611"#;
612 fs::write(project_root.join("Cargo.toml"), cargo_toml)
613 .await
614 .unwrap();
615
616 let changelog = r#"# Changelog
618
619All notable changes to this project will be documented in this file.
620
621The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
622and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
623
624## [Unreleased]
625
626## [1.2.3] - 2025-03-21
627
628### Added
629- New feature X
630
631### Fixed
632- Bug Y
633"#;
634 fs::write(project_root.join("CHANGELOG.md"), changelog)
635 .await
636 .unwrap();
637
638 let config = Config::default();
639 let validator =
640 VersionConsistencyValidator::new(project_root.to_path_buf(), config).unwrap();
641
642 let result = validator.validate().await.unwrap();
643 assert!(result.changelog_status.follows_keep_a_changelog);
644 assert!(result.changelog_status.version_documented);
645 }
646
647 #[tokio::test]
648 async fn test_extract_version_with_workspace_inheritance_dotted() {
649 let temp_dir = TempDir::new().unwrap();
653 let root = temp_dir.path();
654
655 fs::write(
656 root.join("Cargo.toml"),
657 r#"
658[workspace]
659members = ["child"]
660
661[workspace.package]
662version = "1.2.3"
663edition = "2024"
664"#,
665 )
666 .await
667 .unwrap();
668
669 let child = root.join("child");
670 fs::create_dir_all(&child).await.unwrap();
671 fs::write(
672 child.join("Cargo.toml"),
673 r#"
674[package]
675name = "child"
676version.workspace = true
677edition.workspace = true
678"#,
679 )
680 .await
681 .unwrap();
682
683 let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
684 assert_eq!(version, "1.2.3");
685 }
686
687 #[tokio::test]
688 async fn test_extract_version_with_workspace_inheritance_inline() {
689 let temp_dir = TempDir::new().unwrap();
690 let root = temp_dir.path();
691
692 fs::write(
693 root.join("Cargo.toml"),
694 r#"
695[workspace]
696members = ["child"]
697
698[workspace.package]
699version = "2.0.0"
700"#,
701 )
702 .await
703 .unwrap();
704
705 let child = root.join("child");
706 fs::create_dir_all(&child).await.unwrap();
707 fs::write(
708 child.join("Cargo.toml"),
709 r#"
710[package]
711name = "child"
712version = { workspace = true }
713"#,
714 )
715 .await
716 .unwrap();
717
718 let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
719 assert_eq!(version, "2.0.0");
720 }
721
722 #[tokio::test]
723 async fn test_extract_version_from_virtual_workspace_root() {
724 let temp_dir = TempDir::new().unwrap();
727 let root = temp_dir.path();
728
729 fs::write(
730 root.join("Cargo.toml"),
731 r#"
732[workspace]
733members = ["a", "b"]
734
735[workspace.package]
736version = "0.9.0"
737edition = "2024"
738"#,
739 )
740 .await
741 .unwrap();
742
743 let version = VersionConsistencyValidator::extract_version_from_cargo(root).unwrap();
744 assert_eq!(version, "0.9.0");
745 }
746}