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