Researched current industry practice on code organization/maintainability (Ousterhout's deep modules and information hiding, package-by-feature vs. package-by-layer, functional-core/imperative-shell testability, tech-debt prevention via ADR-equivalent inline rationale) and wrote it into ARCHITECTURE.md as a set of concrete, project-specific rules rather than a generic essay — each principle cites a real example already in this codebase or fixed by this commit. Cross-linked from SPEC.md, which stays about product design, not code organization. Applied it to this PR's own code: - Pulled process_package/fetch_and_verify/build_and_publish/run_review/ approve out of main.rs into a new pipeline.rs. main.rs's own main() had grown to 278 lines and zero tests by treating "it's just the entry point" as an excuse to skip separating logic from wiring; now main.rs is argv dispatch only. - Extracted decide_tier_action as a pure function (verification outcome + pending-state -> what to do), replacing dispatch logic that was previously inlined into a function that also made the real network/ build calls. Four unit tests, no I/O, covering all four outcomes. - Added a `//!` module doc comment to every file touched in this branch, each stating that module's one job in a sentence, per the "deep modules" principle the spec argues for. Coverage's reported total drops (94% -> 78%) because pipeline.rs is deliberately NOT excluded from it the way main.rs is, even though it's mostly the same kind of untestable I/O orchestration — excluding it would hide decide_tier_action's real unit-test coverage along with the untested parts. Noted inline in Makefile.toml/ci.yml so the number doesn't look like a quality regression at a glance. Also added a project reference memory pointing at ARCHITECTURE.md rather than duplicating its content there, per this session's own memory-hygiene rules (architecture/conventions are derivable from the repo and shouldn't be duplicated somewhere that can go stale). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
164 lines
5 KiB
Rust
164 lines
5 KiB
Rust
//! Downloads a named GitHub release asset to a local path. The only
|
|
//! module that talks to the releases API for asset bytes — `checker` only
|
|
//! resolves version tags, never downloads.
|
|
|
|
use crate::github::GithubEndpoints;
|
|
use anyhow::{Context, Result};
|
|
use serde::Deserialize;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Release {
|
|
assets: Vec<Asset>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Asset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
}
|
|
|
|
/// A downloaded release asset: its local path plus the URL it came from,
|
|
/// the latter needed for the `source=` line of a generated PKGBUILD (see
|
|
/// `builder`) — `makepkg` uses it only as a fallback if the pre-seeded
|
|
/// local copy ever goes missing.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DownloadedAsset {
|
|
pub path: PathBuf,
|
|
pub download_url: String,
|
|
}
|
|
|
|
/// Downloads the release asset named exactly `asset_name` for `repo`@`tag`
|
|
/// into `dest_dir`, returning the local path and its origin URL.
|
|
pub fn download_asset(
|
|
client: &reqwest::blocking::Client,
|
|
endpoints: &GithubEndpoints,
|
|
repo: &str,
|
|
tag: &str,
|
|
asset_name: &str,
|
|
dest_dir: &Path,
|
|
) -> Result<DownloadedAsset> {
|
|
let api_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api);
|
|
let release: Release = client
|
|
.get(&api_url)
|
|
.send()?
|
|
.error_for_status()
|
|
.with_context(|| format!("fetching release metadata from {api_url}"))?
|
|
.json()?;
|
|
|
|
let asset = release
|
|
.assets
|
|
.iter()
|
|
.find(|a| a.name == asset_name)
|
|
.with_context(|| format!("no asset named '{asset_name}' in {repo}@{tag}"))?;
|
|
|
|
std::fs::create_dir_all(dest_dir)?;
|
|
let dest_path = dest_dir.join(&asset.name);
|
|
let bytes = client
|
|
.get(&asset.browser_download_url)
|
|
.send()?
|
|
.error_for_status()?
|
|
.bytes()?;
|
|
std::fs::write(&dest_path, &bytes)?;
|
|
Ok(DownloadedAsset {
|
|
path: dest_path,
|
|
download_url: asset.browser_download_url.clone(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn download_asset_writes_matching_asset_to_dest_dir() {
|
|
let mut server = mockito::Server::new();
|
|
let endpoints = GithubEndpoints {
|
|
web: server.url(),
|
|
api: server.url(),
|
|
};
|
|
let asset_url = format!("{}/download/thing.tar.gz", server.url());
|
|
let release_body = format!(
|
|
r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"#
|
|
);
|
|
let _release = server
|
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
|
.with_status(200)
|
|
.with_body(release_body)
|
|
.create();
|
|
let _download = server
|
|
.mock("GET", "/download/thing.tar.gz")
|
|
.with_status(200)
|
|
.with_body(b"artifact-bytes".as_slice())
|
|
.create();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let dest_dir = tempfile::tempdir().unwrap();
|
|
let asset = download_asset(
|
|
&client,
|
|
&endpoints,
|
|
"o/r",
|
|
"v1.0.0",
|
|
"thing.tar.gz",
|
|
dest_dir.path(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(asset.path, dest_dir.path().join("thing.tar.gz"));
|
|
assert_eq!(asset.download_url, asset_url);
|
|
assert_eq!(std::fs::read(&asset.path).unwrap(), b"artifact-bytes");
|
|
}
|
|
|
|
#[test]
|
|
fn download_asset_errors_when_no_asset_matches() {
|
|
let mut server = mockito::Server::new();
|
|
let endpoints = GithubEndpoints {
|
|
web: server.url(),
|
|
api: server.url(),
|
|
};
|
|
let _release = server
|
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
|
.with_status(200)
|
|
.with_body(r#"{"assets": [{"name": "other", "browser_download_url": "https://example.invalid/other"}]}"#)
|
|
.create();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let dest_dir = tempfile::tempdir().unwrap();
|
|
let err = download_asset(
|
|
&client,
|
|
&endpoints,
|
|
"o/r",
|
|
"v1.0.0",
|
|
"thing.tar.gz",
|
|
dest_dir.path(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("no asset named"));
|
|
}
|
|
|
|
#[test]
|
|
fn download_asset_errors_when_release_not_found() {
|
|
let mut server = mockito::Server::new();
|
|
let endpoints = GithubEndpoints {
|
|
web: server.url(),
|
|
api: server.url(),
|
|
};
|
|
let _release = server
|
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
|
.with_status(404)
|
|
.create();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let dest_dir = tempfile::tempdir().unwrap();
|
|
let err = download_asset(
|
|
&client,
|
|
&endpoints,
|
|
"o/r",
|
|
"v1.0.0",
|
|
"thing.tar.gz",
|
|
dest_dir.path(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("fetching release metadata"));
|
|
}
|
|
}
|