1package config
2
3import (
4 "cmp"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
16 "github.com/charmbracelet/catwalk/pkg/embedded"
17 "github.com/charmbracelet/crush/internal/home"
18)
19
20type ProviderClient interface {
21 GetProviders() ([]catwalk.Provider, error)
22}
23
24var (
25 providerMu sync.RWMutex
26 providerList []catwalk.Provider
27 providerErr error
28 initialized bool
29)
30
31// file to cache provider data
32func providerCacheFileData() string {
33 xdgDataHome := os.Getenv("XDG_DATA_HOME")
34 if xdgDataHome != "" {
35 return filepath.Join(xdgDataHome, appName, "providers.json")
36 }
37
38 // return the path to the main data directory
39 // for windows, it should be in `%LOCALAPPDATA%/crush/`
40 // for linux and macOS, it should be in `$HOME/.local/share/crush/`
41 if runtime.GOOS == "windows" {
42 localAppData := os.Getenv("LOCALAPPDATA")
43 if localAppData == "" {
44 localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
45 }
46 return filepath.Join(localAppData, appName, "providers.json")
47 }
48
49 return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json")
50}
51
52func saveProvidersInCache(path string, providers []catwalk.Provider) error {
53 slog.Info("Saving cached provider data", "path", path)
54 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
55 return fmt.Errorf("failed to create directory for provider cache: %w", err)
56 }
57
58 data, err := json.MarshalIndent(providers, "", " ")
59 if err != nil {
60 return fmt.Errorf("failed to marshal provider data: %w", err)
61 }
62
63 if err := os.WriteFile(path, data, 0o644); err != nil {
64 return fmt.Errorf("failed to write provider data to cache: %w", err)
65 }
66 return nil
67}
68
69func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
70 data, err := os.ReadFile(path)
71 if err != nil {
72 return nil, fmt.Errorf("failed to read provider cache file: %w", err)
73 }
74
75 var providers []catwalk.Provider
76 if err := json.Unmarshal(data, &providers); err != nil {
77 return nil, fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
78 }
79 return providers, nil
80}
81
82// NOTE(tauraamui) <REF#1>: there seems to be duplication of logic for updating
83// the providers cache/from catwalk, in that this method is only invoked/used by the
84// catwalk CLI command, when it should probably be the exact same behaviour that
85// crush carries out internally as well when it does the sync internally/automatically
86// see (REF#2)
87func UpdateProviders(pathOrUrl string) error {
88 var providers []catwalk.Provider
89 pathOrUrl = cmp.Or(pathOrUrl, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
90
91 switch {
92 case pathOrUrl == "embedded":
93 providers = embedded.GetAll()
94 case strings.HasPrefix(pathOrUrl, "http://") || strings.HasPrefix(pathOrUrl, "https://"):
95 var err error
96 providers, err = catwalk.NewWithURL(pathOrUrl).GetProviders()
97 if err != nil {
98 return fmt.Errorf("failed to fetch providers from Catwalk: %w", err)
99 }
100 default:
101 content, err := os.ReadFile(pathOrUrl)
102 if err != nil {
103 return fmt.Errorf("failed to read file: %w", err)
104 }
105 if err := json.Unmarshal(content, &providers); err != nil {
106 return fmt.Errorf("failed to unmarshal provider data: %w", err)
107 }
108 if len(providers) == 0 {
109 return fmt.Errorf("no providers found in the provided source")
110 }
111 }
112
113 cachePath := providerCacheFileData()
114 if err := saveProvidersInCache(cachePath, providers); err != nil {
115 return fmt.Errorf("failed to save providers to cache: %w", err)
116 }
117
118 slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrUrl, "to", cachePath)
119 return nil
120}
121
122func Providers(cfg *Config) ([]catwalk.Provider, error) {
123 providerMu.Lock()
124 if !initialized {
125 catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
126 client := catwalk.NewWithURL(catwalkURL)
127 path := providerCacheFileData()
128
129 autoUpdateDisabled := cfg.Options.DisableProviderAutoUpdate
130 providerList, providerErr = loadProviders(autoUpdateDisabled, client, path, cfg)
131 initialized = true
132 }
133 providerMu.Unlock()
134
135 providerMu.RLock()
136 defer providerMu.RUnlock()
137 return providerList, providerErr
138}
139
140func reloadProviders(path string) {
141 providerMu.Lock()
142 defer providerMu.Unlock()
143
144 providers, err := loadProvidersFromCache(path)
145 if err != nil {
146 slog.Error("Failed to reload providers from cache", "error", err)
147 return
148 }
149 if len(providers) == 0 {
150 slog.Error("Empty providers list after reload")
151 return
152 }
153
154 providerList = providers
155 providerErr = nil
156 slog.Info("Providers reloaded successfully", "count", len(providers))
157}
158
159func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string, cfg *Config) ([]catwalk.Provider, error) {
160 cacheIsStale, cacheExists := isCacheStale(path)
161
162 catwalkGetAndSave := func() ([]catwalk.Provider, error) {
163 providers, err := client.GetProviders()
164 if err != nil {
165 return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
166 }
167 if len(providers) == 0 {
168 return nil, fmt.Errorf("empty providers list from catwalk")
169 }
170 if err := saveProvidersInCache(path, providers); err != nil {
171 return nil, err
172 }
173 return providers, nil
174 }
175
176 backgroundCacheUpdate := func() {
177 go func() {
178 slog.Info("Updating providers cache in background", "path", path)
179
180 providers, err := client.GetProviders()
181 if err != nil {
182 slog.Error("Failed to fetch providers in background from Catwalk", "error", err)
183 return
184 }
185 if len(providers) == 0 {
186 slog.Error("Empty providers list from Catwalk")
187 return
188 }
189 if err := saveProvidersInCache(path, providers); err != nil {
190 slog.Error("Failed to update providers.json in background", "error", err)
191 return
192 }
193
194 reloadProviders(path)
195 }()
196 }
197
198 switch {
199 case autoUpdateDisabled:
200 slog.Warn("Providers auto-update is disabled")
201
202 if cacheExists {
203 slog.Warn("Using locally cached providers")
204 return loadProvidersFromCache(path)
205 }
206
207 slog.Warn("Saving embedded providers to cache")
208 providers := embedded.GetAll()
209 if err := saveProvidersInCache(path, providers); err != nil {
210 return nil, err
211 }
212 return providers, nil
213
214 case cacheExists && !cacheIsStale:
215 slog.Info("Recent providers cache is available.", "path", path)
216
217 providers, err := loadProvidersFromCache(path)
218 if err != nil {
219 return nil, err
220 }
221 if len(providers) == 0 {
222 return catwalkGetAndSave()
223 }
224 backgroundCacheUpdate()
225 return providers, nil
226
227 default:
228 slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
229
230 providers, err := catwalkGetAndSave()
231 if err != nil {
232 catwalkUrl := fmt.Sprintf("%s/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
233 return nil, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help. %w", catwalkUrl, err) //nolint:staticcheck
234 }
235 return providers, nil
236 }
237}
238
239func isCacheStale(path string) (stale, exists bool) {
240 info, err := os.Stat(path)
241 if err != nil {
242 return true, false
243 }
244 return time.Since(info.ModTime()) > 24*time.Hour, true
245}