ferrous_forge/validation/
version_consistency.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum VersionFormat {
17 SemVer,
19 CalVer,
21}
22
23#[derive(Debug, Clone)]
25pub struct ChangelogRequirements {
26 pub enforce_keep_a_changelog: bool,
28 pub require_version_entry: bool,
30 pub check_on_tag: bool,
32 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
51pub struct VersionConsistencyValidator {
53 project_root: PathBuf,
55 source_version: String,
57 version_format: VersionFormat,
59 version_regex: Regex,
61 exclusions: HashSet<PathBuf>,
63 config: Config,
65 changelog_requirements: ChangelogRequirements,
67}
68
69#[derive(Debug, Clone)]
71pub struct VersionValidationResult {
72 pub passed: bool,
74 pub violations: Vec<Violation>,
76 pub source_version: String,
78 pub version_format: VersionFormat,
80 pub changelog_status: ChangelogStatus,
82}
83
84#[derive(Debug, Clone)]
86pub struct ChangelogStatus {
87 pub exists: bool,
89 pub version_documented: bool,
91 pub follows_keep_a_changelog: bool,
93 pub missing_sections: Vec<String>,
95}
96
97impl VersionConsistencyValidator {
98 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 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 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 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 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 fn detect_version_format(version: &str) -> VersionFormat {
171 if Regex::new(r"^\d{4}\.").unwrap().is_match(version) {
173 VersionFormat::CalVer
174 } else {
175 VersionFormat::SemVer
176 }
177 }
178
179 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 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 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 fn is_valid_version(version: &str) -> bool {
207 let semver_regex =
209 Regex::new(r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$").unwrap();
210
211 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 pub async fn validate(&self) -> Result<VersionValidationResult> {
219 let mut violations = Vec::new();
220
221 if !self
223 .config
224 .validation
225 .check_version_consistency
226 .unwrap_or(true)
227 {
228 return Ok(self.empty_result());
229 }
230
231 self.check_hardcoded_versions(&mut violations).await?;
233
234 let changelog_status = self.validate_changelog().await?;
236
237 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 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 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 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 let trimmed = line.trim();
317 if trimmed.starts_with("//") && !trimmed.starts_with("///") {
318 continue;
319 }
320
321 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 found_version == self.source_version {
328 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 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 let has_keep_a_changelog_format = content.contains("## [Unreleased]")
373 || content_lower.contains("all notable changes")
374 || content.contains("Keep a Changelog");
375
376 let version_documented = content.contains(&format!("[{}]", self.source_version))
378 || content.contains(&format!("## {}", self.source_version));
379
380 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 async fn is_tagging_scenario(&self) -> Result<bool> {
401 if std::env::var("GITHUB_REF_TYPE").unwrap_or_default() == "tag" {
403 return Ok(true);
404 }
405
406 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 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 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 pub fn source_version(&self) -> &str {
460 &self.source_version
461 }
462
463 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 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 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}