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" | _ => {
96            // Return None to indicate pyproject.toml should be used
97            Err(Error::Version("Using pyproject.toml version".to_string()))
98        }
99    }
100}
101
102/// Get version from git tags
103pub fn get_git_version(project_dir: &Path, config: &VersioningConfig) -> Result<String> {
104    let git_info = get_git_info(project_dir, config)?;
105    format_version(&git_info, config)
106}
107
108/// Get raw git version information
109pub fn get_git_info(project_dir: &Path, config: &VersioningConfig) -> Result<GitVersion> {
110    // Check if we're in a git repository
111    let git_dir = project_dir.join(".git");
112    if !git_dir.exists() {
113        // Walk up to find .git
114        let mut current = project_dir.to_path_buf();
115        loop {
116            if current.join(".git").exists() {
117                break;
118            }
119            if !current.pop() {
120                return Err(Error::Version("Not a git repository".to_string()));
121            }
122        }
123    }
124
125    // Get the most recent tag matching our pattern
126    let describe_output = Command::new("git")
127        .args([
128            "describe",
129            "--tags",
130            "--long",
131            "--match",
132            &pattern_to_glob(&config.pattern),
133        ])
134        .current_dir(project_dir)
135        .output()
136        .map_err(|e| Error::Version(format!("Failed to run git describe: {}", e)))?;
137
138    if !describe_output.status.success() {
139        // No tags found, use fallback
140        return Ok(GitVersion {
141            version: config.fallback.clone(),
142            distance: get_commit_count(project_dir)?,
143            commit: get_short_hash(project_dir)?,
144            dirty: is_dirty(project_dir)?,
145            tag: String::new(),
146        });
147    }
148
149    let describe = String::from_utf8_lossy(&describe_output.stdout)
150        .trim()
151        .to_string();
152
153    // Parse git describe output: "v1.2.3-5-gabc1234" or "v1.2.3-0-gabc1234"
154    parse_git_describe(&describe, config)
155}
156
157/// Parse git describe output into GitVersion
158fn parse_git_describe(describe: &str, config: &VersioningConfig) -> Result<GitVersion> {
159    // Format: tag-distance-gcommit
160    // Example: v1.2.3-5-gabc1234
161    let parts: Vec<&str> = describe.rsplitn(3, '-').collect();
162
163    if parts.len() < 3 {
164        return Err(Error::Version(format!(
165            "Invalid git describe output: {}",
166            describe
167        )));
168    }
169
170    let commit = parts[0].trim_start_matches('g').to_string();
171    let distance: u32 = parts[1]
172        .parse()
173        .map_err(|_| Error::Version(format!("Invalid distance in: {}", describe)))?;
174    let tag = parts[2].to_string();
175
176    // Extract version from tag using pattern
177    let version = extract_version_from_tag(&tag, &config.pattern)?;
178
179    Ok(GitVersion {
180        version,
181        distance,
182        commit,
183        dirty: false, // Will be checked separately if needed
184        tag,
185    })
186}
187
188/// Extract version from tag using pattern
189fn extract_version_from_tag(tag: &str, pattern: &str) -> Result<String> {
190    // Convert pattern like "v{version}" to regex
191    // First escape dots in the pattern, then replace {version} placeholder
192    let regex_pattern = pattern
193        .replace(".", r"\.")
194        .replace("{version}", r"(?P<version>.+)");
195
196    let re = regex::Regex::new(&format!("^{}$", regex_pattern))
197        .map_err(|e| Error::Version(format!("Invalid pattern: {}", e)))?;
198
199    if let Some(caps) = re.captures(tag) {
200        if let Some(version) = caps.name("version") {
201            return Ok(version.as_str().to_string());
202        }
203    }
204
205    // Fallback: try simple prefix stripping
206    if pattern.starts_with("v{version}") && tag.starts_with('v') {
207        return Ok(tag[1..].to_string());
208    }
209    if pattern == "{version}" {
210        return Ok(tag.to_string());
211    }
212
213    Err(Error::Version(format!(
214        "Tag '{}' does not match pattern '{}'",
215        tag, pattern
216    )))
217}
218
219/// Convert pattern to git glob for matching
220fn pattern_to_glob(pattern: &str) -> String {
221    pattern.replace("{version}", "*")
222}
223
224/// Format version according to config
225fn format_version(git: &GitVersion, config: &VersioningConfig) -> Result<String> {
226    if git.distance == 0 && !git.dirty {
227        // Exactly on a tag, return clean version
228        return Ok(git.version.clone());
229    }
230
231    let mut version = git.version.clone();
232
233    if config.dev_suffix && git.distance > 0 {
234        // Add dev suffix: 1.2.3.dev5
235        version = format!("{}.dev{}", version, git.distance);
236    }
237
238    if config.commit_hash && (git.distance > 0 || git.dirty) {
239        // Add local version with commit hash: +gabc1234
240        let dirty_suffix = if git.dirty { ".dirty" } else { "" };
241        version = format!("{}+g{}{}", version, git.commit, dirty_suffix);
242    } else if git.dirty {
243        version = format!("{}+dirty", version);
244    }
245
246    Ok(version)
247}
248
249/// Get total commit count in repository
250fn get_commit_count(project_dir: &Path) -> Result<u32> {
251    let output = Command::new("git")
252        .args(["rev-list", "--count", "HEAD"])
253        .current_dir(project_dir)
254        .output()
255        .map_err(|e| Error::Version(format!("Failed to get commit count: {}", e)))?;
256
257    if !output.status.success() {
258        return Ok(0);
259    }
260
261    String::from_utf8_lossy(&output.stdout)
262        .trim()
263        .parse()
264        .map_err(|_| Error::Version("Invalid commit count".to_string()))
265}
266
267/// Get short commit hash
268fn get_short_hash(project_dir: &Path) -> Result<String> {
269    let output = Command::new("git")
270        .args(["rev-parse", "--short", "HEAD"])
271        .current_dir(project_dir)
272        .output()
273        .map_err(|e| Error::Version(format!("Failed to get commit hash: {}", e)))?;
274
275    if !output.status.success() {
276        return Ok("0000000".to_string());
277    }
278
279    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
280}
281
282/// Check if working tree is dirty
283fn is_dirty(project_dir: &Path) -> Result<bool> {
284    let output = Command::new("git")
285        .args(["status", "--porcelain"])
286        .current_dir(project_dir)
287        .output()
288        .map_err(|e| Error::Version(format!("Failed to check git status: {}", e)))?;
289
290    Ok(!output.stdout.is_empty())
291}
292
293/// Bump a version string
294pub fn bump_version(version: &str, part: &str) -> Result<String> {
295    let parts: Vec<&str> = version.split('.').collect();
296
297    if parts.len() < 3 {
298        return Err(Error::Version(format!(
299            "Invalid version format: {}",
300            version
301        )));
302    }
303
304    let major: u32 = parts[0]
305        .parse()
306        .map_err(|_| Error::Version("Invalid major version".to_string()))?;
307    let minor: u32 = parts[1]
308        .parse()
309        .map_err(|_| Error::Version("Invalid minor version".to_string()))?;
310    // Handle versions like "1.2.3-alpha" or "1.2.3.dev1"
311    let patch_str = parts[2]
312        .split(|c: char| !c.is_ascii_digit())
313        .next()
314        .unwrap_or("0");
315    let patch: u32 = patch_str
316        .parse()
317        .map_err(|_| Error::Version("Invalid patch version".to_string()))?;
318
319    match part {
320        "major" => Ok(format!("{}.0.0", major + 1)),
321        "minor" => Ok(format!("{}.{}.0", major, minor + 1)),
322        "patch" => Ok(format!("{}.{}.{}", major, minor, patch + 1)),
323        "pre" | "prerelease" => {
324            // Check if already has prerelease suffix
325            if version.contains("-alpha") || version.contains("-beta") || version.contains("-rc") {
326                // Increment the prerelease number
327                if let Some(idx) = version.rfind(|c: char| c.is_ascii_digit()) {
328                    let (prefix, num_str) = version.split_at(idx);
329                    if let Some(first_digit) = num_str.chars().next() {
330                        if first_digit.is_ascii_digit() {
331                            let num: u32 = num_str
332                                .split(|c: char| !c.is_ascii_digit())
333                                .next()
334                                .unwrap_or("0")
335                                .parse()
336                                .unwrap_or(0);
337                            return Ok(format!("{}{}", prefix, num + 1));
338                        }
339                    }
340                }
341            }
342            // Start new alpha prerelease
343            Ok(format!("{}.{}.{}-alpha.1", major, minor, patch + 1))
344        }
345        _ => Err(Error::Version(format!("Unknown version part: {}", part))),
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_extract_version_v_prefix() {
355        let version = extract_version_from_tag("v1.2.3", "v{version}").unwrap();
356        assert_eq!(version, "1.2.3");
357    }
358
359    #[test]
360    fn test_extract_version_no_prefix() {
361        let version = extract_version_from_tag("1.2.3", "{version}").unwrap();
362        assert_eq!(version, "1.2.3");
363    }
364
365    #[test]
366    fn test_extract_version_custom_prefix() {
367        let version = extract_version_from_tag("release-1.2.3", "release-{version}").unwrap();
368        assert_eq!(version, "1.2.3");
369    }
370
371    #[test]
372    fn test_bump_major() {
373        assert_eq!(bump_version("1.2.3", "major").unwrap(), "2.0.0");
374    }
375
376    #[test]
377    fn test_bump_minor() {
378        assert_eq!(bump_version("1.2.3", "minor").unwrap(), "1.3.0");
379    }
380
381    #[test]
382    fn test_bump_patch() {
383        assert_eq!(bump_version("1.2.3", "patch").unwrap(), "1.2.4");
384    }
385
386    #[test]
387    fn test_format_version_on_tag() {
388        let git = GitVersion {
389            version: "1.2.3".to_string(),
390            distance: 0,
391            commit: "abc1234".to_string(),
392            dirty: false,
393            tag: "v1.2.3".to_string(),
394        };
395        let config = VersioningConfig::default();
396        assert_eq!(format_version(&git, &config).unwrap(), "1.2.3");
397    }
398
399    #[test]
400    fn test_format_version_after_tag() {
401        let git = GitVersion {
402            version: "1.2.3".to_string(),
403            distance: 5,
404            commit: "abc1234".to_string(),
405            dirty: false,
406            tag: "v1.2.3".to_string(),
407        };
408        let config = VersioningConfig::default();
409        assert_eq!(
410            format_version(&git, &config).unwrap(),
411            "1.2.3.dev5+gabc1234"
412        );
413    }
414
415    #[test]
416    fn test_pattern_to_glob() {
417        assert_eq!(pattern_to_glob("v{version}"), "v*");
418        assert_eq!(pattern_to_glob("{version}"), "*");
419        assert_eq!(pattern_to_glob("release-{version}"), "release-*");
420    }
421}