Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
name: CI

on:
pull_request:
push:
branches: [ main, staging, '**' ]

Expand Down
2 changes: 1 addition & 1 deletion cmd/keg/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

func main() {
if err := cmd.Execute(); err != nil {
logger.LogError(err.Error())
logger.LogError("%v", err)
os.Exit(1)
}
}
117 changes: 39 additions & 78 deletions internal/brew/state.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
package brew

import (
"bytes"
"context"
"encoding/json"
"fmt"
"time"

"github.com/MrSnakeDoc/keg/internal/runner"
"github.com/MrSnakeDoc/keg/internal/utils"
)

// BrewState represents the current state of Homebrew packages.
// This is now a wrapper around UnifiedCache for backward compatibility.
type BrewState struct {
Installed map[string]bool
Outdated map[string]PackageInfo
}

// PackageInfo contains version information for a package.
type PackageInfo struct {
Name string
InstalledVersion string
LatestVersion string
}

// brewOutdatedJSON matches the structure of `brew outdated --json=v2`.
type brewOutdatedJSON struct {
Formulae []struct {
Name string `json:"name"`
Expand All @@ -30,96 +29,58 @@ type brewOutdatedJSON struct {
} `json:"formulae"`
}

type cacheFile struct {
Data *brewOutdatedJSON `json:"data"`
Timestamp time.Time `json:"timestamp"`
}

func readCache(filename string) (*brewOutdatedJSON, error) {
path := utils.MakeFilePath(utils.CacheDir, filename)

var cache cacheFile
if err := utils.FileReader(path, "json", &cache); err != nil {
// FetchState retrieves the current brew state using the unified cache.
func FetchState(r runner.CommandRunner) (*BrewState, error) {
cache, err := GetCache(r)
if err != nil {
return nil, err
}

// Check if cache is expired
if time.Since(cache.Timestamp) > utils.CacheExpiry {
return nil, fmt.Errorf("cache expired")
// Refresh cache if needed (respects TTL)
if err := cache.Refresh(context.Background(), false); err != nil {
return nil, err
}

return cache.Data, nil
return &BrewState{
Installed: cache.GetInstalledSet(),
Outdated: cache.GetOutdatedMap(),
}, nil
}

// FetchOutdatedPackages is kept for backward compatibility.
// It now uses the unified cache internally.
func FetchOutdatedPackages(r runner.CommandRunner) (*brewOutdatedJSON, error) {
// 1. call to `brew outdated --json=v2`
output, err := r.Run(context.Background(), 120*time.Second,
runner.Capture, "brew", "outdated", "--json=v2")
cache, err := GetCache(r)
if err != nil {
return nil, fmt.Errorf("failed to get outdated packages: %w", err)
}

// 2. Look for the first β€œ{”
idx := bytes.IndexByte(output, '{')
if idx == -1 {
return nil, fmt.Errorf("no JSON found in brew output:\n%s", output)
}
jsonPart := output[idx:]

// 3. Decoding JSON
var outdated brewOutdatedJSON
if err := json.Unmarshal(jsonPart, &outdated); err != nil {
return nil, fmt.Errorf("failed to parse brew JSON: %w", err)
}

// 4. Write the cache
var cache cacheFile
if err := utils.CreateFile(
utils.MakeFilePath(utils.CacheDir, utils.OutdatedFile),
cache, "json", 0o600); err != nil {
return nil, fmt.Errorf("failed to write cache: %w", err)
return nil, err
}

return &outdated, nil
}

func FetchState(r runner.CommandRunner) (*BrewState, error) {
if r == nil {
r = &runner.ExecRunner{}
if err := cache.Refresh(context.Background(), false); err != nil {
return nil, err
}

installed, err := utils.InstalledSet(r)
if err != nil {
return nil, fmt.Errorf("failed to get installed packages: %w", err)
}
outdatedMap := cache.GetOutdatedMap()

outdated, err := getOutdatedPackages(r)
if err != nil {
return nil, err
result := &brewOutdatedJSON{
Formulae: make([]struct {
Name string `json:"name"`
InstalledVersions []string `json:"installed_versions"`
CurrentVersion string `json:"current_version"`
}, 0, len(outdatedMap)),
}

versionMap := make(map[string]PackageInfo)
for _, f := range outdated.Formulae {
if len(f.InstalledVersions) > 0 {
versionMap[f.Name] = PackageInfo{
Name: f.Name,
InstalledVersion: f.InstalledVersions[0],
LatestVersion: f.CurrentVersion,
}
for name, info := range outdatedMap {
formula := struct {
Name string `json:"name"`
InstalledVersions []string `json:"installed_versions"`
CurrentVersion string `json:"current_version"`
}{
Name: name,
InstalledVersions: []string{info.InstalledVersion},
CurrentVersion: info.LatestVersion,
}
result.Formulae = append(result.Formulae, formula)
}

return &BrewState{
Installed: installed,
Outdated: versionMap,
}, nil
}

func getOutdatedPackages(r runner.CommandRunner) (*brewOutdatedJSON, error) {
data, err := readCache(utils.OutdatedFile)
if err != nil {
return FetchOutdatedPackages(r)
}

return data, nil
return result, nil
}
10 changes: 8 additions & 2 deletions internal/brew/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,13 @@ func TestFetchState_BadJSON(t *testing.T) {
}
return []byte{}, nil
}
if _, err := FetchState(mr); err == nil {
t.Fatal("expected error on invalid JSON")
// FetchState now returns empty maps on JSON error instead of failing
st, err := FetchState(mr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have installed packages but no outdated (due to JSON error)
if len(st.Installed) == 0 {
t.Fatal("expected at least installed packages")
}
}
Loading