Skip to main content

ferrous_forge/validation/
version_consistency.rs

1//! Version and changelog consistency validation
2//!
3//! Enforces Single Source of Truth for version numbers and changelog maintenance.
4//! Supports both SemVer and CalVer version formats.
5
6use 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/// Version format type
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum VersionFormat {
17    /// Semantic Versioning (e.g., 1.2.3)
18    SemVer,
19    /// Calendar Versioning (e.g., 2025.03.21 or 2025.3)
20    CalVer,
21}
22
23/// Changelog validation requirements
24#[derive(Debug, Clone)]
25pub struct ChangelogRequirements {
26    /// Whether to enforce Keep a Changelog format
27    pub enforce_keep_a_changelog: bool,
28    /// Whether to require changelog entry for current version
29    pub require_version_entry: bool,
30    /// Whether to validate on git tag creation
31    pub check_on_tag: bool,
32    /// Required sections in changelog (e.g., ["Added", "Changed", "Fixed"])
33    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
51/// Validator for version consistency and changelog maintenance
52pub struct VersionConsistencyValidator {
53    /// Root directory of the project
54    project_root: PathBuf,
55    /// Version from Cargo.toml (`SSoT`)
56    source_version: String,
57    /// Detected version format
58    version_format: VersionFormat,
59    /// Regex to match version strings in code
60    version_regex: Regex,
61    /// Files/directories to exclude from checking
62    exclusions: HashSet<PathBuf>,
63    /// Config for validation settings
64    config: Config,
65    /// Changelog requirements
66    changelog_requirements: ChangelogRequirements,
67}
68
69/// Result of version validation
70#[derive(Debug, Clone)]
71pub struct VersionValidationResult {
72    /// Whether validation passed
73    pub passed: bool,
74    /// List of violations found
75    pub violations: Vec<Violation>,
76    /// The source version from Cargo.toml
77    pub source_version: String,
78    /// Detected version format
79    pub version_format: VersionFormat,
80    /// Changelog status
81    pub changelog_status: ChangelogStatus,
82}
83
84/// Status of changelog validation
85#[derive(Debug, Clone)]
86pub struct ChangelogStatus {
87    /// Whether changelog exists
88    pub exists: bool,
89    /// Whether current version is documented
90    pub version_documented: bool,
91    /// Whether Keep a Changelog format is followed
92    pub follows_keep_a_changelog: bool,
93    /// Missing required sections
94    pub missing_sections: Vec<String>,
95}
96
97impl VersionConsistencyValidator {
98    /// Create a new version consistency validator
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if:
103    /// - Cargo.toml cannot be read
104    /// - Version cannot be parsed from Cargo.toml
105    /// - Regex compilation fails
106    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        // Build version regex based on format
111        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        // Default exclusions
124        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        // Add configured exclusions
134        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        // Get changelog requirements from config
141        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    /// Detect version format from version string
170    fn detect_version_format(version: &str) -> VersionFormat {
171        // CalVer typically starts with 4-digit year
172        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    /// Extract version from Cargo.toml (`SSoT`).
183    ///
184    /// Handles the full set of forms cargo supports:
185    /// - `[package].version = "1.2.3"`
186    /// - `[package].version.workspace = true` (walks up to the workspace root)
187    /// - `[package].version = { workspace = true }` (inline table form)
188    /// - Virtual manifests with `[workspace.package].version`
189    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        // 1. [package].version — literal string
198        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            // 2. [package].version = { workspace = true } or version.workspace = true
205            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        // 3. Virtual manifest with [workspace.package].version defined here
216        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    /// Walk up from `start` looking for a Cargo.toml whose `[workspace.package]`
232    /// table defines a concrete `version`. This resolves workspace inheritance
233    /// for members that declare `version.workspace = true`.
234    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    /// Check if string is valid version (`SemVer` or `CalVer`)
258    fn is_valid_version(version: &str) -> bool {
259        // SemVer: x.y.z with optional pre-release and build metadata
260        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        // CalVer: YYYY.MM.DD or YYYY.M.D or YYYY.MM
265        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    /// Validate version consistency across the codebase
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if the project files cannot be read or analyzed.
277    pub async fn validate(&self) -> Result<VersionValidationResult> {
278        let mut violations = Vec::new();
279
280        // Check if version consistency checking is enabled
281        if !self
282            .config
283            .validation
284            .check_version_consistency
285            .unwrap_or(true)
286        {
287            return Ok(self.empty_result());
288        }
289
290        // Check for hardcoded versions in code
291        self.check_hardcoded_versions(&mut violations).await?;
292
293        // Check changelog
294        let changelog_status = self.validate_changelog().await?;
295
296        // Add changelog violations
297        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        // Check if we're creating a tag (via git hook or CI)
324        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    /// Check for hardcoded versions in source files
350    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        // Collect file paths in a blocking context to avoid stalling the async
355        // runtime (WalkDir is synchronous and holds directory handles).
356        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    /// Check a single file for hardcoded versions
385    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            // Skip comments (but not doc comments which might need versions)
392            let trimmed = line.trim();
393            if trimmed.starts_with("//") && !trimmed.starts_with("///") {
394                continue;
395            }
396
397            // Check for hardcoded version
398            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 version matches Cargo.toml, check if it's properly sourced
404                if found_version == self.source_version {
405                    // Allow env! macro and CARGO_PKG_VERSION
406                    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    /// Validate changelog format and content
429    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        // Check for Keep a Changelog markers
448        let has_keep_a_changelog_format = content.contains("## [Unreleased]")
449            || content_lower.contains("all notable changes")
450            || content.contains("Keep a Changelog");
451
452        // Check if current version is documented
453        let version_documented = content.contains(&format!("[{}]", self.source_version))
454            || content.contains(&format!("## {}", self.source_version));
455
456        // Check for required sections
457        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    /// Check if we're in a tagging scenario (creating a git tag)
476    async fn is_tagging_scenario(&self) -> Result<bool> {
477        // Check for tag-related environment variables (from CI or hooks)
478        if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
479            return Ok(true);
480        }
481
482        // Check if HEAD is being tagged
483        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    /// Create empty result for when validation is disabled
499    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    /// Get the source version
515    pub fn source_version(&self) -> &str {
516        &self.source_version
517    }
518
519    /// Get detected version format
520    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        // Create Cargo.toml
562        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        // Create proper Keep a Changelog format
573        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        // Virtual workspace root + a member that uses `version.workspace = true`.
606        // Running the validator against the member directory must walk up to the
607        // workspace root and resolve the version from [workspace.package].
608        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        // A pure virtual workspace (no [package]) should resolve its own
681        // [workspace.package].version.
682        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}