use crate::api::Formula;
use crate::error::{Result, WaxError};
use crate::formula_parser::FormulaParser;
use crate::ui::dirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::{debug, info, instrument};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TapKind {
GitHub { user: String, repo: String },
Git { url: String },
LocalDir { path: PathBuf },
LocalFile { path: PathBuf },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tap {
pub full_name: String,
pub kind: TapKind,
pub path: PathBuf,
}
impl Tap {
pub fn from_spec(spec: &str) -> Result<Self> {
let expanded = shellexpand::tilde(spec).to_string();
let path = Path::new(&expanded);
if path.exists() {
if path.is_file() {
if path.extension().and_then(|s| s.to_str()) == Some("rb") {
return Self::new_local_file(path);
} else {
return Err(WaxError::TapError(
"Local file must have .rb extension".to_string(),
));
}
} else if path.is_dir() {
return Self::new_local_dir(path);
}
}
if expanded.starts_with("http://")
|| expanded.starts_with("https://")
|| expanded.starts_with("git@")
{
return Self::new_git(&expanded);
}
let parts: Vec<&str> = spec.split('/').collect();
if parts.len() == 2 && !spec.contains('.') && !spec.starts_with('/') {
return Self::new_github(parts[0], parts[1]);
}
Err(WaxError::TapError(format!(
"Invalid tap specification: {}. Use 'user/repo', a Git URL, or a local path",
spec
)))
}
pub fn new_github(user: &str, repo: &str) -> Result<Self> {
let full_name = format!("{}/{}", user, repo);
let path = Self::tap_directory()?
.join(user)
.join(format!("homebrew-{}", repo));
Ok(Self {
full_name,
kind: TapKind::GitHub {
user: user.to_string(),
repo: repo.to_string(),
},
path,
})
}
pub fn new_git(url: &str) -> Result<Self> {
let name = Self::extract_name_from_url(url);
let path = Self::tap_directory()?.join("custom").join(&name);
Ok(Self {
full_name: format!("custom/{}", name),
kind: TapKind::Git {
url: url.to_string(),
},
path,
})
}
pub fn new_local_dir(dir: &Path) -> Result<Self> {
let canonicalized = dunce::canonicalize(dir)?;
let name = canonicalized
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| WaxError::TapError("Invalid directory path".to_string()))?;
Ok(Self {
full_name: format!("local/{}", name),
kind: TapKind::LocalDir {
path: canonicalized.clone(),
},
path: canonicalized,
})
}
pub fn new_local_file(file: &Path) -> Result<Self> {
let canonicalized = dunce::canonicalize(file)?;
let name = canonicalized
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| WaxError::TapError("Invalid file path".to_string()))?;
Ok(Self {
full_name: format!("local/{}", name),
kind: TapKind::LocalFile {
path: canonicalized.clone(),
},
path: canonicalized,
})
}
fn extract_name_from_url(url: &str) -> String {
let path_part = url
.trim_end_matches(".git")
.split('/')
.next_back()
.unwrap_or("custom-tap");
path_part.to_string()
}
fn tap_directory() -> Result<PathBuf> {
Ok(dirs::wax_dir()?.join("taps"))
}
pub fn formula_dir(&self) -> PathBuf {
match &self.kind {
TapKind::LocalFile { .. } => self.path.parent().unwrap_or(&self.path).to_path_buf(),
_ => {
let formula_subdir = self.path.join("Formula");
if formula_subdir.exists() {
formula_subdir
} else {
self.path.clone()
}
}
}
}
pub fn url(&self) -> Option<String> {
match &self.kind {
TapKind::GitHub { user, repo } => {
Some(format!("https://github.com/{}/homebrew-{}.git", user, repo))
}
TapKind::Git { url } => Some(url.clone()),
TapKind::LocalDir { path } => Some(format!("file://{}", path.display())),
TapKind::LocalFile { path } => Some(format!("file://{}", path.display())),
}
}
}
pub struct TapManager {
taps: HashMap<String, Tap>,
state_path: PathBuf,
}
impl TapManager {
pub fn new() -> Result<Self> {
let state_path = dirs::wax_dir()?.join("taps.json");
Ok(Self {
taps: HashMap::new(),
state_path,
})
}
pub async fn load(&mut self) -> Result<()> {
if !self.state_path.exists() {
return Ok(());
}
let json = fs::read_to_string(&self.state_path).await?;
match serde_json::from_str(&json) {
Ok(taps) => {
self.taps = taps;
}
Err(_) => {
debug!("Migrating legacy taps.json format");
self.taps = Self::migrate_legacy_taps(&json)?;
self.save().await?;
}
}
Ok(())
}
fn migrate_legacy_taps(json: &str) -> Result<HashMap<String, Tap>> {
let legacy: HashMap<String, serde_json::Value> = serde_json::from_str(json)
.map_err(|e| WaxError::CacheError(format!("Failed to parse taps.json: {}", e)))?;
let mut taps = HashMap::new();
for (name, value) in legacy {
let full_name = value
.get("full_name")
.and_then(|v| v.as_str())
.unwrap_or(&name)
.to_string();
let path = value
.get("path")
.and_then(|v| v.as_str())
.map(PathBuf::from)
.unwrap_or_default();
let kind = if let (Some(user), Some(repo)) = (
value.get("user").and_then(|v| v.as_str()),
value.get("repo").and_then(|v| v.as_str()),
) {
TapKind::GitHub {
user: user.to_string(),
repo: repo.to_string(),
}
} else if let Some(url) = value.get("url").and_then(|v| v.as_str()) {
TapKind::Git {
url: url.to_string(),
}
} else if path.is_file() {
TapKind::LocalFile { path: path.clone() }
} else {
TapKind::LocalDir { path: path.clone() }
};
taps.insert(
name,
Tap {
full_name,
kind,
path,
},
);
}
Ok(taps)
}
pub async fn save(&self) -> Result<()> {
let parent = self
.state_path
.parent()
.ok_or_else(|| WaxError::CacheError("Cannot determine parent directory".into()))?;
fs::create_dir_all(parent).await?;
let json = serde_json::to_string_pretty(&self.taps)?;
fs::write(&self.state_path, json).await?;
Ok(())
}
#[instrument(skip(self))]
pub async fn add_tap(&mut self, spec: &str) -> Result<()> {
info!("Adding tap: {}", spec);
let tap = Tap::from_spec(spec)?;
if self.taps.contains_key(&tap.full_name) {
return Err(WaxError::TapError(format!(
"Tap {} is already added",
tap.full_name
)));
}
match &tap.kind {
TapKind::GitHub { .. } | TapKind::Git { .. } => {
if tap.path.exists() {
return Err(WaxError::TapError(format!(
"Tap directory {} already exists",
tap.path.display()
)));
}
fs::create_dir_all(tap.path.parent().unwrap()).await?;
self.clone_tap(&tap).await?;
}
TapKind::LocalDir { path } => {
if !path.exists() {
return Err(WaxError::TapError(format!(
"Local directory does not exist: {}",
path.display()
)));
}
}
TapKind::LocalFile { path } => {
if !path.exists() {
return Err(WaxError::TapError(format!(
"Local file does not exist: {}",
path.display()
)));
}
}
}
self.taps.insert(tap.full_name.clone(), tap);
self.save().await?;
Ok(())
}
#[instrument(skip(self))]
async fn clone_tap(&self, tap: &Tap) -> Result<()> {
let url = tap.url().ok_or_else(|| {
WaxError::TapError("Cannot clone tap without a valid URL".to_string())
})?;
debug!("Cloning tap from {}", url);
let output = tokio::process::Command::new("git")
.arg("clone")
.arg("--depth=1")
.arg("--single-branch")
.arg(&url)
.arg(&tap.path)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WaxError::TapError(format!(
"Failed to clone tap: {}",
stderr
)));
}
Ok(())
}
#[instrument(skip(self))]
pub async fn remove_tap(&mut self, spec: &str) -> Result<()> {
info!("Removing tap: {}", spec);
let tap_to_remove = Tap::from_spec(spec)?;
let full_name = &tap_to_remove.full_name;
let tap = self
.taps
.get(full_name)
.ok_or_else(|| WaxError::TapError(format!("Tap {} not found", full_name)))?
.clone();
match &tap.kind {
TapKind::GitHub { .. } | TapKind::Git { .. } => {
if tap.path.exists() {
fs::remove_dir_all(&tap.path).await?;
}
}
TapKind::LocalDir { .. } | TapKind::LocalFile { .. } => {}
}
self.taps.remove(full_name);
self.save().await?;
Ok(())
}
pub fn list_taps(&self) -> Vec<&Tap> {
self.taps.values().collect()
}
pub async fn repair_all(&mut self) -> Result<Vec<String>> {
let tap_names: Vec<String> = self.taps.keys().cloned().collect();
let mut repaired = Vec::new();
for name in tap_names {
let tap = self.taps[&name].clone();
match &tap.kind {
TapKind::GitHub { .. } | TapKind::Git { .. } => {
let needs_repair = if !tap.path.exists() {
true
} else {
let check = tokio::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&tap.path)
.output()
.await;
check.map(|o| !o.status.success()).unwrap_or(true)
};
if needs_repair {
if tap.path.exists() {
fs::remove_dir_all(&tap.path).await?;
}
if let Some(parent) = tap.path.parent() {
fs::create_dir_all(parent).await?;
}
self.clone_tap(&tap).await?;
repaired.push(name);
}
}
TapKind::LocalDir { .. } | TapKind::LocalFile { .. } => {}
}
}
Ok(repaired)
}
pub async fn has_tap(&self, tap_name: &str) -> bool {
self.taps.contains_key(tap_name)
}
#[instrument(skip(self))]
pub async fn update_tap(&mut self, spec: &str) -> Result<()> {
info!("Updating tap: {}", spec);
let tap_to_update = Tap::from_spec(spec)?;
let full_name = &tap_to_update.full_name;
let tap = self
.taps
.get(full_name)
.ok_or_else(|| WaxError::TapError(format!("Tap {} not found", full_name)))?;
match &tap.kind {
TapKind::GitHub { .. } | TapKind::Git { .. } => {
if !tap.path.exists() {
return Err(WaxError::TapError(format!(
"Tap directory does not exist: {}",
tap.path.display()
)));
}
let fetch_output = tokio::process::Command::new("git")
.args(["fetch", "--depth=1"])
.current_dir(&tap.path)
.output()
.await?;
if !fetch_output.status.success() {
let stderr = String::from_utf8_lossy(&fetch_output.stderr);
return Err(WaxError::TapError(format!(
"Failed to fetch tap updates: {}",
stderr
)));
}
let reset_output = tokio::process::Command::new("git")
.args(["reset", "--hard", "origin/HEAD"])
.current_dir(&tap.path)
.output()
.await?;
if !reset_output.status.success() {
let stderr = String::from_utf8_lossy(&reset_output.stderr);
return Err(WaxError::TapError(format!(
"Failed to update tap: {}",
stderr
)));
}
}
TapKind::LocalDir { .. } | TapKind::LocalFile { .. } => {
info!("Local tap, no update needed (managed externally)");
}
}
Ok(())
}
#[instrument(skip(self))]
pub async fn load_formulae_from_tap(&self, tap: &Tap) -> Result<Vec<Formula>> {
debug!("Loading formulae from tap: {}", tap.full_name);
match &tap.kind {
TapKind::LocalFile { path } => {
if !path.exists() {
return Ok(Vec::new());
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let content = fs::read_to_string(path).await?;
match FormulaParser::parse_ruby_formula(&name, &content) {
Ok(parsed) => {
let formula = Formula {
name: parsed.name.clone(),
full_name: format!("{}/{}", tap.full_name, parsed.name),
desc: parsed.desc.clone(),
homepage: parsed.homepage.clone().unwrap_or_default(),
versions: crate::api::Versions {
stable: parsed.source.version.clone(),
bottle: false,
},
revision: 0,
installed: None,
dependencies: Some(parsed.runtime_dependencies.clone()),
build_dependencies: Some(parsed.build_dependencies.clone()),
bottle: None,
deprecated: false,
disabled: false,
deprecation_reason: None,
disable_reason: None,
keg_only: None,
keg_only_reason: None,
post_install_defined: false,
rb_path: Some(path.clone()),
};
Ok(vec![formula])
}
Err(e) => {
debug!("Failed to parse formula {}: {}", name, e);
Ok(Vec::new())
}
}
}
_ => {
let formula_dir = tap.formula_dir();
if !formula_dir.exists() {
return Ok(Vec::new());
}
let mut formulae = Vec::new();
let mut entries = fs::read_dir(&formula_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("rb") {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let content = fs::read_to_string(&path).await?;
match FormulaParser::parse_ruby_formula(&name, &content) {
Ok(parsed) => {
let formula = Formula {
name: parsed.name.clone(),
full_name: format!("{}/{}", tap.full_name, parsed.name),
desc: parsed.desc.clone(),
homepage: parsed.homepage.clone().unwrap_or_default(),
versions: crate::api::Versions {
stable: parsed.source.version.clone(),
bottle: false,
},
revision: 0,
installed: None,
dependencies: Some(parsed.runtime_dependencies.clone()),
build_dependencies: Some(parsed.build_dependencies.clone()),
bottle: None,
deprecated: false,
disabled: false,
deprecation_reason: None,
disable_reason: None,
keg_only: None,
keg_only_reason: None,
post_install_defined: false,
rb_path: Some(path.clone()),
};
formulae.push(formula);
}
Err(e) => {
debug!("Failed to parse formula {}: {}", name, e);
}
}
}
}
Ok(formulae)
}
}
}
}
impl Default for TapManager {
fn default() -> Self {
Self::new().expect("Failed to initialize TapManager")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_spec_github_user_repo() {
let tap = Tap::from_spec("homebrew/core").unwrap();
assert_eq!(tap.full_name, "homebrew/core");
assert!(matches!(tap.kind, TapKind::GitHub { ref user, ref repo }
if user == "homebrew" && repo == "core"));
}
#[test]
fn from_spec_github_url() {
let tap = Tap::from_spec("https://github.com/homebrew/homebrew-core.git").unwrap();
assert!(matches!(tap.kind, TapKind::Git { .. }));
}
#[test]
fn from_spec_git_at_url() {
let tap = Tap::from_spec("git@github.com:homebrew/homebrew-core.git").unwrap();
assert!(matches!(tap.kind, TapKind::Git { .. }));
}
#[test]
fn from_spec_invalid_returns_error() {
let result = Tap::from_spec("not/a/valid/tap/spec");
assert!(result.is_err(), "expected error for invalid spec");
}
#[test]
fn from_spec_bare_word_returns_error() {
let result = Tap::from_spec("justaword");
assert!(result.is_err());
}
#[test]
fn github_tap_url_format() {
let tap = Tap::from_spec("myuser/mytap").unwrap();
let url = tap.url().unwrap();
assert_eq!(url, "https://github.com/myuser/homebrew-mytap.git");
}
#[test]
fn git_tap_url_passthrough() {
let url = "https://example.com/my-tap.git";
let tap = Tap::from_spec(url).unwrap();
assert_eq!(tap.url().unwrap(), url);
}
#[test]
fn new_tap_manager_starts_empty() {
let mgr = TapManager::new().unwrap();
assert!(mgr.list_taps().is_empty());
}
}