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