将 `read_project_version` 重构为 `read_project_versions`,支持从 Cargo.toml、package.json、pyproject.toml 等多个配置文件同时读取版本。当检测到多个版本时,提供交互式选择界面让用户决定使用哪个版本。
784 lines
24 KiB
Rust
784 lines
24 KiB
Rust
use super::GitRepo;
|
|
use anyhow::{Result, bail};
|
|
use semver::Version;
|
|
use std::path::Path;
|
|
|
|
/// Tag builder for creating tags
|
|
pub struct TagBuilder {
|
|
name: Option<String>,
|
|
message: Option<String>,
|
|
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<String>) -> Self {
|
|
self.name = Some(name.into());
|
|
self
|
|
}
|
|
|
|
/// Set tag message
|
|
pub fn message(mut self, message: impl Into<String>) -> 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<String>) -> 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<String> {
|
|
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<Self> {
|
|
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<Option<Version>> {
|
|
let tags = repo.get_tags()?;
|
|
|
|
let mut versions: Vec<Version> = 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<usize>,
|
|
) -> Result<Vec<super::TagInfo>> {
|
|
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<ConfigVersion> {
|
|
// 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<Version>)] = 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<Version>;
|
|
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<Version> {
|
|
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
|
|
let value = content.parse::<toml::Value>().ok()?;
|
|
let version_str = value.get("package")?.get("version")?.as_str()?;
|
|
Version::parse(version_str).ok()
|
|
}
|
|
|
|
fn try_pyproject_toml(dir: &Path) -> Option<Version> {
|
|
let content = std::fs::read_to_string(dir.join("pyproject.toml")).ok()?;
|
|
let value = content.parse::<toml::Value>().ok()?;
|
|
let version_str = value.get("project")?.get("version")?.as_str()?;
|
|
Version::parse(version_str).ok()
|
|
}
|
|
|
|
fn try_project_toml_julia(dir: &Path) -> Option<Version> {
|
|
let content = std::fs::read_to_string(dir.join("Project.toml")).ok()?;
|
|
let value = content.parse::<toml::Value>().ok()?;
|
|
let version_str = value.get("version")?.as_str()?;
|
|
Version::parse(version_str).ok()
|
|
}
|
|
|
|
fn try_package_json(dir: &Path) -> Option<Version> {
|
|
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<Version> {
|
|
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<Version> {
|
|
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 <version> that is a direct child of <project>, not inside <parent>
|
|
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<Version> {
|
|
// 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 <Version> (preferred) or <VersionPrefix> 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<Version> {
|
|
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<Version> {
|
|
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<Version> {
|
|
// 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<Version> {
|
|
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<Version> {
|
|
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<Version> {
|
|
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<Version> {
|
|
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<Version> {
|
|
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<i64> = 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<i64> = 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"),
|
|
"<project><modelVersion>4.0.0</modelVersion><version>2.0.0</version></project>",
|
|
)
|
|
.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"),
|
|
"<project><parent><version>3.0.0</version></parent><version>2.0.0</version></project>",
|
|
)
|
|
.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));
|
|
}
|
|
}
|