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    let target_path = Path::new(path);
420    let current_entry = tree.get_path(target_path).ok();
421
422    // Root commit: if the file exists now, this commit introduced it
423    if commit.parent_count() == 0 {
424        return current_entry.is_some();
425    }
426
427    for parent in commit.parents() {
428        let Ok(parent_tree) = parent.tree() else {
429            continue;
430        };
431
432        let previous_entry = parent_tree.get_path(target_path).ok();
433        if tree_entries_differ(current_entry.as_ref(), previous_entry.as_ref()) {
434            return true;
435        }
436    }
437
438    false
439}
440
441fn tree_entries_differ(
442    current: Option<&git2::TreeEntry<'_>>,
443    previous: Option<&git2::TreeEntry<'_>>,
444) -> bool {
445    match (current, previous) {
446        (None, None) => false,
447        (Some(_), None) | (None, Some(_)) => true,
448        (Some(current_entry), Some(previous_entry)) => {
449            current_entry.id() != previous_entry.id()
450                || current_entry.filemode() != previous_entry.filemode()
451        }
452    }
453}
454
455/// Regex for parsing semantic versions with various formats
456/// Supports:
457/// - Optional prefix: py-, rust-, python-, etc.
458/// - Optional 'v' prefix
459/// - Version: X.Y, X.Y.Z, X.Y.Z.W
460/// - Pre-release: -alpha, -beta.1, -rc.1 (dash style) OR a1, b1, rc1 (Python style)
461/// - Build metadata: +build.123
462static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
463    Regex::new(
464        r"^(?:[a-z]+-)?v?(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:-([a-zA-Z0-9.-]+))|(?:([a-z]+)(\d+)))?(?:\+(.+))?$"
465    )
466    .expect("Invalid semver regex")
467});
468
469/// Parse a semantic version string
470/// Supports:
471/// - 2-part: 1.0
472/// - 3-part: 1.2.3
473/// - 4-part: 1.2.3.4
474/// - Pre-release: 1.0.0-alpha, 1.0.0-rc.1, 1.0.0-beta.1
475/// - Python-style pre-release: 1.2.3a1, 1.2.3b1, 1.2.3rc1
476/// - Build metadata: 1.0.0+build.123
477/// - With or without 'v' prefix (e.g., v1.0.0)
478/// - With custom prefixes (e.g., py-v1.0.0, rust-v1.0.0, python-1.0.0)
479fn parse_semver(tag: &str) -> Option<SemverInfo> {
480    let caps = SEMVER_REGEX.captures(tag)?;
481
482    let major = caps.get(1)?.as_str().parse::<u32>().ok()?;
483    let minor = caps.get(2)?.as_str().parse::<u32>().ok()?;
484    let patch = caps.get(3).and_then(|m| m.as_str().parse::<u32>().ok());
485    let build = caps.get(4).and_then(|m| m.as_str().parse::<u32>().ok());
486
487    // Pre-release can be either:
488    // - Group 5: dash-style (-alpha, -beta.1, -rc.1)
489    // - Groups 6+7: Python-style (a1, b1, rc1)
490    let pre_release = caps.get(5).map_or_else(
491        || {
492            caps.get(6).map(|py_pre| {
493                let py_num = caps
494                    .get(7)
495                    .map_or(String::new(), |m| m.as_str().to_string());
496                format!("{}{}", py_pre.as_str(), py_num)
497            })
498        },
499        |dash_pre| Some(dash_pre.as_str().to_string()),
500    );
501
502    let build_metadata = caps.get(8).map(|m| m.as_str().to_string());
503
504    Some(SemverInfo {
505        major,
506        minor,
507        patch,
508        build,
509        pre_release,
510        build_metadata,
511    })
512}
513
514/// Check if a tag name is a semantic version
515#[cfg(test)]
516fn is_semver_tag(tag: &str) -> bool {
517    parse_semver(tag).is_some()
518}
519
520/// Parse a GitHub URL to extract owner and repo
521fn parse_github_url(url: &str) -> Option<(String, String)> {
522    // Handle both HTTPS and SSH URLs
523    // HTTPS: https://github.com/owner/repo.git
524    // SSH: git@github.com:owner/repo.git
525
526    if url.contains("github.com") {
527        let parts: Vec<&str> = if url.starts_with("git@") {
528            url.split(':').collect()
529        } else {
530            url.split("github.com/").collect()
531        };
532
533        if let Some(path) = parts.last() {
534            let path = path.trim_end_matches(".git");
535            let repo_parts: Vec<&str> = path.split('/').collect();
536            if repo_parts.len() >= 2 {
537                return Some((repo_parts[0].to_string(), repo_parts[1].to_string()));
538            }
539        }
540    }
541
542    None
543}
544
545/// Format git time to a human-readable string
546fn format_git_time(time: &Time) -> String {
547    use chrono::{DateTime, TimeZone, Utc};
548
549    let datetime: DateTime<Utc> = Utc.timestamp_opt(time.seconds(), 0).unwrap();
550    datetime.format("%Y-%m-%d %H:%M:%S").to_string()
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use std::fs;
557    use tempfile::tempdir;
558
559    #[test]
560    fn test_parse_semver_2_part() {
561        let result = parse_semver("1.0");
562        assert!(result.is_some());
563        let semver = result.unwrap();
564        assert_eq!(semver.major, 1);
565        assert_eq!(semver.minor, 0);
566        assert_eq!(semver.patch, None);
567        assert_eq!(semver.build, None);
568    }
569
570    #[test]
571    fn test_parse_semver_2_part_with_v_prefix() {
572        let result = parse_semver("v2.1");
573        assert!(result.is_some());
574        let semver = result.unwrap();
575        assert_eq!(semver.major, 2);
576        assert_eq!(semver.minor, 1);
577    }
578
579    #[test]
580    fn test_parse_semver_3_part() {
581        let result = parse_semver("1.2.3");
582        assert!(result.is_some());
583        let semver = result.unwrap();
584        assert_eq!(semver.major, 1);
585        assert_eq!(semver.minor, 2);
586        assert_eq!(semver.patch, Some(3));
587        assert_eq!(semver.build, None);
588    }
589
590    #[test]
591    fn test_parse_semver_3_part_with_v_prefix() {
592        let result = parse_semver("v1.2.3");
593        assert!(result.is_some());
594        let semver = result.unwrap();
595        assert_eq!(semver.major, 1);
596        assert_eq!(semver.minor, 2);
597        assert_eq!(semver.patch, Some(3));
598    }
599
600    #[test]
601    fn test_parse_semver_4_part() {
602        let result = parse_semver("1.2.3.4");
603        assert!(result.is_some());
604        let semver = result.unwrap();
605        assert_eq!(semver.major, 1);
606        assert_eq!(semver.minor, 2);
607        assert_eq!(semver.patch, Some(3));
608        assert_eq!(semver.build, Some(4));
609    }
610
611    #[test]
612    fn test_parse_semver_with_pre_release() {
613        let result = parse_semver("1.0.0-alpha");
614        assert!(result.is_some());
615        let semver = result.unwrap();
616        assert_eq!(semver.major, 1);
617        assert_eq!(semver.minor, 0);
618        assert_eq!(semver.patch, Some(0));
619        assert_eq!(semver.pre_release, Some("alpha".to_string()));
620    }
621
622    #[test]
623    fn test_parse_semver_with_pre_release_numeric() {
624        let result = parse_semver("v2.0.0-rc.1");
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, Some(0));
630        assert_eq!(semver.pre_release, Some("rc.1".to_string()));
631    }
632
633    #[test]
634    fn test_parse_semver_with_build_metadata() {
635        let result = parse_semver("1.0.0+build.123");
636        assert!(result.is_some());
637        let semver = result.unwrap();
638        assert_eq!(semver.major, 1);
639        assert_eq!(semver.minor, 0);
640        assert_eq!(semver.patch, Some(0));
641        assert_eq!(semver.build_metadata, Some("build.123".to_string()));
642    }
643
644    #[test]
645    fn test_parse_semver_with_pre_release_and_build() {
646        let result = parse_semver("v1.0.0-beta.2+20130313144700");
647        assert!(result.is_some());
648        let semver = result.unwrap();
649        assert_eq!(semver.major, 1);
650        assert_eq!(semver.minor, 0);
651        assert_eq!(semver.patch, Some(0));
652        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
653        assert_eq!(semver.build_metadata, Some("20130313144700".to_string()));
654    }
655
656    #[test]
657    fn test_parse_semver_2_part_with_pre_release() {
658        let result = parse_semver("2.0-alpha");
659        assert!(result.is_some());
660        let semver = result.unwrap();
661        assert_eq!(semver.major, 2);
662        assert_eq!(semver.minor, 0);
663        assert_eq!(semver.patch, None);
664        assert_eq!(semver.pre_release, Some("alpha".to_string()));
665    }
666
667    #[test]
668    fn test_parse_semver_invalid_single_part() {
669        assert!(parse_semver("1").is_none());
670    }
671
672    #[test]
673    fn test_parse_semver_invalid_non_numeric() {
674        assert!(parse_semver("abc.def").is_none());
675        assert!(parse_semver("1.x.3").is_none());
676    }
677
678    #[test]
679    fn test_parse_semver_invalid_too_many_parts() {
680        assert!(parse_semver("1.2.3.4.5").is_none());
681    }
682
683    #[test]
684    fn test_is_semver_tag() {
685        // Basic versions
686        assert!(is_semver_tag("1.0"));
687        assert!(is_semver_tag("v1.0"));
688        assert!(is_semver_tag("1.2.3"));
689        assert!(is_semver_tag("v1.2.3"));
690        assert!(is_semver_tag("1.2.3.4"));
691
692        // Pre-release versions
693        assert!(is_semver_tag("1.0.0-alpha"));
694        assert!(is_semver_tag("v2.0.0-rc.1"));
695        assert!(is_semver_tag("1.2.3-beta.2"));
696
697        // Python-style pre-release
698        assert!(is_semver_tag("1.2.3a1"));
699        assert!(is_semver_tag("1.2.3b1"));
700        assert!(is_semver_tag("1.2.3rc1"));
701
702        // Build metadata
703        assert!(is_semver_tag("1.0.0+build"));
704
705        // Custom prefixes
706        assert!(is_semver_tag("py-v1.0.0"));
707        assert!(is_semver_tag("rust-v1.2.3-beta.1"));
708        assert!(is_semver_tag("python-1.2.3b1"));
709
710        // Invalid
711        assert!(!is_semver_tag("v1"));
712        assert!(!is_semver_tag("abc"));
713        assert!(!is_semver_tag("1.2.3.4.5"));
714        assert!(!is_semver_tag("server-v-1.0.0")); // Double dash should fail
715    }
716
717    #[test]
718    fn test_parse_semver_with_custom_prefix() {
719        // Test py-v prefix
720        let result = parse_semver("py-v1.0.0-beta.1");
721        assert!(result.is_some());
722        let semver = result.unwrap();
723        assert_eq!(semver.major, 1);
724        assert_eq!(semver.minor, 0);
725        assert_eq!(semver.patch, Some(0));
726        assert_eq!(semver.pre_release, Some("beta.1".to_string()));
727
728        // Test rust-v prefix
729        let result = parse_semver("rust-v1.0.0-beta.2");
730        assert!(result.is_some());
731        let semver = result.unwrap();
732        assert_eq!(semver.major, 1);
733        assert_eq!(semver.minor, 0);
734        assert_eq!(semver.patch, Some(0));
735        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
736
737        // Test prefix without v
738        let result = parse_semver("python-2.1.0");
739        assert!(result.is_some());
740        let semver = result.unwrap();
741        assert_eq!(semver.major, 2);
742        assert_eq!(semver.minor, 1);
743        assert_eq!(semver.patch, Some(0));
744    }
745
746    #[test]
747    fn test_parse_semver_python_style() {
748        // Alpha
749        let result = parse_semver("1.2.3a1");
750        assert!(result.is_some());
751        let semver = result.unwrap();
752        assert_eq!(semver.major, 1);
753        assert_eq!(semver.minor, 2);
754        assert_eq!(semver.patch, Some(3));
755        assert_eq!(semver.pre_release, Some("a1".to_string()));
756
757        // Beta
758        let result = parse_semver("v1.2.3b2");
759        assert!(result.is_some());
760        let semver = result.unwrap();
761        assert_eq!(semver.major, 1);
762        assert_eq!(semver.minor, 2);
763        assert_eq!(semver.patch, Some(3));
764        assert_eq!(semver.pre_release, Some("b2".to_string()));
765
766        // Release candidate
767        let result = parse_semver("2.0.0rc1");
768        assert!(result.is_some());
769        let semver = result.unwrap();
770        assert_eq!(semver.major, 2);
771        assert_eq!(semver.minor, 0);
772        assert_eq!(semver.patch, Some(0));
773        assert_eq!(semver.pre_release, Some("rc1".to_string()));
774
775        // With prefix
776        let result = parse_semver("py-v1.0.0b1");
777        assert!(result.is_some());
778        let semver = result.unwrap();
779        assert_eq!(semver.major, 1);
780        assert_eq!(semver.minor, 0);
781        assert_eq!(semver.patch, Some(0));
782        assert_eq!(semver.pre_release, Some("b1".to_string()));
783    }
784
785    #[test]
786    fn test_parse_semver_rejects_garbage() {
787        // Should reject random strings with -v in them
788        assert!(parse_semver("server-v-config").is_none());
789        assert!(parse_semver("whatever-v-something").is_none());
790
791        // Should reject malformed versions
792        assert!(parse_semver("v1").is_none());
793        assert!(parse_semver("1").is_none());
794        assert!(parse_semver("1.2.3.4.5").is_none());
795        assert!(parse_semver("abc.def").is_none());
796    }
797
798    #[test]
799    fn file_history_tracks_content_and_metadata_changes() {
800        const ORIGINAL_PATH: &str = "config/policy.json";
801        const RENAMED_PATH: &str = "config/policy-renamed.json";
802        const EXECUTABLE_PATH: &str = "scripts/run.sh";
803        const DELETED_PATH: &str = "docs/legacy.md";
804        const DISTRACTION_PATH: &str = "README.md";
805
806        let temp = tempdir().expect("temp dir");
807        let repo = Repository::init(temp.path()).expect("git repo");
808
809        commit_file(&repo, DISTRACTION_PATH, "noise", "add distraction");
810        commit_file(&repo, ORIGINAL_PATH, "{\"version\":1}", "seed config");
811        commit_file(&repo, ORIGINAL_PATH, "{\"version\":2}", "config tweak");
812        let rename_commit = rename_file(&repo, ORIGINAL_PATH, RENAMED_PATH, "rename config");
813        let post_rename_commit = commit_file(
814            &repo,
815            RENAMED_PATH,
816            "{\"version\":3}",
817            "update renamed config",
818        );
819
820        commit_file(
821            &repo,
822            EXECUTABLE_PATH,
823            "#!/bin/sh\\nprintf hi\n",
824            "add runner",
825        );
826        let exec_mode_commit = change_file_mode(
827            &repo,
828            EXECUTABLE_PATH,
829            git2::FileMode::BlobExecutable,
830            "make runner executable",
831        );
832
833        commit_file(&repo, DELETED_PATH, "bye", "add temporary file");
834        let delete_commit = delete_file(&repo, DELETED_PATH, "remove temporary file");
835
836        let git_repo = GitRepo::from_path(temp.path()).expect("git repo wrapper");
837
838        let renamed_info = git_repo.find_file(RENAMED_PATH).expect("renamed file info");
839        assert_eq!(
840            renamed_info.last_commit.hash,
841            post_rename_commit.to_string()
842        );
843
844        let original_info = git_repo
845            .find_file(ORIGINAL_PATH)
846            .expect("original file info");
847        assert_eq!(original_info.last_commit.hash, rename_commit.to_string());
848
849        let exec_info = git_repo.find_file(EXECUTABLE_PATH).expect("exec file info");
850        assert_eq!(exec_info.last_commit.hash, exec_mode_commit.to_string());
851
852        let deleted_info = git_repo.find_file(DELETED_PATH).expect("deleted file info");
853        assert_eq!(deleted_info.last_commit.hash, delete_commit.to_string());
854    }
855
856    fn commit_file(repo: &Repository, path: &str, contents: &str, message: &str) -> git2::Oid {
857        let workdir = repo.workdir().expect("workdir");
858        let file_path = workdir.join(path);
859        if let Some(parent) = file_path.parent() {
860            fs::create_dir_all(parent).expect("create dir");
861        }
862        fs::write(&file_path, contents).expect("write file");
863
864        let mut index = repo.index().expect("index");
865        index.add_path(Path::new(path)).expect("add path");
866        write_tree_and_commit(repo, &mut index, message)
867    }
868
869    fn rename_file(repo: &Repository, from: &str, to: &str, message: &str) -> git2::Oid {
870        let workdir = repo.workdir().expect("workdir");
871        let from_path = workdir.join(from);
872        let to_path = workdir.join(to);
873        if let Some(parent) = to_path.parent() {
874            fs::create_dir_all(parent).expect("create dir");
875        }
876        fs::rename(&from_path, &to_path).expect("rename file");
877
878        let mut index = repo.index().expect("index");
879        index.remove_path(Path::new(from)).expect("remove old path");
880        index.add_path(Path::new(to)).expect("add new path");
881        write_tree_and_commit(repo, &mut index, message)
882    }
883
884    fn delete_file(repo: &Repository, path: &str, message: &str) -> git2::Oid {
885        let workdir = repo.workdir().expect("workdir");
886        let file_path = workdir.join(path);
887        if file_path.exists() {
888            fs::remove_file(&file_path).expect("remove file");
889        }
890
891        let mut index = repo.index().expect("index");
892        index.remove_path(Path::new(path)).expect("remove path");
893        write_tree_and_commit(repo, &mut index, message)
894    }
895
896    fn change_file_mode(
897        repo: &Repository,
898        path: &str,
899        mode: git2::FileMode,
900        message: &str,
901    ) -> git2::Oid {
902        let mut index = repo.index().expect("index");
903        index.add_path(Path::new(path)).expect("add path");
904        force_index_mode(&mut index, path, mode);
905        write_tree_and_commit(repo, &mut index, message)
906    }
907
908    fn force_index_mode(index: &mut git2::Index, path: &str, mode: git2::FileMode) {
909        if let Some(mut entry) = index.get_path(Path::new(path), 0) {
910            entry.mode = u32::try_from(i32::from(mode)).expect("valid file mode");
911            index.add(&entry).expect("re-add entry");
912        }
913    }
914
915    fn write_tree_and_commit(
916        repo: &Repository,
917        index: &mut git2::Index,
918        message: &str,
919    ) -> git2::Oid {
920        index.write().expect("write index");
921        let tree_oid = index.write_tree().expect("tree oid");
922        let tree = repo.find_tree(tree_oid).expect("tree");
923        let sig = test_signature();
924
925        let parents = repo
926            .head()
927            .ok()
928            .and_then(|head| head.target())
929            .and_then(|oid| repo.find_commit(oid).ok())
930            .into_iter()
931            .collect::<Vec<_>>();
932        let parent_refs = parents.iter().collect::<Vec<_>>();
933
934        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs)
935            .expect("commit")
936    }
937
938    fn test_signature() -> git2::Signature<'static> {
939        git2::Signature::now("Test User", "tester@example.com").expect("sig")
940    }
941}