Skip to main content

update_version/parsers/
mod.rs

1use anyhow::Result;
2use log::{debug, info};
3use semver::Version;
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7pub mod package_json_parser;
8pub mod tauri_config_parser;
9pub mod toml_parser;
10
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum ParsingError {
14    #[error("No versions found in directory: {0}")]
15    NoVersionFoundError(String),
16}
17
18/// Options controlling how directory walking behaves with respect to ignore files.
19#[derive(Debug, Clone, Default)]
20pub struct WalkOptions {
21    /// When `true`, disables all ignore file processing (.gitignore, .uvignore, etc.).
22    /// When `false` (default), ignore files are respected.
23    pub no_ignore: bool,
24}
25
26/// Increments a semver version, preserving prerelease labels.
27///
28/// - `1.2.3` → `1.2.4` (no prerelease: bump patch)
29/// - `1.0.0-alpha.0` → `1.0.0-alpha.1` (numeric prerelease suffix: bump it)
30/// - `1.0.0-alpha` → `1.0.1-alpha` (non-numeric prerelease: bump patch, keep label)
31pub fn increment_semver(version: &Version) -> Result<Version> {
32    let mut next = version.clone();
33    next.build = semver::BuildMetadata::EMPTY;
34
35    if version.pre.is_empty() {
36        next.patch += 1;
37        return Ok(next);
38    }
39
40    // Split prerelease into dot-separated identifiers
41    let pre_str = version.pre.as_str();
42    let parts: Vec<&str> = pre_str.split('.').collect();
43
44    // Check if the last identifier is numeric
45    if let Some(last) = parts.last() {
46        if let Ok(n) = last.parse::<u64>() {
47            // Increment the numeric suffix: alpha.0 -> alpha.1
48            let prefix = &parts[..parts.len() - 1];
49            let new_pre = if prefix.is_empty() {
50                format!("{}", n + 1)
51            } else {
52                format!("{}.{}", prefix.join("."), n + 1)
53            };
54            next.pre = semver::Prerelease::new(&new_pre)?;
55            return Ok(next);
56        }
57    }
58
59    // Non-numeric prerelease (e.g. "alpha"): bump patch, keep label
60    next.patch += 1;
61    Ok(next)
62}
63
64pub trait Parser {
65    fn update_version(
66        path: impl AsRef<Path>,
67        version: &Version,
68        options: &WalkOptions,
69    ) -> Result<Vec<PathBuf>> {
70        info!("Updating version to {}", version);
71        let files = Self::get_matching_files(path, options)?;
72        let version_regex = Self::version_match_regex()?;
73        for file in &files {
74            debug!("Checking file: '{}'", file.display());
75            let contents = std::fs::read_to_string(file)?;
76            let new_contents = version_regex
77                .replace(contents.as_str(), Self::version_line_format(version)?)
78                .to_string();
79            std::fs::write(file, new_contents)?;
80        }
81        Ok(files)
82    }
83    fn increment_version(path: impl AsRef<Path>, options: &WalkOptions) -> Result<Vec<PathBuf>> {
84        let path = path.as_ref();
85        let current_version = Self::get_current_version(path, options)?;
86        let new_version = increment_semver(&current_version)?;
87        debug!(
88            "Incrementing version from {} -> {}",
89            current_version, new_version
90        );
91        Self::update_version(path, &new_version, options)
92    }
93    fn get_current_version(path: impl AsRef<Path>, options: &WalkOptions) -> Result<Version> {
94        let path = path.as_ref();
95        let files = Self::get_matching_files(path, options)?;
96        let version_regex = Self::version_match_regex()?;
97
98        for file in files {
99            let contents = std::fs::read_to_string(file)?;
100            if let Some(captures) = version_regex.captures(contents.as_str())
101                && let Some(version) = captures.get(2)
102            {
103                let version = version.as_str();
104                debug!("Found current version: {}", version);
105                return Ok(Version::parse(version)?);
106            }
107        }
108
109        Err(ParsingError::NoVersionFoundError(path.to_string_lossy().to_string()).into())
110    }
111
112    fn get_matching_files(path: impl AsRef<Path>, options: &WalkOptions) -> Result<Vec<PathBuf>> {
113        debug!("Checking matching files");
114        let mut files: Vec<PathBuf> = vec![];
115        let path = path.as_ref();
116        let filename_regex = Self::filename_match_regex()?;
117
118        let mut builder = ignore::WalkBuilder::new(path);
119
120        if options.no_ignore {
121            // Disable ignore file processing but keep hidden file filtering
122            // so .git/ and other hidden directories are still skipped
123            builder.git_ignore(false);
124            builder.git_global(false);
125            builder.git_exclude(false);
126        } else {
127            builder.add_custom_ignore_filename(".uvignore");
128        }
129
130        for item in builder.build() {
131            let item = item?;
132            let path = item.path();
133            if filename_regex.is_match(path.to_string_lossy().as_ref()) {
134                files.push(path.to_path_buf());
135            }
136        }
137
138        // Sort by path depth (shallowest first) then lexicographically for deterministic ordering
139        files.sort_by(|a, b| {
140            a.components().count().cmp(&b.components().count())
141                .then_with(|| a.cmp(b))
142        });
143
144        debug!("Found files: {:?}", files);
145        Ok(files)
146    }
147
148    fn version_match_regex() -> Result<regex::Regex>;
149    fn filename_match_regex() -> Result<regex::Regex>;
150    fn version_line_format(version: &Version) -> Result<String>;
151}