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