use crate::api::{Cask, CaskDetails, FetchResult, Formula, CASK_API_URL, FORMULA_API_URL};
use crate::error::Result;
use crate::tap::TapManager;
use crate::ui::{create_spinner, dirs};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::fs;
use tracing::{debug, info, instrument};
struct FormulaeIndexCache {
signature: u64,
formulae: Arc<Vec<Formula>>,
}
static FORMULAE_INDEX_CACHE: Mutex<Option<FormulaeIndexCache>> = Mutex::new(None);
fn clear_formulae_index_cache() {
if let Ok(mut guard) = FORMULAE_INDEX_CACHE.lock() {
*guard = None;
}
}
async fn formulae_index_signature(cache: &Cache, tap_names: &[String]) -> Result<u64> {
let mut hasher = DefaultHasher::new();
if let Ok(meta) = fs::metadata(cache.formulae_path()).await {
if let Ok(mtime) = meta.modified() {
mtime.hash(&mut hasher);
}
}
for tap_name in tap_names {
tap_name.hash(&mut hasher);
let path = cache.tap_cache_path(tap_name);
if let Ok(meta) = fs::metadata(&path).await {
if let Ok(mtime) = meta.modified() {
mtime.hash(&mut hasher);
}
}
}
Ok(hasher.finish())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMetadata {
pub last_updated: i64,
pub formula_count: usize,
pub cask_count: usize,
pub formulae_etag: Option<String>,
pub formulae_last_modified: Option<String>,
pub casks_etag: Option<String>,
pub casks_last_modified: Option<String>,
}
#[derive(Clone)]
pub struct Cache {
cache_dir: PathBuf,
}
impl Cache {
pub fn new() -> Result<Self> {
let cache_dir = dirs::wax_cache_dir()?;
Ok(Self { cache_dir })
}
pub async fn ensure_cache_dir(&self) -> Result<()> {
fs::create_dir_all(&self.cache_dir).await?;
Ok(())
}
#[cfg_attr(not(windows), allow(dead_code))]
pub fn cache_dir_path(&self) -> &Path {
&self.cache_dir
}
fn formulae_path(&self) -> PathBuf {
self.cache_dir.join("formulae.json")
}
fn casks_path(&self) -> PathBuf {
self.cache_dir.join("casks.json")
}
fn metadata_path(&self) -> PathBuf {
self.cache_dir.join("metadata.json")
}
fn taps_cache_dir(&self) -> PathBuf {
self.cache_dir.join("taps")
}
fn tap_cache_path(&self, tap_name: &str) -> PathBuf {
self.taps_cache_dir()
.join(format!("{}.json", tap_name.replace('/', "-")))
}
const STALE_THRESHOLD_SECS: i64 = 3600;
pub fn is_initialized(&self) -> bool {
self.formulae_path().exists() && self.casks_path().exists()
}
pub async fn ensure_fresh(&self) -> Result<()> {
if !self.is_initialized() {
self.auto_init().await?;
return Ok(());
}
let metadata = self.load_metadata().await?;
let is_stale = match &metadata {
Some(m) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
(now - m.last_updated) > Self::STALE_THRESHOLD_SECS
}
None => true,
};
if is_stale {
let spinner = create_spinner("Refreshing index…");
let (formulae_etag, formulae_last_modified) = metadata
.as_ref()
.map(|m| {
(
m.formulae_etag.as_deref(),
m.formulae_last_modified.as_deref(),
)
})
.unwrap_or((None, None));
let (casks_etag, casks_last_modified) = metadata
.as_ref()
.map(|m| (m.casks_etag.as_deref(), m.casks_last_modified.as_deref()))
.unwrap_or((None, None));
let (formulae_result, casks_result) = tokio::join!(
self.fetch_formulae_conditional(formulae_etag, formulae_last_modified),
self.fetch_casks_conditional(casks_etag, casks_last_modified)
);
let formulae_fetch = formulae_result?;
let casks_fetch = casks_result?;
let formula_count = if let Some(data) = &formulae_fetch.data {
self.save_formulae(data).await?;
data.len()
} else {
metadata.as_ref().map(|m| m.formula_count).unwrap_or(0)
};
let cask_count = if let Some(data) = &casks_fetch.data {
self.save_casks(data).await?;
data.len()
} else {
metadata.as_ref().map(|m| m.cask_count).unwrap_or(0)
};
let new_metadata = CacheMetadata {
last_updated: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
formula_count,
cask_count,
formulae_etag: formulae_fetch
.etag
.or_else(|| metadata.as_ref().and_then(|m| m.formulae_etag.clone())),
formulae_last_modified: formulae_fetch.last_modified.or_else(|| {
metadata
.as_ref()
.and_then(|m| m.formulae_last_modified.clone())
}),
casks_etag: casks_fetch
.etag
.or_else(|| metadata.as_ref().and_then(|m| m.casks_etag.clone())),
casks_last_modified: casks_fetch.last_modified.or_else(|| {
metadata
.as_ref()
.and_then(|m| m.casks_last_modified.clone())
}),
};
self.save_metadata(&new_metadata).await?;
spinner.finish_and_clear();
}
Ok(())
}
#[instrument(skip(self, formulae))]
pub async fn save_formulae(&self, formulae: &[Formula]) -> Result<()> {
self.ensure_cache_dir().await?;
let json = serde_json::to_string(formulae)?;
fs::write(self.formulae_path(), json).await?;
clear_formulae_index_cache();
info!("Saved {} formulae to cache", formulae.len());
Ok(())
}
#[instrument(skip(self, casks))]
pub async fn save_casks(&self, casks: &[Cask]) -> Result<()> {
self.ensure_cache_dir().await?;
let json = serde_json::to_string(casks)?;
fs::write(self.casks_path(), json).await?;
info!("Saved {} casks to cache", casks.len());
Ok(())
}
pub async fn save_metadata(&self, metadata: &CacheMetadata) -> Result<()> {
self.ensure_cache_dir().await?;
let json = serde_json::to_string_pretty(metadata)?;
fs::write(self.metadata_path(), json).await?;
Ok(())
}
pub async fn load_formulae(&self) -> Result<Vec<Formula>> {
let path = self.formulae_path();
if !path.exists() {
self.auto_init().await?;
}
let json = fs::read_to_string(path).await?;
let formulae = serde_json::from_str(&json)?;
Ok(formulae)
}
pub async fn load_casks(&self) -> Result<Vec<Cask>> {
let path = self.casks_path();
if !path.exists() {
self.auto_init().await?;
}
let json = fs::read_to_string(path).await?;
let casks = serde_json::from_str(&json)?;
Ok(casks)
}
async fn auto_init(&self) -> Result<()> {
let spinner = create_spinner("Fetching package index…");
let (formulae_result, casks_result) = tokio::join!(
self.fetch_formulae_conditional(None, None),
self.fetch_casks_conditional(None, None)
);
let formulae_fetch = formulae_result?;
let casks_fetch = casks_result?;
if let Some(formulae) = formulae_fetch.data {
self.save_formulae(&formulae).await?;
}
if let Some(casks) = casks_fetch.data {
self.save_casks(&casks).await?;
}
let metadata = CacheMetadata {
last_updated: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
formula_count: 0,
cask_count: 0,
formulae_etag: formulae_fetch.etag,
formulae_last_modified: formulae_fetch.last_modified,
casks_etag: casks_fetch.etag,
casks_last_modified: casks_fetch.last_modified,
};
self.save_metadata(&metadata).await?;
spinner.finish_and_clear();
Ok(())
}
pub async fn load_metadata(&self) -> Result<Option<CacheMetadata>> {
if !self.metadata_path().exists() {
return Ok(None);
}
let json = fs::read_to_string(self.metadata_path()).await?;
let metadata = serde_json::from_str(&json)?;
Ok(Some(metadata))
}
pub async fn invalidate_tap_cache(&self, tap_name: &str) -> Result<()> {
let path = self.tap_cache_path(tap_name);
if path.exists() {
fs::remove_file(&path).await?;
debug!("Invalidated tap cache for {}", tap_name);
}
clear_formulae_index_cache();
Ok(())
}
pub async fn invalidate_all_tap_caches(&self) -> Result<()> {
let taps_dir = self.taps_cache_dir();
if taps_dir.exists() {
fs::remove_dir_all(&taps_dir).await?;
debug!("Invalidated all tap caches");
}
clear_formulae_index_cache();
Ok(())
}
#[instrument(skip(self))]
pub async fn fetch_formulae_conditional(
&self,
etag: Option<&str>,
last_modified: Option<&str>,
) -> Result<FetchResult<Vec<Formula>>> {
info!("Fetching formulae from API with conditional headers");
let client = crate::http_client::api();
let mut request = client.get(FORMULA_API_URL);
if let Some(etag) = etag {
request = request.header("If-None-Match", etag);
}
if let Some(last_modified) = last_modified {
request = request.header("If-Modified-Since", last_modified);
}
let response = request.send().await?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
info!("Formulae not modified (304)");
return Ok(FetchResult {
data: None,
etag: None,
last_modified: None,
not_modified: true,
});
}
let etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(String::from);
let last_modified = response
.headers()
.get("last-modified")
.and_then(|v| v.to_str().ok())
.map(String::from);
let body = response.bytes().await?;
let formulae: Vec<Formula> = serde_json::from_slice(&body)?;
info!("Fetched {} formulae", formulae.len());
Ok(FetchResult {
data: Some(formulae),
etag,
last_modified,
not_modified: false,
})
}
#[instrument(skip(self))]
pub async fn fetch_casks_conditional(
&self,
etag: Option<&str>,
last_modified: Option<&str>,
) -> Result<FetchResult<Vec<Cask>>> {
info!("Fetching casks from API with conditional headers");
let client = crate::http_client::api();
let mut request = client.get(CASK_API_URL);
if let Some(etag) = etag {
request = request.header("If-None-Match", etag);
}
if let Some(last_modified) = last_modified {
request = request.header("If-Modified-Since", last_modified);
}
let response = request.send().await?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
info!("Casks not modified (304)");
return Ok(FetchResult {
data: None,
etag: None,
last_modified: None,
not_modified: true,
});
}
let etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(String::from);
let last_modified = response
.headers()
.get("last-modified")
.and_then(|v| v.to_str().ok())
.map(String::from);
let body = response.bytes().await?;
let casks: Vec<Cask> = serde_json::from_slice(&body)?;
info!("Fetched {} casks", casks.len());
Ok(FetchResult {
data: Some(casks),
etag,
last_modified,
not_modified: false,
})
}
#[instrument(skip(self))]
pub async fn fetch_cask_details(&self, cask_name: &str) -> Result<CaskDetails> {
crate::error::validate_package_name(cask_name)?;
info!("Fetching details for cask: {}", cask_name);
let client = crate::http_client::api();
let url = format!("https://formulae.brew.sh/api/cask/{}.json", cask_name);
let response = client.get(&url).send().await?;
let cask: CaskDetails = response.json().await?;
info!("Fetched details for cask: {}", cask_name);
Ok(cask)
}
pub async fn load_all_formulae(&self) -> Result<Vec<Formula>> {
let mut tap_manager = TapManager::new()?;
tap_manager.load().await?;
let tap_names: Vec<String> = tap_manager
.trusted_taps()
.into_iter()
.map(|tap| tap.full_name.clone())
.collect();
let signature = formulae_index_signature(self, &tap_names).await?;
if let Ok(guard) = FORMULAE_INDEX_CACHE.lock() {
if let Some(cached) = guard.as_ref() {
if cached.signature == signature {
debug!("Using in-process formulae index cache");
return Ok((*cached.formulae).clone());
}
}
}
let mut all = self.load_formulae().await?;
for tap in tap_manager.trusted_taps() {
let tap_cache_path = self.tap_cache_path(&tap.full_name);
let tap_formulae = if tap_cache_path.exists() {
debug!(
"Loading tap formulae from cache: {}",
tap_cache_path.display()
);
let json = fs::read_to_string(&tap_cache_path).await?;
let mut formulae: Vec<Formula> = serde_json::from_str(&json)?;
let formula_dir = tap.formula_dir();
for f in &mut formulae {
let rb_file = formula_dir.join(format!("{}.rb", f.name));
if rb_file.exists() {
f.rb_path = Some(rb_file);
}
}
formulae
} else {
debug!("Loading tap formulae from filesystem: {}", tap.full_name);
let formulae = tap_manager.load_formulae_from_tap(tap).await?;
fs::create_dir_all(self.taps_cache_dir()).await?;
let json = serde_json::to_string_pretty(&formulae)?;
fs::write(&tap_cache_path, json).await?;
formulae
};
all.extend(tap_formulae);
}
if let Ok(mut guard) = FORMULAE_INDEX_CACHE.lock() {
*guard = Some(FormulaeIndexCache {
signature,
formulae: Arc::new(all.clone()),
});
}
Ok(all)
}
}
impl Default for Cache {
fn default() -> Self {
Self::new().expect("Failed to initialize cache")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_metadata_serializes_roundtrip() {
let meta = CacheMetadata {
last_updated: 1_700_000_000,
formula_count: 8100,
cask_count: 7500,
formulae_etag: Some("\"abc123\"".to_string()),
formulae_last_modified: Some("Thu, 01 Jan 2026 00:00:00 GMT".to_string()),
casks_etag: None,
casks_last_modified: None,
};
let json = serde_json::to_string(&meta).unwrap();
let decoded: CacheMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.last_updated, meta.last_updated);
assert_eq!(decoded.formula_count, meta.formula_count);
assert_eq!(decoded.formulae_etag, meta.formulae_etag);
assert_eq!(decoded.casks_etag, None);
}
#[test]
fn unix_timestamp_is_positive() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
assert!(ts > 1_577_836_800, "timestamp looks wrong: {ts}");
}
#[test]
fn stale_threshold_constant_is_one_hour() {
assert_eq!(Cache::STALE_THRESHOLD_SECS, 3600);
}
}