use crate::error::{Error, Result};
use crate::version_manager::list::{Channel, VersionEntry, list_available_versions};
use crate::version_manager::platform::{DownloadSource, Platform, builds_probe_url};
use crate::version_manager::spec::VersionSpec;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct ResolvedVersion {
pub source: DownloadSource,
pub display_version: String,
pub exact_version_known: bool,
pub exact_version: Option<String>,
pub channel: Option<Channel>,
}
pub async fn resolve(spec: &VersionSpec, platform: &Platform) -> Result<ResolvedVersion> {
match spec {
VersionSpec::Latest => resolve_latest(platform).await,
VersionSpec::Channel(channel) => resolve_channel(*channel, platform).await,
VersionSpec::Major(major) => resolve_major(*major, platform).await,
VersionSpec::Minor(major, minor) => resolve_minor(*major, *minor, platform).await,
VersionSpec::Exact(version) => resolve_exact(version, platform).await,
}
}
async fn resolve_latest(_platform: &Platform) -> Result<ResolvedVersion> {
Ok(ResolvedVersion {
source: DownloadSource::Builds {
version_path: "master".to_string(),
},
display_version: "latest".to_string(),
exact_version_known: false,
exact_version: None,
channel: None,
})
}
async fn resolve_channel(channel: Channel, platform: &Platform) -> Result<ResolvedVersion> {
let available = list_available_versions().await?;
let entry = available
.iter()
.find(|e| e.channel == channel)
.ok_or_else(|| Error::NoMatchingVersion(channel.to_string()))?;
let minor = extract_minor(&entry.version)?;
if probe_builds(&minor, platform).await {
return Ok(ResolvedVersion {
source: DownloadSource::Builds {
version_path: minor.clone(),
},
display_version: format!("{} ({})", minor, channel),
exact_version_known: false,
exact_version: None,
channel: Some(channel),
});
}
Ok(fallback_source(&entry.version, entry.channel, platform))
}
async fn resolve_major(major: u32, platform: &Platform) -> Result<ResolvedVersion> {
let mut highest_available: Option<u32> = None;
let client = reqwest::Client::builder()
.user_agent(crate::user_agent::user_agent())
.build()
.map_err(|e| Error::Download(e.to_string()))?;
for minor in 1..=12 {
let url = builds_probe_url(&format!("{}.{}", major, minor), platform);
match client.head(&url).send().await {
Ok(resp) if resp.status().is_success() => {
highest_available = Some(minor);
}
_ => {}
}
}
if let Some(minor) = highest_available {
let version_path = format!("{}.{}", major, minor);
return Ok(ResolvedVersion {
source: DownloadSource::Builds {
version_path: version_path.clone(),
},
display_version: version_path,
exact_version_known: false,
exact_version: None,
channel: None,
});
}
for minor in (1..=12).rev() {
let prefix = format!("{}.{}", major, minor);
if let Ok(entry) = find_version_by_refs(&prefix).await {
return Ok(fallback_source(&entry.version, entry.channel, platform));
}
}
Err(Error::NoMatchingVersion(major.to_string()))
}
async fn resolve_minor(major: u32, minor: u32, platform: &Platform) -> Result<ResolvedVersion> {
let version_path = format!("{}.{}", major, minor);
if probe_builds(&version_path, platform).await {
return Ok(ResolvedVersion {
source: DownloadSource::Builds {
version_path: version_path.clone(),
},
display_version: version_path,
exact_version_known: false,
exact_version: None,
channel: None,
});
}
let entry = find_version_by_refs(&version_path).await?;
Ok(fallback_source(&entry.version, entry.channel, platform))
}
async fn resolve_exact(version: &str, platform: &Platform) -> Result<ResolvedVersion> {
let channel = find_exact_channel(version).await?;
Ok(fallback_source(version, channel, platform))
}
async fn find_exact_channel(version: &str) -> Result<Channel> {
let url = format!(
"https://api.github.com/repos/ClickHouse/ClickHouse/git/matching-refs/tags/v{}-",
version
);
let client = reqwest::Client::builder()
.user_agent(crate::user_agent::user_agent())
.build()?;
let response = client
.get(&url)
.send()
.await?
.error_for_status()
.map_err(|e| Error::Download(format!("GitHub API request failed: {}", e)))?;
let refs: Vec<GitRef> = response.json().await?;
parse_exact_channel(&refs, version)
}
fn parse_exact_channel(refs: &[GitRef], version: &str) -> Result<Channel> {
for git_ref in refs {
let Some(tag) = git_ref.ref_name.strip_prefix("refs/tags/v") else {
continue;
};
if let Some(dash_pos) = tag.rfind('-') {
let suffix = &tag[dash_pos + 1..];
if let Some(channel) = Channel::from_tag_suffix(suffix) {
return Ok(channel);
}
}
}
Err(Error::NoMatchingVersion(version.to_string()))
}
fn fallback_source(version: &str, channel: Channel, platform: &Platform) -> ResolvedVersion {
let source = if platform.packages_arch().is_some() {
DownloadSource::Packages {
channel,
version: version.to_string(),
}
} else {
DownloadSource::GitHub {
version: version.to_string(),
channel,
}
};
ResolvedVersion {
source,
display_version: version.to_string(),
exact_version_known: true,
exact_version: Some(version.to_string()),
channel: Some(channel),
}
}
async fn probe_builds(version_path: &str, platform: &Platform) -> bool {
let url = builds_probe_url(version_path, platform);
let client = match reqwest::Client::builder()
.user_agent(crate::user_agent::user_agent())
.build()
{
Ok(c) => c,
Err(_) => return false,
};
match client.head(&url).send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
#[derive(Deserialize)]
struct GitRef {
#[serde(rename = "ref")]
ref_name: String,
}
async fn find_version_by_refs(prefix: &str) -> Result<VersionEntry> {
let url = format!(
"https://api.github.com/repos/ClickHouse/ClickHouse/git/matching-refs/tags/v{}.",
prefix
);
let client = reqwest::Client::builder()
.user_agent(crate::user_agent::user_agent())
.build()?;
let response = client
.get(&url)
.send()
.await?
.error_for_status()
.map_err(|e| Error::Download(format!("GitHub API request failed: {}", e)))?;
let refs: Vec<GitRef> = response.json().await?;
parse_version_refs(&refs, prefix)
}
fn parse_version_refs(refs: &[GitRef], prefix: &str) -> Result<VersionEntry> {
use super::list::compare_versions;
use std::cmp::Ordering;
let mut best: Option<VersionEntry> = None;
let mut any: Option<VersionEntry> = None;
for git_ref in refs {
let Some(tag) = git_ref.ref_name.strip_prefix("refs/tags/v") else {
continue;
};
if let Some(dash_pos) = tag.rfind('-') {
let version = &tag[..dash_pos];
let suffix = &tag[dash_pos + 1..];
let is_higher = |current: &Option<VersionEntry>| match current {
Some(existing) => {
compare_versions(version, &existing.version) == Ordering::Greater
}
None => true,
};
if let Some(channel) = Channel::from_tag_suffix(suffix) {
if is_higher(&best) {
best = Some(VersionEntry {
version: version.to_string(),
channel,
});
}
} else if is_higher(&any) {
any = Some(VersionEntry {
version: version.to_string(),
channel: Channel::Stable,
});
}
}
}
best.or(any)
.ok_or_else(|| Error::NoMatchingVersion(prefix.to_string()))
}
fn extract_minor(version: &str) -> Result<String> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() >= 2 {
Ok(format!("{}.{}", parts[0], parts[1]))
} else {
Err(Error::NoMatchingVersion(format!(
"cannot extract minor version from '{}'",
version
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version_manager::platform::Os;
fn make_ref(name: &str) -> GitRef {
GitRef {
ref_name: name.to_string(),
}
}
#[test]
fn test_extract_minor() {
assert_eq!(extract_minor("25.12.9.61").unwrap(), "25.12");
assert_eq!(extract_minor("24.8.6.70").unwrap(), "24.8");
assert_eq!(extract_minor("25.12").unwrap(), "25.12");
}
#[test]
fn test_extract_minor_invalid() {
assert!(extract_minor("25").is_err());
}
#[test]
fn test_fallback_source_linux() {
let platform = Platform {
os: Os::Linux,
arch: crate::version_manager::platform::Arch::X86_64,
};
let resolved = fallback_source("25.12.9.61", Channel::Stable, &platform);
assert!(matches!(resolved.source, DownloadSource::Packages { .. }));
assert_eq!(resolved.exact_version, Some("25.12.9.61".to_string()));
assert!(resolved.exact_version_known);
}
#[test]
fn test_fallback_source_macos() {
let platform = Platform {
os: Os::MacOS,
arch: crate::version_manager::platform::Arch::Aarch64,
};
let resolved = fallback_source("25.12.9.61", Channel::Stable, &platform);
assert!(matches!(resolved.source, DownloadSource::GitHub { .. }));
assert_eq!(resolved.exact_version, Some("25.12.9.61".to_string()));
assert!(resolved.exact_version_known);
}
#[test]
fn test_parse_version_refs_stable_tag() {
let refs = vec![make_ref("refs/tags/v25.12.9.61-stable")];
let entry = parse_version_refs(&refs, "25.12").unwrap();
assert_eq!(entry.version, "25.12.9.61");
assert_eq!(entry.channel, Channel::Stable);
}
#[test]
fn test_parse_version_refs_lts_tag() {
let refs = vec![make_ref("refs/tags/v24.8.10.6-lts")];
let entry = parse_version_refs(&refs, "24.8").unwrap();
assert_eq!(entry.version, "24.8.10.6");
assert_eq!(entry.channel, Channel::Lts);
}
#[test]
fn test_parse_version_refs_prefers_stable_over_unknown() {
let refs = vec![
make_ref("refs/tags/v26.4.1.1-new"),
make_ref("refs/tags/v26.4.2.5-stable"),
];
let entry = parse_version_refs(&refs, "26.4").unwrap();
assert_eq!(entry.version, "26.4.2.5");
assert_eq!(entry.channel, Channel::Stable);
}
#[test]
fn test_parse_version_refs_falls_back_to_unknown_suffix() {
let refs = vec![make_ref("refs/tags/v26.4.1.1-new")];
let entry = parse_version_refs(&refs, "26.4").unwrap();
assert_eq!(entry.version, "26.4.1.1");
assert_eq!(entry.channel, Channel::Stable);
}
#[test]
fn test_parse_version_refs_empty_refs() {
let refs: Vec<GitRef> = vec![];
assert!(parse_version_refs(&refs, "99.99").is_err());
}
#[test]
fn test_parse_version_refs_no_matching_tags() {
let refs = vec![
make_ref("refs/heads/main"),
make_ref("something/else"),
];
assert!(parse_version_refs(&refs, "25.12").is_err());
}
#[test]
fn test_parse_version_refs_no_dash_in_tag() {
let refs = vec![make_ref("refs/tags/v25.12.9.61")];
assert!(parse_version_refs(&refs, "25.12").is_err());
}
#[test]
fn test_parse_version_refs_picks_highest_stable() {
let refs = vec![
make_ref("refs/tags/v25.12.1.10-stable"),
make_ref("refs/tags/v25.12.9.61-stable"),
];
let entry = parse_version_refs(&refs, "25.12").unwrap();
assert_eq!(entry.version, "25.12.9.61");
}
#[test]
fn test_parse_version_refs_unordered_picks_highest() {
let refs = vec![
make_ref("refs/tags/v25.12.10.5-stable"),
make_ref("refs/tags/v25.12.9.61-stable"),
];
let entry = parse_version_refs(&refs, "25.12").unwrap();
assert_eq!(entry.version, "25.12.10.5");
}
#[test]
fn test_parse_version_refs_stable_beats_later_unknown() {
let refs = vec![
make_ref("refs/tags/v26.4.2.5-stable"),
make_ref("refs/tags/v26.4.3.1-beta"),
];
let entry = parse_version_refs(&refs, "26.4").unwrap();
assert_eq!(entry.version, "26.4.2.5");
assert_eq!(entry.channel, Channel::Stable);
}
#[test]
fn test_parse_exact_channel_stable() {
let refs = vec![make_ref("refs/tags/v25.12.9.61-stable")];
assert_eq!(
parse_exact_channel(&refs, "25.12.9.61").unwrap(),
Channel::Stable
);
}
#[test]
fn test_parse_exact_channel_lts() {
let refs = vec![make_ref("refs/tags/v24.8.10.6-lts")];
assert_eq!(
parse_exact_channel(&refs, "24.8.10.6").unwrap(),
Channel::Lts
);
}
#[test]
fn test_parse_exact_channel_unknown_suffix_errors() {
let refs = vec![make_ref("refs/tags/v26.4.1.1-new")];
assert!(parse_exact_channel(&refs, "26.4.1.1").is_err());
}
#[test]
fn test_parse_exact_channel_empty_refs() {
let refs: Vec<GitRef> = vec![];
assert!(parse_exact_channel(&refs, "25.12.9.61").is_err());
}
}