use crate::error::{Error, Result};
use crate::manifest::{MirrorManifest, PackageInfo};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
#[derive(Debug, Clone)]
pub struct MirrorClientConfig {
pub url: String,
pub fallback: Fallback,
pub verify_checksums: bool,
pub cache_dir: PathBuf,
}
impl Default for MirrorClientConfig {
fn default() -> Self {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("stout")
.join("mirror");
Self {
url: String::new(),
fallback: Fallback::Error,
verify_checksums: false,
cache_dir,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum Fallback {
#[default]
Error,
Warn,
Silent,
}
impl std::str::FromStr for Fallback {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"error" => Ok(Self::Error),
"warn" => Ok(Self::Warn),
"silent" => Ok(Self::Silent),
_ => Err(format!("Unknown fallback mode: {}", s)),
}
}
}
pub struct MirrorClient {
config: MirrorClientConfig,
manifest: Option<MirrorManifest>,
is_file_mirror: bool,
}
impl MirrorClient {
pub fn new(config: MirrorClientConfig) -> Self {
let is_file_mirror = config.url.starts_with("file://");
Self {
config,
manifest: None,
is_file_mirror,
}
}
pub async fn connect(&mut self) -> Result<()> {
info!("Connecting to mirror: {}", self.config.url);
let manifest = if self.is_file_mirror {
self.load_file_manifest()?
} else {
self.fetch_http_manifest().await?
};
info!(
"Connected: {} formulas, {} casks available",
manifest.formulas.count, manifest.casks.count
);
self.manifest = Some(manifest);
Ok(())
}
fn load_file_manifest(&self) -> Result<MirrorManifest> {
let path = self.file_path("manifest.json")?;
MirrorManifest::load(&path)
}
async fn fetch_http_manifest(&self) -> Result<MirrorManifest> {
let url = format!("{}/manifest.json", self.config.url.trim_end_matches('/'));
debug!("Fetching manifest from {}", url);
let client = reqwest::Client::new();
let response = client.get(&url).send().await?;
let manifest = response.json().await?;
Ok(manifest)
}
fn file_path(&self, relative: &str) -> Result<PathBuf> {
let base = self
.config
.url
.strip_prefix("file://")
.ok_or_else(|| Error::Manifest("Invalid file:// URL".to_string()))?;
Ok(PathBuf::from(base).join(relative))
}
pub fn has_formula(&self, name: &str) -> bool {
self.manifest
.as_ref()
.map(|m| m.formulas.packages.contains_key(name))
.unwrap_or(false)
}
pub fn get_formula(&self, name: &str) -> Option<&PackageInfo> {
self.manifest.as_ref()?.get_formula(name)
}
pub async fn download_bottle(
&self,
name: &str,
platform: &str,
dest: &Path,
) -> Result<PathBuf> {
let manifest = self
.manifest
.as_ref()
.ok_or_else(|| Error::Manifest("Not connected to mirror".to_string()))?;
let formula = manifest
.get_formula(name)
.ok_or_else(|| Error::PackageNotFound(name.to_string()))?;
let bottle = formula
.bottles
.get(platform)
.ok_or_else(|| Error::PlatformNotAvailable(platform.to_string(), name.to_string()))?;
let bottle_path = if self.is_file_mirror {
self.copy_file_bottle(&bottle.path, dest)?
} else {
self.download_http_bottle(&bottle.path, dest).await?
};
if self.config.verify_checksums {
let actual = sha256_file(&bottle_path)?;
if actual != bottle.sha256 {
return Err(Error::ChecksumMismatch(
name.to_string(),
bottle.sha256.clone(),
actual,
));
}
debug!("Checksum verified for {}", name);
}
Ok(bottle_path)
}
fn copy_file_bottle(&self, relative_path: &str, dest: &Path) -> Result<PathBuf> {
let src = self.file_path(relative_path)?;
let dest_file = dest.join(
src.file_name()
.ok_or_else(|| Error::Manifest("Invalid bottle path".to_string()))?,
);
if let Some(parent) = dest_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest_file)?;
Ok(dest_file)
}
async fn download_http_bottle(&self, relative_path: &str, dest: &Path) -> Result<PathBuf> {
let url = format!(
"{}/{}",
self.config.url.trim_end_matches('/'),
relative_path
);
debug!("Downloading bottle from {}", url);
let client = reqwest::Client::new();
let response = client.get(&url).send().await?;
let bytes = response.bytes().await?;
let filename = relative_path
.rsplit('/')
.next()
.ok_or_else(|| Error::Manifest("Invalid bottle path".to_string()))?;
let dest_file = dest.join(filename);
if let Some(parent) = dest_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest_file, &bytes)?;
Ok(dest_file)
}
pub fn get_platforms(&self, name: &str) -> Vec<String> {
self.manifest
.as_ref()
.and_then(|m| m.get_formula(name))
.map(|f| f.bottles.keys().cloned().collect())
.unwrap_or_default()
}
pub fn list_formulas(&self) -> Vec<&str> {
self.manifest
.as_ref()
.map(|m| m.formulas.packages.keys().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn handle_not_found(&self, name: &str) -> Result<()> {
match self.config.fallback {
Fallback::Error => Err(Error::PackageNotFound(name.to_string())),
Fallback::Warn => {
eprintln!(
"Warning: '{}' not in mirror, trying upstream...",
name
);
Ok(())
}
Fallback::Silent => Ok(()),
}
}
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes = std::fs::read(path)?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(format!("{:x}", hasher.finalize()))
}