use std::sync::Arc;
use serde::Deserialize;
use tokio::sync::OnceCell;
use super::SourceAdapter;
use crate::model::{Match, Query, Source};
use crate::Result;
const DEFAULT_BASE_URL: &str = "https://formulae.brew.sh";
static SHARED_CATALOG: std::sync::OnceLock<Arc<OnceCell<Vec<BrewPackage>>>> =
std::sync::OnceLock::new();
#[derive(Debug, Clone)]
pub struct Homebrew {
client: reqwest::Client,
base_url: String,
catalog: Arc<OnceCell<Vec<BrewPackage>>>,
}
impl Homebrew {
pub fn new(client: reqwest::Client) -> Self {
let catalog = SHARED_CATALOG
.get_or_init(|| Arc::new(OnceCell::new()))
.clone();
Self {
client,
base_url: DEFAULT_BASE_URL.to_string(),
catalog,
}
}
pub fn with_base_url(client: reqwest::Client, base_url: String) -> Self {
Self {
client,
base_url,
catalog: Arc::new(OnceCell::new()),
}
}
async fn fetch_catalog(&self) -> Result<Vec<BrewPackage>> {
let formula_url = format!("{}/api/formula.json", self.base_url);
let cask_url = format!("{}/api/cask.json", self.base_url);
let formula_res = self
.client
.get(&formula_url)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?;
let formulae: Vec<BrewFormula> = formula_res.error_for_status()?.json().await?;
let cask_res = self
.client
.get(&cask_url)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?;
let casks: Vec<BrewCask> = cask_res.error_for_status()?.json().await?;
let mut packages: Vec<BrewPackage> = formulae
.into_iter()
.map(|f| BrewPackage {
name: f.name,
desc: f.desc,
homepage: f.homepage,
})
.collect();
packages.extend(casks.into_iter().map(|c| BrewPackage {
name: c.token,
desc: c.desc,
homepage: c.homepage,
}));
Ok(packages)
}
}
#[derive(Debug)]
struct BrewPackage {
name: String,
desc: Option<String>,
homepage: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BrewFormula {
name: String,
#[serde(default)]
desc: Option<String>,
#[serde(default)]
homepage: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BrewCask {
token: String,
#[serde(default)]
desc: Option<String>,
#[serde(default)]
homepage: Option<String>,
}
#[async_trait::async_trait]
impl SourceAdapter for Homebrew {
fn id(&self) -> Source {
Source::Homebrew
}
async fn search(&self, query: &Query) -> Result<Vec<Match>> {
let packages = self
.catalog
.get_or_try_init(|| self.fetch_catalog())
.await?;
let keywords_lower: Vec<String> = query.keywords.iter().map(|k| k.to_lowercase()).collect();
Ok(packages
.iter()
.filter(|pkg| {
let name_lower = pkg.name.to_lowercase();
let desc_lower = pkg.desc.as_deref().unwrap_or("").to_lowercase();
keywords_lower
.iter()
.all(|kw| name_lower.contains(kw) || desc_lower.contains(kw))
})
.take(20)
.map(|pkg| {
let url = pkg
.homepage
.as_ref()
.filter(|h| !h.is_empty())
.cloned()
.unwrap_or_else(|| format!("{}/formula/{}", self.base_url, pkg.name));
Match {
name: pkg.name.clone(),
source: Source::Homebrew,
url,
description: pkg.desc.clone().unwrap_or_default(),
popularity: None,
similarity: 0.0,
last_updated: None,
}
})
.collect())
}
}