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 let root = self.project_root.clone();
352 let exclusions = self.exclusions.clone();
353
354 let rs_paths: Vec<PathBuf> = tokio::task::spawn_blocking(move || {
357 WalkDir::new(&root)
358 .into_iter()
359 .filter_map(|e| e.ok())
360 .filter(|e| {
361 let p = e.path();
362 if exclusions.iter().any(|ex| p.starts_with(ex)) {
363 return false;
364 }
365 let s = p.to_string_lossy();
366 !(s.contains("/tests/")
367 || s.contains("/test/")
368 || s.contains("/fixtures/")
369 || s.contains("/examples/"))
370 && p.extension().is_some_and(|ext| ext == "rs")
371 })
372 .map(|e| e.path().to_path_buf())
373 .collect()
374 })
375 .await
376 .map_err(|e| Error::process(format!("Task join error: {}", e)))?;
377
378 for path in rs_paths {
379 self.check_file(&path, violations).await?;
380 }
381 Ok(())
382 }
383
384 async fn check_file(&self, path: &Path, violations: &mut Vec<Violation>) -> Result<()> {
386 let content = tokio::fs::read_to_string(path)
387 .await
388 .map_err(|e| Error::io(format!("Failed to read {}: {}", path.display(), e)))?;
389
390 for (line_num, line) in content.lines().enumerate() {
391 let trimmed = line.trim();
393 if trimmed.starts_with("//") && !trimmed.starts_with("///") {
394 continue;
395 }
396
397 if let Some(captures) = self.version_regex.captures(line)
399 && let Some(version_match) = captures.get(1)
400 {
401 let found_version = version_match.as_str();
402
403 if found_version == self.source_version {
405 if !line.contains("env!(\"CARGO_PKG_VERSION\")")
407 && !line.contains("CARGO_PKG_VERSION")
408 && !line.contains("clap::crate_version!")
409 {
410 violations.push(Violation {
411 violation_type: ViolationType::HardcodedVersion,
412 file: path.to_path_buf(),
413 line: line_num + 1,
414 message: format!(
415 "Hardcoded version '{}' found. Use env!(\"CARGO_PKG_VERSION\") or clap::crate_version!() for SSoT.",
416 found_version
417 ),
418 severity: Severity::Error,
419 });
420 }
421 }
422 }
423 }
424
425 Ok(())
426 }
427
428 async fn validate_changelog(&self) -> Result<ChangelogStatus> {
430 let changelog_path = self.project_root.join("CHANGELOG.md");
431
432 if !changelog_path.exists() {
433 return Ok(ChangelogStatus {
434 exists: false,
435 version_documented: false,
436 follows_keep_a_changelog: false,
437 missing_sections: vec![],
438 });
439 }
440
441 let content = tokio::fs::read_to_string(&changelog_path)
442 .await
443 .map_err(|e| Error::io(format!("Failed to read CHANGELOG.md: {}", e)))?;
444
445 let content_lower = content.to_lowercase();
446
447 let has_keep_a_changelog_format = content.contains("## [Unreleased]")
449 || content_lower.contains("all notable changes")
450 || content.contains("Keep a Changelog");
451
452 let version_documented = content.contains(&format!("[{}]", self.source_version))
454 || content.contains(&format!("## {}", self.source_version));
455
456 let mut missing_sections = Vec::new();
458 for section in &self.changelog_requirements.required_sections {
459 let section_lower = section.to_lowercase();
460 if !content_lower.contains(&format!("### {}", section_lower))
461 && !content_lower.contains(&format!("## {}", section_lower))
462 {
463 missing_sections.push(section.clone());
464 }
465 }
466
467 Ok(ChangelogStatus {
468 exists: true,
469 version_documented,
470 follows_keep_a_changelog: has_keep_a_changelog_format,
471 missing_sections,
472 })
473 }
474
475 async fn is_tagging_scenario(&self) -> Result<bool> {
477 if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
479 return Ok(true);
480 }
481
482 let output = tokio::process::Command::new("git")
484 .args(["describe", "--exact-match", "--tags", "HEAD"])
485 .current_dir(&self.project_root)
486 .output()
487 .await;
488
489 if let Ok(output) = output
490 && output.status.success()
491 {
492 return Ok(true);
493 }
494
495 Ok(false)
496 }
497
498 fn empty_result(&self) -> VersionValidationResult {
500 VersionValidationResult {
501 passed: true,
502 violations: vec![],
503 source_version: self.source_version.clone(),
504 version_format: self.version_format,
505 changelog_status: ChangelogStatus {
506 exists: false,
507 version_documented: false,
508 follows_keep_a_changelog: false,
509 missing_sections: vec![],
510 },
511 }
512 }
513
514 pub fn source_version(&self) -> &str {
516 &self.source_version
517 }
518
519 pub fn version_format(&self) -> VersionFormat {
521 self.version_format
522 }
523}
524
525#[cfg(test)]
526#[allow(clippy::unwrap_used)]
527mod tests {
528 use super::*;
529 use tempfile::TempDir;
530 use tokio::fs;
531
532 #[tokio::test]
533 async fn test_detects_semver() {
534 assert_eq!(
535 VersionConsistencyValidator::detect_version_format("1.2.3"),
536 VersionFormat::SemVer
537 );
538 assert_eq!(
539 VersionConsistencyValidator::detect_version_format("1.2.3-alpha"),
540 VersionFormat::SemVer
541 );
542 }
543
544 #[tokio::test]
545 async fn test_detects_calver() {
546 assert_eq!(
547 VersionConsistencyValidator::detect_version_format("2025.03.21"),
548 VersionFormat::CalVer
549 );
550 assert_eq!(
551 VersionConsistencyValidator::detect_version_format("2025.3"),
552 VersionFormat::CalVer
553 );
554 }
555
556 #[tokio::test]
557 async fn test_validates_changelog_format() {
558 let temp_dir = TempDir::new().unwrap();
559 let project_root = temp_dir.path();
560
561 let cargo_toml = r#"
563[package]
564name = "test-project"
565version = "1.2.3"
566edition = "2021"
567"#;
568 fs::write(project_root.join("Cargo.toml"), cargo_toml)
569 .await
570 .unwrap();
571
572 let changelog = r#"# Changelog
574
575All notable changes to this project will be documented in this file.
576
577The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
578and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
579
580## [Unreleased]
581
582## [1.2.3] - 2025-03-21
583
584### Added
585- New feature X
586
587### Fixed
588- Bug Y
589"#;
590 fs::write(project_root.join("CHANGELOG.md"), changelog)
591 .await
592 .unwrap();
593
594 let config = Config::default();
595 let validator =
596 VersionConsistencyValidator::new(project_root.to_path_buf(), config).unwrap();
597
598 let result = validator.validate().await.unwrap();
599 assert!(result.changelog_status.follows_keep_a_changelog);
600 assert!(result.changelog_status.version_documented);
601 }
602
603 #[tokio::test]
604 async fn test_extract_version_with_workspace_inheritance_dotted() {
605 let temp_dir = TempDir::new().unwrap();
609 let root = temp_dir.path();
610
611 fs::write(
612 root.join("Cargo.toml"),
613 r#"
614[workspace]
615members = ["child"]
616
617[workspace.package]
618version = "1.2.3"
619edition = "2024"
620"#,
621 )
622 .await
623 .unwrap();
624
625 let child = root.join("child");
626 fs::create_dir_all(&child).await.unwrap();
627 fs::write(
628 child.join("Cargo.toml"),
629 r#"
630[package]
631name = "child"
632version.workspace = true
633edition.workspace = true
634"#,
635 )
636 .await
637 .unwrap();
638
639 let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
640 assert_eq!(version, "1.2.3");
641 }
642
643 #[tokio::test]
644 async fn test_extract_version_with_workspace_inheritance_inline() {
645 let temp_dir = TempDir::new().unwrap();
646 let root = temp_dir.path();
647
648 fs::write(
649 root.join("Cargo.toml"),
650 r#"
651[workspace]
652members = ["child"]
653
654[workspace.package]
655version = "2.0.0"
656"#,
657 )
658 .await
659 .unwrap();
660
661 let child = root.join("child");
662 fs::create_dir_all(&child).await.unwrap();
663 fs::write(
664 child.join("Cargo.toml"),
665 r#"
666[package]
667name = "child"
668version = { workspace = true }
669"#,
670 )
671 .await
672 .unwrap();
673
674 let version = VersionConsistencyValidator::extract_version_from_cargo(&child).unwrap();
675 assert_eq!(version, "2.0.0");
676 }
677
678 #[tokio::test]
679 async fn test_extract_version_from_virtual_workspace_root() {
680 let temp_dir = TempDir::new().unwrap();
683 let root = temp_dir.path();
684
685 fs::write(
686 root.join("Cargo.toml"),
687 r#"
688[workspace]
689members = ["a", "b"]
690
691[workspace.package]
692version = "0.9.0"
693edition = "2024"
694"#,
695 )
696 .await
697 .unwrap();
698
699 let version = VersionConsistencyValidator::extract_version_from_cargo(root).unwrap();
700 assert_eq!(version, "0.9.0");
701 }
702}