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(autoUpdateDisabled bool) ([]catwalk.Provider, error) {
123 catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
124 client := catwalk.NewWithURL(catwalkURL)
125 return ProvidersWithClient(autoUpdateDisabled, client, providerCacheFileData())
126}
127
128func ProvidersWithClient(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
129 if !initialized {
130 providerMu.Lock()
131 providerList, providerErr = loadProviders(autoUpdateDisabled, client, path)
132 initialized = true
133 providerMu.Unlock()
134 }
135
136 providerMu.RLock()
137 defer providerMu.RUnlock()
138 return providerList, providerErr
139}
140
141func reloadProviders(path string) {
142 providerMu.Lock()
143 defer providerMu.Unlock()
144
145 providers, err := loadProvidersFromCache(path)
146 if err != nil {
147 slog.Error("Failed to reload providers from cache", "error", err)
148 return
149 }
150 if len(providers) == 0 {
151 slog.Error("Empty providers list after reload")
152 return
153 }
154
155 providerList = providers
156 providerErr = nil
157 slog.Info("Providers reloaded successfully", "count", len(providers))
158}
159
160func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
161 cacheIsStale, cacheExists := isCacheStale(path)
162
163 catwalkGetAndSave := func() ([]catwalk.Provider, error) {
164 providers, err := client.GetProviders()
165 if err != nil {
166 return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
167 }
168 if len(providers) == 0 {
169 return nil, fmt.Errorf("empty providers list from catwalk")
170 }
171 if err := saveProvidersInCache(path, providers); err != nil {
172 return nil, err
173 }
174 return providers, nil
175 }
176
177 backgroundCacheUpdate := func() {
178 go func() {
179 slog.Info("Updating providers cache in background", "path", path)
180
181 providers, err := client.GetProviders()
182 if err != nil {
183 slog.Error("Failed to fetch providers in background from Catwalk", "error", err)
184 return
185 }
186 if len(providers) == 0 {
187 slog.Error("Empty providers list from Catwalk")
188 return
189 }
190 if err := saveProvidersInCache(path, providers); err != nil {
191 slog.Error("Failed to update providers.json in background", "error", err)
192 return
193 }
194
195 reloadProviders(path)
196 }()
197 }
198
199 switch {
200 case autoUpdateDisabled:
201 slog.Warn("Providers auto-update is disabled")
202
203 if cacheExists {
204 slog.Warn("Using locally cached providers")
205 return loadProvidersFromCache(path)
206 }
207
208 slog.Warn("Saving embedded providers to cache")
209 providers := embedded.GetAll()
210 if err := saveProvidersInCache(path, providers); err != nil {
211 return nil, err
212 }
213 return providers, nil
214
215 case cacheExists && !cacheIsStale:
216 slog.Info("Recent providers cache is available.", "path", path)
217
218 providers, err := loadProvidersFromCache(path)
219 if err != nil {
220 return nil, err
221 }
222 if len(providers) == 0 {
223 return catwalkGetAndSave()
224 }
225 backgroundCacheUpdate()
226 return providers, nil
227
228 default:
229 slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
230
231 providers, err := catwalkGetAndSave()
232 if err != nil {
233 catwalkUrl := fmt.Sprintf("%s/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
234 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
235 }
236 return providers, nil
237 }
238}
239
240func isCacheStale(path string) (stale, exists bool) {
241 info, err := os.Stat(path)
242 if err != nil {
243 return true, false
244 }
245 return time.Since(info.ModTime()) > 24*time.Hour, true
246}