From 36bbd2f1bb5dcafa575a7402cc80043214f3fb2f Mon Sep 17 00:00:00 2001 From: sdairs Date: Wed, 5 Aug 2026 13:35:56 +0100 Subject: [PATCH 1/2] Hint retry for unavailable exact builds (#341) --- crates/clickhousectl/src/error.rs | 26 ++++++++ .../src/version_manager/resolve.rs | 60 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/clickhousectl/src/error.rs b/crates/clickhousectl/src/error.rs index 8345d11d..b4a54332 100644 --- a/crates/clickhousectl/src/error.rs +++ b/crates/clickhousectl/src/error.rs @@ -42,6 +42,15 @@ pub enum Error { #[error("No matching version found for: {0}")] NoMatchingVersion(String), + #[error( + "build {version} is no longer available for download.\nNearest available in the {series} series: {available} (try `clickhousectl local install {series}`)" + )] + ExactVersionUnavailable { + version: String, + series: String, + available: String, + }, + #[error("{0}")] InvalidVersion(String), @@ -143,6 +152,23 @@ mod tests { ); } + #[test] + fn exact_version_unavailable_error_has_an_actionable_retry() { + let error = Error::ExactVersionUnavailable { + version: "26.2.8.7".into(), + series: "26.2".into(), + available: "26.2.20.4".into(), + }; + + assert_eq!( + error.to_string(), + "build 26.2.8.7 is no longer available for download.\n\ + Nearest available in the 26.2 series: 26.2.20.4 \ + (try `clickhousectl local install 26.2`)" + ); + assert_eq!(error.exit_code(), 1); + } + #[test] fn child_exit_codes_pass_through_without_changing_normal_mappings() { assert_eq!(Error::ChildExit(42).exit_code(), 42); diff --git a/crates/clickhousectl/src/version_manager/resolve.rs b/crates/clickhousectl/src/version_manager/resolve.rs index 7dc5a72a..45377453 100644 --- a/crates/clickhousectl/src/version_manager/resolve.rs +++ b/crates/clickhousectl/src/version_manager/resolve.rs @@ -179,8 +179,31 @@ async fn resolve_exact(version: &str, platform: &Platform) -> Result Ok(fallback_source(version, channel, platform)), + Err(Error::NoMatchingVersion(_)) => { + let series = extract_minor(version)?; + let available = match find_version_by_refs(&series).await { + Ok(entry) => Some(entry), + Err(Error::NoMatchingVersion(_)) => None, + Err(error) => return Err(error), + }; + + Err(exact_version_no_match(version, &series, available.as_ref())) + } + Err(error) => Err(error), + } +} + +fn exact_version_no_match(version: &str, series: &str, available: Option<&VersionEntry>) -> Error { + match available { + Some(entry) => Error::ExactVersionUnavailable { + version: version.to_string(), + series: series.to_string(), + available: entry.version.clone(), + }, + None => Error::NoMatchingVersion(version.to_string()), + } } /// Look up the channel for an exact version via GitHub's matching-refs API @@ -511,6 +534,39 @@ mod tests { assert!(parse_exact_channel(&refs, "25.12.9.61").is_err()); } + #[test] + fn test_exact_version_no_match_hints_highest_series_version() { + let refs = vec![ + make_ref("refs/tags/v26.2.19.43-stable"), + make_ref("refs/tags/v26.2.9.9-stable"), + make_ref("refs/tags/v26.2.20.4-stable"), + ]; + let available = parse_version_refs(&refs, "26.2").unwrap(); + + let error = exact_version_no_match("26.2.8.7", "26.2", Some(&available)); + + assert!(matches!( + error, + Error::ExactVersionUnavailable { + ref version, + ref series, + ref available, + } if version == "26.2.8.7" + && series == "26.2" + && available == "26.2.20.4" + )); + } + + #[test] + fn test_exact_version_no_match_without_series_preserves_generic_error() { + let error = exact_version_no_match("99.99.1.1", "99.99", None); + + assert!(matches!( + error, + Error::NoMatchingVersion(ref version) if version == "99.99.1.1" + )); + } + // -- find_local_match tests -- fn installed(versions: &[&str]) -> Vec { From faedd734c2c5a259660b6e8251f9ca498599d08b Mon Sep 17 00:00:00 2001 From: sdairs Date: Wed, 5 Aug 2026 18:43:49 +0100 Subject: [PATCH 2/2] Preserve exact version miss semantics --- crates/clickhousectl/src/error.rs | 3 ++ .../src/version_manager/resolve.rs | 39 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/clickhousectl/src/error.rs b/crates/clickhousectl/src/error.rs index b4a54332..10df5f1b 100644 --- a/crates/clickhousectl/src/error.rs +++ b/crates/clickhousectl/src/error.rs @@ -51,6 +51,9 @@ pub enum Error { available: String, }, + #[error("build {0} exists, but its release channel could not be determined")] + UnknownVersionChannel(String), + #[error("{0}")] InvalidVersion(String), diff --git a/crates/clickhousectl/src/version_manager/resolve.rs b/crates/clickhousectl/src/version_manager/resolve.rs index 45377453..bef8d5ea 100644 --- a/crates/clickhousectl/src/version_manager/resolve.rs +++ b/crates/clickhousectl/src/version_manager/resolve.rs @@ -183,11 +183,8 @@ async fn resolve_exact(version: &str, platform: &Platform) -> Result Ok(fallback_source(version, channel, platform)), Err(Error::NoMatchingVersion(_)) => { let series = extract_minor(version)?; - let available = match find_version_by_refs(&series).await { - Ok(entry) => Some(entry), - Err(Error::NoMatchingVersion(_)) => None, - Err(error) => return Err(error), - }; + // The exact miss is definitive; fetching a retry hint is best-effort. + let available = find_version_by_refs(&series).await.ok(); Err(exact_version_no_match(version, &series, available.as_ref())) } @@ -228,10 +225,18 @@ async fn find_exact_channel(version: &str) -> Result { /// Parse the channel from a list of git refs for an exact version. /// Looks for tags like "refs/tags/v26.4.1.562-stable" and extracts the channel suffix. fn parse_exact_channel(refs: &[GitRef], version: &str) -> Result { + let version_prefix = format!("{}-", version); + let mut exact_tag_found = false; + for git_ref in refs { let Some(tag) = git_ref.ref_name.strip_prefix("refs/tags/v") else { continue; }; + if !tag.starts_with(&version_prefix) { + continue; + } + exact_tag_found = true; + if let Some(dash_pos) = tag.rfind('-') { let suffix = &tag[dash_pos + 1..]; if let Some(channel) = Channel::from_tag_suffix(suffix) { @@ -240,7 +245,11 @@ fn parse_exact_channel(refs: &[GitRef], version: &str) -> Result { } } - Err(Error::NoMatchingVersion(version.to_string())) + if exact_tag_found { + Err(Error::UnknownVersionChannel(version.to_string())) + } else { + Err(Error::NoMatchingVersion(version.to_string())) + } } /// Build a fallback download source: packages for Linux, GitHub for macOS @@ -525,13 +534,27 @@ mod tests { fn test_parse_exact_channel_unknown_suffix_errors() { // parse_exact_channel does NOT fall back to unknown suffixes let refs = vec![make_ref("refs/tags/v26.4.1.1-new")]; - assert!(parse_exact_channel(&refs, "26.4.1.1").is_err()); + let error = parse_exact_channel(&refs, "26.4.1.1").unwrap_err(); + + assert_eq!( + error.to_string(), + "build 26.4.1.1 exists, but its release channel could not be determined" + ); + assert!(matches!( + error, + Error::UnknownVersionChannel(ref version) if version == "26.4.1.1" + )); } #[test] fn test_parse_exact_channel_empty_refs() { let refs: Vec = vec![]; - assert!(parse_exact_channel(&refs, "25.12.9.61").is_err()); + let error = parse_exact_channel(&refs, "25.12.9.61").unwrap_err(); + + assert!(matches!( + error, + Error::NoMatchingVersion(ref version) if version == "25.12.9.61" + )); } #[test]