Skip to main content

standard_version/
version_file.rs

1//! Version file detection and updating.
2//!
3//! Provides the [`VersionFile`] trait for ecosystem-specific version file
4//! engines, and the [`update_version_files`] function that discovers and
5//! updates version files at a repository root.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::cargo::CargoVersionFile;
11use crate::gradle::GradleVersionFile;
12use crate::json::{DenoVersionFile, JsonVersionFile};
13use crate::project::{ProjectJsonVersionFile, ProjectTomlVersionFile, ProjectYamlVersionFile};
14use crate::pubspec::PubspecVersionFile;
15use crate::pyproject::PyprojectVersionFile;
16use crate::regex_engine::RegexVersionFile;
17use crate::version_plain::PlainVersionFile;
18
19// ---------------------------------------------------------------------------
20// Error
21// ---------------------------------------------------------------------------
22
23/// Errors that can occur when reading or writing version files.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum VersionFileError {
27    /// The expected file was not found on disk.
28    #[error("file not found: {}", .0.display())]
29    FileNotFound(PathBuf),
30    /// The file does not contain a version field this engine can handle.
31    #[error("no version field found")]
32    NoVersionField,
33    /// Writing the updated content back to disk failed.
34    #[error("write failed: {0}")]
35    WriteFailed(#[source] std::io::Error),
36    /// Reading the file from disk failed.
37    #[error("read failed: {0}")]
38    ReadFailed(#[source] std::io::Error),
39    /// A user-supplied regex pattern is invalid or has no capture groups.
40    #[error("invalid regex: {0}")]
41    InvalidRegex(String),
42}
43
44// ---------------------------------------------------------------------------
45// Trait
46// ---------------------------------------------------------------------------
47
48/// A version file engine that can detect, read, and write a version field
49/// inside a specific file format (e.g. `Cargo.toml`, `package.json`).
50pub trait VersionFile {
51    /// Human-readable name (e.g. `"Cargo.toml"`).
52    fn name(&self) -> &str;
53
54    /// Filenames to look for at the repository root.
55    fn filenames(&self) -> &[&str];
56
57    /// Check if `content` contains a version field this engine handles.
58    fn detect(&self, content: &str) -> bool;
59
60    /// Extract the current version string from file content.
61    fn read_version(&self, content: &str) -> Option<String>;
62
63    /// Return updated file content with `new_version` replacing the old value.
64    fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError>;
65
66    /// Compare old and new file content and return optional extra information
67    /// about side-effects (e.g. `VERSION_CODE` increment in gradle).
68    ///
69    /// The default implementation returns `None`.
70    fn extra_info(&self, _old_content: &str, _new_content: &str) -> Option<String> {
71        None
72    }
73}
74
75// ---------------------------------------------------------------------------
76// UpdateResult
77// ---------------------------------------------------------------------------
78
79/// The outcome of updating a single version file.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct UpdateResult {
82    /// Absolute path to the file that was updated.
83    pub path: PathBuf,
84    /// Human-readable engine name (e.g. `"Cargo.toml"`).
85    pub name: String,
86    /// Version string before the update.
87    pub old_version: String,
88    /// Version string after the update.
89    pub new_version: String,
90    /// Optional extra info (e.g. `"VERSION_CODE: 42 → 43"`).
91    pub extra: Option<String>,
92}
93
94// ---------------------------------------------------------------------------
95// DetectedFile
96// ---------------------------------------------------------------------------
97
98/// Information about a detected version file (no writes performed).
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct DetectedFile {
101    /// Absolute path to the file.
102    pub path: PathBuf,
103    /// Human-readable engine name (e.g. `"Cargo.toml"`).
104    pub name: String,
105    /// Current version string in the file.
106    pub old_version: String,
107}
108
109// ---------------------------------------------------------------------------
110// CustomVersionFile
111// ---------------------------------------------------------------------------
112
113/// A user-defined version file matched by path and regex.
114///
115/// Processed by [`RegexVersionFile`](crate::regex_engine::RegexVersionFile)
116/// during [`update_version_files`].
117#[derive(Debug, Clone)]
118pub struct CustomVersionFile {
119    /// Path to the file, relative to the repository root.
120    pub path: PathBuf,
121    /// Regex pattern whose first capture group contains the version string.
122    pub pattern: String,
123}
124
125// ---------------------------------------------------------------------------
126// update_version_files
127// ---------------------------------------------------------------------------
128
129/// Discover and update version files at `root`.
130///
131/// Iterates all built-in version file engines ([`CargoVersionFile`],
132/// [`PyprojectVersionFile`], [`JsonVersionFile`], [`DenoVersionFile`],
133/// [`PubspecVersionFile`], [`GradleVersionFile`], [`PlainVersionFile`])
134/// and, for each file that is detected, replaces the version string with
135/// `new_version`. Then processes any user-defined `custom_files` using the
136/// [`RegexVersionFile`] engine.
137///
138/// Updated content is written back to disk.
139///
140/// # Errors
141///
142/// Returns a [`VersionFileError`] if a detected file cannot be read or
143/// written, or if a custom file has an invalid regex pattern.
144pub fn update_version_files(
145    root: &Path,
146    new_version: &str,
147    custom_files: &[CustomVersionFile],
148) -> Result<Vec<UpdateResult>, VersionFileError> {
149    // Validate all custom regexes upfront before any file writes.
150    let custom_engines: Vec<RegexVersionFile> = custom_files
151        .iter()
152        .map(RegexVersionFile::new)
153        .collect::<Result<Vec<_>, _>>()?;
154
155    let engines: Vec<Box<dyn VersionFile>> = vec![
156        Box::new(CargoVersionFile),
157        Box::new(PyprojectVersionFile),
158        Box::new(JsonVersionFile),
159        Box::new(DenoVersionFile),
160        Box::new(PubspecVersionFile),
161        Box::new(GradleVersionFile),
162        Box::new(ProjectTomlVersionFile),
163        Box::new(ProjectJsonVersionFile),
164        Box::new(ProjectYamlVersionFile),
165        Box::new(PlainVersionFile),
166    ];
167
168    let mut results = Vec::new();
169
170    for engine in &engines {
171        for filename in engine.filenames() {
172            let path = root.join(filename);
173            if !path.exists() {
174                continue;
175            }
176
177            let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
178
179            if !engine.detect(&content) {
180                continue;
181            }
182
183            let old_version = match engine.read_version(&content) {
184                Some(v) => v,
185                None => continue,
186            };
187
188            let updated = engine.write_version(&content, new_version)?;
189            let extra = engine.extra_info(&content, &updated);
190            // Read the actual version from updated content (may differ for
191            // pubspec build numbers or other engines with side-effects).
192            let actual_new_version = engine
193                .read_version(&updated)
194                .unwrap_or_else(|| new_version.to_string());
195            fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
196
197            results.push(UpdateResult {
198                path,
199                name: engine.name().to_string(),
200                old_version,
201                new_version: actual_new_version,
202                extra,
203            });
204        }
205    }
206
207    // Process custom version files (already validated).
208    for engine in &custom_engines {
209        let path = root.join(engine.path());
210        if !path.exists() {
211            continue;
212        }
213        let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
214        if !engine.detect(&content) {
215            continue;
216        }
217        let old_version = match engine.read_version(&content) {
218            Some(v) => v,
219            None => continue,
220        };
221        let updated = engine.write_version(&content, new_version)?;
222        let actual_new_version = engine
223            .read_version(&updated)
224            .unwrap_or_else(|| new_version.to_string());
225        fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
226        results.push(UpdateResult {
227            path,
228            name: engine.name(),
229            old_version,
230            new_version: actual_new_version,
231            extra: None,
232        });
233    }
234
235    Ok(results)
236}
237
238// ---------------------------------------------------------------------------
239// detect_version_files
240// ---------------------------------------------------------------------------
241
242/// Detect version files at `root` without modifying them.
243///
244/// Returns a list of [`DetectedFile`] entries for each file that is found,
245/// detected, and has a readable version string. No files are written.
246///
247/// # Errors
248///
249/// Returns a [`VersionFileError`] if a file cannot be read or if a custom
250/// regex pattern is invalid.
251pub fn detect_version_files(
252    root: &Path,
253    custom_files: &[CustomVersionFile],
254) -> Result<Vec<DetectedFile>, VersionFileError> {
255    // Validate all custom regexes upfront.
256    let custom_engines: Vec<RegexVersionFile> = custom_files
257        .iter()
258        .map(RegexVersionFile::new)
259        .collect::<Result<Vec<_>, _>>()?;
260
261    let engines: Vec<Box<dyn VersionFile>> = vec![
262        Box::new(CargoVersionFile),
263        Box::new(PyprojectVersionFile),
264        Box::new(JsonVersionFile),
265        Box::new(DenoVersionFile),
266        Box::new(PubspecVersionFile),
267        Box::new(GradleVersionFile),
268        Box::new(ProjectTomlVersionFile),
269        Box::new(ProjectJsonVersionFile),
270        Box::new(ProjectYamlVersionFile),
271        Box::new(PlainVersionFile),
272    ];
273
274    let mut results = Vec::new();
275
276    for engine in &engines {
277        for filename in engine.filenames() {
278            let path = root.join(filename);
279            if !path.exists() {
280                continue;
281            }
282            let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
283            if !engine.detect(&content) {
284                continue;
285            }
286            let old_version = match engine.read_version(&content) {
287                Some(v) => v,
288                None => continue,
289            };
290            results.push(DetectedFile {
291                path,
292                name: engine.name().to_string(),
293                old_version,
294            });
295        }
296    }
297
298    for engine in &custom_engines {
299        let path = root.join(engine.path());
300        if !path.exists() {
301            continue;
302        }
303        let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
304        if !engine.detect(&content) {
305            continue;
306        }
307        let old_version = match engine.read_version(&content) {
308            Some(v) => v,
309            None => continue,
310        };
311        results.push(DetectedFile {
312            path,
313            name: engine.name(),
314            old_version,
315        });
316    }
317
318    Ok(results)
319}
320
321// ---------------------------------------------------------------------------
322// Tests
323// ---------------------------------------------------------------------------
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::fs;
329
330    #[test]
331    fn update_version_files_updates_cargo_toml() {
332        let dir = tempfile::tempdir().unwrap();
333        let cargo_toml = dir.path().join("Cargo.toml");
334        fs::write(
335            &cargo_toml,
336            r#"[package]
337name = "example"
338version = "0.1.0"
339edition = "2024"
340"#,
341        )
342        .unwrap();
343
344        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
345
346        assert_eq!(results.len(), 1);
347        assert_eq!(results[0].old_version, "0.1.0");
348        assert_eq!(results[0].new_version, "2.0.0");
349        assert_eq!(results[0].name, "Cargo.toml");
350        assert_eq!(results[0].path, cargo_toml);
351
352        let on_disk = fs::read_to_string(&cargo_toml).unwrap();
353        assert!(on_disk.contains("version = \"2.0.0\""));
354    }
355
356    #[test]
357    fn update_version_files_skips_missing_file() {
358        let dir = tempfile::tempdir().unwrap();
359        // No Cargo.toml present.
360        let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
361        assert!(results.is_empty());
362    }
363
364    #[test]
365    fn update_version_files_skips_undetected() {
366        let dir = tempfile::tempdir().unwrap();
367        let cargo_toml = dir.path().join("Cargo.toml");
368        // File exists but has no [package] section.
369        fs::write(&cargo_toml, "[dependencies]\nfoo = \"1\"\n").unwrap();
370
371        let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
372        assert!(results.is_empty());
373    }
374
375    #[test]
376    fn update_version_files_updates_pyproject_toml() {
377        let dir = tempfile::tempdir().unwrap();
378        let pyproject = dir.path().join("pyproject.toml");
379        fs::write(
380            &pyproject,
381            r#"[project]
382name = "example"
383version = "0.1.0"
384requires-python = ">=3.8"
385"#,
386        )
387        .unwrap();
388
389        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
390
391        assert_eq!(results.len(), 1);
392        assert_eq!(results[0].old_version, "0.1.0");
393        assert_eq!(results[0].new_version, "2.0.0");
394        assert_eq!(results[0].name, "pyproject.toml");
395        assert_eq!(results[0].path, pyproject);
396
397        let on_disk = fs::read_to_string(&pyproject).unwrap();
398        assert!(on_disk.contains("version = \"2.0.0\""));
399    }
400
401    #[test]
402    fn update_version_files_updates_pubspec_yaml() {
403        let dir = tempfile::tempdir().unwrap();
404        let pubspec = dir.path().join("pubspec.yaml");
405        fs::write(
406            &pubspec,
407            "name: my_app\nversion: 1.0.0\ndescription: test\n",
408        )
409        .unwrap();
410
411        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
412
413        assert_eq!(results.len(), 1);
414        assert_eq!(results[0].old_version, "1.0.0");
415        assert_eq!(results[0].new_version, "2.0.0");
416        assert_eq!(results[0].name, "pubspec.yaml");
417
418        let on_disk = fs::read_to_string(&pubspec).unwrap();
419        assert!(on_disk.contains("version: 2.0.0"));
420    }
421
422    #[test]
423    fn update_version_files_updates_gradle_properties() {
424        let dir = tempfile::tempdir().unwrap();
425        let gradle = dir.path().join("gradle.properties");
426        fs::write(&gradle, "VERSION_NAME=1.0.0\nVERSION_CODE=10\n").unwrap();
427
428        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
429
430        assert_eq!(results.len(), 1);
431        assert_eq!(results[0].old_version, "1.0.0");
432        assert_eq!(results[0].name, "gradle.properties");
433        assert_eq!(
434            results[0].extra,
435            Some("VERSION_CODE: 10 \u{2192} 11".to_string()),
436        );
437
438        let on_disk = fs::read_to_string(&gradle).unwrap();
439        assert!(on_disk.contains("VERSION_NAME=2.0.0"));
440        assert!(on_disk.contains("VERSION_CODE=11"));
441    }
442
443    #[test]
444    fn update_version_files_updates_version_file() {
445        let dir = tempfile::tempdir().unwrap();
446        let version = dir.path().join("VERSION");
447        fs::write(&version, "1.0.0\n").unwrap();
448
449        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
450
451        assert_eq!(results.len(), 1);
452        assert_eq!(results[0].old_version, "1.0.0");
453        assert_eq!(results[0].name, "VERSION");
454
455        let on_disk = fs::read_to_string(&version).unwrap();
456        assert_eq!(on_disk, "2.0.0\n");
457    }
458
459    #[test]
460    fn update_version_files_updates_multiple_files() {
461        let dir = tempfile::tempdir().unwrap();
462        fs::write(
463            dir.path().join("Cargo.toml"),
464            "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
465        )
466        .unwrap();
467        fs::write(dir.path().join("pubspec.yaml"), "name: x\nversion: 1.0.0\n").unwrap();
468        fs::write(dir.path().join("VERSION"), "1.0.0\n").unwrap();
469
470        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
471        assert_eq!(results.len(), 3);
472    }
473
474    #[test]
475    fn error_display() {
476        let err = VersionFileError::NoVersionField;
477        assert_eq!(err.to_string(), "no version field found");
478
479        let err = VersionFileError::FileNotFound(PathBuf::from("/tmp/gone"));
480        assert!(err.to_string().contains("/tmp/gone"));
481    }
482}