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}