refactor(services): introduce DataProvider pattern for data loading

Extract data loading logic into a dedicated DataProvider service that
handles all data retrieval operations (formulae, casks, analytics, cache).
BrewService now delegates to DataProvider via interface for better
testability and separation of concerns.

Also centralizes cache file name constants in dataprovider.go for
maintainability.
This commit is contained in:
Vito Castellano 2025-12-29 00:42:54 +01:00
commit 1440949df3
No known key found for this signature in database
GPG key ID: E13085DB38BC5819
3 changed files with 363 additions and 285 deletions

View file

@ -65,6 +65,9 @@ type BrewServiceInterface interface {
// BrewService provides methods to interact with Homebrew, including
// retrieving formulae, casks, and handling analytics.
type BrewService struct {
// Data provider for loading packages
dataProvider DataProviderInterface
// Formula lists
all *[]models.Formula
installed *[]models.Formula
@ -87,6 +90,7 @@ type BrewService struct {
// NewBrewService creates a new instance of BrewService with initialized package lists.
var NewBrewService = func() BrewServiceInterface {
return &BrewService{
dataProvider: NewDataProvider(),
all: new([]models.Formula),
installed: new([]models.Formula),
remote: new([]models.Formula),
@ -135,4 +139,3 @@ func (s *BrewService) UpdateHomebrew() error {
cmd := exec.Command("brew", "update")
return cmd.Run()
}

View file

@ -2,320 +2,65 @@ package services
import (
"bbrew/internal/models"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// SetupData initializes the BrewService by loading installed packages, remote formulae, casks, and analytics data.
// Uses the DataProvider for all data retrieval operations.
func (s *BrewService) SetupData(forceDownload bool) error {
// Load formulae
if err := s.loadInstalled(forceDownload); err != nil {
// Load installed formulae
installed, err := s.dataProvider.LoadInstalledFormulae(forceDownload)
if err != nil {
return fmt.Errorf("failed to load installed formulae: %w", err)
}
*s.installed = installed
if err := s.loadRemote(forceDownload); err != nil {
// Load remote formulae
remote, err := s.dataProvider.LoadRemoteFormulae(forceDownload)
if err != nil {
return fmt.Errorf("failed to load remote formulae: %w", err)
}
*s.remote = remote
if err := s.loadAnalytics(forceDownload); err != nil {
// Load formulae analytics
analytics, err := s.dataProvider.LoadFormulaeAnalytics(forceDownload)
if err != nil {
return fmt.Errorf("failed to load formulae analytics: %w", err)
}
s.analytics = analytics
// Load casks
if err := s.loadInstalledCasks(forceDownload); err != nil {
// Load installed casks
installedCasks, err := s.dataProvider.LoadInstalledCasks(forceDownload)
if err != nil {
return fmt.Errorf("failed to load installed casks: %w", err)
}
*s.installedCasks = installedCasks
if err := s.loadRemoteCasks(forceDownload); err != nil {
// Load remote casks
remoteCasks, err := s.dataProvider.LoadRemoteCasks(forceDownload)
if err != nil {
return fmt.Errorf("failed to load remote casks: %w", err)
}
*s.remoteCasks = remoteCasks
if err := s.loadCaskAnalytics(forceDownload); err != nil {
// Load cask analytics
caskAnalytics, err := s.dataProvider.LoadCaskAnalytics(forceDownload)
if err != nil {
return fmt.Errorf("failed to load cask analytics: %w", err)
}
s.caskAnalytics = caskAnalytics
return nil
}
// loadInstalled retrieves installed formulae, optionally using cache.
func (s *BrewService) loadInstalled(forceDownload bool) error {
if err := ensureCacheDir(); err != nil {
return err
}
const cacheFile = "installed.json"
if !forceDownload {
if data := readCacheFile(cacheFile, 10); data != nil {
*s.installed = make([]models.Formula, 0)
if err := json.Unmarshal(data, &s.installed); err == nil {
s.markFormulaeAsInstalled()
return nil
}
}
}
cmd := exec.Command("brew", "info", "--json=v1", "--installed")
output, err := cmd.Output()
if err != nil {
return err
}
*s.installed = make([]models.Formula, 0)
if err := json.Unmarshal(output, &s.installed); err != nil {
return err
}
s.markFormulaeAsInstalled()
writeCacheFile(cacheFile, output)
return nil
}
// markFormulaeAsInstalled sets LocallyInstalled and LocalPath for all installed formulae.
func (s *BrewService) markFormulaeAsInstalled() {
prefix := s.GetPrefixPath()
for i := range *s.installed {
(*s.installed)[i].LocallyInstalled = true
(*s.installed)[i].LocalPath = filepath.Join(prefix, "Cellar", (*s.installed)[i].Name)
}
}
// loadInstalledCasks retrieves installed casks, optionally using cache.
func (s *BrewService) loadInstalledCasks(forceDownload bool) error {
if err := ensureCacheDir(); err != nil {
return err
}
const cacheFile = "installed-casks.json"
if !forceDownload {
if data := readCacheFile(cacheFile, 10); data != nil {
var response struct {
Casks []models.Cask `json:"casks"`
}
if err := json.Unmarshal(data, &response); err == nil {
*s.installedCasks = response.Casks
s.markCasksAsInstalled()
return nil
}
}
}
// Get list of installed cask names
listCmd := exec.Command("brew", "list", "--cask")
listOutput, err := listCmd.Output()
if err != nil {
*s.installedCasks = make([]models.Cask, 0)
return nil
}
caskNames := strings.Split(strings.TrimSpace(string(listOutput)), "\n")
if len(caskNames) == 0 || (len(caskNames) == 1 && caskNames[0] == "") {
*s.installedCasks = make([]models.Cask, 0)
return nil
}
// Get info for each installed cask
args := append([]string{"info", "--json=v2", "--cask"}, caskNames...)
infoCmd := exec.Command("brew", args...)
infoOutput, err := infoCmd.Output()
if err != nil {
*s.installedCasks = make([]models.Cask, 0)
return nil
}
var response struct {
Casks []models.Cask `json:"casks"`
}
if err := json.Unmarshal(infoOutput, &response); err != nil {
return err
}
*s.installedCasks = response.Casks
s.markCasksAsInstalled()
writeCacheFile(cacheFile, infoOutput)
return nil
}
// markCasksAsInstalled sets LocallyInstalled and IsCask for all installed casks.
func (s *BrewService) markCasksAsInstalled() {
for i := range *s.installedCasks {
(*s.installedCasks)[i].LocallyInstalled = true
(*s.installedCasks)[i].IsCask = true
}
}
// loadRemote retrieves the list of remote Homebrew formulae from the API and caches them locally.
func (s *BrewService) loadRemote(forceDownload bool) error {
if err := ensureCacheDir(); err != nil {
return err
}
const cacheFile = "formula.json"
if !forceDownload {
if data := readCacheFile(cacheFile, 1000); data != nil {
*s.remote = make([]models.Formula, 0)
if err := json.Unmarshal(data, &s.remote); err == nil && len(*s.remote) > 0 {
return nil
}
}
}
body, err := fetchFromAPI(FormulaeAPIURL)
if err != nil {
return err
}
*s.remote = make([]models.Formula, 0)
if err := json.Unmarshal(body, s.remote); err != nil {
return err
}
writeCacheFile(cacheFile, body)
return nil
}
// loadRemoteCasks retrieves the list of remote Homebrew casks from the API and caches them locally.
func (s *BrewService) loadRemoteCasks(forceDownload bool) error {
if err := ensureCacheDir(); err != nil {
return err
}
const cacheFile = "cask.json"
if !forceDownload {
if data := readCacheFile(cacheFile, 1000); data != nil {
*s.remoteCasks = make([]models.Cask, 0)
if err := json.Unmarshal(data, &s.remoteCasks); err == nil && len(*s.remoteCasks) > 0 {
return nil
}
}
}
body, err := fetchFromAPI(CaskAPIURL)
if err != nil {
return err
}
*s.remoteCasks = make([]models.Cask, 0)
if err := json.Unmarshal(body, s.remoteCasks); err != nil {
return err
}
writeCacheFile(cacheFile, body)
return nil
}
// loadAnalytics retrieves the analytics data for Homebrew formulae from the API and caches them locally.
func (s *BrewService) loadAnalytics(forceDownload bool) error {
if err := ensureCacheDir(); err != nil {
return err
}
const cacheFile = "analytics.json"
if !forceDownload {
if data := readCacheFile(cacheFile, 100); data != nil {
analytics := models.Analytics{}
if err := json.Unmarshal(data, &analytics); err == nil && len(analytics.Items) > 0 {
s.analytics = make(map[string]models.AnalyticsItem)
for _, f := range analytics.Items {
s.analytics[f.Formula] = f
}
return nil
}
}
}
body, err := fetchFromAPI(AnalyticsAPIURL)
if err != nil {
return err
}
analytics := models.Analytics{}
if err := json.Unmarshal(body, &analytics); err != nil {
return err
}
s.analytics = make(map[string]models.AnalyticsItem)
for _, f := range analytics.Items {
s.analytics[f.Formula] = f
}
writeCacheFile(cacheFile, body)
return nil
}
// loadCaskAnalytics retrieves the analytics data for Homebrew casks from the API and caches them locally.
func (s *BrewService) loadCaskAnalytics(forceDownload bool) error {
if err := ensureCacheDir(); err != nil {
return err
}
const cacheFile = "cask-analytics.json"
if !forceDownload {
if data := readCacheFile(cacheFile, 100); data != nil {
analytics := models.Analytics{}
if err := json.Unmarshal(data, &analytics); err == nil && len(analytics.Items) > 0 {
s.caskAnalytics = make(map[string]models.AnalyticsItem)
for _, c := range analytics.Items {
if c.Cask != "" {
s.caskAnalytics[c.Cask] = c
}
}
return nil
}
}
}
body, err := fetchFromAPI(CaskAnalyticsAPIURL)
if err != nil {
return err
}
analytics := models.Analytics{}
if err := json.Unmarshal(body, &analytics); err != nil {
return err
}
s.caskAnalytics = make(map[string]models.AnalyticsItem)
for _, c := range analytics.Items {
if c.Cask != "" {
s.caskAnalytics[c.Cask] = c
}
}
writeCacheFile(cacheFile, body)
return nil
}
// LoadTapPackagesCache loads cached tap packages from disk.
// Delegates to the DataProvider.
func (s *BrewService) LoadTapPackagesCache() map[string]models.Package {
result := make(map[string]models.Package)
const cacheFile = "tap_packages.json"
if data := readCacheFile(cacheFile, 10); data != nil {
var packages []models.Package
if err := json.Unmarshal(data, &packages); err == nil {
for _, pkg := range packages {
result[pkg.Name] = pkg
}
}
}
return result
return s.dataProvider.LoadTapPackagesCache()
}
// SaveTapPackagesToCache saves tap packages to disk cache.
// Delegates to the DataProvider.
func (s *BrewService) SaveTapPackagesToCache(packages []models.Package) error {
if err := ensureCacheDir(); err != nil {
return err
}
data, err := json.Marshal(packages)
if err != nil {
return err
}
writeCacheFile("tap_packages.json", data)
return nil
return s.dataProvider.SaveTapPackagesToCache(packages)
}

View file

@ -0,0 +1,330 @@
package services
import (
"bbrew/internal/models"
"encoding/json"
"os/exec"
"path/filepath"
"strings"
)
// Cache file names
const (
cacheFileInstalled = "installed.json"
cacheFileInstalledCasks = "installed-casks.json"
cacheFileFormulae = "formula.json"
cacheFileCasks = "cask.json"
cacheFileAnalytics = "analytics.json"
cacheFileCaskAnalytics = "cask-analytics.json"
cacheFileTapPackages = "tap_packages.json"
)
// DataProviderInterface defines the contract for data loading operations.
type DataProviderInterface interface {
// Formulae
LoadInstalledFormulae(forceDownload bool) ([]models.Formula, error)
LoadRemoteFormulae(forceDownload bool) ([]models.Formula, error)
LoadFormulaeAnalytics(forceDownload bool) (map[string]models.AnalyticsItem, error)
// Casks
LoadInstalledCasks(forceDownload bool) ([]models.Cask, error)
LoadRemoteCasks(forceDownload bool) ([]models.Cask, error)
LoadCaskAnalytics(forceDownload bool) (map[string]models.AnalyticsItem, error)
// Tap packages cache
LoadTapPackagesCache() map[string]models.Package
SaveTapPackagesToCache(packages []models.Package) error
}
// DataProvider implements DataProviderInterface.
type DataProvider struct {
prefixPath string
}
// NewDataProvider creates a new DataProvider instance.
func NewDataProvider() *DataProvider {
return &DataProvider{}
}
// getPrefixPath returns the Homebrew prefix path, caching it.
func (d *DataProvider) getPrefixPath() string {
if d.prefixPath != "" {
return d.prefixPath
}
cmd := exec.Command("brew", "--prefix")
output, err := cmd.Output()
if err != nil {
d.prefixPath = "Unknown"
return d.prefixPath
}
d.prefixPath = strings.TrimSpace(string(output))
return d.prefixPath
}
// LoadInstalledFormulae retrieves installed formulae, optionally using cache.
func (d *DataProvider) LoadInstalledFormulae(forceDownload bool) ([]models.Formula, error) {
if err := ensureCacheDir(); err != nil {
return nil, err
}
if !forceDownload {
if data := readCacheFile(cacheFileInstalled, 10); data != nil {
var formulae []models.Formula
if err := json.Unmarshal(data, &formulae); err == nil {
d.markFormulaeAsInstalled(&formulae)
return formulae, nil
}
}
}
cmd := exec.Command("brew", "info", "--json=v1", "--installed")
output, err := cmd.Output()
if err != nil {
return nil, err
}
var formulae []models.Formula
if err := json.Unmarshal(output, &formulae); err != nil {
return nil, err
}
d.markFormulaeAsInstalled(&formulae)
writeCacheFile(cacheFileInstalled, output)
return formulae, nil
}
// markFormulaeAsInstalled sets LocallyInstalled and LocalPath for formulae.
func (d *DataProvider) markFormulaeAsInstalled(formulae *[]models.Formula) {
prefix := d.getPrefixPath()
for i := range *formulae {
(*formulae)[i].LocallyInstalled = true
(*formulae)[i].LocalPath = filepath.Join(prefix, "Cellar", (*formulae)[i].Name)
}
}
// LoadInstalledCasks retrieves installed casks, optionally using cache.
func (d *DataProvider) LoadInstalledCasks(forceDownload bool) ([]models.Cask, error) {
if err := ensureCacheDir(); err != nil {
return nil, err
}
if !forceDownload {
if data := readCacheFile(cacheFileInstalledCasks, 10); data != nil {
var response struct {
Casks []models.Cask `json:"casks"`
}
if err := json.Unmarshal(data, &response); err == nil {
d.markCasksAsInstalled(&response.Casks)
return response.Casks, nil
}
}
}
// Get list of installed cask names
listCmd := exec.Command("brew", "list", "--cask")
listOutput, err := listCmd.Output()
if err != nil {
return []models.Cask{}, nil // No casks installed
}
caskNames := strings.Split(strings.TrimSpace(string(listOutput)), "\n")
if len(caskNames) == 0 || (len(caskNames) == 1 && caskNames[0] == "") {
return []models.Cask{}, nil
}
// Get info for each installed cask
args := append([]string{"info", "--json=v2", "--cask"}, caskNames...)
infoCmd := exec.Command("brew", args...)
infoOutput, err := infoCmd.Output()
if err != nil {
return []models.Cask{}, nil
}
var response struct {
Casks []models.Cask `json:"casks"`
}
if err := json.Unmarshal(infoOutput, &response); err != nil {
return nil, err
}
d.markCasksAsInstalled(&response.Casks)
writeCacheFile(cacheFileInstalledCasks, infoOutput)
return response.Casks, nil
}
// markCasksAsInstalled sets LocallyInstalled and IsCask for casks.
func (d *DataProvider) markCasksAsInstalled(casks *[]models.Cask) {
for i := range *casks {
(*casks)[i].LocallyInstalled = true
(*casks)[i].IsCask = true
}
}
// LoadRemoteFormulae retrieves remote formulae from API, optionally using cache.
func (d *DataProvider) LoadRemoteFormulae(forceDownload bool) ([]models.Formula, error) {
if err := ensureCacheDir(); err != nil {
return nil, err
}
if !forceDownload {
if data := readCacheFile(cacheFileFormulae, 1000); data != nil {
var formulae []models.Formula
if err := json.Unmarshal(data, &formulae); err == nil && len(formulae) > 0 {
return formulae, nil
}
}
}
body, err := fetchFromAPI(FormulaeAPIURL)
if err != nil {
return nil, err
}
var formulae []models.Formula
if err := json.Unmarshal(body, &formulae); err != nil {
return nil, err
}
writeCacheFile(cacheFileFormulae, body)
return formulae, nil
}
// LoadRemoteCasks retrieves remote casks from API, optionally using cache.
func (d *DataProvider) LoadRemoteCasks(forceDownload bool) ([]models.Cask, error) {
if err := ensureCacheDir(); err != nil {
return nil, err
}
if !forceDownload {
if data := readCacheFile(cacheFileCasks, 1000); data != nil {
var casks []models.Cask
if err := json.Unmarshal(data, &casks); err == nil && len(casks) > 0 {
return casks, nil
}
}
}
body, err := fetchFromAPI(CaskAPIURL)
if err != nil {
return nil, err
}
var casks []models.Cask
if err := json.Unmarshal(body, &casks); err != nil {
return nil, err
}
writeCacheFile(cacheFileCasks, body)
return casks, nil
}
// LoadFormulaeAnalytics retrieves formulae analytics from API, optionally using cache.
func (d *DataProvider) LoadFormulaeAnalytics(forceDownload bool) (map[string]models.AnalyticsItem, error) {
if err := ensureCacheDir(); err != nil {
return nil, err
}
if !forceDownload {
if data := readCacheFile(cacheFileAnalytics, 100); data != nil {
analytics := models.Analytics{}
if err := json.Unmarshal(data, &analytics); err == nil && len(analytics.Items) > 0 {
result := make(map[string]models.AnalyticsItem)
for _, f := range analytics.Items {
result[f.Formula] = f
}
return result, nil
}
}
}
body, err := fetchFromAPI(AnalyticsAPIURL)
if err != nil {
return nil, err
}
analytics := models.Analytics{}
if err := json.Unmarshal(body, &analytics); err != nil {
return nil, err
}
result := make(map[string]models.AnalyticsItem)
for _, f := range analytics.Items {
result[f.Formula] = f
}
writeCacheFile(cacheFileAnalytics, body)
return result, nil
}
// LoadCaskAnalytics retrieves cask analytics from API, optionally using cache.
func (d *DataProvider) LoadCaskAnalytics(forceDownload bool) (map[string]models.AnalyticsItem, error) {
if err := ensureCacheDir(); err != nil {
return nil, err
}
if !forceDownload {
if data := readCacheFile(cacheFileCaskAnalytics, 100); data != nil {
analytics := models.Analytics{}
if err := json.Unmarshal(data, &analytics); err == nil && len(analytics.Items) > 0 {
result := make(map[string]models.AnalyticsItem)
for _, c := range analytics.Items {
if c.Cask != "" {
result[c.Cask] = c
}
}
return result, nil
}
}
}
body, err := fetchFromAPI(CaskAnalyticsAPIURL)
if err != nil {
return nil, err
}
analytics := models.Analytics{}
if err := json.Unmarshal(body, &analytics); err != nil {
return nil, err
}
result := make(map[string]models.AnalyticsItem)
for _, c := range analytics.Items {
if c.Cask != "" {
result[c.Cask] = c
}
}
writeCacheFile(cacheFileCaskAnalytics, body)
return result, nil
}
// LoadTapPackagesCache loads cached tap packages from disk.
func (d *DataProvider) LoadTapPackagesCache() map[string]models.Package {
result := make(map[string]models.Package)
if data := readCacheFile(cacheFileTapPackages, 10); data != nil {
var packages []models.Package
if err := json.Unmarshal(data, &packages); err == nil {
for _, pkg := range packages {
result[pkg.Name] = pkg
}
}
}
return result
}
// SaveTapPackagesToCache saves tap packages to disk cache.
func (d *DataProvider) SaveTapPackagesToCache(packages []models.Package) error {
if err := ensureCacheDir(); err != nil {
return err
}
data, err := json.Marshal(packages)
if err != nil {
return err
}
writeCacheFile(cacheFileTapPackages, data)
return nil
}