pkgwatch/src/fetcher.rs
Austin Schaefer 3f30e0acc6
All checks were successful
CI / build (pull_request) Successful in 54s
CI / test (pull_request) Successful in 4m6s
CI / audit (pull_request) Successful in 17s
CI / coverage (pull_request) Successful in 6m19s
Add ARCHITECTURE.md and apply it to this PR's code
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>
2026-09-17 11:42:18 +02:00

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"));
}
}