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 "pyproject" | _ => {
96 Err(Error::Version("Using pyproject.toml version".to_string()))
98 }
99 }
100}
101
102pub 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
108pub fn get_git_info(project_dir: &Path, config: &VersioningConfig) -> Result<GitVersion> {
110 let git_dir = project_dir.join(".git");
112 if !git_dir.exists() {
113 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 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 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(&describe, config)
155}
156
157fn parse_git_describe(describe: &str, config: &VersioningConfig) -> Result<GitVersion> {
159 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 let version = extract_version_from_tag(&tag, &config.pattern)?;
178
179 Ok(GitVersion {
180 version,
181 distance,
182 commit,
183 dirty: false, tag,
185 })
186}
187
188fn extract_version_from_tag(tag: &str, pattern: &str) -> Result<String> {
190 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 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
219fn pattern_to_glob(pattern: &str) -> String {
221 pattern.replace("{version}", "*")
222}
223
224fn format_version(git: &GitVersion, config: &VersioningConfig) -> Result<String> {
226 if git.distance == 0 && !git.dirty {
227 return Ok(git.version.clone());
229 }
230
231 let mut version = git.version.clone();
232
233 if config.dev_suffix && git.distance > 0 {
234 version = format!("{}.dev{}", version, git.distance);
236 }
237
238 if config.commit_hash && (git.distance > 0 || git.dirty) {
239 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
249fn 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
267fn 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
282fn 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
293pub 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 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 if version.contains("-alpha") || version.contains("-beta") || version.contains("-rc") {
326 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 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}