bold-brew/internal/services/brewfile.go
Vito Castellano 0cd8f5059f
refactor(dataprovider): improve method naming consistency
Rename Load* methods to Get* with forceRefresh parameter for clarity:
- LoadInstalledFormulae → GetInstalledFormulae
- LoadRemoteFormulae → GetRemoteFormulae
- LoadTapPackages → GetTapPackages (and similar)

Rename Get* methods that execute commands to Fetch* for accuracy:
- GetInstalledCaskNames → FetchInstalledCaskNames
- GetInstalledFormulaNames → FetchInstalledFormulaNames

Replace forceDownload parameter with forceRefresh throughout.
2025-12-29 12:10:06 +01:00

253 lines
7.9 KiB
Go

// Package services provides Brewfile support for Bold Brew.
//
// This file handles parsing Brewfile entries (taps, formulae, casks),
// loading packages from third-party taps, and installing missing taps
// at application startup.
//
// NOTE: These methods are only active in Brewfile mode (bbrew -f <file>).
// In normal mode, these functions are not called.
//
// Execution sequence (Brewfile mode only):
//
// 1. Boot() → loadBrewfilePackages()
// Initial load using cached tap data for fast startup.
//
// 2. BuildApp() → goroutine:
// a) installBrewfileTapsAtStartup()
// Installs any missing taps from the Brewfile.
// b) updateHomeBrew() → forceRefreshResults()
// Refreshes Homebrew data and reloads packages.
//
// 3. forceRefreshResults() → fetchTapPackages() + loadBrewfilePackages()
// Fetches fresh tap package info and rebuilds the package list.
package services
import (
"bbrew/internal/models"
"fmt"
"os"
"sort"
"strings"
)
// parseBrewfileWithTaps parses a Brewfile and returns taps and packages separately.
func parseBrewfileWithTaps(filepath string) (*models.BrewfileResult, error) {
// #nosec G304 -- filepath is user-provided via CLI flag
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to read Brewfile: %w", err)
}
result := &models.BrewfileResult{
Taps: []string{},
Packages: []models.BrewfileEntry{},
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Parse tap entries: tap "user/repo"
if strings.HasPrefix(line, "tap ") {
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start != -1 && end != -1 && start < end {
tapName := line[start+1 : end]
result.Taps = append(result.Taps, tapName)
}
}
// Parse brew entries: brew "package-name"
if strings.HasPrefix(line, "brew ") {
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start != -1 && end != -1 && start < end {
packageName := line[start+1 : end]
result.Packages = append(result.Packages, models.BrewfileEntry{
Name: packageName,
IsCask: false,
})
}
}
// Parse cask entries: cask "package-name"
if strings.HasPrefix(line, "cask ") {
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start != -1 && end != -1 && start < end {
packageName := line[start+1 : end]
result.Packages = append(result.Packages, models.BrewfileEntry{
Name: packageName,
IsCask: true,
})
}
}
}
return result, nil
}
// loadBrewfilePackages parses the Brewfile and creates a filtered package list.
// Uses the DataProvider to load tap packages from cache or fetch via brew info.
func (s *AppService) loadBrewfilePackages() error {
result, err := parseBrewfileWithTaps(s.brewfilePath)
if err != nil {
return err
}
// Store taps for later installation
s.brewfileTaps = result.Taps
// Create a map for quick lookup of Brewfile entries
packageMap := make(map[string]models.PackageType)
for _, entry := range result.Packages {
if entry.IsCask {
packageMap[entry.Name] = models.PackageTypeCask
} else {
packageMap[entry.Name] = models.PackageTypeFormula
}
}
// Track which packages were found (to avoid duplicates)
foundPackages := make(map[string]bool)
// Get actual installed packages (2 calls total, much faster than per-package checks)
installedCasks := s.dataProvider.FetchInstalledCaskNames()
installedFormulae := s.dataProvider.FetchInstalledFormulaNames()
// Filter packages to only include those in the Brewfile
*s.brewfilePackages = []models.Package{}
for _, pkg := range *s.packages {
if pkgType, exists := packageMap[pkg.Name]; exists && pkgType == pkg.Type {
// Skip if already added (prevent duplicates)
if foundPackages[pkg.Name] {
continue
}
// Verify installation status against actual installed lists
if pkgType == models.PackageTypeCask {
pkg.LocallyInstalled = installedCasks[pkg.Name]
} else {
pkg.LocallyInstalled = installedFormulae[pkg.Name]
}
*s.brewfilePackages = append(*s.brewfilePackages, pkg)
foundPackages[pkg.Name] = true
}
}
// Collect entries not found in main list (tap packages)
var tapEntries []models.BrewfileEntry
for _, entry := range result.Packages {
if !foundPackages[entry.Name] {
tapEntries = append(tapEntries, entry)
}
}
// Load tap packages from cache (fast startup)
if len(tapEntries) > 0 {
// Build existing packages map
existingPackages := make(map[string]models.Package)
for _, pkg := range *s.packages {
existingPackages[pkg.Name] = pkg
}
// Use DataProvider to load tap packages (from cache only at startup, no fetch)
tapPackages, _ := s.dataProvider.GetTapPackages(tapEntries, existingPackages, false)
// Add tap packages to brewfilePackages, updating installed status (avoid duplicates)
for _, pkg := range tapPackages {
if foundPackages[pkg.Name] {
continue // Already added
}
if pkg.Type == models.PackageTypeCask {
pkg.LocallyInstalled = installedCasks[pkg.Name]
} else {
pkg.LocallyInstalled = installedFormulae[pkg.Name]
}
*s.brewfilePackages = append(*s.brewfilePackages, pkg)
foundPackages[pkg.Name] = true
}
}
// Sort by name for consistent display
sort.Slice(*s.brewfilePackages, func(i, j int) bool {
return (*s.brewfilePackages)[i].Name < (*s.brewfilePackages)[j].Name
})
return nil
}
// fetchTapPackages fetches info for packages from third-party taps and adds them to s.packages.
// This is called after taps are installed so that loadBrewfilePackages can find them.
// Uses the DataProvider to fetch and cache tap package data.
func (s *AppService) fetchTapPackages() {
if !s.IsBrewfileMode() || len(s.brewfileTaps) == 0 {
return
}
result, err := parseBrewfileWithTaps(s.brewfilePath)
if err != nil {
return
}
// Build a map of existing packages for quick lookup
existingPackages := make(map[string]models.Package)
for _, pkg := range *s.packages {
existingPackages[pkg.Name] = pkg
}
// Use DataProvider to fetch all tap packages (force download to get fresh data)
tapPackages, _ := s.dataProvider.GetTapPackages(result.Packages, existingPackages, true)
// Add tap packages to s.packages (avoiding duplicates)
for _, pkg := range tapPackages {
if _, exists := existingPackages[pkg.Name]; !exists {
*s.packages = append(*s.packages, pkg)
}
}
}
// installBrewfileTapsAtStartup installs any missing taps from the Brewfile at app startup.
// This runs before updateHomeBrew, which will then reload all data including the new taps.
func (s *AppService) installBrewfileTapsAtStartup() {
// Check which taps need to be installed
var tapsToInstall []string
for _, tap := range s.brewfileTaps {
if !s.brewService.IsTapInstalled(tap) {
tapsToInstall = append(tapsToInstall, tap)
}
}
if len(tapsToInstall) == 0 {
return // All taps already installed
}
// Install missing taps
for _, tap := range tapsToInstall {
tap := tap // Create local copy for closures
s.app.QueueUpdateDraw(func() {
s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Installing tap %s...", tap))
fmt.Fprintf(s.layout.GetOutput().View(), "[TAP] Installing %s...\n", tap)
})
if err := s.brewService.InstallTap(tap, s.app, s.layout.GetOutput().View()); err != nil {
s.app.QueueUpdateDraw(func() {
s.layout.GetNotifier().ShowError(fmt.Sprintf("Failed to install tap %s", tap))
fmt.Fprintf(s.layout.GetOutput().View(), "[ERROR] Failed to install tap %s\n", tap)
})
} else {
s.app.QueueUpdateDraw(func() {
s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("Tap %s installed", tap))
fmt.Fprintf(s.layout.GetOutput().View(), "[SUCCESS] tap %s installed\n", tap)
})
}
}
s.app.QueueUpdateDraw(func() {
s.layout.GetNotifier().ShowSuccess("All taps installed")
})
}