1use std::path::Path;
7use std::process::Command;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{Error, Result};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub struct VersioningConfig {
17 #[serde(default = "default_source")]
19 pub source: String,
20
21 #[serde(default = "default_pattern")]
24 pub pattern: String,
25
26 #[serde(default = "default_style")]
28 pub style: String,
29
30 #[serde(default = "default_true")]
32 pub dev_suffix: bool,
33
34 #[serde(default = "default_true")]
36 pub commit_hash: bool,
37
38 #[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#[derive(Debug, Clone)]
78pub struct GitVersion {
79 pub version: String,
81 pub distance: u32,
83 pub commit: String,
85 pub dirty: bool,
87 pub tag: String,
89}
90
91pub 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 _ => {
97 Err(Error::Version("Using pyproject.toml version".to_string()))
99 }
100 }
101}
102
103pub 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
109pub fn get_git_info(project_dir: &Path, config: &VersioningConfig) -> Result<GitVersion> {
111 let git_dir = project_dir.join(".git");
113 if !git_dir.exists() {
114 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 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 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(&describe, config)
156}
157
158fn parse_git_describe(describe: &str, config: &VersioningConfig) -> Result<GitVersion> {
160 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 let version = extract_version_from_tag(&tag, &config.pattern)?;
179
180 Ok(GitVersion {
181 version,
182 distance,
183 commit,
184 dirty: false, tag,
186 })
187}
188
189fn extract_version_from_tag(tag: &str, pattern: &str) -> Result<String> {
191 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 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
220fn pattern_to_glob(pattern: &str) -> String {
222 pattern.replace("{version}", "*")
223}
224
225fn format_version(git: &GitVersion, config: &VersioningConfig) -> Result<String> {
227 if git.distance == 0 && !git.dirty {
228 return Ok(git.version.clone());
230 }
231
232 let mut version = git.version.clone();
233
234 if config.dev_suffix && git.distance > 0 {
235 version = format!("{}.dev{}", version, git.distance);
237 }
238
239 if config.commit_hash && (git.distance > 0 || git.dirty) {
240 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
250fn 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
268fn 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
283fn 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
294pub 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 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 if version.contains("-alpha") || version.contains("-beta") || version.contains("-rc") {
327 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 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}