on main help sign in
chore: change config signature

- make some functions pointer methods - add env tests

commite74e9dfc43d8d1ef68fa2543d75961cbb179e9c3 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 4 Jul 2025, 23:25 (1 year ago) parent1a4f006
6 files changed+239−38
cmd/logs.go+1−2 pkg/config/config.go+54−5 pkg/config/load.go+19−6 pkg/config/load_test.go+20−19 pkg/env/env_test.go+142 pkg/log/log.go+3−6
cmd/logs.go+1−2
@@ -8,7 +8,6 @@ import (
88 "time"
99
1010 "github.com/charmbracelet/crush/pkg/config"
11− "github.com/charmbracelet/crush/pkg/env"
1211 "github.com/charmbracelet/log/v2"
1312 "github.com/nxadm/tail"
1413 "github.com/spf13/cobra"
@@ -27,7 +26,7 @@ var logsCmd = &cobra.Command{
2726 if err != nil {
2827 return fmt.Errorf("failed to get current working directory: %v", err)
2928 }
30− cfg, err := config.Load(cwd, env.New())
29+ cfg, err := config.Load(cwd, false)
3130 if err != nil {
3231 return fmt.Errorf("failed to load configuration: %v", err)
3332 }
pkg/config/config.go+54−5
@@ -1,6 +1,11 @@
11package config
22
3−import "github.com/charmbracelet/crush/internal/fur/provider"
3+import (
4+ "slices"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/fur/provider"
8+)
49
510const (
611 appName = "crush"
@@ -70,7 +75,7 @@ const (
7075 MCPHttp MCPType = "http"
7176)
7277
73−type MCP struct {
78+type MCPConfig struct {
7479 Command string `json:"command,omitempty" `
7580 Env []string `json:"env,omitempty"`
7681 Args []string `json:"args,omitempty"`
@@ -103,20 +108,64 @@ type Options struct {
103108 DataDirectory string `json:"data_directory,omitempty"`
104109}
105110
111+type MCPs map[string]MCPConfig
112+
113+type MCP struct {
114+ Name string `json:"name"`
115+ MCP MCPConfig `json:"mcp"`
116+}
117+
118+func (m MCPs) Sorted() []MCP {
119+ sorted := make([]MCP, 0, len(m))
120+ for k, v := range m {
121+ sorted = append(sorted, MCP{
122+ Name: k,
123+ MCP: v,
124+ })
125+ }
126+ slices.SortFunc(sorted, func(a, b MCP) int {
127+ return strings.Compare(a.Name, b.Name)
128+ })
129+ return sorted
130+}
131+
132+type LSPs map[string]LSPConfig
133+
134+type LSP struct {
135+ Name string `json:"name"`
136+ LSP LSPConfig `json:"lsp"`
137+}
138+
139+func (l LSPs) Sorted() []LSP {
140+ sorted := make([]LSP, 0, len(l))
141+ for k, v := range l {
142+ sorted = append(sorted, LSP{
143+ Name: k,
144+ LSP: v,
145+ })
146+ }
147+ slices.SortFunc(sorted, func(a, b LSP) int {
148+ return strings.Compare(a.Name, b.Name)
149+ })
150+ return sorted
151+}
152+
106153// Config holds the configuration for crush.
107154type Config struct {
108− workingDir string `json:"-"`
109155 // We currently only support large/small as values here.
110156 Models map[string]SelectedModel `json:"models,omitempty"`
111157
112158 // The providers that are configured
113159 Providers map[string]ProviderConfig `json:"providers,omitempty"`
114160
115− MCP map[string]MCP `json:"mcp,omitempty"`
161+ MCP MCPs `json:"mcp,omitempty"`
116162
117− LSP map[string]LSPConfig `json:"lsp,omitempty"`
163+ LSP LSPs `json:"lsp,omitempty"`
118164
119165 Options *Options `json:"options,omitempty"`
166+
167+ // Internal
168+ workingDir string `json:"-"`
120169}
121170
122171func (c *Config) WorkingDir() string {
pkg/config/load.go+19−6
@@ -13,6 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/fur/client"
1414 "github.com/charmbracelet/crush/internal/fur/provider"
1515 "github.com/charmbracelet/crush/pkg/env"
16+ "github.com/charmbracelet/crush/pkg/log"
1617)
1718
1819// LoadReader config via io.Reader.
@@ -31,7 +32,7 @@ func LoadReader(fd io.Reader) (*Config, error) {
3132}
3233
3334// Load loads the configuration from the default paths.
34−func Load(workingDir string, env env.Env) (*Config, error) {
35+func Load(workingDir string, debug bool) (*Config, error) {
3536 // uses default config paths
3637 configPaths := []string{
3738 globalConfig(),
@@ -40,6 +41,17 @@ func Load(workingDir string, env env.Env) (*Config, error) {
4041 filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)),
4142 }
4243 cfg, err := loadFromConfigPaths(configPaths)
44+
45+ if debug {
46+ cfg.Options.Debug = true
47+ }
48+
49+ // Init logs
50+ log.Init(
51+ filepath.Join(cfg.Options.DataDirectory, "logs", fmt.Sprintf("%s.log", appName)),
52+ cfg.Options.Debug,
53+ )
54+
4355 if err != nil {
4456 return nil, fmt.Errorf("failed to load config: %w", err)
4557 }
@@ -47,7 +59,7 @@ func Load(workingDir string, env env.Env) (*Config, error) {
4759 // e.x validate the models
4860 // e.x validate provider config
4961
50− setDefaults(workingDir, cfg)
62+ cfg.setDefaults(workingDir)
5163
5264 // Load known providers, this loads the config from fur
5365 providers, err := LoadProviders(client.New())
@@ -55,16 +67,17 @@ func Load(workingDir string, env env.Env) (*Config, error) {
5567 return nil, fmt.Errorf("failed to load providers: %w", err)
5668 }
5769
70+ env := env.New()
5871 // Configure providers
5972 valueResolver := NewShellVariableResolver(env)
60− if err := configureProviders(cfg, env, valueResolver, providers); err != nil {
73+ if err := cfg.configureProviders(env, valueResolver, providers); err != nil {
6174 return nil, fmt.Errorf("failed to configure providers: %w", err)
6275 }
6376
6477 return cfg, nil
6578}
6679
67−func configureProviders(cfg *Config, env env.Env, resolver VariableResolver, knownProviders []provider.Provider) error {
80+func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []provider.Provider) error {
6881 for _, p := range knownProviders {
6982
7083 config, ok := cfg.Providers[string(p.ID)]
@@ -169,7 +182,7 @@ func hasAWSCredentials(env env.Env) bool {
169182 return false
170183}
171184
172−func setDefaults(workingDir string, cfg *Config) {
185+func (cfg *Config) setDefaults(workingDir string) {
173186 cfg.workingDir = workingDir
174187 if cfg.Options == nil {
175188 cfg.Options = &Options{}
@@ -190,7 +203,7 @@ func setDefaults(workingDir string, cfg *Config) {
190203 cfg.Models = make(map[string]SelectedModel)
191204 }
192205 if cfg.MCP == nil {
193− cfg.MCP = make(map[string]MCP)
206+ cfg.MCP = make(map[string]MCPConfig)
194207 }
195208 if cfg.LSP == nil {
196209 cfg.LSP = make(map[string]LSPConfig)
pkg/config/load_test.go+20−19
@@ -27,7 +27,7 @@ func TestConfig_LoadFromReaders(t *testing.T) {
2727func TestConfig_setDefaults(t *testing.T) {
2828 cfg := &Config{}
2929
30− setDefaults("/tmp", cfg)
30+ cfg.setDefaults("/tmp")
3131
3232 assert.NotNil(t, cfg.Options)
3333 assert.NotNil(t, cfg.Options.TUI)
@@ -56,12 +56,12 @@ func TestConfig_configureProviders(t *testing.T) {
5656 }
5757
5858 cfg := &Config{}
59− setDefaults("/tmp", cfg)
59+ cfg.setDefaults("/tmp")
6060 env := env.NewFromMap(map[string]string{
6161 "OPENAI_API_KEY": "test-key",
6262 })
6363 resolver := NewEnvironmentVariableResolver(env)
64− err := configureProviders(cfg, env, resolver, knownProviders)
64+ err := cfg.configureProviders(env, resolver, knownProviders)
6565 assert.NoError(t, err)
6666 assert.Len(t, cfg.Providers, 1)
6767
@@ -98,12 +98,13 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
9898 },
9999 },
100100 }
101− setDefaults("/tmp", cfg)
101+ cfg.setDefaults("/tmp")
102+
102103 env := env.NewFromMap(map[string]string{
103104 "OPENAI_API_KEY": "test-key",
104105 })
105106 resolver := NewEnvironmentVariableResolver(env)
106− err := configureProviders(cfg, env, resolver, knownProviders)
107+ err := cfg.configureProviders(env, resolver, knownProviders)
107108 assert.NoError(t, err)
108109 assert.Len(t, cfg.Providers, 1)
109110
@@ -139,12 +140,12 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
139140 },
140141 },
141142 }
142− setDefaults("/tmp", cfg)
143+ cfg.setDefaults("/tmp")
143144 env := env.NewFromMap(map[string]string{
144145 "OPENAI_API_KEY": "test-key",
145146 })
146147 resolver := NewEnvironmentVariableResolver(env)
147− err := configureProviders(cfg, env, resolver, knownProviders)
148+ err := cfg.configureProviders(env, resolver, knownProviders)
148149 assert.NoError(t, err)
149150 // Should be to because of the env variable
150151 assert.Len(t, cfg.Providers, 2)
@@ -171,13 +172,13 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
171172 }
172173
173174 cfg := &Config{}
174− setDefaults("/tmp", cfg)
175+ cfg.setDefaults("/tmp")
175176 env := env.NewFromMap(map[string]string{
176177 "AWS_ACCESS_KEY_ID": "test-key-id",
177178 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
178179 })
179180 resolver := NewEnvironmentVariableResolver(env)
180− err := configureProviders(cfg, env, resolver, knownProviders)
181+ err := cfg.configureProviders(env, resolver, knownProviders)
181182 assert.NoError(t, err)
182183 assert.Len(t, cfg.Providers, 1)
183184
@@ -200,10 +201,10 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
200201 }
201202
202203 cfg := &Config{}
203− setDefaults("/tmp", cfg)
204+ cfg.setDefaults("/tmp")
204205 env := env.NewFromMap(map[string]string{})
205206 resolver := NewEnvironmentVariableResolver(env)
206− err := configureProviders(cfg, env, resolver, knownProviders)
207+ err := cfg.configureProviders(env, resolver, knownProviders)
207208 assert.NoError(t, err)
208209 // Provider should not be configured without credentials
209210 assert.Len(t, cfg.Providers, 0)
@@ -222,13 +223,13 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
222223 }
223224
224225 cfg := &Config{}
225− setDefaults("/tmp", cfg)
226+ cfg.setDefaults("/tmp")
226227 env := env.NewFromMap(map[string]string{
227228 "AWS_ACCESS_KEY_ID": "test-key-id",
228229 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
229230 })
230231 resolver := NewEnvironmentVariableResolver(env)
231− err := configureProviders(cfg, env, resolver, knownProviders)
232+ err := cfg.configureProviders(env, resolver, knownProviders)
232233 assert.Error(t, err)
233234}
234235
@@ -245,14 +246,14 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
245246 }
246247
247248 cfg := &Config{}
248− setDefaults("/tmp", cfg)
249+ cfg.setDefaults("/tmp")
249250 env := env.NewFromMap(map[string]string{
250251 "GOOGLE_GENAI_USE_VERTEXAI": "true",
251252 "GOOGLE_CLOUD_PROJECT": "test-project",
252253 "GOOGLE_CLOUD_LOCATION": "us-central1",
253254 })
254255 resolver := NewEnvironmentVariableResolver(env)
255− err := configureProviders(cfg, env, resolver, knownProviders)
256+ err := cfg.configureProviders(env, resolver, knownProviders)
256257 assert.NoError(t, err)
257258 assert.Len(t, cfg.Providers, 1)
258259
@@ -277,14 +278,14 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
277278 }
278279
279280 cfg := &Config{}
280− setDefaults("/tmp", cfg)
281+ cfg.setDefaults("/tmp")
281282 env := env.NewFromMap(map[string]string{
282283 "GOOGLE_GENAI_USE_VERTEXAI": "false",
283284 "GOOGLE_CLOUD_PROJECT": "test-project",
284285 "GOOGLE_CLOUD_LOCATION": "us-central1",
285286 })
286287 resolver := NewEnvironmentVariableResolver(env)
287− err := configureProviders(cfg, env, resolver, knownProviders)
288+ err := cfg.configureProviders(env, resolver, knownProviders)
288289 assert.NoError(t, err)
289290 // Provider should not be configured without proper credentials
290291 assert.Len(t, cfg.Providers, 0)
@@ -303,13 +304,13 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
303304 }
304305
305306 cfg := &Config{}
306− setDefaults("/tmp", cfg)
307+ cfg.setDefaults("/tmp")
307308 env := env.NewFromMap(map[string]string{
308309 "GOOGLE_GENAI_USE_VERTEXAI": "true",
309310 "GOOGLE_CLOUD_LOCATION": "us-central1",
310311 })
311312 resolver := NewEnvironmentVariableResolver(env)
312− err := configureProviders(cfg, env, resolver, knownProviders)
313+ err := cfg.configureProviders(env, resolver, knownProviders)
313314 assert.NoError(t, err)
314315 // Provider should not be configured without project
315316 assert.Len(t, cfg.Providers, 0)
pkg/env/env_test.gonew file+142
@@ -0,0 +1,142 @@
1+package env
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestOsEnv_Get(t *testing.T) {
12+ env := New()
13+
14+ // Test getting an existing environment variable
15+ os.Setenv("TEST_VAR", "test_value")
16+ defer os.Unsetenv("TEST_VAR")
17+
18+ value := env.Get("TEST_VAR")
19+ assert.Equal(t, "test_value", value)
20+
21+ // Test getting a non-existent environment variable
22+ value = env.Get("NON_EXISTENT_VAR")
23+ assert.Equal(t, "", value)
24+}
25+
26+func TestOsEnv_Env(t *testing.T) {
27+ env := New()
28+
29+ envVars := env.Env()
30+
31+ // Environment should not be empty in normal circumstances
32+ assert.NotNil(t, envVars)
33+ assert.Greater(t, len(envVars), 0)
34+
35+ // Each environment variable should be in key=value format
36+ for _, envVar := range envVars {
37+ assert.Contains(t, envVar, "=")
38+ }
39+}
40+
41+func TestNewFromMap(t *testing.T) {
42+ testMap := map[string]string{
43+ "KEY1": "value1",
44+ "KEY2": "value2",
45+ }
46+
47+ env := NewFromMap(testMap)
48+ assert.NotNil(t, env)
49+ assert.IsType(t, &mapEnv{}, env)
50+}
51+
52+func TestMapEnv_Get(t *testing.T) {
53+ testMap := map[string]string{
54+ "KEY1": "value1",
55+ "KEY2": "value2",
56+ }
57+
58+ env := NewFromMap(testMap)
59+
60+ // Test getting existing keys
61+ assert.Equal(t, "value1", env.Get("KEY1"))
62+ assert.Equal(t, "value2", env.Get("KEY2"))
63+
64+ // Test getting non-existent key
65+ assert.Equal(t, "", env.Get("NON_EXISTENT"))
66+}
67+
68+func TestMapEnv_Env(t *testing.T) {
69+ t.Run("with values", func(t *testing.T) {
70+ testMap := map[string]string{
71+ "KEY1": "value1",
72+ "KEY2": "value2",
73+ }
74+
75+ env := NewFromMap(testMap)
76+ envVars := env.Env()
77+
78+ assert.Len(t, envVars, 2)
79+
80+ // Convert to map for easier testing (order is not guaranteed)
81+ envMap := make(map[string]string)
82+ for _, envVar := range envVars {
83+ parts := strings.SplitN(envVar, "=", 2)
84+ assert.Len(t, parts, 2)
85+ envMap[parts[0]] = parts[1]
86+ }
87+
88+ assert.Equal(t, "value1", envMap["KEY1"])
89+ assert.Equal(t, "value2", envMap["KEY2"])
90+ })
91+
92+ t.Run("empty map", func(t *testing.T) {
93+ env := NewFromMap(map[string]string{})
94+ envVars := env.Env()
95+ assert.Nil(t, envVars)
96+ })
97+
98+ t.Run("nil map", func(t *testing.T) {
99+ env := NewFromMap(nil)
100+ envVars := env.Env()
101+ assert.Nil(t, envVars)
102+ })
103+}
104+
105+func TestMapEnv_GetEmptyValue(t *testing.T) {
106+ testMap := map[string]string{
107+ "EMPTY_KEY": "",
108+ "NORMAL_KEY": "value",
109+ }
110+
111+ env := NewFromMap(testMap)
112+
113+ // Test that empty values are returned correctly
114+ assert.Equal(t, "", env.Get("EMPTY_KEY"))
115+ assert.Equal(t, "value", env.Get("NORMAL_KEY"))
116+}
117+
118+func TestMapEnv_EnvFormat(t *testing.T) {
119+ testMap := map[string]string{
120+ "KEY_WITH_EQUALS": "value=with=equals",
121+ "KEY_WITH_SPACES": "value with spaces",
122+ }
123+
124+ env := NewFromMap(testMap)
125+ envVars := env.Env()
126+
127+ assert.Len(t, envVars, 2)
128+
129+ // Check that the format is correct even with special characters
130+ found := make(map[string]bool)
131+ for _, envVar := range envVars {
132+ if envVar == "KEY_WITH_EQUALS=value=with=equals" {
133+ found["equals"] = true
134+ }
135+ if envVar == "KEY_WITH_SPACES=value with spaces" {
136+ found["spaces"] = true
137+ }
138+ }
139+
140+ assert.True(t, found["equals"], "Should handle values with equals signs")
141+ assert.True(t, found["spaces"], "Should handle values with spaces")
142+}
pkg/log/log.go+3−6
@@ -2,20 +2,17 @@ package log
22
33import (
44 "log/slog"
5− "path/filepath"
65 "sync"
76
8− "github.com/charmbracelet/crush/pkg/config"
9−
107 "gopkg.in/natefinch/lumberjack.v2"
118)
129
1310var initOnce sync.Once
1411
15−func Init(cfg *config.Config) {
12+func Init(logFile string, debug bool) {
1613 initOnce.Do(func() {
1714 logRotator := &lumberjack.Logger{
18− Filename: filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log"),
15+ Filename: logFile,
1916 MaxSize: 10, // Max size in MB
2017 MaxBackups: 0, // Number of backups
2118 MaxAge: 30, // Days
@@ -23,7 +20,7 @@ func Init(cfg *config.Config) {
2320 }
2421
2522 level := slog.LevelInfo
26− if cfg.Options.Debug {
23+ if debug {
2724 level = slog.LevelDebug
2825 }
2926