use super::GitRepo; use anyhow::{Result, bail}; use semver::Version; use std::path::Path; /// Tag builder for creating tags pub struct TagBuilder { name: Option, message: Option, annotate: bool, sign: bool, force: bool, version_prefix: String, } impl TagBuilder { /// Create new tag builder pub fn new() -> Self { Self { name: None, message: None, annotate: true, sign: false, force: false, version_prefix: "v".to_string(), } } /// Set tag name pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); self } /// Set tag message pub fn message(mut self, message: impl Into) -> Self { self.message = Some(message.into()); self } /// Create annotated tag pub fn annotate(mut self, annotate: bool) -> Self { self.annotate = annotate; self } /// Sign the tag pub fn sign(mut self, sign: bool) -> Self { self.sign = sign; self } /// Force overwrite existing tag pub fn force(mut self, force: bool) -> Self { self.force = force; self } /// Set version prefix pub fn version_prefix(mut self, prefix: impl Into) -> Self { self.version_prefix = prefix.into(); self } /// Set semantic version pub fn version(mut self, version: &Version) -> Self { self.name = Some(format!("{}{}", self.version_prefix, version)); self } /// Build tag message pub fn build_message(&self) -> Result { let message = self.message.as_ref().cloned().unwrap_or_else(|| { let name = self.name.as_deref().unwrap_or("unknown"); format!("Release {}", name) }); Ok(message) } /// Execute tag creation pub fn execute(&self, repo: &GitRepo) -> Result<()> { let name = self .name .as_ref() .ok_or_else(|| anyhow::anyhow!("Tag name is required"))?; if !self.force { let existing_tags = repo.get_tags()?; if existing_tags.iter().any(|t| t.name == *name) { bail!("Tag '{}' already exists. Use --force to overwrite.", name); } } let message = if self.annotate { Some(self.build_message()?.as_str().to_string()) } else { None }; repo.create_tag(name, message.as_deref(), self.sign)?; Ok(()) } /// Execute and push tag pub fn execute_and_push(&self, repo: &GitRepo, remote: &str) -> Result<()> { self.execute(repo)?; let name = self.name.as_ref().unwrap(); repo.push(remote, &format!("refs/tags/{}", name))?; Ok(()) } } impl Default for TagBuilder { fn default() -> Self { Self::new() } } /// Semantic version bump types #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VersionBump { Major, Minor, Patch, Prerelease, } impl VersionBump { /// Parse from string pub fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "major" => Ok(Self::Major), "minor" => Ok(Self::Minor), "patch" => Ok(Self::Patch), "prerelease" | "pre" => Ok(Self::Prerelease), _ => bail!( "Invalid version bump: {}. Use: major, minor, patch, prerelease", s ), } } /// Get all variants pub fn variants() -> &'static [&'static str] { &["major", "minor", "patch", "prerelease"] } } /// Get latest version tag from repository pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result> { let tags = repo.get_tags()?; let mut versions: Vec = tags .iter() .filter_map(|t| { let name = &t.name; let version_str = name.strip_prefix(prefix).unwrap_or(name); Version::parse(version_str).ok() }) .collect(); versions.sort_by(|a, b| b.cmp(a)); // Descending order Ok(versions.into_iter().next()) } /// Bump version pub fn bump_version(version: &Version, bump: VersionBump, prerelease_id: Option<&str>) -> Version { match bump { VersionBump::Major => Version::new(version.major + 1, 0, 0), VersionBump::Minor => Version::new(version.major, version.minor + 1, 0), VersionBump::Patch => Version::new(version.major, version.minor, version.patch + 1), VersionBump::Prerelease => { let pre = prerelease_id.unwrap_or("alpha"); let pre_id = format!("{}.0", pre); Version::parse(&format!("{}-{}", version, pre_id)).unwrap_or_else(|_| version.clone()) } } } /// Suggest next version based on commits pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump { let mut has_breaking = false; let mut has_feature = false; let mut has_fix = false; for commit in commits { let msg = commit.message.to_lowercase(); if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") { has_breaking = true; } if let Some(commit_type) = commit.commit_type() { match commit_type.as_str() { "feat" => has_feature = true, "fix" => has_fix = true, _ => {} } } } if has_breaking { VersionBump::Major } else if has_feature { VersionBump::Minor } else if has_fix { VersionBump::Patch } else { VersionBump::Patch } } /// Generate tag message from commits pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String { let mut message = format!("Release {}\n\n", version); // Group commits by type let mut features = vec![]; let mut fixes = vec![]; let mut other = vec![]; let mut breaking = vec![]; for commit in commits { let subject = commit.subject(); if commit.message.contains("BREAKING CHANGE") { breaking.push(subject.to_string()); } if let Some(commit_type) = commit.commit_type() { match commit_type.as_str() { "feat" => features.push(subject.to_string()), "fix" => fixes.push(subject.to_string()), _ => other.push(subject.to_string()), } } else { other.push(subject.to_string()); } } // Build message if !breaking.is_empty() { message.push_str("## Breaking Changes\n\n"); for item in &breaking { message.push_str(&format!("- {}\n", item)); } message.push('\n'); } if !features.is_empty() { message.push_str("## Features\n\n"); for item in &features { message.push_str(&format!("- {}\n", item)); } message.push('\n'); } if !fixes.is_empty() { message.push_str("## Bug Fixes\n\n"); for item in &fixes { message.push_str(&format!("- {}\n", item)); } message.push('\n'); } if !other.is_empty() { message.push_str("## Other Changes\n\n"); for item in &other { message.push_str(&format!("- {}\n", item)); } } message } /// Tag deletion helper pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> { repo.delete_tag(name)?; if let Some(remote) = remote { use std::process::Command; let refspec = format!(":refs/tags/{}", name); let output = Command::new("git") .args(["push", remote, &refspec]) .current_dir(repo.path()) .output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Failed to delete remote tag: {}", stderr); } } Ok(()) } /// List tags with filtering pub fn list_tags( repo: &GitRepo, pattern: Option<&str>, limit: Option, ) -> Result> { let tags = repo.get_tags()?; let filtered: Vec<_> = tags .into_iter() .filter(|t| { if let Some(p) = pattern { t.name.contains(p) } else { true } }) .collect(); if let Some(limit) = limit { Ok(filtered.into_iter().take(limit).collect()) } else { Ok(filtered) } } /// Sort tags by semver version descending, then by time descending. /// Non-semver tags are placed after semver tags, sorted by time descending. pub fn sort_tags_by_semver(tags: &mut [super::TagInfo]) { use semver::Version; tags.sort_by(|a, b| { match ( Version::parse(a.version_name()), Version::parse(b.version_name()), ) { (Ok(va), Ok(vb)) => { // Both semver: version descending, then time descending as tiebreaker vb.cmp(&va).then_with(|| b.time.cmp(&a.time)) } (Ok(_), Err(_)) => std::cmp::Ordering::Less, // semver tags first (Err(_), Ok(_)) => std::cmp::Ordering::Greater, // semver tags first (Err(_), Err(_)) => b.time.cmp(&a.time), // both non-semver: time descending } }); } /// A version found in a project config file, tagged with its source. #[derive(Debug, Clone)] pub struct ConfigVersion { /// Source file name, e.g. "Cargo.toml", "package.json" pub source: String, /// Parsed semantic version pub version: Version, } /// Read project versions from all supported config files in the project root. /// /// If `go.mod` exists the function returns an empty `Vec` immediately — Go projects /// use git tags as the version authority and store nothing in go.mod. /// /// Otherwise every supported format is tried. Results are returned in a stable /// order (see `PARSER_ORDER`). Files that don't exist or whose version can't be /// parsed are silently skipped. pub fn read_project_versions(project_dir: &Path) -> Vec { // Go projects: go.mod has no version field; skip directly to commit analysis if project_dir.join("go.mod").exists() { return vec![]; } let parsers: &[(&str, fn(&Path) -> Option)] = PARSER_ORDER; let mut results = Vec::new(); for &(source, parser) in parsers { if let Some(v) = parser(project_dir) { results.push(ConfigVersion { source: source.to_string(), version: v, }); } } results } /// Stable order for parser dispatch. No priority implied — all results are /// surfaced to the user when more than one is found. type Parser = fn(&Path) -> Option; const PARSER_ORDER: &[(&str, Parser)] = &[ ("Cargo.toml", try_cargo_toml), ("package.json", try_package_json), ("pyproject.toml", try_pyproject_toml), ("pom.xml", try_pom_xml), ("pubspec.yaml", try_pubspec_yaml), ("build.sbt", try_build_sbt), ("project.clj", try_project_clj), ("mix.exs", try_mix_exs), ("package.yaml", try_package_yaml), ("Project.toml", try_project_toml_julia), ("composer.json", try_composer_json), (".csproj", try_csproj), ("CMakeLists.txt", try_cmake_lists), ("meson.build", try_meson_build), (".gemspec", try_gemspec), ]; // ── Structured parsers ────────────────────────────────────────────── fn try_cargo_toml(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?; let value = content.parse::().ok()?; let version_str = value.get("package")?.get("version")?.as_str()?; Version::parse(version_str).ok() } fn try_pyproject_toml(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("pyproject.toml")).ok()?; let value = content.parse::().ok()?; let version_str = value.get("project")?.get("version")?.as_str()?; Version::parse(version_str).ok() } fn try_project_toml_julia(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("Project.toml")).ok()?; let value = content.parse::().ok()?; let version_str = value.get("version")?.as_str()?; Version::parse(version_str).ok() } fn try_package_json(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("package.json")).ok()?; let value: serde_json::Value = serde_json::from_str(&content).ok()?; let version_str = value.get("version")?.as_str()?; Version::parse(version_str).ok() } fn try_composer_json(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("composer.json")).ok()?; let value: serde_json::Value = serde_json::from_str(&content).ok()?; let version_str = value.get("version")?.as_str()?; Version::parse(version_str).ok() } fn try_pom_xml(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("pom.xml")).ok()?; let doc = roxmltree::Document::parse(&content).ok()?; let project = doc.root_element(); // Read that is a direct child of , not inside for child in project.children() { if child.is_element() && child.tag_name().name() == "version" { if let Some(t) = child.text() { return Version::parse(t.trim()).ok(); } } } None } fn try_csproj(dir: &Path) -> Option { // Find any .csproj file in the root (take the first) let csproj = std::fs::read_dir(dir) .ok()? .filter_map(|e| e.ok()) .map(|e| e.path()) .find(|p| p.extension().map_or(false, |ext| ext == "csproj"))?; let content = std::fs::read_to_string(&csproj).ok()?; let doc = roxmltree::Document::parse(&content).ok()?; // Look for (preferred) or anywhere in the document for node in doc.descendants() { if node.is_element() { match node.tag_name().name() { "Version" => { if let Some(t) = node.text() { return Version::parse(t.trim()).ok(); } } "VersionPrefix" => { if let Some(t) = node.text() { return Version::parse(t.trim()).ok(); } } _ => {} } } } None } // ── Regex-based parsers (code-as-config or simple data formats) ───── fn try_pubspec_yaml(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("pubspec.yaml")).ok()?; let re = regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } fn try_package_yaml(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("package.yaml")).ok()?; let re = regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } fn try_gemspec(dir: &Path) -> Option { // A directory may contain multiple .gemspec files; take the first. let gemspec = std::fs::read_dir(dir) .ok()? .filter_map(|e| e.ok()) .map(|e| e.path()) .find(|p| p.extension().map_or(false, |ext| ext == "gemspec"))?; let content = std::fs::read_to_string(&gemspec).ok()?; let re = regex::Regex::new(r#"spec\.version\s*=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } fn try_build_sbt(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("build.sbt")).ok()?; let re = regex::Regex::new(r#"version\s*:=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } fn try_project_clj(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("project.clj")).ok()?; let re = regex::Regex::new(r#"\(defproject\s+[^\s]+\s+"(\d+\.\d+\.\d+[^"]*)""#).ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } fn try_mix_exs(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("mix.exs")).ok()?; let re = regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } fn try_cmake_lists(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?; let re = regex::Regex::new(r"project\s*\([^)]*VERSION\s+(\d+\.\d+\.\d+[^\s)]*)").ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } fn try_meson_build(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("meson.build")).ok()?; let re = regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; let caps = re.captures(&content)?; Version::parse(caps.get(1)?.as_str()).ok() } #[cfg(test)] mod tests { use super::*; use crate::git::TagInfo; fn make_tag(name: &str, time: i64) -> TagInfo { TagInfo { name: name.to_string(), target: "abc123".to_string(), message: String::new(), time, } } #[test] fn test_sort_by_semver_descending() { let mut tags = vec![ make_tag("v1.0.0", 100), make_tag("v2.0.0", 200), make_tag("v1.5.0", 150), ]; sort_tags_by_semver(&mut tags); let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, vec!["v2.0.0", "v1.5.0", "v1.0.0"]); } #[test] fn test_sort_semver_ties_broken_by_time() { let mut tags = vec![ make_tag("v1.0.0", 100), make_tag("v1.0.0", 300), make_tag("v1.0.0", 200), ]; sort_tags_by_semver(&mut tags); let times: Vec = tags.iter().map(|t| t.time).collect(); assert_eq!(times, vec![300, 200, 100]); } #[test] fn test_sort_non_semver_tags_at_end() { let mut tags = vec![ make_tag("release-2024", 400), make_tag("v1.0.0", 100), make_tag("staging", 300), make_tag("v0.2.0", 200), ]; sort_tags_by_semver(&mut tags); let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names[0], "v1.0.0"); assert_eq!(names[1], "v0.2.0"); assert_eq!(names[2], "release-2024"); assert_eq!(names[3], "staging"); } #[test] fn test_sort_no_prefix_semver() { let mut tags = vec![ make_tag("1.0.0", 100), make_tag("2.0.0", 200), make_tag("0.1.0", 50), ]; sort_tags_by_semver(&mut tags); let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, vec!["2.0.0", "1.0.0", "0.1.0"]); } #[test] fn test_sort_all_non_semver_by_time() { let mut tags = vec![ make_tag("release-2024", 100), make_tag("release-2023", 300), make_tag("beta", 200), ]; sort_tags_by_semver(&mut tags); let times: Vec = tags.iter().map(|t| t.time).collect(); assert_eq!(times, vec![300, 200, 100]); } #[test] fn test_read_versions_cargo_toml() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\nversion = \"0.3.0\"\n", ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 1); assert_eq!(versions[0].source, "Cargo.toml"); assert_eq!(versions[0].version, Version::new(0, 3, 0)); } #[test] fn test_read_versions_pyproject_toml() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("pyproject.toml"), "[project]\nname = \"test\"\nversion = \"0.2.1\"\n", ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 1); assert_eq!(versions[0].source, "pyproject.toml"); assert_eq!(versions[0].version, Version::new(0, 2, 1)); } #[test] fn test_read_versions_no_config_files() { let dir = tempfile::TempDir::new().unwrap(); let versions = read_project_versions(dir.path()); assert!(versions.is_empty()); } #[test] fn test_read_versions_multiple_configs_returns_all() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\nversion = \"0.3.0\"\n", ) .unwrap(); std::fs::write( dir.path().join("pyproject.toml"), "[project]\nname = \"test\"\nversion = \"0.2.1\"\n", ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 2); // Order is defined by PARSER_ORDER: Cargo.toml before pyproject.toml assert_eq!(versions[0].source, "Cargo.toml"); assert_eq!(versions[0].version, Version::new(0, 3, 0)); assert_eq!(versions[1].source, "pyproject.toml"); assert_eq!(versions[1].version, Version::new(0, 2, 1)); } #[test] fn test_read_versions_package_json() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("package.json"), r#"{"name": "test", "version": "1.2.3"}"#, ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 1); assert_eq!(versions[0].source, "package.json"); assert_eq!(versions[0].version, Version::new(1, 2, 3)); } #[test] fn test_read_versions_pom_xml() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("pom.xml"), "4.0.02.0.0", ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 1); assert_eq!(versions[0].source, "pom.xml"); assert_eq!(versions[0].version, Version::new(2, 0, 0)); } #[test] fn test_read_versions_pom_xml_ignores_parent_version() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("pom.xml"), "3.0.02.0.0", ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 1); assert_eq!(versions[0].version, Version::new(2, 0, 0)); } #[test] fn test_read_versions_pubspec_yaml() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("pubspec.yaml"), "name: myapp\nversion: 1.0.0+1\n", ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 1); assert_eq!(versions[0].source, "pubspec.yaml"); assert_eq!( versions[0].version, Version::parse("1.0.0+1").unwrap() ); } #[test] fn test_read_versions_go_mod_skips_all() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("go.mod"), "module example.com/m\n").unwrap(); std::fs::write( dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\nversion = \"0.3.0\"\n", ) .unwrap(); // go.mod triggers immediate return — Cargo.toml is ignored let versions = read_project_versions(dir.path()); assert!(versions.is_empty()); } #[test] fn test_read_versions_project_toml_julia() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("Project.toml"), "name = \"MyPkg\"\nversion = \"0.1.0\"\n", ) .unwrap(); let versions = read_project_versions(dir.path()); assert_eq!(versions.len(), 1); assert_eq!(versions[0].source, "Project.toml"); assert_eq!(versions[0].version, Version::new(0, 1, 0)); } }