diff --git a/Cargo.lock b/Cargo.lock index 848c0233d..256e7d5ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,7 @@ dependencies = [ "miette 7.6.0", "reqwest", "serde", + "serde_json", "sha2", "starlark_syntax", "tempfile", diff --git a/crates/aspect-launcher/BUILD.bazel b/crates/aspect-launcher/BUILD.bazel index 7e11481d8..3f2912ccb 100644 --- a/crates/aspect-launcher/BUILD.bazel +++ b/crates/aspect-launcher/BUILD.bazel @@ -1,7 +1,7 @@ load("@aspect_bazel_lib//lib:expand_template.bzl", "expand_template") load("//bazel/release:release.bzl", "release") load("//bazel/release/homebrew:multi_platform_brew_artifacts.bzl", "multi_platform_brew_artifacts") -load("//bazel/rust:defs.bzl", "rust_binary") +load("//bazel/rust:defs.bzl", "rust_binary", "rust_test") load("//bazel/rust:multi_platform_rust_binaries.bzl", "multi_platform_rust_binaries") rust_binary( @@ -25,6 +25,15 @@ rust_binary( visibility = ["//:__pkg__"], ) +rust_test( + name = "test", + size = "small", + crate = ":aspect-launcher", + deps = [ + "@crates//:serde_json", + ], +) + release( name = "release", targets = [":bins", ":brew"], diff --git a/crates/aspect-launcher/Cargo.toml b/crates/aspect-launcher/Cargo.toml index 76f823865..bf70a7cfa 100644 --- a/crates/aspect-launcher/Cargo.toml +++ b/crates/aspect-launcher/Cargo.toml @@ -27,3 +27,6 @@ sha2 = "0.10.9" starlark_syntax = "0.13.0" tempfile = "3.20.0" tokio = { version = "1.45.1", features = ["fs", "macros", "rt", "rt-multi-thread"] } + +[dev-dependencies] +serde_json = "1.0" diff --git a/crates/aspect-launcher/README.md b/crates/aspect-launcher/README.md index d1b4e0bfb..c1f7b1cd2 100644 --- a/crates/aspect-launcher/README.md +++ b/crates/aspect-launcher/README.md @@ -1,7 +1,172 @@ # aspect-launcher -With a bare minimum of code, perform the following. +The aspect-launcher is a thin bootstrap binary that provisions and executes the +full `aspect-cli`. It is distributed as the `aspect` binary that users install +(e.g. via Homebrew). When a user runs `aspect build //...`, the launcher: -- Look for an `.aspect/config.toml` -- Read `.aspect_cli.version` -- ... +1. Locates the project root (walks up from cwd looking for `MODULE.aspect`, + `MODULE.bazel`, `WORKSPACE`, etc.) +2. Reads `.aspect/version.axl` (if present) to determine which version of + `aspect-cli` to use and where to download it from +3. Downloads (or retrieves from cache) the correct `aspect-cli` binary +4. `exec`s the real `aspect-cli`, forwarding all arguments + +The launcher also forks a child process to report anonymous usage telemetry +(honoring `DO_NOT_TRACK`). + +## version.axl + +The file `.aspect/version.axl` controls which `aspect-cli` version the launcher +provisions. It uses Starlark syntax and contains a single `version()` call. + +### Pinned version (recommended) + +```starlark +version("2026.11.6") +``` + +This pins the project to a specific `aspect-cli` release. The launcher downloads +directly from `https://github.com/aspect-build/aspect-cli/releases/download/v2026.11.6/` +with no GitHub API call needed. + +### Pinned version with custom sources + +```starlark +version( + "2026.11.6", + sources = [ + local("bazel-bin/cli/aspect"), + github( + org = "aspect-build", + repo = "aspect-cli", + ), + ], +) +``` + +Sources are tried in order. This example first checks for a local build, then +falls back to GitHub. + +### No version.axl + +When no `.aspect/version.axl` file exists, the launcher uses its own compiled-in +version and the default GitHub source. This means the `aspect-cli` version +floats with the installed launcher version. + +### Can you have a version.axl without pinning? + +While the parser technically allows `version()` with no positional argument +(falling back to the launcher's built-in version), this is equivalent to not +having a `version.axl` at all. If you create a `version.axl`, you should +specify a version string. The only reason to have a `version.axl` without a +pinned version would be to customize the `sources` list, e.g.: + +```starlark +version( + sources = [ + local("bazel-bin/cli/aspect"), + github(org = "my-fork", repo = "aspect-cli"), + ], +) +``` + +This is a niche use case. In general, if `version.axl` exists, pin a version. + +### version() reference + +``` +version(?, sources = [...]?) +``` + +**Arguments:** + +| Argument | Required | Description | +|----------|----------|-------------| +| *(positional)* | No | Version string (e.g. `"2026.11.6"`). If omitted, defaults to the launcher's own version. | +| `sources` | No | List of source specifiers, tried in order. If omitted, defaults to `[github(org = "aspect-build", repo = "aspect-cli")]`. | + +### Source types + +#### github() + +```starlark +github( + org = "aspect-build", # required + repo = "aspect-cli", # required + tag = "v{version}", # optional, default: "v{version}" + artifact = "{repo}-{target}", # optional, default: "{repo}-{target}" +) +``` + +#### http() + +```starlark +http( + url = "https://example.com/aspect-cli-{version}-{target}", # required +) +``` + +#### local() + +```starlark +local("bazel-bin/cli/aspect") # path relative to project root +``` + +### Template variables + +The `tag`, `artifact`, and `url` fields support these placeholders: + +| Variable | Description | Example | +|----------|-------------|---------| +| `{version}` | The version string from `version()` | `2026.11.6` | +| `{os}` | Operating system | `darwin`, `linux` | +| `{arch}` | CPU architecture (Bazel naming) | `aarch64`, `x86_64` | +| `{target}` | LLVM target triple | `aarch64-apple-darwin`, `x86_64-unknown-linux-musl` | + +## Download flow + +### Pinned version (version specified in version.axl) + +``` +version.axl: version("2026.11.6", sources = [github(org = "aspect-build", repo = "aspect-cli")]) +``` + +1. Tag is computed: `v2026.11.6` +2. Cache is checked — if the binary is already cached, it is used immediately +3. Direct download from + `https://github.com/aspect-build/aspect-cli/releases/download/v2026.11.6/aspect-cli-{target}` +4. If the download fails, the error is reported — **no fallback to a different + version**. When you pin, you are guaranteed to get exactly that version or + an error. + +### Unpinned version (no version.axl, or version.axl without a version string) + +``` +(no .aspect/version.axl file) +``` + +1. Launcher queries the GitHub releases API + (`/repos/{org}/{repo}/releases?per_page=10`) +2. Scans the most recent releases to find the first one that contains the + matching artifact — this gives us a concrete tag (e.g. `v2026.11.5`) +3. Direct download from + `https://github.com/{org}/{repo}/releases/download/{resolved_tag}/{artifact}` +4. The downloaded binary is cached for future runs + +This means the unpinned case always gets the latest *available* release — it +gracefully handles the window during a new release where assets haven't finished +uploading by using the most recent release that has them. + +## Caching + +Downloaded binaries are cached under the system cache directory +(`~/Library/Caches/aspect/launcher/` on macOS, `~/.cache/aspect/launcher/` on +Linux). The cache path is derived from a SHA-256 hash of the tool name and +source URL, so different versions coexist without conflict. + +The cache location can be overridden with the `ASPECT_CLI_DOWNLOADER_CACHE` +environment variable. + +## Debugging + +Set `ASPECT_DEBUG=1` to enable verbose logging of the download and caching flow. diff --git a/crates/aspect-launcher/src/cache.rs b/crates/aspect-launcher/src/cache.rs index be996419f..f3cb36db7 100644 --- a/crates/aspect-launcher/src/cache.rs +++ b/crates/aspect-launcher/src/cache.rs @@ -40,3 +40,48 @@ impl AspectCache { self.root.join(format!("bin/{0}/{1}/{0}", tool_name, hash)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_path_structure() { + let cache = AspectCache::from(PathBuf::from("/tmp/cache")); + let path = cache.tool_path( + &"aspect-cli".to_string(), + &"https://github.com/aspect-build/aspect-cli/releases/tags/v1.0.0".to_string(), + ); + // Path should be: /tmp/cache/bin/{tool_name}/{hash}/{tool_name} + let components: Vec<_> = path.components().collect(); + let path_str = path.to_str().unwrap(); + assert!(path_str.starts_with("/tmp/cache/bin/aspect-cli/")); + assert!(path_str.ends_with("/aspect-cli")); + // Should have the structure: root/bin/name/hash/name + assert_eq!(components.len(), 7); // /tmp/cache/bin/aspect-cli/{hash}/aspect-cli + } + + #[test] + fn test_tool_path_deterministic() { + let cache = AspectCache::from(PathBuf::from("/tmp/cache")); + let path1 = cache.tool_path(&"tool".to_string(), &"source-a".to_string()); + let path2 = cache.tool_path(&"tool".to_string(), &"source-a".to_string()); + assert_eq!(path1, path2); + } + + #[test] + fn test_tool_path_different_sources_differ() { + let cache = AspectCache::from(PathBuf::from("/tmp/cache")); + let path1 = cache.tool_path(&"tool".to_string(), &"source-a".to_string()); + let path2 = cache.tool_path(&"tool".to_string(), &"source-b".to_string()); + assert_ne!(path1, path2); + } + + #[test] + fn test_tool_path_different_names_differ() { + let cache = AspectCache::from(PathBuf::from("/tmp/cache")); + let path1 = cache.tool_path(&"tool-a".to_string(), &"source".to_string()); + let path2 = cache.tool_path(&"tool-b".to_string(), &"source".to_string()); + assert_ne!(path1, path2); + } +} diff --git a/crates/aspect-launcher/src/config.rs b/crates/aspect-launcher/src/config.rs index d5d244466..15a23f85f 100644 --- a/crates/aspect-launcher/src/config.rs +++ b/crates/aspect-launcher/src/config.rs @@ -4,7 +4,6 @@ use std::fmt::Debug; use std::fs; use std::path::{Path, PathBuf}; -use aspect_telemetry::cargo_pkg_short_version; use miette::{Result, miette}; use starlark_syntax::syntax::ast::{ArgumentP, AstExpr, AstLiteral, CallArgsP, Expr, Stmt}; use starlark_syntax::syntax::{AstModule, Dialect}; @@ -19,7 +18,9 @@ pub struct AspectLauncherConfig { #[derive(Debug, Clone)] pub struct AspectCliConfig { sources: Vec, - version: String, + /// The pinned version string, or `None` if the version should be resolved + /// by querying the releases API for the latest available release. + version: Option, } #[derive(Debug, Clone)] @@ -43,7 +44,9 @@ pub enum ToolSource { pub trait ToolSpec: Debug { fn name(&self) -> String; - fn version(&self) -> &String; + /// The pinned version string, or `None` when the version should be resolved + /// at download time (e.g. by querying the GitHub releases API). + fn version(&self) -> Option<&str>; fn sources(&self) -> &Vec; } @@ -56,8 +59,8 @@ impl ToolSpec for AspectCliConfig { &self.sources } - fn version(&self) -> &String { - &self.version + fn version(&self) -> Option<&str> { + self.version.as_deref() } } @@ -73,7 +76,7 @@ fn default_cli_sources() -> Vec { fn default_aspect_cli_config() -> AspectCliConfig { AspectCliConfig { sources: default_cli_sources(), - version: cargo_pkg_short_version(), + version: None, } } @@ -281,7 +284,7 @@ fn parse_version_axl(content: &str) -> Result { Ok(AspectLauncherConfig { aspect_cli: AspectCliConfig { - version: version.unwrap_or_else(cargo_pkg_short_version), + version, sources: sources.unwrap_or_else(default_cli_sources), }, }) @@ -316,6 +319,265 @@ pub fn load_config(path: &PathBuf) -> Result { /// **Errors** /// /// Returns an error if the current working directory cannot be obtained or if loading the config fails. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_version_with_pinned_version_and_github_source() { + let content = r#" +version( + "2026.11.6", + sources = [ + github( + org = "aspect-build", + repo = "aspect-cli", + ), + ], +) +"#; + let config = parse_version_axl(content).unwrap(); + assert_eq!(config.aspect_cli.version(), Some("2026.11.6")); + assert_eq!(config.aspect_cli.sources().len(), 1); + match &config.aspect_cli.sources()[0] { + ToolSource::GitHub { + org, + repo, + tag, + artifact, + } => { + assert_eq!(org, "aspect-build"); + assert_eq!(repo, "aspect-cli"); + assert_eq!(tag, ""); + assert_eq!(artifact, ""); + } + other => panic!("expected GitHub source, got {:?}", other), + } + } + + #[test] + fn test_parse_version_with_custom_tag_and_artifact() { + let content = r#" +version( + "1.2.3", + sources = [ + github( + org = "my-org", + repo = "my-repo", + tag = "release-{version}", + artifact = "my-tool-{target}", + ), + ], +) +"#; + let config = parse_version_axl(content).unwrap(); + assert_eq!(config.aspect_cli.version(), Some("1.2.3")); + match &config.aspect_cli.sources()[0] { + ToolSource::GitHub { + tag, artifact, .. + } => { + assert_eq!(tag, "release-{version}"); + assert_eq!(artifact, "my-tool-{target}"); + } + other => panic!("expected GitHub source, got {:?}", other), + } + } + + #[test] + fn test_parse_version_with_no_version_uses_default() { + let content = r#"version()"#; + let config = parse_version_axl(content).unwrap(); + assert_eq!(config.aspect_cli.version(), None); + } + + #[test] + fn test_parse_version_with_custom_sources_but_no_version() { + let content = r#" +version( + sources = [ + local("bazel-bin/cli/aspect"), + github(org = "my-fork", repo = "aspect-cli"), + ], +) +"#; + let config = parse_version_axl(content).unwrap(); + assert_eq!(config.aspect_cli.version(), None); + assert_eq!(config.aspect_cli.sources().len(), 2); + assert!(matches!( + &config.aspect_cli.sources()[0], + ToolSource::Local { path } if path == "bazel-bin/cli/aspect" + )); + match &config.aspect_cli.sources()[1] { + ToolSource::GitHub { org, repo, .. } => { + assert_eq!(org, "my-fork"); + assert_eq!(repo, "aspect-cli"); + } + other => panic!("expected GitHub source, got {:?}", other), + } + } + + #[test] + fn test_parse_version_with_http_source() { + let content = r#" +version( + "1.0.0", + sources = [ + http( + url = "https://example.com/tool-{version}-{target}", + ), + ], +) +"#; + let config = parse_version_axl(content).unwrap(); + match &config.aspect_cli.sources()[0] { + ToolSource::Http { url, headers } => { + assert_eq!(url, "https://example.com/tool-{version}-{target}"); + assert!(headers.is_empty()); + } + other => panic!("expected Http source, got {:?}", other), + } + } + + #[test] + fn test_parse_version_with_http_source_headers_is_broken() { + // NOTE: extract_named_string_args fails on non-string named args + // like `headers = {...}`. This is a known bug. + let content = r#" +version( + "1.0.0", + sources = [ + http( + url = "https://example.com/tool", + headers = {"Authorization": "Bearer token"}, + ), + ], +) +"#; + let result = parse_version_axl(content); + assert!(result.is_err(), "http() with headers is currently broken"); + } + + #[test] + fn test_parse_version_with_local_source() { + let content = r#" +version( + "1.0.0", + sources = [ + local("bazel-bin/cli/aspect"), + ], +) +"#; + let config = parse_version_axl(content).unwrap(); + match &config.aspect_cli.sources()[0] { + ToolSource::Local { path } => { + assert_eq!(path, "bazel-bin/cli/aspect"); + } + other => panic!("expected Local source, got {:?}", other), + } + } + + #[test] + fn test_parse_version_with_multiple_sources() { + let content = r#" +version( + "1.0.0", + sources = [ + local("bazel-bin/cli/aspect"), + github(org = "aspect-build", repo = "aspect-cli"), + ], +) +"#; + let config = parse_version_axl(content).unwrap(); + assert_eq!(config.aspect_cli.sources().len(), 2); + assert!(matches!( + &config.aspect_cli.sources()[0], + ToolSource::Local { .. } + )); + assert!(matches!( + &config.aspect_cli.sources()[1], + ToolSource::GitHub { .. } + )); + } + + #[test] + fn test_parse_version_no_sources_uses_default() { + let content = r#"version("1.0.0")"#; + let config = parse_version_axl(content).unwrap(); + assert_eq!(config.aspect_cli.sources().len(), 1); + match &config.aspect_cli.sources()[0] { + ToolSource::GitHub { org, repo, .. } => { + assert_eq!(org, "aspect-build"); + assert_eq!(repo, "aspect-cli"); + } + other => panic!("expected default GitHub source, got {:?}", other), + } + } + + #[test] + fn test_parse_version_missing_version_call_errors() { + let content = r#"print("hello")"#; + let result = parse_version_axl(content); + assert!(result.is_err()); + } + + #[test] + fn test_parse_version_invalid_syntax_errors() { + let content = r#"version(123)"#; + let result = parse_version_axl(content); + assert!(result.is_err()); + } + + #[test] + fn test_parse_version_unknown_argument_errors() { + let content = r#"version("1.0.0", flavor = "spicy")"#; + let result = parse_version_axl(content); + assert!(result.is_err()); + } + + #[test] + fn test_parse_version_duplicate_positional_errors() { + let content = r#"version("1.0.0", "2.0.0")"#; + let result = parse_version_axl(content); + assert!(result.is_err()); + } + + #[test] + fn test_parse_github_missing_org_errors() { + let content = r#"version("1.0.0", sources = [github(repo = "aspect-cli")])"#; + let result = parse_version_axl(content); + assert!(result.is_err()); + } + + #[test] + fn test_parse_github_missing_repo_errors() { + let content = r#"version("1.0.0", sources = [github(org = "aspect-build")])"#; + let result = parse_version_axl(content); + assert!(result.is_err()); + } + + #[test] + fn test_parse_unknown_source_type_errors() { + let content = r#"version("1.0.0", sources = [ftp("foo")])"#; + let result = parse_version_axl(content); + assert!(result.is_err()); + } + + #[test] + fn test_default_config() { + let config = super::default_config(); + assert_eq!(config.aspect_cli.version(), None); + assert_eq!(config.aspect_cli.sources().len(), 1); + assert!(matches!( + &config.aspect_cli.sources()[0], + ToolSource::GitHub { + org, + repo, + .. + } if org == "aspect-build" && repo == "aspect-cli" + )); + } +} + pub fn autoconf() -> Result<(PathBuf, AspectLauncherConfig)> { let current_dir = current_dir().map_err(|e| miette!("failed to get current directory: {}", e))?; diff --git a/crates/aspect-launcher/src/main.rs b/crates/aspect-launcher/src/main.rs index 98f8b299d..32e6f5591 100644 --- a/crates/aspect-launcher/src/main.rs +++ b/crates/aspect-launcher/src/main.rs @@ -126,12 +126,13 @@ async fn _download_into_cache( #[derive(Deserialize, Debug)] struct Release { + tag_name: String, + #[serde(default)] assets: Vec, } #[derive(Deserialize, Debug)] struct ReleaseArtifact { - url: String, name: String, } @@ -196,7 +197,9 @@ async fn configure_tool_task( for source in tool.sources() { match source { ToolSource::Http { url, headers } => { - let url = replace_vars(url, tool.version()); + let fallback_version = cargo_pkg_short_version(); + let version = tool.version().unwrap_or(&fallback_version); + let url = replace_vars(url, version); let req_headers = headermap_from_hashmap(headers.iter()); let req = client .request(Method::GET, &url) @@ -250,25 +253,82 @@ async fn configure_tool_task( tag, artifact, } => { - let tag = if tag.is_empty() { - format!("v{}", tool.version()) - } else { - replace_vars(tag, tool.version()) - }; + let fallback_version = cargo_pkg_short_version(); + let pinned_version = tool.version(); + let version_for_vars = pinned_version.unwrap_or(&fallback_version); + let artifact = if artifact.is_empty() { format!("{}-{}", repo, LLVM_TRIPLE) } else { - replace_vars(artifact, tool.version()) + replace_vars(artifact, version_for_vars) + }; + + // Step 1: Resolve the tag. + // If a version is pinned, compute the tag directly. + // If unpinned, query the releases API to find the latest + // release that has the matching artifact. + let resolved_tag = if let Some(version) = pinned_version { + if tag.is_empty() { + format!("v{}", version) + } else { + replace_vars(tag, version) + } + } else { + let releases_url = format!( + "https://api.github.com/repos/{org}/{repo}/releases?per_page=10" + ); + if debug_mode() { + eprintln!( + "{:} source {:?} querying releases from {:?}", + tool.name(), + source, + releases_url, + ); + } + let releases_req = gh_request(&client, releases_url) + .header( + HeaderName::from_static("accept"), + HeaderValue::from_static("application/vnd.github+json"), + ) + .build() + .into_diagnostic()?; + let releases_resp = client + .execute(releases_req) + .await + .into_diagnostic()?; + if !releases_resp.status().is_success() { + errs.push(Err(miette!( + "github releases list request for {org}/{repo} failed with status {}", + releases_resp.status() + ))); + continue; + } + let releases: Vec = + releases_resp.json().await.into_diagnostic()?; + let found = releases.into_iter().find(|r| { + r.assets.iter().any(|a| a.name == *artifact) + }); + match found { + Some(release) => release.tag_name, + None => { + errs.push(Err(miette!( + "unable to find release artifact {artifact} in any recent {org}/{repo} release" + ))); + continue; + } + } }; - let url = - format!("https://api.github.com/repos/{org}/{repo}/releases/tags/{tag}"); + // Step 2: Download from the direct release URL using the resolved tag. + let direct_url = format!( + "https://github.com/{org}/{repo}/releases/download/{resolved_tag}/{artifact}" + ); - let tool_dest_file = cache.tool_path(&tool.name(), &url); + let tool_dest_file = cache.tool_path(&tool.name(), &direct_url); let mut extra_envs = HashMap::new(); extra_envs.insert("ASPECT_LAUNCHER_ASPECT_CLI_ORG".to_string(), org.clone()); extra_envs.insert("ASPECT_LAUNCHER_ASPECT_CLI_REPO".to_string(), repo.clone()); - extra_envs.insert("ASPECT_LAUNCHER_ASPECT_CLI_TAG".to_string(), tag.clone()); + extra_envs.insert("ASPECT_LAUNCHER_ASPECT_CLI_TAG".to_string(), resolved_tag.clone()); extra_envs.insert( "ASPECT_LAUNCHER_ASPECT_CLI_ARTIFACT".to_string(), artifact.clone(), @@ -279,7 +339,7 @@ async fn configure_tool_task( "{:} source {:?} found in cache {:?}", tool.name(), source, - &url + &direct_url ); }; return Ok(( @@ -290,65 +350,36 @@ async fn configure_tool_task( } fs::create_dir_all(tool_dest_file.parent().unwrap()).into_diagnostic()?; - let req = gh_request(&client, url) + if debug_mode() { + eprintln!( + "{:} source {:?} downloading {:?} to {:?}", + tool.name(), + source, + direct_url, + tool_dest_file + ); + }; + let req = gh_request(&client, direct_url) .header( HeaderName::from_static("accept"), - HeaderValue::from_static("application/vnd.github+json"), + HeaderValue::from_static("application/octet-stream"), ) .build() .into_diagnostic()?; - - let resp = client - .execute(req.try_clone().unwrap()) - .await - .into_diagnostic()?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - errs.push(Err(miette!( - "GitHub API request failed with status {}: {}", - status, - body - ))); + let download_msg = + format!("downloading aspect cli version {} file {}", resolved_tag, artifact); + if let err @ Err(_) = + _download_into_cache(&client, &tool_dest_file, req, &download_msg) + .await + { + errs.push(err); continue; } - let release_data: Release = resp.json::().await.into_diagnostic()?; - for asset in release_data.assets { - if asset.name == *artifact { - if debug_mode() { - eprintln!( - "{:} source {:?} downloading {:?} to {:?}", - tool.name(), - source, - asset.url, - tool_dest_file - ); - }; - let req = gh_request(&client, asset.url) - .header( - HeaderName::from_static("accept"), - HeaderValue::from_static("application/octet-stream"), - ) - .build() - .into_diagnostic()?; - let download_msg = - format!("downloading aspect cli version {} file {}", tag, artifact); - if let err @ Err(_) = - _download_into_cache(&client, &tool_dest_file, req, &download_msg) - .await - { - errs.push(err); - break; - } - return Ok(( - tool_dest_file, - ASPECT_LAUNCHER_METHOD_GITHUB.to_string(), - extra_envs, - )); - } - } - errs.push(Err(miette!("unable to find a release artifact in github!"))); - continue; + return Ok(( + tool_dest_file, + ASPECT_LAUNCHER_METHOD_GITHUB.to_string(), + extra_envs, + )); } ToolSource::Local { path } => { let tool_dest_file = cache.tool_path(&tool.name(), path); @@ -492,3 +523,119 @@ fn main() -> Result { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_replace_vars_version() { + let result = replace_vars("tool-{version}", "1.2.3"); + assert_eq!(result, format!("tool-1.2.3")); + } + + #[test] + fn test_replace_vars_os() { + let result = replace_vars("{os}", "1.0.0"); + assert_eq!(result, GOOS); + } + + #[test] + fn test_replace_vars_arch() { + let result = replace_vars("{arch}", "1.0.0"); + assert_eq!(result, BZLARCH); + } + + #[test] + fn test_replace_vars_target() { + let result = replace_vars("{target}", "1.0.0"); + assert_eq!(result, LLVM_TRIPLE); + } + + #[test] + fn test_replace_vars_multiple() { + let result = replace_vars("tool-{version}-{os}-{arch}", "3.0.0"); + assert_eq!(result, format!("tool-3.0.0-{}-{}", GOOS, BZLARCH)); + } + + #[test] + fn test_replace_vars_no_placeholders() { + let result = replace_vars("plain-string", "1.0.0"); + assert_eq!(result, "plain-string"); + } + + #[test] + fn test_release_deserialize_with_assets() { + let json = r#"{ + "tag_name": "v1.0.0", + "assets": [ + {"name": "tool-linux"}, + {"name": "tool-macos"} + ] + }"#; + let release: Release = serde_json::from_str(json).unwrap(); + assert_eq!(release.tag_name, "v1.0.0"); + assert_eq!(release.assets.len(), 2); + assert_eq!(release.assets[0].name, "tool-linux"); + assert_eq!(release.assets[1].name, "tool-macos"); + } + + #[test] + fn test_release_deserialize_without_assets() { + let json = r#"{"tag_name": "v2.0.0"}"#; + let release: Release = serde_json::from_str(json).unwrap(); + assert_eq!(release.tag_name, "v2.0.0"); + assert!(release.assets.is_empty()); + } + + #[test] + fn test_release_deserialize_empty_assets() { + let json = r#"{"tag_name": "v3.0.0", "assets": []}"#; + let release: Release = serde_json::from_str(json).unwrap(); + assert_eq!(release.tag_name, "v3.0.0"); + assert!(release.assets.is_empty()); + } + + #[test] + fn test_release_deserialize_ignores_extra_fields() { + let json = r#"{ + "tag_name": "v1.0.0", + "id": 12345, + "draft": false, + "prerelease": false, + "assets": [] + }"#; + let release: Release = serde_json::from_str(json).unwrap(); + assert_eq!(release.tag_name, "v1.0.0"); + } + + #[test] + fn test_release_list_deserialize() { + let json = r#"[ + {"tag_name": "v2.0.0", "assets": []}, + {"tag_name": "v1.0.0", "assets": [{"name": "tool"}]} + ]"#; + let releases: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(releases.len(), 2); + assert!(releases[0].assets.is_empty()); + assert_eq!(releases[1].assets[0].name, "tool"); + } + + #[test] + fn test_headermap_from_hashmap() { + let headers = vec![ + ("Content-Type", "application/json"), + ("Authorization", "Bearer token"), + ]; + let map = headermap_from_hashmap(headers.into_iter()); + assert_eq!(map.get("content-type").unwrap(), "application/json"); + assert_eq!(map.get("authorization").unwrap(), "Bearer token"); + } + + #[test] + fn test_headermap_from_hashmap_empty() { + let headers: Vec<(&str, &str)> = vec![]; + let map = headermap_from_hashmap(headers.into_iter()); + assert!(map.is_empty()); + } +}