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> {
190 let cargo_path = project_root.join("Cargo.toml");
191 let content = std::fs::read_to_string(&cargo_path)
192 .map_err(|e| Error::io(format!("Failed to read Cargo.toml: {}", e)))?;
193
194 let parsed: toml::Value = toml::from_str(&content)
195 .map_err(|e| Error::validation(format!("Failed to parse Cargo.toml: {}", e)))?;
196
197 if let Some(version) = parsed.get("package").and_then(|p| p.get("version")) {
199 if let Some(s) = version.as_str()
200 && Self::is_valid_version(s)
201 {
202 return Ok(s.to_string());
203 }
204 let is_inherited = version
206 .as_table()
207 .and_then(|t| t.get("workspace"))
208 .and_then(toml::Value::as_bool)
209 == Some(true);
210 if is_inherited {
211 return Self::resolve_workspace_version(project_root);
212 }
213 }
214
215 if let Some(s) = parsed
217 .get("workspace")
218 .and_then(|w| w.get("package"))
219 .and_then(|p| p.get("version"))
220 .and_then(toml::Value::as_str)
221 && Self::is_valid_version(s)
222 {
223 return Ok(s.to_string());
224 }
225
226 Err(Error::validation(
227 "Could not parse version from Cargo.toml".to_string(),
228 ))
229 }
230
231 fn resolve_workspace_version(start: &Path) -> Result<String> {
235 let mut current: Option<&Path> = Some(start);
236 while let Some(dir) = current {
237 let cargo = dir.join("Cargo.toml");
238 if cargo.exists()
239 && let Ok(content) = std::fs::read_to_string(&cargo)
240 && let Ok(parsed) = toml::from_str::<toml::Value>(&content)
241 && let Some(s) = parsed
242 .get("workspace")
243 .and_then(|w| w.get("package"))
244 .and_then(|p| p.get("version"))
245 .and_then(toml::Value::as_str)
246 && Self::is_valid_version(s)
247 {
248 return Ok(s.to_string());
249 }
250 current = dir.parent();
251 }
252 Err(Error::validation(
253 "Could not resolve workspace-inherited version: no workspace root with [workspace.package].version found".to_string(),
254 ))
255 }
256
257 fn is_valid_version(version: &str) -> bool {
259 let semver_ok = Regex::new(r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$")
261 .ok()
262 .is_some_and(|re| re.is_match(version));
263
264 let calver_ok = Regex::new(r"^\d{4}(?:\.\d{1,2}){1,2}(?:-[a-zA-Z0-9.-]+)?$")
266 .ok()
267 .is_some_and(|re| re.is_match(version));
268
269 semver_ok || calver_ok
270 }
271
272 pub async fn validate(&self) -> Result<VersionValidationResult> {
278 let mut violations = Vec::new();
279
280 if !self
282 .config
283 .validation
284 .check_version_consistency
285 .unwrap_or(true)
286 {
287 return Ok(self.empty_result());
288 }
289
290 self.check_hardcoded_versions(&mut violations).await?;
292
293 let changelog_status = self.validate_changelog().await?;
295
296 if self.changelog_requirements.require_version_entry && !changelog_status.version_documented
298 {
299 violations.push(Violation {
300 violation_type: ViolationType::MissingChangelogEntry,
301 file: self.project_root.join("CHANGELOG.md"),
302 line: 1,
303 message: format!(
304 "Version {} is not documented in CHANGELOG.md. Add entry following Keep a Changelog format.",
305 self.source_version
306 ),
307 severity: Severity::Error,
308 });
309 }
310
311 if self.changelog_requirements.enforce_keep_a_changelog
312 && !changelog_status.follows_keep_a_changelog
313 {
314 violations.push(Violation {
315 violation_type: ViolationType::InvalidChangelogFormat,
316 file: self.project_root.join("CHANGELOG.md"),
317 line: 1,
318 message: "CHANGELOG.md does not follow Keep a Changelog format. See https://keepachangelog.com/".to_string(),
319 severity: Severity::Warning,
320 });
321 }
322
323 if self.is_tagging_scenario().await?
325 && self.changelog_requirements.check_on_tag
326 && !changelog_status.version_documented
327 {
328 violations.push(Violation {
329 violation_type: ViolationType::MissingChangelogEntry,
330 file: self.project_root.join("CHANGELOG.md"),
331 line: 1,
332 message: format!(
333 "Cannot create tag for version {}: No changelog entry found. Document changes before tagging.",
334 self.source_version
335 ),
336 severity: Severity::Error,
337 });
338 }
339
340 Ok(VersionValidationResult {
341 passed: violations.is_empty(),
342 violations,
343 source_version: self.source_version.clone(),
344 version_format: self.version_format,
345 changelog_status,
346 })
347 }
348
349 async fn check_hardcoded_versions(&self, violations: &mut Vec<Violation>) -> Result<()> {
351 for entry in WalkDir::new(&self.project_root)
352 .into_iter()
353 .filter_map(|e| e.ok())
354 {
355 let path = entry.path();
356
357 if self.is_excluded(path) {
358 continue;
359 }
360
361 if path.extension().is_some_and(|ext| ext == "rs") {
362 self.check_file(path, violations).await?;
363 }
364 }
365 Ok(())
366 }
367
368 async fn check_file(&self, path: &Path, violations: &mut Vec<Violation>) -> Result<()> {
370 let content = tokio::fs::read_to_string(path)
371 .await
372 .map_err(|e| Error::io(format!("Failed to read {}: {}", path.display(), e)))?;
373
374 for (line_num, line) in content.lines().enumerate() {
375 let trimmed = line.trim();
377 if trimmed.starts_with("//") && !trimmed.starts_with("///") {
378 continue;
379 }
380
381 if let Some(captures) = self.version_regex.captures(line)
383 && let Some(version_match) = captures.get(1)
384 {
385 let found_version = version_match.as_str();
386
387 if found_version == self.source_version {
389 if !line.contains("env!(\"CARGO_PKG_VERSION\")")
391 && !line.contains("CARGO_PKG_VERSION")
392 && !line.contains("clap::crate_version!")
393 {
394 violations.push(Violation {
395 violation_type: ViolationType::HardcodedVersion,
396 file: path.to_path_buf(),
397 line: line_num + 1,
398 message: format!(
399 "Hardcoded version '{}' found. Use env!(\"CARGO_PKG_VERSION\") or clap::crate_version!() for SSoT.",
400 found_version
401 ),
402 severity: Severity::Error,
403 });
404 }
405 }
406 }
407 }
408
409 Ok(())
410 }
411
412 async fn validate_changelog(&self) -> Result<ChangelogStatus> {
414 let changelog_path = self.project_root.join("CHANGELOG.md");
415
416 if !changelog_path.exists() {
417 return Ok(ChangelogStatus {
418 exists: false,
419 version_documented: false,
420 follows_keep_a_changelog: false,
421 missing_sections: vec![],
422 });
423 }
424
425 let content = tokio::fs::read_to_string(&changelog_path)
426 .await
427 .map_err(|e| Error::io(format!("Failed to read CHANGELOG.md: {}", e)))?;
428
429 let content_lower = content.to_lowercase();
430
431 let has_keep_a_changelog_format = content.contains("## [Unreleased]")
433 || content_lower.contains("all notable changes")
434 || content.contains("Keep a Changelog");
435
436 let version_documented = content.contains(&format!("[{}]", self.source_version))
438 || content.contains(&format!("## {}", self.source_version));
439
440 let mut missing_sections = Vec::new();
442 for section in &self.changelog_requirements.required_sections {
443 let section_lower = section.to_lowercase();
444 if !content_lower.contains(&format!("### {}", section_lower))
445 && !content_lower.contains(&format!("## {}", section_lower))
446 {
447 missing_sections.push(section.clone());
448 }
449 }
450
451 Ok(ChangelogStatus {
452 exists: true,
453 version_documented,
454 follows_keep_a_changelog: has_keep_a_changelog_format,
455 missing_sections,
456 })
457 }
458
459 async fn is_tagging_scenario(&self) -> Result<bool> {
461 if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
463 return Ok(true);
464 }
465
466 let output = tokio::process::Command::new("git")
468 .args(["describe", "--exact-match", "--tags", "HEAD"])
469 .current_dir(&self.project_root)
470 .output()
471 .await;
472
473 if let Ok(output) = output
474 && output.status.success()
475 {
476 return Ok(true);
477 }
478
479 Ok(false)
480 }
481
482 fn is_excluded(&self, path: &Path) -> bool {
484 for exclusion in &self.exclusions {
485 if path.starts_with(exclusion) {
486 return true;
487 }
488 }
489
490 let path_str = path.to_string_lossy();
491 if path_str.contains("/tests/")
492 || path_str.contains("/test/")
493 || path_str.contains("/fixtures/")
494 || path_str.contains("/examples/")
495 {
496 return true;
497 }
498
499 false
500 }
501
502 fn empty_result(&self) -> VersionValidationResult {
504 VersionValidationResult {
505 passed: true,
506 violations: vec![],
507 source_version: self.source_version.clone(),
508 version_format: self.version_format,
509 changelog_status: ChangelogStatus {
510 exists: false,
511 version_documented: false,
512 follows_keep_a_changelog: false,
513 missing_sections: vec![],
514 },
515 }
516 }
517
518 pub fn source_version(&self) -> &str {
520 &self.source_version
521 }
522
523 pub fn version_format(&self) -> VersionFormat {
525 self.version_format
526 }
527}
528
529#[cfg(test)]
530#[allow(clippy::unwrap_used)]
531mod tests {
532 use super::*;
533 use tempfile::TempDir;
534 use tokio::fs;
535
536 #[tokio::test]
537 async fn test_detects_semver() {
538 assert_eq!(
539 VersionConsistencyValidator::detect_version_format("1.2.3"),
540 VersionFormat::SemVer
541 );
542 assert_eq!(
543 VersionConsistencyValidator::detect_version_format("1.2.3-alpha"),
544 VersionFormat::SemVer
545 );
546 }
547
548 #[tokio::test]
549 async fn test_detects_calver() {
550 assert_eq!(
551 VersionConsistencyValidator::detect_version_format("2025.03.21"),
552 VersionFormat::CalVer
553 );
554 assert_eq!(
555 VersionConsistencyValidator::detect_version_format("2025.3"),
556 VersionFormat::CalVer
557 );
558 }
559
560 #[tokio::test]
561 async fn test_validates_changelog_format() {
562 let temp_dir = TempDir::new().unwrap();
563 let project_root = temp_dir.path();
564
565 let cargo_toml = r#"
567[package]
568name = "test-project"
569version = "1.2.3"
570edition = "2021"
571"#;
572 fs::write(project_root.join("Cargo.toml"), cargo_toml)
573 .await
574 .unwrap();
575
576 let changelog = r#"# Changelog
578
579All notable changes to this project will be documented in this file.
580
581The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
582and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
583
584## [Unreleased]
585
586## [1.2.3] - 2025-03-21
587
588### Added
589- New feature X
590
591### Fixed
592- Bug Y
593"#;
594 fs::write(project_root.join("CHANGELOG.md"), changelog)
595 .await
596 .unwrap();
597
598 let config = Config::default();
599 let validator =
600 VersionConsistencyValidator::new(project_root.to_path_buf(), config).unwrap();
601
602 let result = validator.validate().await.unwrap();
603 assert!(result.changelog_status.follows_keep_a_changelog);
604 assert!(result.changelog_status.version_documented);
605 }
606
607 #[tokio::test]
608 async fn test_extract_version_with_workspace_inheritance_dotted() {
609 let temp_dir = TempDir::new().unwrap();
613 let root = temp_dir.path();
614
615 fs::write(
616 root.join("Cargo.toml"),
617 r#"
618[workspace]
619members = ["child"]
620
621[workspace.package]
622version = "1.2.3"
623edition = "2024"
624"#,
625 )
626 .await
627 .unwrap();
628
629 let child = root.join("child");
630 fs::create_dir_all(&child).await.unwrap();
631 fs::write(
632 child.join("Cargo.toml"),
633 r#"
634[package]
635name = "child"
636version.workspace = true
637edition.workspace = true
638"#,
639 )
640 .await
641 .unwrap();
642
643 let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
644 assert_eq!(version, "1.2.3");
645 }
646
647 #[tokio::test]
648 async fn test_extract_version_with_workspace_inheritance_inline() {
649 let temp_dir = TempDir::new().unwrap();
650 let root = temp_dir.path();
651
652 fs::write(
653 root.join("Cargo.toml"),
654 r#"
655[workspace]
656members = ["child"]
657
658[workspace.package]
659version = "2.0.0"
660"#,
661 )
662 .await
663 .unwrap();
664
665 let child = root.join("child");
666 fs::create_dir_all(&child).await.unwrap();
667 fs::write(
668 child.join("Cargo.toml"),
669 r#"
670[package]
671name = "child"
672version = { workspace = true }
673"#,
674 )
675 .await
676 .unwrap();
677
678 let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
679 assert_eq!(version, "2.0.0");
680 }
681
682 #[tokio::test]
683 async fn test_extract_version_from_virtual_workspace_root() {
684 let temp_dir = TempDir::new().unwrap();
687 let root = temp_dir.path();
688
689 fs::write(
690 root.join("Cargo.toml"),
691 r#"
692[workspace]
693members = ["a", "b"]
694
695[workspace.package]
696version = "0.9.0"
697edition = "2024"
698"#,
699 )
700 .await
701 .unwrap();
702
703 let version = VersionConsistencyValidator::extract_version_from_cargo(root).unwrap();
704 assert_eq!(version, "0.9.0");
705 }
706}