use std::env;
use anyhow::{
Context,
Result,
};
use crate::version::{
format_version,
increment_patch,
parse_version,
};
#[allow(clippy::disallowed_methods)] pub async fn get_latest_release_version(
owner: &str,
repo: &str,
github_token: Option<&str>,
) -> Result<Option<String>> {
let env_token = env::var("GITHUB_TOKEN").ok();
let token = github_token.or(env_token.as_deref());
let result = if let Some(token) = token {
get_latest_release_via_api(owner, repo, Some(token)).await
} else {
get_latest_release_via_api(owner, repo, None).await
};
match result {
Ok(version) => Ok(Some(version)),
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("No releases found") {
Ok(None)
} else if error_msg.contains("404") || error_msg.contains("Not Found") {
if token.is_none() {
Err(anyhow::anyhow!(
"Repository not found or is private. For private repositories, \
set GITHUB_TOKEN environment variable or pass --github-token"
)
.context(error_msg))
} else {
Err(e)
}
} else if error_msg.contains("403") || error_msg.contains("Forbidden") {
Err(anyhow::anyhow!(
"Access forbidden. This may be a private repository. \
Ensure GITHUB_TOKEN has appropriate permissions."
)
.context(error_msg))
} else {
Err(e)
}
}
}
}
async fn get_latest_release_via_api(
owner: &str,
repo: &str,
token: Option<&str>,
) -> Result<String> {
let octocrab = if let Some(token) = token {
octocrab::OctocrabBuilder::new()
.personal_token(token.to_string())
.build()
.context("Failed to create GitHub API client")?
} else {
octocrab::Octocrab::builder()
.build()
.context("Failed to create GitHub API client")?
};
let releases = octocrab
.repos(owner, repo)
.releases()
.list()
.per_page(1)
.send()
.await
.context("Failed to query GitHub releases")?;
let release = releases.items.first().context("No releases found")?;
let tag_name = release.tag_name.as_str();
let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
let version = version.strip_prefix('V').unwrap_or(version);
Ok(version.to_string())
}
fn get_latest_git_tag_version() -> Result<Option<String>> {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let repo = gix::discover(cwd)
.context("Failed to discover git repository. Ensure you're in a git repository.")?;
let mut version_tags: Vec<(String, (u32, u32, u32))> = repo
.references()?
.prefixed("refs/tags/")?
.filter_map(|r: Result<gix::Reference<'_>, _>| r.ok())
.filter_map(|r| {
let name_full = r.name().as_bstr().to_string();
let name = name_full.strip_prefix("refs/tags/").unwrap_or(&name_full);
let version_str = name
.strip_prefix('v')
.or_else(|| name.strip_prefix('V'))
.unwrap_or(name);
if let Ok((major, minor, patch)) = parse_version(version_str) {
Some((name.to_string(), (major, minor, patch)))
} else {
None
}
})
.collect();
version_tags.sort_by_key(|a| a.1);
Ok(version_tags
.last()
.map(|(tag_name, _): &(String, (u32, u32, u32))| {
tag_name
.strip_prefix('v')
.or_else(|| tag_name.strip_prefix('V'))
.unwrap_or(tag_name)
.to_string()
}))
}
pub async fn calculate_next_version(
_owner: &str,
_repo: &str,
_github_token: Option<&str>,
) -> Result<(String, String)> {
let latest_version_str = match get_latest_git_tag_version()? {
Some(v) => v,
None => {
return Ok(("0.0.0".to_string(), "0.0.1".to_string()));
}
};
let (major, minor, patch) = parse_version(&latest_version_str)
.with_context(|| format!("Failed to parse latest version: {}", latest_version_str))?;
let (major, minor, patch) = increment_patch(major, minor, patch);
let next_version = format_version(major, minor, patch);
Ok((latest_version_str, next_version))
}
#[cfg(test)]
mod tests {
use std::process::Command;
use tempfile::TempDir;
use super::*;
fn create_test_git_repo_with_tags(tags: &[&str]) -> TempDir {
let dir = tempfile::tempdir().unwrap();
Command::new("git")
.arg("init")
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("README.md"), "# Test\n").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir.path())
.output()
.unwrap();
for tag in tags {
Command::new("git")
.args(["tag", "-a", tag, "-m", &format!("Release {}", tag)])
.current_dir(dir.path())
.output()
.unwrap();
}
dir
}
#[test]
fn test_get_latest_git_tag_version_no_tags() {
let dir = create_test_git_repo_with_tags(&[]);
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = get_latest_git_tag_version().unwrap();
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_get_latest_git_tag_version_single_tag() {
let _dir = create_test_git_repo_with_tags(&["v0.1.0"]);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let result = get_latest_git_tag_version().unwrap();
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result, Some("0.1.0".to_string()));
}
#[test]
fn test_get_latest_git_tag_version_multiple_tags() {
let _dir = create_test_git_repo_with_tags(&["v0.1.0", "v0.2.0", "v0.1.5"]);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let result = get_latest_git_tag_version().unwrap();
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result, Some("0.2.0".to_string()));
}
#[test]
fn test_get_latest_git_tag_version_without_v_prefix() {
let _dir = create_test_git_repo_with_tags(&["0.3.0", "v0.2.0"]);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let result = get_latest_git_tag_version().unwrap();
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result, Some("0.3.0".to_string()));
}
#[tokio::test]
async fn test_calculate_next_version_no_tags() {
let _dir = create_test_git_repo_with_tags(&[]);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let (latest, next) = calculate_next_version("test", "repo", None).await.unwrap();
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(latest, "0.0.0");
assert_eq!(next, "0.0.1");
}
#[tokio::test]
async fn test_calculate_next_version_with_tags() {
let _dir = create_test_git_repo_with_tags(&["v0.1.2"]);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let (latest, next) = calculate_next_version("test", "repo", None).await.unwrap();
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(latest, "0.1.2");
assert_eq!(next, "0.1.3");
}
#[tokio::test]
#[ignore] async fn test_get_latest_release_via_api() {
if let Ok(Some(version)) = get_latest_release_version("rust-lang", "rust", None).await {
println!("Latest rust release: {}", version);
}
}
}