From aa81635072a50e95f3ba42036dd57f923062ab31 Mon Sep 17 00:00:00 2001 From: Evan McGrane Date: Sat, 28 Feb 2026 18:31:57 +0000 Subject: [PATCH] feat: add version selection to installer --- cli/main.rs | 55 +++++- poly-bench-project/src/runtime_installer.rs | 185 ++++++++++++++++++-- 2 files changed, 227 insertions(+), 13 deletions(-) diff --git a/cli/main.rs b/cli/main.rs index 84c00fb..8d73ac9 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -1332,6 +1332,7 @@ fn init_interactive() -> Result<(String, Vec)> { match choice { InstallChoice::Install => { if project::runtime_installer::can_auto_install(lang) { + let version = prompt_version_select(&theme, lang, &label)?; let custom_path = prompt_install_path( &theme, lang, @@ -1342,6 +1343,7 @@ fn init_interactive() -> Result<(String, Vec)> { lang, project::runtime_installer::InstallLocation::UserLocal, custom_path, + version, ) { Err(e) => { eprintln!("{} Failed to install {}: {}", "✗".red(), label, e); @@ -1419,6 +1421,55 @@ fn prompt_install_or_skip( Ok(if selected == 0 { InstallChoice::Install } else { InstallChoice::Skip }) } +/// Prompts for version selection: latest (recommended) or choose from fetched top 5. +/// Returns None to use default/latest, Some(version) when user picks a specific version. +fn prompt_version_select( + theme: &init_t3::T3StyleTheme, + lang: poly_bench_dsl::Lang, + label: &str, +) -> Result> { + use colored::Colorize; + use dialoguer::Select; + use miette::miette; + + if !project::runtime_installer::supports_version_selection(lang) { + return Ok(None); + } + + let choices = ["Latest (recommended)".to_string(), "Choose version...".to_string()]; + let selected = Select::with_theme(theme) + .with_prompt(&format!("Which {} version to install?", label)) + .items(&choices) + .default(0) + .interact() + .map_err(|e| miette!("Prompt failed: {}", e))?; + + if selected == 0 { + return Ok(None); + } + + let versions = match project::runtime_installer::fetch_available_versions(lang) { + Ok(v) => v, + Err(e) => { + eprintln!("{} Could not fetch versions: {}. Using latest.", "⚠".yellow(), e); + return Ok(None); + } + }; + + if versions.is_empty() { + return Ok(None); + } + + let selected_idx = Select::with_theme(theme) + .with_prompt("Select version") + .items(&versions) + .default(0) + .interact() + .map_err(|e| miette!("Prompt failed: {}", e))?; + + Ok(versions.get(selected_idx).cloned()) +} + /// Prompts for install path: default or custom. Returns None for default, Some(path) for custom. fn prompt_install_path( theme: &init_t3::T3StyleTheme, @@ -1608,8 +1659,10 @@ fn cmd_add_runtime(runtime: &str, user_local: bool) -> Result<()> { } else { project::runtime_installer::InstallLocation::System }; + let version = prompt_version_select(&theme, lang, &label)?; let custom_path = prompt_install_path(&theme, lang, loc, &label)?; - match project::runtime_installer::install_lang(lang, loc, custom_path)? { + match project::runtime_installer::install_lang(lang, loc, custom_path, version)? + { Some(custom_bin_dir) => { if let Ok(current) = std::env::var("PATH") { let sep = if cfg!(windows) { ";" } else { ":" }; diff --git a/poly-bench-project/src/runtime_installer.rs b/poly-bench-project/src/runtime_installer.rs index e5d143c..1e712de 100644 --- a/poly-bench-project/src/runtime_installer.rs +++ b/poly-bench-project/src/runtime_installer.rs @@ -101,6 +101,150 @@ pub fn can_auto_install(lang: Lang) -> bool { lang != Lang::C } +/// Returns true if this language supports interactive version selection (fetch + pick from list). +pub fn supports_version_selection(lang: Lang) -> bool { + matches!(lang, Lang::Go | Lang::TypeScript | Lang::Zig | Lang::CSharp) +} + +/// Fetches available versions for a language from official APIs. Returns up to 5 versions. +/// Returns error on network failure or parse error. +pub fn fetch_available_versions(lang: Lang) -> Result> { + match lang { + Lang::Go => fetch_go_versions(), + Lang::TypeScript => fetch_node_versions(), + Lang::Zig => fetch_zig_versions(), + Lang::CSharp => fetch_dotnet_versions(), + _ => Err(miette::miette!( + "Version selection not supported for {}", + poly_bench_runtime::lang_label(lang) + )), + } +} + +fn fetch_go_versions() -> Result> { + let url = "https://go.dev/dl/?mode=json"; + let resp: Vec = ureq::get(url) + .call() + .map_err(|e| miette::miette!("Failed to fetch Go versions: {}", e))? + .body_mut() + .read_json() + .map_err(|e| miette::miette!("Failed to parse Go versions: {}", e))?; + + let versions: Vec = resp + .into_iter() + .filter_map(|v: serde_json::Value| { + let stable = v.get("stable").and_then(|s| s.as_bool()).unwrap_or(false); + if !stable { + return None; + } + v.get("version").and_then(|s: &serde_json::Value| s.as_str()).map(|s| s.to_string()) + }) + .take(5) + .collect(); + + if versions.is_empty() { + return Err(miette::miette!("No stable Go versions found")); + } + Ok(versions) +} + +fn fetch_node_versions() -> Result> { + let url = "https://nodejs.org/dist/index.json"; + let resp: Vec = ureq::get(url) + .call() + .map_err(|e| miette::miette!("Failed to fetch Node.js versions: {}", e))? + .body_mut() + .read_json() + .map_err(|e| miette::miette!("Failed to parse Node.js versions: {}", e))?; + + let versions: Vec = resp + .into_iter() + .filter_map(|v: serde_json::Value| { + v.get("version").and_then(|s: &serde_json::Value| s.as_str()).map(|s| s.to_string()) + }) + .take(5) + .collect(); + + if versions.is_empty() { + return Err(miette::miette!("No Node.js versions found")); + } + Ok(versions) +} + +fn fetch_zig_versions() -> Result> { + let url = "https://ziglang.org/download/index.json"; + let resp: serde_json::Value = ureq::get(url) + .call() + .map_err(|e| miette::miette!("Failed to fetch Zig versions: {}", e))? + .body_mut() + .read_json() + .map_err(|e| miette::miette!("Failed to parse Zig versions: {}", e))?; + + let obj = resp.as_object().ok_or_else(|| miette::miette!("Zig index is not an object"))?; + + let mut versions: Vec = obj + .keys() + .filter(|k: &&String| { + let k = k.as_str(); + k != "master" && + !k.contains("-dev") && + k.chars().next().map(|c: char| c.is_ascii_digit()).unwrap_or(false) + }) + .cloned() + .collect(); + + versions.sort_by(|a, b| semver_compare_zig(a, b).unwrap_or(std::cmp::Ordering::Equal)); + versions.reverse(); + versions.truncate(5); + + if versions.is_empty() { + return Err(miette::miette!("No Zig versions found")); + } + Ok(versions) +} + +fn semver_compare_zig(a: &str, b: &str) -> Option { + let parse = |s: &str| { + let parts: Vec = s.split('.').filter_map(|p| p.parse::().ok()).collect(); + ( + parts.get(0).copied().unwrap_or(0), + parts.get(1).copied().unwrap_or(0), + parts.get(2).copied().unwrap_or(0), + ) + }; + let va = parse(a); + let vb = parse(b); + Some(va.cmp(&vb)) +} + +fn fetch_dotnet_versions() -> Result> { + let url = "https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json"; + let resp: serde_json::Value = ureq::get(url) + .call() + .map_err(|e| miette::miette!("Failed to fetch .NET versions: {}", e))? + .body_mut() + .read_json() + .map_err(|e| miette::miette!("Failed to parse .NET versions: {}", e))?; + + let index = resp + .get("releases-index") + .and_then(|v: &serde_json::Value| v.as_array()) + .ok_or_else(|| miette::miette!("Invalid .NET releases index"))?; + + let versions: Vec = index + .iter() + .filter_map(|v: &serde_json::Value| { + v.get("latest-sdk").and_then(|s: &serde_json::Value| s.as_str()).map(|s| s.to_string()) + }) + .take(5) + .collect(); + + if versions.is_empty() { + return Err(miette::miette!("No .NET SDK versions found")); + } + Ok(versions) +} + /// Returns the bin directory to prepend to PATH when the binary is not on PATH but exists in a /// standard location. Returns None if already on PATH. pub fn lang_bin_path_for_prepend(lang: Lang) -> Option { @@ -269,10 +413,12 @@ pub fn ensure_path_in_shell_config(bin_dir: &Path) -> Result> { /// Install a language runtime. For C, returns error with install_hint. /// Returns Some(bin_dir) when installed to custom path (caller should add to shell config). +/// `version` is optional; when None, uses the default/latest for that language. pub fn install_lang( lang: Lang, location: InstallLocation, custom_path: Option, + version: Option, ) -> Result> { if !can_auto_install(lang) { return Err(runtime_check::not_installed_error(lang)); @@ -284,12 +430,12 @@ pub fn install_lang( let pb = terminal::indented_spinner(&format!("Installing {}...", label)); let result = match lang { - Lang::Go => install_go(location, custom_path), - Lang::TypeScript => install_node(location, custom_path), + Lang::Go => install_go(location, custom_path, version), + Lang::TypeScript => install_node(location, custom_path, version), Lang::Rust => install_rust(location, custom_path), Lang::Python => install_python(location, custom_path), - Lang::Zig => install_zig(location, custom_path), - Lang::CSharp => install_dotnet(location, custom_path), + Lang::Zig => install_zig(location, custom_path, version), + Lang::CSharp => install_dotnet(location, custom_path, version), Lang::C => Err(runtime_check::not_installed_error(lang)), }; @@ -320,9 +466,15 @@ fn platform() -> (String, String) { (arch, os) } -fn install_go(location: InstallLocation, custom_path: Option) -> Result> { +fn install_go( + location: InstallLocation, + custom_path: Option, + version_opt: Option, +) -> Result> { let (arch, os) = platform(); - let version = "1.22.4"; + let version = version_opt + .map(|v| v.trim_start_matches("go").to_string()) + .unwrap_or_else(|| "1.22.4".to_string()); let filename = format!("go{}.{}-{}.tar.gz", version, os, arch); let url = format!("https://go.dev/dl/{}", filename); @@ -370,9 +522,12 @@ fn install_go(location: InstallLocation, custom_path: Option) -> Result fn install_node( location: InstallLocation, custom_path: Option, + version_opt: Option, ) -> Result> { let (arch, os) = platform(); - let version = "22.11.0"; + let version = version_opt + .map(|v| v.trim_start_matches('v').to_string()) + .unwrap_or_else(|| "22.11.0".to_string()); let (node_arch, node_os) = match (os.as_str(), arch.as_str()) { ("darwin", "arm64") => ("arm64", "darwin"), ("darwin", "amd64") => ("x64", "darwin"), @@ -582,9 +737,13 @@ fn install_python( Ok(custom_path.map(|_| path_to_add)) } -fn install_zig(location: InstallLocation, custom_path: Option) -> Result> { +fn install_zig( + location: InstallLocation, + custom_path: Option, + version_opt: Option, +) -> Result> { let (arch, os) = platform(); - let version = "0.13.0"; + let version = version_opt.unwrap_or_else(|| "0.13.0".to_string()); let (zig_arch, zig_os) = match (os.as_str(), arch.as_str()) { ("darwin", "arm64") => ("aarch64", "macos"), ("darwin", "amd64") => ("x86_64", "macos"), @@ -594,12 +753,13 @@ fn install_zig(location: InstallLocation, custom_path: Option) -> Resul ("windows", "arm64") => ("aarch64", "windows"), _ => ("x86_64", "linux"), }; - let filename = format!("zig-{}-{}-{}.tar.xz", zig_os, zig_arch, version); + // Zig format: zig-{arch}-{os}-{version}.tar.xz (e.g. zig-aarch64-macos-0.15.2.tar.xz) + let filename = format!("zig-{}-{}-{}.tar.xz", zig_arch, zig_os, version); let url = format!("https://ziglang.org/download/{}/{}", version, filename); let install_dir = custom_path.clone().unwrap_or_else(|| lang_install_dir(Lang::Zig, location).unwrap()); - let zig_extracted = install_dir.join(format!("zig-{}-{}-{}", zig_os, zig_arch, version)); + let zig_extracted = install_dir.join(format!("zig-{}-{}-{}", zig_arch, zig_os, version)); let zig_bin = zig_extracted.join("zig"); let zig_bin_exe = zig_extracted.join("zig.exe"); if (zig_bin.exists() || zig_bin_exe.exists()) && which::which("zig").is_err() { @@ -654,9 +814,10 @@ fn install_zig(location: InstallLocation, custom_path: Option) -> Resul fn install_dotnet( location: InstallLocation, custom_path: Option, + version_opt: Option, ) -> Result> { let (arch, os) = platform(); - let version = "8.0.203"; + let version = version_opt.unwrap_or_else(|| "8.0.203".to_string()); let (dotnet_arch, dotnet_os) = match (os.as_str(), arch.as_str()) { ("darwin", "arm64") => ("arm64", "osx"), ("darwin", "amd64") => ("x64", "osx"),