wtg_cli/
git.rs

1use crate::error::{Result, WtgError};
2use crate::github::ReleaseInfo;
3use git2::{Commit, Oid, Repository, Time};
4use regex::Regex;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, LazyLock, Mutex};
7
8#[derive(Clone)]
9pub struct GitRepo {
10    repo: Arc<Mutex<Repository>>,
11    path: PathBuf,
12}
13
14#[derive(Debug, Clone)]
15pub struct CommitInfo {
16    pub hash: String,
17    pub short_hash: String,
18    pub message: String,
19    pub message_lines: usize,
20    pub author_name: String,
21    pub author_email: String,
22    pub date: String,
23    pub timestamp: i64, // Unix timestamp for the commit
24}
25
26impl CommitInfo {
27    /// Get the commit date as an RFC3339 string for GitHub API filtering
28    #[must_use]
29    pub fn date_rfc3339(&self) -> String {
30        use chrono::{DateTime, TimeZone, Utc};
31        let datetime: DateTime<Utc> = Utc.timestamp_opt(self.timestamp, 0).unwrap();
32        datetime.to_rfc3339()
33    }
34}
35
36#[derive(Debug, Clone)]
37pub struct FileInfo {
38    pub path: String,
39    pub last_commit: CommitInfo,
40    pub previous_authors: Vec<(String, String, String)>, // (hash, name, email)
41}
42
43#[derive(Debug, Clone)]
44pub struct TagInfo {
45    pub name: String,
46    pub commit_hash: String,
47    pub is_semver: bool,
48    pub semver_info: Option<SemverInfo>,
49    pub is_release: bool,             // Whether this is a GitHub release
50    pub release_name: Option<String>, // GitHub release name (if is_release)
51    pub release_url: Option<String>,  // GitHub release URL (if is_release)
52    pub published_at: Option<String>, // GitHub release published date (if is_release)
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SemverInfo {
57    pub major: u32,
58    pub minor: u32,
59    pub patch: Option<u32>,
60    pub build: Option<u32>,
61    pub pre_release: Option<String>,
62    pub build_metadata: Option<String>,
63}
64
65impl GitRepo {
66    /// Open the git repository from the current directory
67    pub fn open() -> Result<Self> {
68        let repo = Repository::discover(".").map_err(|_| WtgError::NotInGitRepo)?;
69        let path = repo.path().to_path_buf();
70        Ok(Self {
71            repo: Arc::new(Mutex::new(repo)),
72            path,
73        })
74    }
75
76    /// Open the git repository from a specific path
77    pub fn from_path(path: &Path) -> Result<Self> {
78        let repo = Repository::open(path).map_err(|_| WtgError::NotInGitRepo)?;
79        let repo_path = repo.path().to_path_buf();
80        Ok(Self {
81            repo: Arc::new(Mutex::new(repo)),
82            path: repo_path,
83        })
84    }
85
86    /// Get the repository path
87    #[must_use]
88    pub fn path(&self) -> &Path {
89        &self.path
90    }
91
92    fn with_repo<T>(&self, f: impl FnOnce(&Repository) -> T) -> T {
93        let repo = self.repo.lock().expect("git repository mutex poisoned");
94        f(&repo)
95    }
96
97    /// Try to find a commit by hash (can be short or full)
98    #[must_use]
99    pub fn find_commit(&self, hash_str: &str) -> Option<CommitInfo> {
100        self.with_repo(|repo| {
101            if let Ok(oid) = Oid::from_str(hash_str)
102                && let Ok(commit) = repo.find_commit(oid)
103            {
104                return Some(Self::commit_to_info(&commit));
105            }
106
107            if hash_str.len() >= 7
108                && let Ok(obj) = repo.revparse_single(hash_str)
109                && let Ok(commit) = obj.peel_to_commit()
110            {
111                return Some(Self::commit_to_info(&commit));
112            }
113
114            None
115        })
116    }
117
118    /// Find a file in the repository
119    #[must_use]
120    pub fn find_file(&self, path: &str) -> Option<FileInfo> {
121        self.with_repo(|repo| {
122            let mut revwalk = repo.revwalk().ok()?;
123            revwalk.push_head().ok()?;
124
125            for oid in revwalk {
126                let oid = oid.ok()?;
127                let commit = repo.find_commit(oid).ok()?;
128
129                if commit_touches_file(&commit, path) {
130                    let commit_info = Self::commit_to_info(&commit);
131                    let previous_authors = Self::get_previous_authors(repo, path, &commit, 4);
132
133                    return Some(FileInfo {
134                        path: path.to_string(),
135                        last_commit: commit_info,
136                        previous_authors,
137                    });
138                }
139            }
140
141            None
142        })
143    }
144
145    /// Get previous authors for a file (excluding the last commit)
146    fn get_previous_authors(
147        repo: &Repository,
148        path: &str,
149        last_commit: &Commit,
150        limit: usize,
151    ) -> Vec<(String, String, String)> {
152        let mut authors = Vec::new();
153        let Ok(mut revwalk) = repo.revwalk() else {
154            return authors;
155        };
156
157        if revwalk.push_head().is_err() {
158            return authors;
159        }
160
161        let mut found_last = false;
162
163        for oid in revwalk {
164            if authors.len() >= limit {
165                break;
166            }
167
168            let Ok(oid) = oid else { continue };
169
170            let Ok(commit) = repo.find_commit(oid) else {
171                continue;
172            };
173
174            if commit.id() == last_commit.id() {
175                found_last = true;
176                continue;
177            }
178
179            if !found_last {
180                continue;
181            }
182
183            if commit_touches_file(&commit, path) {
184                authors.push((
185                    commit.id().to_string()[..7].to_string(),
186                    commit.author().name().unwrap_or("Unknown").to_string(),
187                    commit.author().email().unwrap_or("").to_string(),
188                ));
189            }
190        }
191
192        authors
193    }
194
195    /// Get all tags in the repository
196    #[must_use]
197    pub fn get_tags(&self) -> Vec<TagInfo> {
198        self.get_tags_with_releases(&[])
199    }
200
201    /// Get all tags in the repository, enriched with GitHub release info
202    #[must_use]
203    pub fn get_tags_with_releases(&self, github_releases: &[ReleaseInfo]) -> Vec<TagInfo> {
204        let release_map: std::collections::HashMap<String, &ReleaseInfo> = github_releases
205            .iter()
206            .map(|r| (r.tag_name.clone(), r))
207            .collect();
208
209        self.with_repo(|repo| {
210            let mut tags = Vec::new();
211
212            if let Ok(tag_names) = repo.tag_names(None) {
213                for tag_name in tag_names.iter().flatten() {
214                    if let Ok(obj) = repo.revparse_single(tag_name)
215                        && let Ok(commit) = obj.peel_to_commit()
216                    {
217                        let semver_info = parse_semver(tag_name);
218                        let is_semver = semver_info.is_some();
219
220                        let (is_release, release_name, release_url, published_at) = release_map
221                            .get(tag_name)
222                            .map_or((false, None, None, None), |release| {
223                                (
224                                    true,
225                                    release.name.clone(),
226                                    Some(release.url.clone()),
227                                    release.published_at.clone(),
228                                )
229                            });
230
231                        tags.push(TagInfo {
232                            name: tag_name.to_string(),
233                            commit_hash: commit.id().to_string(),
234                            is_semver,
235                            semver_info,
236                            is_release,
237                            release_name,
238                            release_url,
239                            published_at,
240                        });
241                    }
242                }
243            }
244
245            tags
246        })
247    }
248
249    /// Expose tags that contain the specified commit.
250    #[must_use]
251    pub fn tags_containing_commit(&self, commit_hash: &str) -> Vec<TagInfo> {
252        let Ok(commit_oid) = Oid::from_str(commit_hash) else {
253            return Vec::new();
254        };
255
256        self.find_tags_containing_commit(commit_oid)
257            .unwrap_or_default()
258    }
259
260    /// Convert a GitHub release into tag metadata if the tag exists locally.
261    #[must_use]
262    pub fn tag_from_release(&self, release: &ReleaseInfo) -> Option<TagInfo> {
263        self.with_repo(|repo| {
264            let obj = repo.revparse_single(&release.tag_name).ok()?;
265            let commit = obj.peel_to_commit().ok()?;
266            let semver_info = parse_semver(&release.tag_name);
267
268            Some(TagInfo {
269                name: release.tag_name.clone(),
270                commit_hash: commit.id().to_string(),
271                is_semver: semver_info.is_some(),
272                semver_info,
273                is_release: true,
274                release_name: release.name.clone(),
275                release_url: Some(release.url.clone()),
276                published_at: release.published_at.clone(),
277            })
278        })
279    }
280
281    /// Check whether a release tag contains the specified commit.
282    #[must_use]
283    pub fn tag_contains_commit(&self, tag_commit_hash: &str, commit_hash: &str) -> bool {
284        let Ok(tag_oid) = Oid::from_str(tag_commit_hash) else {
285            return false;
286        };
287        let Ok(commit_oid) = Oid::from_str(commit_hash) else {
288            return false;
289        };
290
291        self.is_ancestor(commit_oid, tag_oid)
292    }
293
294    /// Find all tags that contain a given commit (git-only, no GitHub enrichment)
295    /// Returns None if no tags contain the commit
296    /// Performance: Filters by timestamp before doing expensive ancestry checks
297    fn find_tags_containing_commit(&self, commit_oid: Oid) -> Option<Vec<TagInfo>> {
298        self.with_repo(|repo| {
299            let target_commit = repo.find_commit(commit_oid).ok()?;
300            let target_timestamp = target_commit.time().seconds();
301
302            let mut containing_tags = Vec::new();
303            let tag_names = repo.tag_names(None).ok()?;
304
305            for tag_name in tag_names.iter().flatten() {
306                if let Ok(obj) = repo.revparse_single(tag_name)
307                    && let Ok(commit) = obj.peel_to_commit()
308                {
309                    let tag_oid = commit.id();
310
311                    // Performance: Skip tags with commits older than target
312                    // (they cannot possibly contain the target commit)
313                    if commit.time().seconds() < target_timestamp {
314                        continue;
315                    }
316
317                    // Check if this tag points to the commit or if the tag is a descendant
318                    if tag_oid == commit_oid
319                        || repo
320                            .graph_descendant_of(tag_oid, commit_oid)
321                            .unwrap_or(false)
322                    {
323                        let semver_info = parse_semver(tag_name);
324
325                        containing_tags.push(TagInfo {
326                            name: tag_name.to_string(),
327                            commit_hash: tag_oid.to_string(),
328                            is_semver: semver_info.is_some(),
329                            semver_info,
330                            is_release: false,
331                            release_name: None,
332                            release_url: None,
333                            published_at: None,
334                        });
335                    }
336                }
337            }
338
339            if containing_tags.is_empty() {
340                None
341            } else {
342                Some(containing_tags)
343            }
344        })
345    }
346
347    /// Get commit timestamp for sorting (helper)
348    pub(crate) fn get_commit_timestamp(&self, commit_hash: &str) -> i64 {
349        self.with_repo(|repo| {
350            Oid::from_str(commit_hash)
351                .and_then(|oid| repo.find_commit(oid))
352                .map(|c| c.time().seconds())
353                .unwrap_or(0)
354        })
355    }
356
357    /// Check if commit1 is an ancestor of commit2
358    fn is_ancestor(&self, ancestor: Oid, descendant: Oid) -> bool {
359        self.with_repo(|repo| {
360            repo.graph_descendant_of(descendant, ancestor)
361                .unwrap_or(false)
362        })
363    }
364
365    /// Get the GitHub remote URL if it exists (checks all remotes)
366    #[must_use]
367    pub fn github_remote(&self) -> Option<(String, String)> {
368        self.with_repo(|repo| {
369            for remote_name in ["origin", "upstream"] {
370                if let Ok(remote) = repo.find_remote(remote_name)
371                    && let Some(url) = remote.url()
372                    && let Some(github_info) = parse_github_url(url)
373                {
374                    return Some(github_info);
375                }
376            }
377
378            if let Ok(remotes) = repo.remotes() {
379                for remote_name in remotes.iter().flatten() {
380                    if let Ok(remote) = repo.find_remote(remote_name)
381                        && let Some(url) = remote.url()
382                        && let Some(github_info) = parse_github_url(url)
383                    {
384                        return Some(github_info);
385                    }
386                }
387            }
388
389            None
390        })
391    }
392
393    /// Convert a `git2::Commit` to `CommitInfo`
394    fn commit_to_info(commit: &Commit) -> CommitInfo {
395        let message = commit.message().unwrap_or("").to_string();
396        let lines: Vec<&str> = message.lines().collect();
397        let message_lines = lines.len();
398        let time = commit.time();
399
400        CommitInfo {
401            hash: commit.id().to_string(),
402            short_hash: commit.id().to_string()[..7].to_string(),
403            message: (*lines.first().unwrap_or(&"")).to_string(),
404            message_lines,
405            author_name: commit.author().name().unwrap_or("Unknown").to_string(),
406            author_email: commit.author().email().unwrap_or("").to_string(),
407            date: format_git_time(&time),
408            timestamp: time.seconds(),
409        }
410    }
411}
412
413/// Check if a commit touches a specific file
414fn commit_touches_file(commit: &Commit, path: &str) -> bool {
415    let Ok(tree) = commit.tree() else {
416        return false;
417    };
418
419    // Check if the file exists in this commit's tree
420    tree.get_path(Path::new(path)).is_ok()
421}
422
423/// Regex for parsing semantic versions with various formats
424/// Supports:
425/// - Optional prefix: py-, rust-, python-, etc.
426/// - Optional 'v' prefix
427/// - Version: X.Y, X.Y.Z, X.Y.Z.W
428/// - Pre-release: -alpha, -beta.1, -rc.1 (dash style) OR a1, b1, rc1 (Python style)
429/// - Build metadata: +build.123
430static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
431    Regex::new(
432        r"^(?:[a-z]+-)?v?(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:-([a-zA-Z0-9.-]+))|(?:([a-z]+)(\d+)))?(?:\+(.+))?$"
433    )
434    .expect("Invalid semver regex")
435});
436
437/// Parse a semantic version string
438/// Supports:
439/// - 2-part: 1.0
440/// - 3-part: 1.2.3
441/// - 4-part: 1.2.3.4
442/// - Pre-release: 1.0.0-alpha, 1.0.0-rc.1, 1.0.0-beta.1
443/// - Python-style pre-release: 1.2.3a1, 1.2.3b1, 1.2.3rc1
444/// - Build metadata: 1.0.0+build.123
445/// - With or without 'v' prefix (e.g., v1.0.0)
446/// - With custom prefixes (e.g., py-v1.0.0, rust-v1.0.0, python-1.0.0)
447fn parse_semver(tag: &str) -> Option<SemverInfo> {
448    let caps = SEMVER_REGEX.captures(tag)?;
449
450    let major = caps.get(1)?.as_str().parse::<u32>().ok()?;
451    let minor = caps.get(2)?.as_str().parse::<u32>().ok()?;
452    let patch = caps.get(3).and_then(|m| m.as_str().parse::<u32>().ok());
453    let build = caps.get(4).and_then(|m| m.as_str().parse::<u32>().ok());
454
455    // Pre-release can be either:
456    // - Group 5: dash-style (-alpha, -beta.1, -rc.1)
457    // - Groups 6+7: Python-style (a1, b1, rc1)
458    let pre_release = caps.get(5).map_or_else(
459        || {
460            caps.get(6).map(|py_pre| {
461                let py_num = caps
462                    .get(7)
463                    .map_or(String::new(), |m| m.as_str().to_string());
464                format!("{}{}", py_pre.as_str(), py_num)
465            })
466        },
467        |dash_pre| Some(dash_pre.as_str().to_string()),
468    );
469
470    let build_metadata = caps.get(8).map(|m| m.as_str().to_string());
471
472    Some(SemverInfo {
473        major,
474        minor,
475        patch,
476        build,
477        pre_release,
478        build_metadata,
479    })
480}
481
482/// Check if a tag name is a semantic version
483#[cfg(test)]
484fn is_semver_tag(tag: &str) -> bool {
485    parse_semver(tag).is_some()
486}
487
488/// Parse a GitHub URL to extract owner and repo
489fn parse_github_url(url: &str) -> Option<(String, String)> {
490    // Handle both HTTPS and SSH URLs
491    // HTTPS: https://github.com/owner/repo.git
492    // SSH: git@github.com:owner/repo.git
493
494    if url.contains("github.com") {
495        let parts: Vec<&str> = if url.starts_with("git@") {
496            url.split(':').collect()
497        } else {
498            url.split("github.com/").collect()
499        };
500
501        if let Some(path) = parts.last() {
502            let path = path.trim_end_matches(".git");
503            let repo_parts: Vec<&str> = path.split('/').collect();
504            if repo_parts.len() >= 2 {
505                return Some((repo_parts[0].to_string(), repo_parts[1].to_string()));
506            }
507        }
508    }
509
510    None
511}
512
513/// Format git time to a human-readable string
514fn format_git_time(time: &Time) -> String {
515    use chrono::{DateTime, TimeZone, Utc};
516
517    let datetime: DateTime<Utc> = Utc.timestamp_opt(time.seconds(), 0).unwrap();
518    datetime.format("%Y-%m-%d %H:%M:%S").to_string()
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_parse_semver_2_part() {
527        let result = parse_semver("1.0");
528        assert!(result.is_some());
529        let semver = result.unwrap();
530        assert_eq!(semver.major, 1);
531        assert_eq!(semver.minor, 0);
532        assert_eq!(semver.patch, None);
533        assert_eq!(semver.build, None);
534    }
535
536    #[test]
537    fn test_parse_semver_2_part_with_v_prefix() {
538        let result = parse_semver("v2.1");
539        assert!(result.is_some());
540        let semver = result.unwrap();
541        assert_eq!(semver.major, 2);
542        assert_eq!(semver.minor, 1);
543    }
544
545    #[test]
546    fn test_parse_semver_3_part() {
547        let result = parse_semver("1.2.3");
548        assert!(result.is_some());
549        let semver = result.unwrap();
550        assert_eq!(semver.major, 1);
551        assert_eq!(semver.minor, 2);
552        assert_eq!(semver.patch, Some(3));
553        assert_eq!(semver.build, None);
554    }
555
556    #[test]
557    fn test_parse_semver_3_part_with_v_prefix() {
558        let result = parse_semver("v1.2.3");
559        assert!(result.is_some());
560        let semver = result.unwrap();
561        assert_eq!(semver.major, 1);
562        assert_eq!(semver.minor, 2);
563        assert_eq!(semver.patch, Some(3));
564    }
565
566    #[test]
567    fn test_parse_semver_4_part() {
568        let result = parse_semver("1.2.3.4");
569        assert!(result.is_some());
570        let semver = result.unwrap();
571        assert_eq!(semver.major, 1);
572        assert_eq!(semver.minor, 2);
573        assert_eq!(semver.patch, Some(3));
574        assert_eq!(semver.build, Some(4));
575    }
576
577    #[test]
578    fn test_parse_semver_with_pre_release() {
579        let result = parse_semver("1.0.0-alpha");
580        assert!(result.is_some());
581        let semver = result.unwrap();
582        assert_eq!(semver.major, 1);
583        assert_eq!(semver.minor, 0);
584        assert_eq!(semver.patch, Some(0));
585        assert_eq!(semver.pre_release, Some("alpha".to_string()));
586    }
587
588    #[test]
589    fn test_parse_semver_with_pre_release_numeric() {
590        let result = parse_semver("v2.0.0-rc.1");
591        assert!(result.is_some());
592        let semver = result.unwrap();
593        assert_eq!(semver.major, 2);
594        assert_eq!(semver.minor, 0);
595        assert_eq!(semver.patch, Some(0));
596        assert_eq!(semver.pre_release, Some("rc.1".to_string()));
597    }
598
599    #[test]
600    fn test_parse_semver_with_build_metadata() {
601        let result = parse_semver("1.0.0+build.123");
602        assert!(result.is_some());
603        let semver = result.unwrap();
604        assert_eq!(semver.major, 1);
605        assert_eq!(semver.minor, 0);
606        assert_eq!(semver.patch, Some(0));
607        assert_eq!(semver.build_metadata, Some("build.123".to_string()));
608    }
609
610    #[test]
611    fn test_parse_semver_with_pre_release_and_build() {
612        let result = parse_semver("v1.0.0-beta.2+20130313144700");
613        assert!(result.is_some());
614        let semver = result.unwrap();
615        assert_eq!(semver.major, 1);
616        assert_eq!(semver.minor, 0);
617        assert_eq!(semver.patch, Some(0));
618        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
619        assert_eq!(semver.build_metadata, Some("20130313144700".to_string()));
620    }
621
622    #[test]
623    fn test_parse_semver_2_part_with_pre_release() {
624        let result = parse_semver("2.0-alpha");
625        assert!(result.is_some());
626        let semver = result.unwrap();
627        assert_eq!(semver.major, 2);
628        assert_eq!(semver.minor, 0);
629        assert_eq!(semver.patch, None);
630        assert_eq!(semver.pre_release, Some("alpha".to_string()));
631    }
632
633    #[test]
634    fn test_parse_semver_invalid_single_part() {
635        assert!(parse_semver("1").is_none());
636    }
637
638    #[test]
639    fn test_parse_semver_invalid_non_numeric() {
640        assert!(parse_semver("abc.def").is_none());
641        assert!(parse_semver("1.x.3").is_none());
642    }
643
644    #[test]
645    fn test_parse_semver_invalid_too_many_parts() {
646        assert!(parse_semver("1.2.3.4.5").is_none());
647    }
648
649    #[test]
650    fn test_is_semver_tag() {
651        // Basic versions
652        assert!(is_semver_tag("1.0"));
653        assert!(is_semver_tag("v1.0"));
654        assert!(is_semver_tag("1.2.3"));
655        assert!(is_semver_tag("v1.2.3"));
656        assert!(is_semver_tag("1.2.3.4"));
657
658        // Pre-release versions
659        assert!(is_semver_tag("1.0.0-alpha"));
660        assert!(is_semver_tag("v2.0.0-rc.1"));
661        assert!(is_semver_tag("1.2.3-beta.2"));
662
663        // Python-style pre-release
664        assert!(is_semver_tag("1.2.3a1"));
665        assert!(is_semver_tag("1.2.3b1"));
666        assert!(is_semver_tag("1.2.3rc1"));
667
668        // Build metadata
669        assert!(is_semver_tag("1.0.0+build"));
670
671        // Custom prefixes
672        assert!(is_semver_tag("py-v1.0.0"));
673        assert!(is_semver_tag("rust-v1.2.3-beta.1"));
674        assert!(is_semver_tag("python-1.2.3b1"));
675
676        // Invalid
677        assert!(!is_semver_tag("v1"));
678        assert!(!is_semver_tag("abc"));
679        assert!(!is_semver_tag("1.2.3.4.5"));
680        assert!(!is_semver_tag("server-v-1.0.0")); // Double dash should fail
681    }
682
683    #[test]
684    fn test_parse_semver_with_custom_prefix() {
685        // Test py-v prefix
686        let result = parse_semver("py-v1.0.0-beta.1");
687        assert!(result.is_some());
688        let semver = result.unwrap();
689        assert_eq!(semver.major, 1);
690        assert_eq!(semver.minor, 0);
691        assert_eq!(semver.patch, Some(0));
692        assert_eq!(semver.pre_release, Some("beta.1".to_string()));
693
694        // Test rust-v prefix
695        let result = parse_semver("rust-v1.0.0-beta.2");
696        assert!(result.is_some());
697        let semver = result.unwrap();
698        assert_eq!(semver.major, 1);
699        assert_eq!(semver.minor, 0);
700        assert_eq!(semver.patch, Some(0));
701        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
702
703        // Test prefix without v
704        let result = parse_semver("python-2.1.0");
705        assert!(result.is_some());
706        let semver = result.unwrap();
707        assert_eq!(semver.major, 2);
708        assert_eq!(semver.minor, 1);
709        assert_eq!(semver.patch, Some(0));
710    }
711
712    #[test]
713    fn test_parse_semver_python_style() {
714        // Alpha
715        let result = parse_semver("1.2.3a1");
716        assert!(result.is_some());
717        let semver = result.unwrap();
718        assert_eq!(semver.major, 1);
719        assert_eq!(semver.minor, 2);
720        assert_eq!(semver.patch, Some(3));
721        assert_eq!(semver.pre_release, Some("a1".to_string()));
722
723        // Beta
724        let result = parse_semver("v1.2.3b2");
725        assert!(result.is_some());
726        let semver = result.unwrap();
727        assert_eq!(semver.major, 1);
728        assert_eq!(semver.minor, 2);
729        assert_eq!(semver.patch, Some(3));
730        assert_eq!(semver.pre_release, Some("b2".to_string()));
731
732        // Release candidate
733        let result = parse_semver("2.0.0rc1");
734        assert!(result.is_some());
735        let semver = result.unwrap();
736        assert_eq!(semver.major, 2);
737        assert_eq!(semver.minor, 0);
738        assert_eq!(semver.patch, Some(0));
739        assert_eq!(semver.pre_release, Some("rc1".to_string()));
740
741        // With prefix
742        let result = parse_semver("py-v1.0.0b1");
743        assert!(result.is_some());
744        let semver = result.unwrap();
745        assert_eq!(semver.major, 1);
746        assert_eq!(semver.minor, 0);
747        assert_eq!(semver.patch, Some(0));
748        assert_eq!(semver.pre_release, Some("b1".to_string()));
749    }
750
751    #[test]
752    fn test_parse_semver_rejects_garbage() {
753        // Should reject random strings with -v in them
754        assert!(parse_semver("server-v-config").is_none());
755        assert!(parse_semver("whatever-v-something").is_none());
756
757        // Should reject malformed versions
758        assert!(parse_semver("v1").is_none());
759        assert!(parse_semver("1").is_none());
760        assert!(parse_semver("1.2.3.4.5").is_none());
761        assert!(parse_semver("abc.def").is_none());
762    }
763}