Skip to main content

pro_core/versioning/
mod.rs

1//! Dynamic versioning from git tags
2//!
3//! Supports automatic version derivation from git tags, similar to
4//! poetry-dynamic-versioning. Configuration via `[tool.rx.versioning]`.
5
6use std::path::Path;
7use std::process::Command;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{Error, Result};
12
13/// Versioning configuration from `[tool.rx.versioning]`
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub struct VersioningConfig {
17    /// Version source: "git-tag" or "pyproject" (default)
18    #[serde(default = "default_source")]
19    pub source: String,
20
21    /// Tag pattern for extracting version (default: "v{version}")
22    /// Supports: "v{version}", "{version}", "release-{version}"
23    #[serde(default = "default_pattern")]
24    pub pattern: String,
25
26    /// Version style: "pep440" (default) or "semver"
27    #[serde(default = "default_style")]
28    pub style: String,
29
30    /// Whether to include dev suffix for commits after tag
31    #[serde(default = "default_true")]
32    pub dev_suffix: bool,
33
34    /// Whether to include commit hash in local version
35    #[serde(default = "default_true")]
36    pub commit_hash: bool,
37
38    /// Fallback version if no git tag found
39    #[serde(default = "default_fallback")]
40    pub fallback: String,
41}
42
43fn default_source() -> String {
44    "pyproject".to_string()
45}
46
47fn default_pattern() -> String {
48    "v{version}".to_string()
49}
50
51fn default_style() -> String {
52    "pep440".to_string()
53}
54
55fn default_true() -> bool {
56    true
57}
58
59fn default_fallback() -> String {
60    "0.0.0".to_string()
61}
62
63impl Default for VersioningConfig {
64    fn default() -> Self {
65        Self {
66            source: default_source(),
67            pattern: default_pattern(),
68            style: default_style(),
69            dev_suffix: default_true(),
70            commit_hash: default_true(),
71            fallback: default_fallback(),
72        }
73    }
74}
75
76/// Git version information
77#[derive(Debug, Clone)]
78pub struct GitVersion {
79    /// The base version from the tag
80    pub version: String,
81    /// Number of commits since the tag
82    pub distance: u32,
83    /// Short commit hash
84    pub commit: String,
85    /// Whether the working tree is dirty
86    pub dirty: bool,
87    /// The full tag name
88    pub tag: String,
89}
90
91/// Get the current version, either from git or pyproject.toml
92pub fn get_version(project_dir: &Path, config: &VersioningConfig) -> Result<String> {
93    match config.source.as_str() {
94        "git-tag" | "git" => get_git_version(project_dir, config),
95        // "pyproject" or any other source falls back to pyproject.toml
96        _ => {
97            // Return error to indicate pyproject.toml should be used
98            Err(Error::Version("Using pyproject.toml version".to_string()))
99        }
100    }
101}
102
103/// Get version from git tags
104pub fn get_git_version(project_dir: &Path, config: &VersioningConfig) -> Result<String> {
105    let git_info = get_git_info(project_dir, config)?;
106    format_version(&git_info, config)
107}
108
109/// Get raw git version information
110pub fn get_git_info(project_dir: &Path, config: &VersioningConfig) -> Result<GitVersion> {
111    // Check if we're in a git repository
112    let git_dir = project_dir.join(".git");
113    if !git_dir.exists() {
114        // Walk up to find .git
115        let mut current = project_dir.to_path_buf();
116        loop {
117            if current.join(".git").exists() {
118                break;
119            }
120            if !current.pop() {
121                return Err(Error::Version("Not a git repository".to_string()));
122            }
123        }
124    }
125
126    // Get the most recent tag matching our pattern
127    let describe_output = Command::new("git")
128        .args([
129            "describe",
130            "--tags",
131            "--long",
132            "--match",
133            &pattern_to_glob(&config.pattern),
134        ])
135        .current_dir(project_dir)
136        .output()
137        .map_err(|e| Error::Version(format!("Failed to run git describe: {}", e)))?;
138
139    if !describe_output.status.success() {
140        // No tags found, use fallback
141        return Ok(GitVersion {
142            version: config.fallback.clone(),
143            distance: get_commit_count(project_dir)?,
144            commit: get_short_hash(project_dir)?,
145            dirty: is_dirty(project_dir)?,
146            tag: String::new(),
147        });
148    }
149
150    let describe = String::from_utf8_lossy(&describe_output.stdout)
151        .trim()
152        .to_string();
153
154    // Parse git describe output: "v1.2.3-5-gabc1234" or "v1.2.3-0-gabc1234"
155    parse_git_describe(&describe, config)
156}
157
158/// Parse git describe output into GitVersion
159fn parse_git_describe(describe: &str, config: &VersioningConfig) -> Result<GitVersion> {
160    // Format: tag-distance-gcommit
161    // Example: v1.2.3-5-gabc1234
162    let parts: Vec<&str> = describe.rsplitn(3, '-').collect();
163
164    if parts.len() < 3 {
165        return Err(Error::Version(format!(
166            "Invalid git describe output: {}",
167            describe
168        )));
169    }
170
171    let commit = parts[0].trim_start_matches('g').to_string();
172    let distance: u32 = parts[1]
173        .parse()
174        .map_err(|_| Error::Version(format!("Invalid distance in: {}", describe)))?;
175    let tag = parts[2].to_string();
176
177    // Extract version from tag using pattern
178    let version = extract_version_from_tag(&tag, &config.pattern)?;
179
180    Ok(GitVersion {
181        version,
182        distance,
183        commit,
184        dirty: false, // Will be checked separately if needed
185        tag,
186    })
187}
188
189/// Extract version from tag using pattern
190fn extract_version_from_tag(tag: &str, pattern: &str) -> Result<String> {
191    // Convert pattern like "v{version}" to regex
192    // First escape dots in the pattern, then replace {version} placeholder
193    let regex_pattern = pattern
194        .replace(".", r"\.")
195        .replace("{version}", r"(?P<version>.+)");
196
197    let re = regex::Regex::new(&format!("^{}$", regex_pattern))
198        .map_err(|e| Error::Version(format!("Invalid pattern: {}", e)))?;
199
200    if let Some(caps) = re.captures(tag) {
201        if let Some(version) = caps.name("version") {
202            return Ok(version.as_str().to_string());
203        }
204    }
205
206    // Fallback: try simple prefix stripping
207    if pattern.starts_with("v{version}") && tag.starts_with('v') {
208        return Ok(tag[1..].to_string());
209    }
210    if pattern == "{version}" {
211        return Ok(tag.to_string());
212    }
213
214    Err(Error::Version(format!(
215        "Tag '{}' does not match pattern '{}'",
216        tag, pattern
217    )))
218}
219
220/// Convert pattern to git glob for matching
221fn pattern_to_glob(pattern: &str) -> String {
222    pattern.replace("{version}", "*")
223}
224
225/// Format version according to config
226fn format_version(git: &GitVersion, config: &VersioningConfig) -> Result<String> {
227    if git.distance == 0 && !git.dirty {
228        // Exactly on a tag, return clean version
229        return Ok(git.version.clone());
230    }
231
232    let mut version = git.version.clone();
233
234    if config.dev_suffix && git.distance > 0 {
235        // Add dev suffix: 1.2.3.dev5
236        version = format!("{}.dev{}", version, git.distance);
237    }
238
239    if config.commit_hash && (git.distance > 0 || git.dirty) {
240        // Add local version with commit hash: +gabc1234
241        let dirty_suffix = if git.dirty { ".dirty" } else { "" };
242        version = format!("{}+g{}{}", version, git.commit, dirty_suffix);
243    } else if git.dirty {
244        version = format!("{}+dirty", version);
245    }
246
247    Ok(version)
248}
249
250/// Get total commit count in repository
251fn get_commit_count(project_dir: &Path) -> Result<u32> {
252    let output = Command::new("git")
253        .args(["rev-list", "--count", "HEAD"])
254        .current_dir(project_dir)
255        .output()
256        .map_err(|e| Error::Version(format!("Failed to get commit count: {}", e)))?;
257
258    if !output.status.success() {
259        return Ok(0);
260    }
261
262    String::from_utf8_lossy(&output.stdout)
263        .trim()
264        .parse()
265        .map_err(|_| Error::Version("Invalid commit count".to_string()))
266}
267
268/// Get short commit hash
269fn get_short_hash(project_dir: &Path) -> Result<String> {
270    let output = Command::new("git")
271        .args(["rev-parse", "--short", "HEAD"])
272        .current_dir(project_dir)
273        .output()
274        .map_err(|e| Error::Version(format!("Failed to get commit hash: {}", e)))?;
275
276    if !output.status.success() {
277        return Ok("0000000".to_string());
278    }
279
280    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
281}
282
283/// Check if working tree is dirty
284fn is_dirty(project_dir: &Path) -> Result<bool> {
285    let output = Command::new("git")
286        .args(["status", "--porcelain"])
287        .current_dir(project_dir)
288        .output()
289        .map_err(|e| Error::Version(format!("Failed to check git status: {}", e)))?;
290
291    Ok(!output.stdout.is_empty())
292}
293
294/// Bump a version string
295pub fn bump_version(version: &str, part: &str) -> Result<String> {
296    let parts: Vec<&str> = version.split('.').collect();
297
298    if parts.len() < 3 {
299        return Err(Error::Version(format!(
300            "Invalid version format: {}",
301            version
302        )));
303    }
304
305    let major: u32 = parts[0]
306        .parse()
307        .map_err(|_| Error::Version("Invalid major version".to_string()))?;
308    let minor: u32 = parts[1]
309        .parse()
310        .map_err(|_| Error::Version("Invalid minor version".to_string()))?;
311    // Handle versions like "1.2.3-alpha" or "1.2.3.dev1"
312    let patch_str = parts[2]
313        .split(|c: char| !c.is_ascii_digit())
314        .next()
315        .unwrap_or("0");
316    let patch: u32 = patch_str
317        .parse()
318        .map_err(|_| Error::Version("Invalid patch version".to_string()))?;
319
320    match part {
321        "major" => Ok(format!("{}.0.0", major + 1)),
322        "minor" => Ok(format!("{}.{}.0", major, minor + 1)),
323        "patch" => Ok(format!("{}.{}.{}", major, minor, patch + 1)),
324        "pre" | "prerelease" => {
325            // Check if already has prerelease suffix
326            if version.contains("-alpha") || version.contains("-beta") || version.contains("-rc") {
327                // Increment the prerelease number
328                if let Some(idx) = version.rfind(|c: char| c.is_ascii_digit()) {
329                    let (prefix, num_str) = version.split_at(idx);
330                    if let Some(first_digit) = num_str.chars().next() {
331                        if first_digit.is_ascii_digit() {
332                            let num: u32 = num_str
333                                .split(|c: char| !c.is_ascii_digit())
334                                .next()
335                                .unwrap_or("0")
336                                .parse()
337                                .unwrap_or(0);
338                            return Ok(format!("{}{}", prefix, num + 1));
339                        }
340                    }
341                }
342            }
343            // Start new alpha prerelease
344            Ok(format!("{}.{}.{}-alpha.1", major, minor, patch + 1))
345        }
346        _ => Err(Error::Version(format!("Unknown version part: {}", part))),
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_extract_version_v_prefix() {
356        let version = extract_version_from_tag("v1.2.3", "v{version}").unwrap();
357        assert_eq!(version, "1.2.3");
358    }
359
360    #[test]
361    fn test_extract_version_no_prefix() {
362        let version = extract_version_from_tag("1.2.3", "{version}").unwrap();
363        assert_eq!(version, "1.2.3");
364    }
365
366    #[test]
367    fn test_extract_version_custom_prefix() {
368        let version = extract_version_from_tag("release-1.2.3", "release-{version}").unwrap();
369        assert_eq!(version, "1.2.3");
370    }
371
372    #[test]
373    fn test_bump_major() {
374        assert_eq!(bump_version("1.2.3", "major").unwrap(), "2.0.0");
375    }
376
377    #[test]
378    fn test_bump_minor() {
379        assert_eq!(bump_version("1.2.3", "minor").unwrap(), "1.3.0");
380    }
381
382    #[test]
383    fn test_bump_patch() {
384        assert_eq!(bump_version("1.2.3", "patch").unwrap(), "1.2.4");
385    }
386
387    #[test]
388    fn test_format_version_on_tag() {
389        let git = GitVersion {
390            version: "1.2.3".to_string(),
391            distance: 0,
392            commit: "abc1234".to_string(),
393            dirty: false,
394            tag: "v1.2.3".to_string(),
395        };
396        let config = VersioningConfig::default();
397        assert_eq!(format_version(&git, &config).unwrap(), "1.2.3");
398    }
399
400    #[test]
401    fn test_format_version_after_tag() {
402        let git = GitVersion {
403            version: "1.2.3".to_string(),
404            distance: 5,
405            commit: "abc1234".to_string(),
406            dirty: false,
407            tag: "v1.2.3".to_string(),
408        };
409        let config = VersioningConfig::default();
410        assert_eq!(
411            format_version(&git, &config).unwrap(),
412            "1.2.3.dev5+gabc1234"
413        );
414    }
415
416    #[test]
417    fn test_pattern_to_glob() {
418        assert_eq!(pattern_to_glob("v{version}"), "v*");
419        assert_eq!(pattern_to_glob("{version}"), "*");
420        assert_eq!(pattern_to_glob("release-{version}"), "release-*");
421    }
422}