use fs_err as fs;
use indexmap::IndexMap;
use minijinja::Value;
use rattler_conda_types::Platform;
use rattler_digest::Sha256Hash;
use regex::Regex;
use reqwest::Client;
use serde::Deserialize;
use serde_yaml::Value as YamlValue;
use std::collections::BTreeMap;
use std::path::Path;
use thiserror::Error;
use rattler_build_jinja::{Jinja, JinjaConfig};
#[derive(Debug, Error)]
pub enum BumpRecipeError {
#[error("Failed to parse URL: {0}")]
UrlParse(#[from] url::ParseError),
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Could not detect version provider from URL: {0}")]
UnknownProvider(String),
#[error("Failed to fetch new version: {0}")]
VersionFetch(String),
#[error("Failed to parse recipe: {0}")]
RecipeParse(String),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("No source URL found in recipe")]
NoSourceUrl,
#[error("Version not found in recipe")]
VersionNotFound,
#[error("No new version available (current: {0})")]
NoNewVersion(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionProvider {
GitHub {
owner: String,
repo: String,
},
PyPI {
package: String,
},
CratesIo {
crate_name: String,
},
Generic {
url_template: String,
},
}
impl std::fmt::Display for VersionProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VersionProvider::GitHub { owner, repo } => write!(f, "GitHub ({}/{})", owner, repo),
VersionProvider::PyPI { package } => write!(f, "PyPI ({})", package),
VersionProvider::CratesIo { crate_name } => write!(f, "crates.io ({})", crate_name),
VersionProvider::Generic { url_template } => write!(f, "Generic ({})", url_template),
}
}
}
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
prerelease: bool,
draft: bool,
}
#[derive(Debug, Deserialize)]
struct PyPIInfo {
info: PyPIPackageInfo,
}
#[derive(Debug, Deserialize)]
struct PyPIPackageInfo {
version: String,
}
#[derive(Debug, Deserialize)]
struct CratesIoInfo {
#[serde(rename = "crate")]
crate_info: CrateInfo,
}
#[derive(Debug, Deserialize)]
struct CrateInfo {
max_stable_version: String,
}
#[derive(Debug, Default)]
pub struct RecipeContext {
pub version: Option<String>,
pub build_number: Option<BuildNumber>,
pub source_urls: Vec<String>,
pub sha256_checksums: Vec<String>,
pub raw_context: IndexMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct BuildNumber {
pub value: u64,
pub location: BuildNumberLocation,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BuildNumberLocation {
ContextNumber,
ContextBuildNumber,
BuildSection,
}
impl RecipeContext {
pub fn from_recipe_file(path: &Path) -> Result<Self, BumpRecipeError> {
let content = fs::read_to_string(path)?;
Self::from_yaml_content(&content)
}
pub fn from_yaml_content(content: &str) -> Result<Self, BumpRecipeError> {
let mut ctx = RecipeContext::default();
let yaml: YamlValue = serde_yaml::from_str(content)
.map_err(|e| BumpRecipeError::RecipeParse(format!("YAML parse error: {}", e)))?;
if let Some(context) = yaml.get("context").and_then(|v| v.as_mapping()) {
for (key, value) in context {
if let Some(key_str) = key.as_str() {
let value_str = match value {
YamlValue::String(s) => s.clone(),
YamlValue::Number(n) => n.to_string(),
YamlValue::Bool(b) => b.to_string(),
_ => continue, };
ctx.raw_context
.insert(key_str.to_string(), value_str.clone());
if key_str == "version"
&& !value_str.starts_with('$')
&& !value_str.starts_with('{')
{
ctx.version = Some(value_str);
}
if (key_str == "number" || key_str == "build_number")
&& let Some(num) = value.as_u64()
{
let location = if key_str == "number" {
BuildNumberLocation::ContextNumber
} else {
BuildNumberLocation::ContextBuildNumber
};
ctx.build_number = Some(BuildNumber {
value: num,
location,
});
}
}
}
}
if ctx.build_number.is_none()
&& let Some(build) = yaml.get("build").and_then(|v| v.as_mapping())
&& let Some(num) = build.get("number").and_then(|v| v.as_u64())
{
ctx.build_number = Some(BuildNumber {
value: num,
location: BuildNumberLocation::BuildSection,
});
}
Self::extract_sources(&yaml, &mut ctx)?;
Ok(ctx)
}
fn extract_sources(yaml: &YamlValue, ctx: &mut RecipeContext) -> Result<(), BumpRecipeError> {
let source = match yaml.get("source") {
Some(s) => s,
None => return Ok(()), };
let sources: Vec<&YamlValue> = if source.is_sequence() {
source.as_sequence().unwrap().iter().collect()
} else {
vec![source]
};
for src in sources {
if let Some(url) = src.get("url") {
match url {
YamlValue::String(s) => {
ctx.source_urls.push(s.clone());
}
YamlValue::Sequence(urls) => {
for u in urls {
if let YamlValue::String(s) = u {
ctx.source_urls.push(s.clone());
}
}
}
_ => {}
}
}
if let Some(YamlValue::String(sha)) = src.get("sha256") {
ctx.sha256_checksums.push(sha.clone());
}
}
Ok(())
}
}
pub fn detect_provider(url: &str) -> Result<VersionProvider, BumpRecipeError> {
if let Some(caps) = Regex::new(r"github\.com/([^/]+)/([^/]+)/archive")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::GitHub {
owner: caps[1].to_string(),
repo: caps[2].to_string(),
});
}
if let Some(caps) = Regex::new(r"github\.com/([^/]+)/([^/]+)/releases/download")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::GitHub {
owner: caps[1].to_string(),
repo: caps[2].to_string(),
});
}
if let Some(caps) = Regex::new(r"api\.github\.com/repos/([^/]+)/([^/]+)/tarball")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::GitHub {
owner: caps[1].to_string(),
repo: caps[2].to_string(),
});
}
if let Some(caps) = Regex::new(r"raw\.githubusercontent\.com/([^/]+)/([^/]+)")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::GitHub {
owner: caps[1].to_string(),
repo: caps[2].to_string(),
});
}
if let Some(caps) = Regex::new(r"pypi\.io/packages/source/./([^/]+)")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::PyPI {
package: caps[1].to_string(),
});
}
if let Some(caps) = Regex::new(r"files\.pythonhosted\.org/packages/source/./([^/]+)")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::PyPI {
package: caps[1].to_string(),
});
}
if let Some(caps) = Regex::new(r"pypi\.org/packages/source/./([^/]+)")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::PyPI {
package: caps[1].to_string(),
});
}
if let Some(caps) = Regex::new(r"crates\.io/api/v1/crates/([^/]+)")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::CratesIo {
crate_name: caps[1].to_string(),
});
}
if let Some(caps) = Regex::new(r"static\.crates\.io/crates/([^/]+)")
.unwrap()
.captures(url)
{
return Ok(VersionProvider::CratesIo {
crate_name: caps[1].to_string(),
});
}
Ok(VersionProvider::Generic {
url_template: url.to_string(),
})
}
pub async fn fetch_latest_version(
client: &Client,
provider: &VersionProvider,
include_prerelease: bool,
) -> Result<String, BumpRecipeError> {
match provider {
VersionProvider::GitHub { owner, repo } => {
fetch_github_latest_version(client, owner, repo, include_prerelease).await
}
VersionProvider::PyPI { package } => fetch_pypi_latest_version(client, package).await,
VersionProvider::CratesIo { crate_name } => {
fetch_crates_io_latest_version(client, crate_name).await
}
VersionProvider::Generic { .. } => Err(BumpRecipeError::VersionFetch(
"Cannot auto-detect version for generic URLs. Please specify --version manually."
.to_string(),
)),
}
}
async fn fetch_github_latest_version(
client: &Client,
owner: &str,
repo: &str,
include_prerelease: bool,
) -> Result<String, BumpRecipeError> {
let url = format!("https://api.github.com/repos/{}/{}/releases", owner, repo);
let response = client
.get(&url)
.header("User-Agent", "rattler-build")
.header("Accept", "application/vnd.github.v3+json")
.send()
.await?;
if !response.status().is_success() {
return Err(BumpRecipeError::VersionFetch(format!(
"GitHub API returned status {}: {}",
response.status(),
response.text().await.unwrap_or_default()
)));
}
let releases: Vec<GitHubRelease> = response.json().await?;
for release in releases {
if release.draft {
continue;
}
if !include_prerelease && release.prerelease {
continue;
}
let version = release
.tag_name
.strip_prefix('v')
.unwrap_or(&release.tag_name)
.to_string();
return Ok(version);
}
let tags_url = format!("https://api.github.com/repos/{}/{}/tags", owner, repo);
let response = client
.get(&tags_url)
.header("User-Agent", "rattler-build")
.header("Accept", "application/vnd.github.v3+json")
.send()
.await?;
if response.status().is_success() {
#[derive(Deserialize)]
struct Tag {
name: String,
}
let tags: Vec<Tag> = response.json().await?;
if let Some(tag) = tags.first() {
let version = tag.name.strip_prefix('v').unwrap_or(&tag.name).to_string();
return Ok(version);
}
}
Err(BumpRecipeError::VersionFetch(
"No releases or tags found".to_string(),
))
}
async fn fetch_pypi_latest_version(
client: &Client,
package: &str,
) -> Result<String, BumpRecipeError> {
let url = format!("https://pypi.org/pypi/{}/json", package);
let response = client
.get(&url)
.header("User-Agent", "rattler-build")
.send()
.await?;
if !response.status().is_success() {
return Err(BumpRecipeError::VersionFetch(format!(
"PyPI API returned status {}",
response.status()
)));
}
let info: PyPIInfo = response.json().await?;
Ok(info.info.version)
}
async fn fetch_crates_io_latest_version(
client: &Client,
crate_name: &str,
) -> Result<String, BumpRecipeError> {
let url = format!("https://crates.io/api/v1/crates/{}", crate_name);
let response = client
.get(&url)
.header("User-Agent", "rattler-build")
.send()
.await?;
if !response.status().is_success() {
return Err(BumpRecipeError::VersionFetch(format!(
"crates.io API returned status {}",
response.status()
)));
}
let info: CratesIoInfo = response.json().await?;
Ok(info.crate_info.max_stable_version)
}
pub async fn fetch_sha256(client: &Client, url: &str) -> Result<Sha256Hash, BumpRecipeError> {
let response = client
.get(url)
.header("User-Agent", "rattler-build")
.send()
.await?;
if !response.status().is_success() {
return Err(BumpRecipeError::VersionFetch(format!(
"Failed to download source (HTTP {}): {}",
response.status(),
url
)));
}
let bytes = response.bytes().await?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(hasher.finalize())
}
fn reset_build_number(content: &str, build_num: &BuildNumber) -> String {
let pattern = match build_num.location {
BuildNumberLocation::ContextNumber => {
format!(r"(\s+number:\s*){}", build_num.value)
}
BuildNumberLocation::ContextBuildNumber => {
format!(r"(\s+build_number:\s*){}", build_num.value)
}
BuildNumberLocation::BuildSection => {
format!(r"(\s+number:\s*){}", build_num.value)
}
};
Regex::new(&pattern)
.map(|re| re.replace(content, "${1}0").to_string())
.unwrap_or_else(|_| content.to_string())
}
pub fn build_url_with_version(
url_template: &str,
version: &str,
raw_context: &IndexMap<String, String>,
) -> Result<String, BumpRecipeError> {
let jinja_config = JinjaConfig {
target_platform: Platform::current(),
host_platform: Platform::current(),
build_platform: Platform::current(),
variant: BTreeMap::new(),
experimental: false,
recipe_path: None,
..Default::default()
};
let mut jinja = Jinja::new(jinja_config);
jinja
.context_mut()
.insert("version".to_string(), Value::from(version));
for (key, raw_value) in raw_context {
if key == "version" {
continue;
}
let rendered_value = if raw_value.contains("${{") || raw_value.contains("{{") {
jinja
.render_str(raw_value)
.unwrap_or_else(|_| raw_value.clone())
} else {
raw_value.clone()
};
jinja
.context_mut()
.insert(key.clone(), Value::from(rendered_value));
}
jinja
.render_str(url_template)
.map_err(|e| BumpRecipeError::RecipeParse(format!("Failed to render URL: {}", e)))
}
#[derive(Debug)]
pub struct BumpResult {
pub old_version: String,
pub new_version: String,
pub old_sha256: Vec<String>,
pub new_sha256: Vec<String>,
pub provider: Option<VersionProvider>,
}
pub async fn bump_recipe(
recipe_path: &Path,
new_version: Option<&str>,
client: &Client,
include_prerelease: bool,
dry_run: bool,
keep_build_number: bool,
) -> Result<BumpResult, BumpRecipeError> {
let ctx = RecipeContext::from_recipe_file(recipe_path)?;
let old_version = ctx
.version
.clone()
.ok_or(BumpRecipeError::VersionNotFound)?;
let source_url_template = ctx
.source_urls
.first()
.ok_or(BumpRecipeError::NoSourceUrl)?;
let rendered_url = build_url_with_version(source_url_template, &old_version, &ctx.raw_context)?;
let provider = detect_provider(&rendered_url)?;
tracing::debug!("Detected version provider: {}", provider);
let new_version = if let Some(v) = new_version {
v.to_string()
} else {
tracing::debug!("Auto-detecting latest version...");
fetch_latest_version(client, &provider, include_prerelease).await?
};
if new_version == old_version {
return Err(BumpRecipeError::NoNewVersion(old_version));
}
tracing::info!("Bumping {} -> {}", old_version, new_version);
let mut new_sha256s = Vec::new();
for url_template in &ctx.source_urls {
let new_url = build_url_with_version(url_template, &new_version, &ctx.raw_context)?;
tracing::debug!("Resolved URL: {}", new_url);
tracing::info!("Fetching {}", new_url);
let sha256 = fetch_sha256(client, &new_url).await?;
let sha256_str = format!("{:x}", sha256);
tracing::debug!("Fetched SHA256: {}", sha256_str);
new_sha256s.push(sha256_str);
}
if !dry_run {
let mut content = fs::read_to_string(recipe_path)?;
content = content.replace(&old_version, &new_version);
for (old_sha, new_sha) in ctx.sha256_checksums.iter().zip(new_sha256s.iter()) {
content = content.replacen(old_sha, new_sha, 1);
}
if !keep_build_number
&& let Some(build_num) = &ctx.build_number
&& build_num.value != 0
{
content = reset_build_number(&content, build_num);
tracing::debug!("Reset build number from {} to 0", build_num.value);
}
fs::write(recipe_path, &content)?;
tracing::info!("Updated {}", recipe_path.display());
} else {
tracing::info!("Dry run - no changes written");
}
Ok(BumpResult {
old_version,
new_version,
old_sha256: ctx.sha256_checksums,
new_sha256: new_sha256s,
provider: Some(provider),
})
}
pub async fn check_for_updates(
recipe_path: &Path,
client: &Client,
include_prerelease: bool,
) -> Result<Option<String>, BumpRecipeError> {
let ctx = RecipeContext::from_recipe_file(recipe_path)?;
let current_version = ctx
.version
.clone()
.ok_or(BumpRecipeError::VersionNotFound)?;
let source_url_template = ctx
.source_urls
.first()
.ok_or(BumpRecipeError::NoSourceUrl)?;
let rendered_url =
build_url_with_version(source_url_template, ¤t_version, &ctx.raw_context)?;
let provider = detect_provider(&rendered_url)?;
match fetch_latest_version(client, &provider, include_prerelease).await {
Ok(latest_version) => {
if latest_version != current_version {
Ok(Some(latest_version))
} else {
Ok(None)
}
}
Err(e) => {
tracing::warn!("Could not fetch latest version: {}", e);
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_github_provider() {
let test_cases = vec![
(
"https://github.com/owner/repo/archive/v1.0.0.tar.gz",
VersionProvider::GitHub {
owner: "owner".to_string(),
repo: "repo".to_string(),
},
),
(
"https://github.com/owner/repo/releases/download/v1.0.0/file.tar.gz",
VersionProvider::GitHub {
owner: "owner".to_string(),
repo: "repo".to_string(),
},
),
(
"https://api.github.com/repos/owner/repo/tarball/v1.0.0",
VersionProvider::GitHub {
owner: "owner".to_string(),
repo: "repo".to_string(),
},
),
];
for (url, expected) in test_cases {
let provider = detect_provider(url).unwrap();
assert_eq!(provider, expected, "Failed for URL: {}", url);
}
}
#[test]
fn test_detect_pypi_provider() {
let test_cases = vec![
(
"https://pypi.io/packages/source/p/package/package-1.0.0.tar.gz",
VersionProvider::PyPI {
package: "package".to_string(),
},
),
(
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.0.tar.gz",
VersionProvider::PyPI {
package: "requests".to_string(),
},
),
];
for (url, expected) in test_cases {
let provider = detect_provider(url).unwrap();
assert_eq!(provider, expected, "Failed for URL: {}", url);
}
}
#[test]
fn test_detect_crates_io_provider() {
let url = "https://crates.io/api/v1/crates/serde/1.0.0/download";
let provider = detect_provider(url).unwrap();
assert_eq!(
provider,
VersionProvider::CratesIo {
crate_name: "serde".to_string()
}
);
}
#[test]
fn test_detect_generic_provider() {
let url = "https://example.com/files/package-1.0.0.tar.gz";
let provider = detect_provider(url).unwrap();
assert!(matches!(provider, VersionProvider::Generic { .. }));
}
#[test]
fn test_build_url_with_version() {
let test_cases = vec![
(
"https://github.com/owner/repo/archive/v${{ version }}.tar.gz",
"2.0.0",
"https://github.com/owner/repo/archive/v2.0.0.tar.gz",
),
(
"https://pypi.io/packages/source/p/pkg/pkg-${{version}}.tar.gz",
"1.2.3",
"https://pypi.io/packages/source/p/pkg/pkg-1.2.3.tar.gz",
),
];
for (template, version, expected) in test_cases {
let context = IndexMap::new();
let result = build_url_with_version(template, version, &context).unwrap();
assert_eq!(result, expected, "Failed for template: {}", template);
}
}
#[test]
fn test_build_url_with_full_context() {
let mut context = IndexMap::new();
context.insert("name".to_string(), "mypackage".to_string());
context.insert("version".to_string(), "1.0.0".to_string());
let template = "https://example.com/${{ name }}/${{ name }}-${{ version }}.tar.gz";
let result = build_url_with_version(template, "2.0.0", &context).unwrap();
assert_eq!(
result,
"https://example.com/mypackage/mypackage-2.0.0.tar.gz"
);
}
#[test]
fn test_build_url_with_derived_context() {
let mut context = IndexMap::new();
context.insert("name".to_string(), "mypackage".to_string());
context.insert("version".to_string(), "1.0.0".to_string());
context.insert(
"version_underscore".to_string(),
"${{ version | replace('.', '_') }}".to_string(),
);
let template = "https://example.com/${{ name }}-${{ version_underscore }}.tar.gz";
let result = build_url_with_version(template, "2.0.0", &context).unwrap();
assert_eq!(result, "https://example.com/mypackage-2_0_0.tar.gz");
}
#[test]
fn test_parse_recipe_context() {
let yaml = r#"
context:
version: "1.2.3"
package:
name: test-package
version: ${{ version }}
source:
url: https://github.com/owner/repo/archive/v${{ version }}.tar.gz
sha256: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
build:
number: 0
"#;
let ctx = RecipeContext::from_yaml_content(yaml).unwrap();
assert_eq!(ctx.version, Some("1.2.3".to_string()));
assert_eq!(ctx.source_urls.len(), 1);
assert!(ctx.source_urls[0].contains("github.com"));
assert_eq!(ctx.sha256_checksums.len(), 1);
}
#[test]
fn test_parse_recipe_context_with_jinja_version() {
let yaml = r#"
context:
version: "2.0.0"
package:
name: example
version: ${{ version }}
source:
- url: https://example.com/pkg-${{ version }}.tar.gz
sha256: abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
- url: https://example.com/extra-${{ version }}.tar.gz
sha256: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
"#;
let ctx = RecipeContext::from_yaml_content(yaml).unwrap();
assert_eq!(ctx.version, Some("2.0.0".to_string()));
assert_eq!(ctx.source_urls.len(), 2);
assert_eq!(ctx.sha256_checksums.len(), 2);
}
#[test]
fn test_parse_build_number_from_build_section() {
let yaml = r#"
context:
version: "1.0.0"
build:
number: 5
"#;
let ctx = RecipeContext::from_yaml_content(yaml).unwrap();
assert!(ctx.build_number.is_some());
let build_num = ctx.build_number.unwrap();
assert_eq!(build_num.value, 5);
assert_eq!(build_num.location, BuildNumberLocation::BuildSection);
}
#[test]
fn test_parse_build_number_from_context() {
let yaml = r#"
context:
version: "1.0.0"
build_number: 3
build:
number: ${{ build_number }}
"#;
let ctx = RecipeContext::from_yaml_content(yaml).unwrap();
assert!(ctx.build_number.is_some());
let build_num = ctx.build_number.unwrap();
assert_eq!(build_num.value, 3);
assert_eq!(build_num.location, BuildNumberLocation::ContextBuildNumber);
}
#[test]
fn test_reset_build_number() {
let content = r#"
context:
version: "1.0.0"
build:
number: 5
"#;
let build_num = BuildNumber {
value: 5,
location: BuildNumberLocation::BuildSection,
};
let result = reset_build_number(content, &build_num);
assert!(result.contains("number: 0"));
assert!(!result.contains("number: 5"));
}
#[test]
fn test_reset_build_number_in_context() {
let content = r#"
context:
version: "1.0.0"
build_number: 7
build:
number: ${{ build_number }}
"#;
let build_num = BuildNumber {
value: 7,
location: BuildNumberLocation::ContextBuildNumber,
};
let result = reset_build_number(content, &build_num);
assert!(result.contains("build_number: 0"));
assert!(!result.contains("build_number: 7"));
assert!(result.contains("${{ build_number }}"));
}
}