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}\.").unwrap().is_match(version) {
173            VersionFormat::CalVer
174        } else {
175            VersionFormat::SemVer
176        }
177    }
178
179    /// Extract version from Cargo.toml (SSoT)
180    fn extract_version_from_cargo(project_root: &Path) -> Result<String> {
181        let cargo_path = project_root.join("Cargo.toml");
182        let content = std::fs::read_to_string(&cargo_path)
183            .map_err(|e| Error::io(format!("Failed to read Cargo.toml: {}", e)))?;
184
185        // Parse version from Cargo.toml
186        for line in content.lines() {
187            if let Some(version) = line.trim().strip_prefix("version") {
188                if let Some(eq_idx) = version.find('=') {
189                    let version_str = &version[eq_idx + 1..].trim();
190                    // Remove quotes
191                    let version_clean = version_str.trim_matches('"').trim_matches('\'');
192
193                    if Self::is_valid_version(version_clean) {
194                        return Ok(version_clean.to_string());
195                    }
196                }
197            }
198        }
199
200        Err(Error::validation(
201            "Could not parse version from Cargo.toml".to_string(),
202        ))
203    }
204
205    /// Check if string is valid version (SemVer or CalVer)
206    fn is_valid_version(version: &str) -> bool {
207        // SemVer: x.y.z with optional pre-release and build metadata
208        let semver_regex =
209            Regex::new(r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$").unwrap();
210
211        // CalVer: YYYY.MM.DD or YYYY.M.D or YYYY.MM
212        let calver_regex = Regex::new(r"^\d{4}(?:\.\d{1,2}){1,2}(?:-[a-zA-Z0-9.-]+)?$").unwrap();
213
214        semver_regex.is_match(version) || calver_regex.is_match(version)
215    }
216
217    /// Validate version consistency across the codebase
218    pub async fn validate(&self) -> Result<VersionValidationResult> {
219        let mut violations = Vec::new();
220
221        // Check if version consistency checking is enabled
222        if !self
223            .config
224            .validation
225            .check_version_consistency
226            .unwrap_or(true)
227        {
228            return Ok(self.empty_result());
229        }
230
231        // Check for hardcoded versions in code
232        self.check_hardcoded_versions(&mut violations).await?;
233
234        // Check changelog
235        let changelog_status = self.validate_changelog().await?;
236
237        // Add changelog violations
238        if self.changelog_requirements.require_version_entry && !changelog_status.version_documented
239        {
240            violations.push(Violation {
241                violation_type: ViolationType::MissingChangelogEntry,
242                file: self.project_root.join("CHANGELOG.md"),
243                line: 1,
244                message: format!(
245                    "Version {} is not documented in CHANGELOG.md. Add entry following Keep a Changelog format.",
246                    self.source_version
247                ),
248                severity: Severity::Error,
249            });
250        }
251
252        if self.changelog_requirements.enforce_keep_a_changelog
253            && !changelog_status.follows_keep_a_changelog
254        {
255            violations.push(Violation {
256                violation_type: ViolationType::InvalidChangelogFormat,
257                file: self.project_root.join("CHANGELOG.md"),
258                line: 1,
259                message: "CHANGELOG.md does not follow Keep a Changelog format. See https://keepachangelog.com/".to_string(),
260                severity: Severity::Warning,
261            });
262        }
263
264        // Check if we're creating a tag (via git hook or CI)
265        if self.is_tagging_scenario().await? && self.changelog_requirements.check_on_tag {
266            if !changelog_status.version_documented {
267                violations.push(Violation {
268                    violation_type: ViolationType::MissingChangelogEntry,
269                    file: self.project_root.join("CHANGELOG.md"),
270                    line: 1,
271                    message: format!(
272                        "Cannot create tag for version {}: No changelog entry found. Document changes before tagging.",
273                        self.source_version
274                    ),
275                    severity: Severity::Error,
276                });
277            }
278        }
279
280        Ok(VersionValidationResult {
281            passed: violations.is_empty(),
282            violations,
283            source_version: self.source_version.clone(),
284            version_format: self.version_format,
285            changelog_status,
286        })
287    }
288
289    /// Check for hardcoded versions in source files
290    async fn check_hardcoded_versions(&self, violations: &mut Vec<Violation>) -> Result<()> {
291        for entry in WalkDir::new(&self.project_root)
292            .into_iter()
293            .filter_map(|e| e.ok())
294        {
295            let path = entry.path();
296
297            if self.is_excluded(path) {
298                continue;
299            }
300
301            if path.extension().map_or(false, |ext| ext == "rs") {
302                self.check_file(path, violations).await?;
303            }
304        }
305        Ok(())
306    }
307
308    /// Check a single file for hardcoded versions
309    async fn check_file(&self, path: &Path, violations: &mut Vec<Violation>) -> Result<()> {
310        let content = tokio::fs::read_to_string(path)
311            .await
312            .map_err(|e| Error::io(format!("Failed to read {}: {}", path.display(), e)))?;
313
314        for (line_num, line) in content.lines().enumerate() {
315            // Skip comments (but not doc comments which might need versions)
316            let trimmed = line.trim();
317            if trimmed.starts_with("//") && !trimmed.starts_with("///") {
318                continue;
319            }
320
321            // Check for hardcoded version
322            if let Some(captures) = self.version_regex.captures(line) {
323                if let Some(version_match) = captures.get(1) {
324                    let found_version = version_match.as_str();
325
326                    // If version matches Cargo.toml, check if it's properly sourced
327                    if found_version == self.source_version {
328                        // Allow env! macro and CARGO_PKG_VERSION
329                        if !line.contains("env!(\"CARGO_PKG_VERSION\")")
330                            && !line.contains("CARGO_PKG_VERSION")
331                            && !line.contains("clap::crate_version!")
332                        {
333                            violations.push(Violation {
334                                violation_type: ViolationType::HardcodedVersion,
335                                file: path.to_path_buf(),
336                                line: line_num + 1,
337                                message: format!(
338                                    "Hardcoded version '{}' found. Use env!(\"CARGO_PKG_VERSION\") or clap::crate_version!() for SSoT.",
339                                    found_version
340                                ),
341                                severity: Severity::Error,
342                            });
343                        }
344                    }
345                }
346            }
347        }
348
349        Ok(())
350    }
351
352    /// Validate changelog format and content
353    async fn validate_changelog(&self) -> Result<ChangelogStatus> {
354        let changelog_path = self.project_root.join("CHANGELOG.md");
355
356        if !changelog_path.exists() {
357            return Ok(ChangelogStatus {
358                exists: false,
359                version_documented: false,
360                follows_keep_a_changelog: false,
361                missing_sections: vec![],
362            });
363        }
364
365        let content = tokio::fs::read_to_string(&changelog_path)
366            .await
367            .map_err(|e| Error::io(format!("Failed to read CHANGELOG.md: {}", e)))?;
368
369        let content_lower = content.to_lowercase();
370
371        // Check for Keep a Changelog markers
372        let has_keep_a_changelog_format = content.contains("## [Unreleased]")
373            || content_lower.contains("all notable changes")
374            || content.contains("Keep a Changelog");
375
376        // Check if current version is documented
377        let version_documented = content.contains(&format!("[{}]", self.source_version))
378            || content.contains(&format!("## {}", self.source_version));
379
380        // Check for required sections
381        let mut missing_sections = Vec::new();
382        for section in &self.changelog_requirements.required_sections {
383            let section_lower = section.to_lowercase();
384            if !content_lower.contains(&format!("### {}", section_lower))
385                && !content_lower.contains(&format!("## {}", section_lower))
386            {
387                missing_sections.push(section.clone());
388            }
389        }
390
391        Ok(ChangelogStatus {
392            exists: true,
393            version_documented,
394            follows_keep_a_changelog: has_keep_a_changelog_format,
395            missing_sections,
396        })
397    }
398
399    /// Check if we're in a tagging scenario (creating a git tag)
400    async fn is_tagging_scenario(&self) -> Result<bool> {
401        // Check for tag-related environment variables (from CI or hooks)
402        if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
403            return Ok(true);
404        }
405
406        // Check if HEAD is being tagged
407        let output = tokio::process::Command::new("git")
408            .args(["describe", "--exact-match", "--tags", "HEAD"])
409            .current_dir(&self.project_root)
410            .output()
411            .await;
412
413        if let Ok(output) = output {
414            if output.status.success() {
415                return Ok(true);
416            }
417        }
418
419        Ok(false)
420    }
421
422    /// Check if a path is excluded from validation
423    fn is_excluded(&self, path: &Path) -> bool {
424        for exclusion in &self.exclusions {
425            if path.starts_with(exclusion) {
426                return true;
427            }
428        }
429
430        let path_str = path.to_string_lossy();
431        if path_str.contains("/tests/")
432            || path_str.contains("/test/")
433            || path_str.contains("/fixtures/")
434            || path_str.contains("/examples/")
435        {
436            return true;
437        }
438
439        false
440    }
441
442    /// Create empty result for when validation is disabled
443    fn empty_result(&self) -> VersionValidationResult {
444        VersionValidationResult {
445            passed: true,
446            violations: vec![],
447            source_version: self.source_version.clone(),
448            version_format: self.version_format,
449            changelog_status: ChangelogStatus {
450                exists: false,
451                version_documented: false,
452                follows_keep_a_changelog: false,
453                missing_sections: vec![],
454            },
455        }
456    }
457
458    /// Get the source version
459    pub fn source_version(&self) -> &str {
460        &self.source_version
461    }
462
463    /// Get detected version format
464    pub fn version_format(&self) -> VersionFormat {
465        self.version_format
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use tempfile::TempDir;
473    use tokio::fs;
474
475    #[tokio::test]
476    async fn test_detects_semver() {
477        assert_eq!(
478            VersionConsistencyValidator::detect_version_format("1.2.3"),
479            VersionFormat::SemVer
480        );
481        assert_eq!(
482            VersionConsistencyValidator::detect_version_format("1.2.3-alpha"),
483            VersionFormat::SemVer
484        );
485    }
486
487    #[tokio::test]
488    async fn test_detects_calver() {
489        assert_eq!(
490            VersionConsistencyValidator::detect_version_format("2025.03.21"),
491            VersionFormat::CalVer
492        );
493        assert_eq!(
494            VersionConsistencyValidator::detect_version_format("2025.3"),
495            VersionFormat::CalVer
496        );
497    }
498
499    #[tokio::test]
500    async fn test_validates_changelog_format() {
501        let temp_dir = TempDir::new().unwrap();
502        let project_root = temp_dir.path();
503
504        // Create Cargo.toml
505        let cargo_toml = r#"
506[package]
507name = "test-project"
508version = "1.2.3"
509edition = "2021"
510"#;
511        fs::write(project_root.join("Cargo.toml"), cargo_toml)
512            .await
513            .unwrap();
514
515        // Create proper Keep a Changelog format
516        let changelog = r#"# Changelog
517
518All notable changes to this project will be documented in this file.
519
520The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
521and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
522
523## [Unreleased]
524
525## [1.2.3] - 2025-03-21
526
527### Added
528- New feature X
529
530### Fixed
531- Bug Y
532"#;
533        fs::write(project_root.join("CHANGELOG.md"), changelog)
534            .await
535            .unwrap();
536
537        let config = Config::default();
538        let validator =
539            VersionConsistencyValidator::new(project_root.to_path_buf(), config).unwrap();
540
541        let result = validator.validate().await.unwrap();
542        assert!(result.changelog_status.follows_keep_a_changelog);
543        assert!(result.changelog_status.version_documented);
544    }
545}