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 — files
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("CHANGELOG"));
129
130        // Default exclusions — directories
131        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        // Add configured exclusions
147        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        // Get changelog requirements from config
154        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    /// Detect version format from version string
183    fn detect_version_format(version: &str) -> VersionFormat {
184        // CalVer typically starts with 4-digit year
185        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    /// Extract version from Cargo.toml (`SSoT`).
196    ///
197    /// Handles the full set of forms cargo supports:
198    /// - `[package].version = "1.2.3"`
199    /// - `[package].version.workspace = true` (walks up to the workspace root)
200    /// - `[package].version = { workspace = true }` (inline table form)
201    /// - Virtual manifests with `[workspace.package].version`
202    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        // 1. [package].version — literal string
211        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            // 2. [package].version = { workspace = true } or version.workspace = true
218            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        // 3. Virtual manifest with [workspace.package].version defined here
229        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    /// Walk up from `start` looking for a Cargo.toml whose `[workspace.package]`
245    /// table defines a concrete `version`. This resolves workspace inheritance
246    /// for members that declare `version.workspace = true`.
247    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    /// Check if string is valid version (`SemVer` or `CalVer`)
271    fn is_valid_version(version: &str) -> bool {
272        // SemVer: x.y.z with optional pre-release and build metadata
273        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        // CalVer: YYYY.MM.DD or YYYY.M.D or YYYY.MM
278        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    /// Validate version consistency across the codebase
286    ///
287    /// # Errors
288    ///
289    /// Returns an error if the project files cannot be read or analyzed.
290    pub async fn validate(&self) -> Result<VersionValidationResult> {
291        let mut violations = Vec::new();
292
293        // Check if version consistency checking is enabled
294        if !self
295            .config
296            .validation
297            .check_version_consistency
298            .unwrap_or(true)
299        {
300            return Ok(self.empty_result());
301        }
302
303        // Check for hardcoded versions in code
304        self.check_hardcoded_versions(&mut violations).await?;
305
306        // Check changelog
307        let changelog_status = self.validate_changelog().await?;
308
309        // Add changelog violations
310        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        // Check if we're creating a tag (via git hook or CI)
337        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    /// Check for hardcoded versions in source files
363    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        // Collect file paths in a blocking context to avoid stalling the async
368        // runtime (WalkDir is synchronous and holds directory handles).
369        /// Directory names to skip at any nesting depth.
370        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                // Skip non-Rust directories at any depth
391                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    /// Check a single file for hardcoded versions
429    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            // Skip comments (but not doc comments which might need versions)
436            let trimmed = line.trim();
437            if trimmed.starts_with("//") && !trimmed.starts_with("///") {
438                continue;
439            }
440
441            // Check for hardcoded version
442            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 version matches Cargo.toml, check if it's properly sourced
448                if found_version == self.source_version {
449                    // Allow env! macro and CARGO_PKG_VERSION
450                    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    /// Validate changelog format and content
473    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        // Check for Keep a Changelog markers
492        let has_keep_a_changelog_format = content.contains("## [Unreleased]")
493            || content_lower.contains("all notable changes")
494            || content.contains("Keep a Changelog");
495
496        // Check if current version is documented
497        let version_documented = content.contains(&format!("[{}]", self.source_version))
498            || content.contains(&format!("## {}", self.source_version));
499
500        // Check for required sections
501        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    /// Check if we're in a tagging scenario (creating a git tag)
520    async fn is_tagging_scenario(&self) -> Result<bool> {
521        // Check for tag-related environment variables (from CI or hooks)
522        if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
523            return Ok(true);
524        }
525
526        // Check if HEAD is being tagged
527        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    /// Create empty result for when validation is disabled
543    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    /// Get the source version
559    pub fn source_version(&self) -> &str {
560        &self.source_version
561    }
562
563    /// Get detected version format
564    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        // Create Cargo.toml
606        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        // Create proper Keep a Changelog format
617        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        // Virtual workspace root + a member that uses `version.workspace = true`.
650        // Running the validator against the member directory must walk up to the
651        // workspace root and resolve the version from [workspace.package].
652        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        // A pure virtual workspace (no [package]) should resolve its own
725        // [workspace.package].version.
726        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}