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        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    /// Check a single file for hardcoded versions
369    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            // Skip comments (but not doc comments which might need versions)
376            let trimmed = line.trim();
377            if trimmed.starts_with("//") && !trimmed.starts_with("///") {
378                continue;
379            }
380
381            // Check for hardcoded version
382            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 version matches Cargo.toml, check if it's properly sourced
388                if found_version == self.source_version {
389                    // Allow env! macro and CARGO_PKG_VERSION
390                    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    /// Validate changelog format and content
413    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        // Check for Keep a Changelog markers
432        let has_keep_a_changelog_format = content.contains("## [Unreleased]")
433            || content_lower.contains("all notable changes")
434            || content.contains("Keep a Changelog");
435
436        // Check if current version is documented
437        let version_documented = content.contains(&format!("[{}]", self.source_version))
438            || content.contains(&format!("## {}", self.source_version));
439
440        // Check for required sections
441        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    /// Check if we're in a tagging scenario (creating a git tag)
460    async fn is_tagging_scenario(&self) -> Result<bool> {
461        // Check for tag-related environment variables (from CI or hooks)
462        if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
463            return Ok(true);
464        }
465
466        // Check if HEAD is being tagged
467        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    /// Check if a path is excluded from validation
483    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    /// Create empty result for when validation is disabled
503    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    /// Get the source version
519    pub fn source_version(&self) -> &str {
520        &self.source_version
521    }
522
523    /// Get detected version format
524    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        // Create Cargo.toml
566        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        // Create proper Keep a Changelog format
577        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        // Virtual workspace root + a member that uses `version.workspace = true`.
610        // Running the validator against the member directory must walk up to the
611        // workspace root and resolve the version from [workspace.package].
612        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        // A pure virtual workspace (no [package]) should resolve its own
685        // [workspace.package].version.
686        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}