Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- internal/config/agent_id_test.go +29 -0
- internal/config/atomicwrite.go +63 -0
- internal/config/atomicwrite_test.go +43 -0
- internal/config/atomicwrite_unix.go +7 -0
- internal/config/atomicwrite_windows_test.go +45 -0
- internal/config/attribution_migration_test.go +93 -0
- internal/config/config_unix.go +7 -0
- internal/config/copilot.go +1 -0
- internal/config/docker_mcp.go +141 -0
- internal/config/docker_mcp_test.go +195 -0
- internal/config/init.go +127 -0
- internal/config/load.go +1454 -0
- internal/config/load_test.go +2536 -0
- internal/config/lsp_defaults_test.go +35 -0
- internal/config/openai_test.go +220 -0
- internal/config/options_request_timeout_test.go +63 -0
- internal/config/provider_empty_test.go +39 -0
- internal/config/recent_models_test.go +158 -0
- internal/config/reload_crushrc_test.go +137 -0
- internal/config/reload_hooks_test.go +102 -0
- internal/config/resolve_real_test.go +512 -0
- internal/config/resolve_test.go +229 -0
- internal/config/scope.go +29 -0
- internal/config/scopeb_race_test.go +74 -0
- internal/config/shellconfig_hook_test.go +37 -0
- internal/config/shellconfig_lsp_test.go +28 -0
- internal/config/shellconfig_option_test.go +134 -0
- internal/config/shellconfig_precedence_test.go +35 -0
- internal/config/shellconfig_provider_test.go +85 -0
- internal/config/store.go +1430 -0
- internal/config/update_model_bench_test.go +108 -0
- internal/csync/doc.go +3 -0
- internal/csync/maps.go +174 -0
- internal/csync/maps_test.go +770 -0
- internal/csync/slices.go +121 -0
- internal/csync/slices_test.go +214 -0
- internal/csync/value.go +44 -0
- internal/csync/value_test.go +97 -0
- internal/csync/versionedmap.go +56 -0
- internal/csync/versionedmap_test.go +89 -0
- internal/diff/diff.go +29 -0
- internal/diffdetect/detect.go +36 -0
- internal/diffdetect/detect_test.go +152 -0
- internal/filetracker/service.go +93 -0
- internal/filetracker/service_test.go +116 -0
- internal/lock/lock.go +75 -0
- internal/lock/lock_test.go +168 -0
- internal/lock/lock_unix.go +45 -0
- internal/lock/lock_windows.go +57 -0
- internal/logout/choose.go +160 -0
internal/config/agent_id_test.go
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"github.com/stretchr/testify/assert"
|
| 7 |
+
"github.com/stretchr/testify/require"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func TestConfig_AgentIDs(t *testing.T) {
|
| 11 |
+
cfg := &Config{
|
| 12 |
+
Options: &Options{
|
| 13 |
+
DisabledTools: []string{},
|
| 14 |
+
},
|
| 15 |
+
}
|
| 16 |
+
cfg.SetupAgents()
|
| 17 |
+
|
| 18 |
+
t.Run("Coder agent should have correct ID", func(t *testing.T) {
|
| 19 |
+
coderAgent, ok := cfg.Agents[AgentCoder]
|
| 20 |
+
require.True(t, ok)
|
| 21 |
+
assert.Equal(t, AgentCoder, coderAgent.ID, "Coder agent ID should be '%s'", AgentCoder)
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
t.Run("Task agent should have correct ID", func(t *testing.T) {
|
| 25 |
+
taskAgent, ok := cfg.Agents[AgentTask]
|
| 26 |
+
require.True(t, ok)
|
| 27 |
+
assert.Equal(t, AgentTask, taskAgent.ID, "Task agent ID should be '%s'", AgentTask)
|
| 28 |
+
})
|
| 29 |
+
}
|
internal/config/atomicwrite.go
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"path/filepath"
|
| 6 |
+
"time"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
// atomicWriteFile writes data to a file atomically by writing to a unique
|
| 10 |
+
// temporary file in the same directory and renaming it into place. This
|
| 11 |
+
// prevents concurrent readers from observing a partially-written file.
|
| 12 |
+
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
| 13 |
+
path = filepath.Clean(path)
|
| 14 |
+
dir := filepath.Dir(path)
|
| 15 |
+
f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
|
| 16 |
+
if err != nil {
|
| 17 |
+
return err
|
| 18 |
+
}
|
| 19 |
+
tmp := f.Name()
|
| 20 |
+
if _, err := f.Write(data); err != nil {
|
| 21 |
+
f.Close()
|
| 22 |
+
os.Remove(tmp)
|
| 23 |
+
return err
|
| 24 |
+
}
|
| 25 |
+
if err := f.Chmod(perm); err != nil {
|
| 26 |
+
f.Close()
|
| 27 |
+
os.Remove(tmp)
|
| 28 |
+
return err
|
| 29 |
+
}
|
| 30 |
+
if err := f.Close(); err != nil {
|
| 31 |
+
os.Remove(tmp)
|
| 32 |
+
return err
|
| 33 |
+
}
|
| 34 |
+
if err := renameFile(tmp, path); err != nil {
|
| 35 |
+
os.Remove(tmp)
|
| 36 |
+
return err
|
| 37 |
+
}
|
| 38 |
+
return nil
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// renameRetryBudget bounds how long renameFile keeps retrying transient
|
| 42 |
+
// failures before giving up and returning the error.
|
| 43 |
+
const renameRetryBudget = 2 * time.Second
|
| 44 |
+
|
| 45 |
+
// renameFile renames tmp over path. On Windows the rename fails with
|
| 46 |
+
// ERROR_ACCESS_DENIED or ERROR_SHARING_VIOLATION while another process
|
| 47 |
+
// (antivirus, search indexer) or a concurrent reader briefly holds a
|
| 48 |
+
// handle on the destination, so transient failures are retried with
|
| 49 |
+
// backoff. On other platforms isTransientRenameError is always false
|
| 50 |
+
// and this is a plain os.Rename.
|
| 51 |
+
func renameFile(tmp, path string) error {
|
| 52 |
+
var slept time.Duration
|
| 53 |
+
delay := time.Millisecond
|
| 54 |
+
for {
|
| 55 |
+
err := os.Rename(tmp, path)
|
| 56 |
+
if err == nil || !isTransientRenameError(err) || slept >= renameRetryBudget {
|
| 57 |
+
return err
|
| 58 |
+
}
|
| 59 |
+
time.Sleep(delay)
|
| 60 |
+
slept += delay
|
| 61 |
+
delay = min(delay*2, 50*time.Millisecond)
|
| 62 |
+
}
|
| 63 |
+
}
|
internal/config/atomicwrite_test.go
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"path/filepath"
|
| 6 |
+
"runtime"
|
| 7 |
+
"testing"
|
| 8 |
+
|
| 9 |
+
"github.com/stretchr/testify/require"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
func TestAtomicWriteFile(t *testing.T) {
|
| 13 |
+
t.Parallel()
|
| 14 |
+
dir := t.TempDir()
|
| 15 |
+
path := filepath.Join(dir, "test.json")
|
| 16 |
+
|
| 17 |
+
require.NoError(t, atomicWriteFile(path, []byte(`{"key":"value"}`), 0o600))
|
| 18 |
+
|
| 19 |
+
data, err := os.ReadFile(path)
|
| 20 |
+
require.NoError(t, err)
|
| 21 |
+
require.Equal(t, `{"key":"value"}`, string(data))
|
| 22 |
+
|
| 23 |
+
// No temp files should linger.
|
| 24 |
+
entries, err := os.ReadDir(dir)
|
| 25 |
+
require.NoError(t, err)
|
| 26 |
+
require.Len(t, entries, 1)
|
| 27 |
+
require.Equal(t, "test.json", entries[0].Name())
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func TestAtomicWriteFile_PermissionsApplied(t *testing.T) {
|
| 31 |
+
if runtime.GOOS == "windows" {
|
| 32 |
+
t.Skip("Windows does not support Unix file permissions")
|
| 33 |
+
}
|
| 34 |
+
t.Parallel()
|
| 35 |
+
dir := t.TempDir()
|
| 36 |
+
path := filepath.Join(dir, "test.json")
|
| 37 |
+
|
| 38 |
+
require.NoError(t, atomicWriteFile(path, []byte(`{}`), 0o600))
|
| 39 |
+
|
| 40 |
+
info, err := os.Stat(path)
|
| 41 |
+
require.NoError(t, err)
|
| 42 |
+
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
| 43 |
+
}
|
internal/config/atomicwrite_unix.go
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//go:build !windows
|
| 2 |
+
|
| 3 |
+
package config
|
| 4 |
+
|
| 5 |
+
// isTransientRenameError reports whether err is a rename failure that
|
| 6 |
+
// can resolve on its own. Only Windows has such failures.
|
| 7 |
+
func isTransientRenameError(error) bool { return false }
|
internal/config/atomicwrite_windows_test.go
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//go:build windows
|
| 2 |
+
|
| 3 |
+
package config
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
"golang.org/x/sys/windows"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// Regression test for the windows-latest CI flake where
|
| 16 |
+
// TestConfigStore_SetConfigFields_concurrentInProcess failed with
|
| 17 |
+
// "rename ...crush.json.<rand>.tmp ...crush.json: Access is denied":
|
| 18 |
+
// renaming over a destination while another handle is open on it
|
| 19 |
+
// without sharing must be retried until the handle closes, not
|
| 20 |
+
// surfaced as an error.
|
| 21 |
+
func TestAtomicWriteFile_RetriesWhileDestinationHandleOpen(t *testing.T) {
|
| 22 |
+
t.Parallel()
|
| 23 |
+
dir := t.TempDir()
|
| 24 |
+
path := filepath.Join(dir, "test.json")
|
| 25 |
+
require.NoError(t, atomicWriteFile(path, []byte(`{"v":1}`), 0o600))
|
| 26 |
+
|
| 27 |
+
// Hold the destination open with no sharing flags so the rename
|
| 28 |
+
// fails with ERROR_ACCESS_DENIED/ERROR_SHARING_VIOLATION until the
|
| 29 |
+
// handle closes, mimicking an antivirus scan or exclusive reader.
|
| 30 |
+
p, err := windows.UTF16PtrFromString(path)
|
| 31 |
+
require.NoError(t, err)
|
| 32 |
+
h, err := windows.CreateFile(p, windows.GENERIC_READ, 0, nil,
|
| 33 |
+
windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
| 34 |
+
require.NoError(t, err)
|
| 35 |
+
go func() {
|
| 36 |
+
time.Sleep(200 * time.Millisecond)
|
| 37 |
+
windows.CloseHandle(h)
|
| 38 |
+
}()
|
| 39 |
+
|
| 40 |
+
require.NoError(t, atomicWriteFile(path, []byte(`{"v":2}`), 0o600))
|
| 41 |
+
|
| 42 |
+
data, err := os.ReadFile(path)
|
| 43 |
+
require.NoError(t, err)
|
| 44 |
+
require.JSONEq(t, `{"v":2}`, string(data))
|
| 45 |
+
}
|
internal/config/attribution_migration_test.go
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"github.com/stretchr/testify/require"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func TestAttributionMigration(t *testing.T) {
|
| 10 |
+
t.Parallel()
|
| 11 |
+
|
| 12 |
+
tests := []struct {
|
| 13 |
+
name string
|
| 14 |
+
configJSON string
|
| 15 |
+
expectedTrailer TrailerStyle
|
| 16 |
+
expectedGenerate bool
|
| 17 |
+
}{
|
| 18 |
+
{
|
| 19 |
+
name: "old setting co_authored_by=true migrates to co-authored-by",
|
| 20 |
+
configJSON: `{
|
| 21 |
+
"options": {
|
| 22 |
+
"attribution": {
|
| 23 |
+
"co_authored_by": true,
|
| 24 |
+
"generated_with": false
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}`,
|
| 28 |
+
expectedTrailer: TrailerStyleCoAuthoredBy,
|
| 29 |
+
expectedGenerate: false,
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
name: "old setting co_authored_by=false migrates to none",
|
| 33 |
+
configJSON: `{
|
| 34 |
+
"options": {
|
| 35 |
+
"attribution": {
|
| 36 |
+
"co_authored_by": false,
|
| 37 |
+
"generated_with": true
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}`,
|
| 41 |
+
expectedTrailer: TrailerStyleNone,
|
| 42 |
+
expectedGenerate: true,
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
name: "new setting takes precedence over old setting",
|
| 46 |
+
configJSON: `{
|
| 47 |
+
"options": {
|
| 48 |
+
"attribution": {
|
| 49 |
+
"trailer_style": "assisted-by",
|
| 50 |
+
"co_authored_by": true,
|
| 51 |
+
"generated_with": false
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}`,
|
| 55 |
+
expectedTrailer: TrailerStyleAssistedBy,
|
| 56 |
+
expectedGenerate: false,
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
name: "default when neither setting present",
|
| 60 |
+
configJSON: `{
|
| 61 |
+
"options": {
|
| 62 |
+
"attribution": {
|
| 63 |
+
"generated_with": true
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
}`,
|
| 67 |
+
expectedTrailer: TrailerStyleAssistedBy,
|
| 68 |
+
expectedGenerate: true,
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
name: "default when attribution is null",
|
| 72 |
+
configJSON: `{
|
| 73 |
+
"options": {}
|
| 74 |
+
}`,
|
| 75 |
+
expectedTrailer: TrailerStyleAssistedBy,
|
| 76 |
+
expectedGenerate: true,
|
| 77 |
+
},
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
for _, tt := range tests {
|
| 81 |
+
t.Run(tt.name, func(t *testing.T) {
|
| 82 |
+
t.Parallel()
|
| 83 |
+
|
| 84 |
+
cfg, err := loadFromBytes([][]byte{[]byte(tt.configJSON)})
|
| 85 |
+
require.NoError(t, err)
|
| 86 |
+
|
| 87 |
+
cfg.setDefaults(t.TempDir(), "")
|
| 88 |
+
|
| 89 |
+
require.Equal(t, tt.expectedTrailer, cfg.Options.Attribution.TrailerStyle)
|
| 90 |
+
require.Equal(t, tt.expectedGenerate, cfg.Options.Attribution.GeneratedWith)
|
| 91 |
+
})
|
| 92 |
+
}
|
| 93 |
+
}
|
internal/config/config_unix.go
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//go:build !windows
|
| 2 |
+
|
| 3 |
+
package config
|
| 4 |
+
|
| 5 |
+
// systemConfigPath is the system-wide configuration file path. It is
|
| 6 |
+
// loaded at the lowest priority so user and project configs override it.
|
| 7 |
+
const systemConfigPath = "/etc/crush/crush.json"
|
internal/config/copilot.go
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
package config
|
internal/config/docker_mcp.go
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
"os/exec"
|
| 7 |
+
"sync"
|
| 8 |
+
"time"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
var dockerMCPVersionRunner = func(ctx context.Context) error {
|
| 12 |
+
cmd := exec.CommandContext(ctx, "docker", "mcp", "version")
|
| 13 |
+
return cmd.Run()
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const dockerMCPAvailabilityTTL = 10 * time.Second
|
| 17 |
+
|
| 18 |
+
var dockerMCPAvailabilityCache struct {
|
| 19 |
+
mu sync.Mutex
|
| 20 |
+
available bool
|
| 21 |
+
checkedAt time.Time
|
| 22 |
+
known bool
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// DockerMCPName is the name of the Docker MCP configuration.
|
| 26 |
+
const DockerMCPName = "docker"
|
| 27 |
+
|
| 28 |
+
// IsDockerMCPAvailable checks if Docker MCP is available by running
|
| 29 |
+
// 'docker mcp version'.
|
| 30 |
+
func IsDockerMCPAvailable() bool {
|
| 31 |
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
| 32 |
+
defer cancel()
|
| 33 |
+
|
| 34 |
+
err := dockerMCPVersionRunner(ctx)
|
| 35 |
+
return err == nil
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// DockerMCPAvailabilityCached returns the cached Docker MCP availability and
|
| 39 |
+
// whether the cached value is still fresh.
|
| 40 |
+
func DockerMCPAvailabilityCached() (available bool, known bool) {
|
| 41 |
+
dockerMCPAvailabilityCache.mu.Lock()
|
| 42 |
+
defer dockerMCPAvailabilityCache.mu.Unlock()
|
| 43 |
+
|
| 44 |
+
if !dockerMCPAvailabilityCache.known {
|
| 45 |
+
return false, false
|
| 46 |
+
}
|
| 47 |
+
if time.Since(dockerMCPAvailabilityCache.checkedAt) > dockerMCPAvailabilityTTL {
|
| 48 |
+
return dockerMCPAvailabilityCache.available, false
|
| 49 |
+
}
|
| 50 |
+
return dockerMCPAvailabilityCache.available, true
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// RefreshDockerMCPAvailability refreshes and caches Docker MCP availability.
|
| 54 |
+
func RefreshDockerMCPAvailability() bool {
|
| 55 |
+
available := IsDockerMCPAvailable()
|
| 56 |
+
dockerMCPAvailabilityCache.mu.Lock()
|
| 57 |
+
dockerMCPAvailabilityCache.available = available
|
| 58 |
+
dockerMCPAvailabilityCache.checkedAt = time.Now()
|
| 59 |
+
dockerMCPAvailabilityCache.known = true
|
| 60 |
+
dockerMCPAvailabilityCache.mu.Unlock()
|
| 61 |
+
return available
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// IsDockerMCPEnabled checks if Docker MCP is already configured.
|
| 65 |
+
func (c *Config) IsDockerMCPEnabled() bool {
|
| 66 |
+
if c.MCP == nil {
|
| 67 |
+
return false
|
| 68 |
+
}
|
| 69 |
+
_, exists := c.MCP[DockerMCPName]
|
| 70 |
+
return exists
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// DockerMCPConfig returns the default Docker MCP stdio configuration.
|
| 74 |
+
func DockerMCPConfig() MCPConfig {
|
| 75 |
+
return MCPConfig{
|
| 76 |
+
Type: MCPStdio,
|
| 77 |
+
Command: "docker",
|
| 78 |
+
Args: []string{"mcp", "gateway", "run"},
|
| 79 |
+
Disabled: false,
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// PrepareDockerMCPConfig validates Docker MCP availability and stages the
|
| 84 |
+
// Docker MCP configuration in memory.
|
| 85 |
+
func (s *ConfigStore) PrepareDockerMCPConfig() (MCPConfig, error) {
|
| 86 |
+
if !IsDockerMCPAvailable() {
|
| 87 |
+
return MCPConfig{}, fmt.Errorf("docker mcp is not available, please ensure docker is installed and 'docker mcp version' succeeds")
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
mcpConfig := DockerMCPConfig()
|
| 91 |
+
// In-memory only; persistence happens in PersistDockerMCPConfig.
|
| 92 |
+
s.mutateInMemory(func(c *Config) {
|
| 93 |
+
if c.MCP == nil {
|
| 94 |
+
c.MCP = make(map[string]MCPConfig)
|
| 95 |
+
}
|
| 96 |
+
c.MCP[DockerMCPName] = mcpConfig
|
| 97 |
+
})
|
| 98 |
+
return mcpConfig, nil
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// PersistDockerMCPConfig persists a previously prepared Docker MCP
|
| 102 |
+
// configuration to the global config file.
|
| 103 |
+
func (s *ConfigStore) PersistDockerMCPConfig(mcpConfig MCPConfig) error {
|
| 104 |
+
if err := s.SetConfigField(ScopeGlobal, "mcp."+DockerMCPName, mcpConfig); err != nil {
|
| 105 |
+
return fmt.Errorf("failed to persist docker mcp configuration: %w", err)
|
| 106 |
+
}
|
| 107 |
+
return nil
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// EnableDockerMCP adds Docker MCP configuration and persists it.
|
| 111 |
+
func (s *ConfigStore) EnableDockerMCP() error {
|
| 112 |
+
mcpConfig, err := s.PrepareDockerMCPConfig()
|
| 113 |
+
if err != nil {
|
| 114 |
+
return err
|
| 115 |
+
}
|
| 116 |
+
if err := s.PersistDockerMCPConfig(mcpConfig); err != nil {
|
| 117 |
+
return err
|
| 118 |
+
}
|
| 119 |
+
return nil
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// DisableDockerMCP removes Docker MCP configuration and persists the change.
|
| 123 |
+
func (s *ConfigStore) DisableDockerMCP() error {
|
| 124 |
+
return s.update(ScopeGlobal, func(c *Config) map[string]any {
|
| 125 |
+
if c.MCP == nil {
|
| 126 |
+
return nil
|
| 127 |
+
}
|
| 128 |
+
delete(c.MCP, DockerMCPName)
|
| 129 |
+
return map[string]any{"mcp": c.MCP}
|
| 130 |
+
})
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// RemoveDockerMCPInMemory removes the Docker MCP entry from the in-memory
|
| 134 |
+
// config via copy-on-write, without persisting to disk. It rolls back a
|
| 135 |
+
// staged PrepareDockerMCPConfig when starting or persisting the server
|
| 136 |
+
// fails, so callers must not mutate Config().MCP directly.
|
| 137 |
+
func (s *ConfigStore) RemoveDockerMCPInMemory() {
|
| 138 |
+
s.mutateInMemory(func(c *Config) {
|
| 139 |
+
delete(c.MCP, DockerMCPName)
|
| 140 |
+
})
|
| 141 |
+
}
|
internal/config/docker_mcp_test.go
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"testing"
|
| 9 |
+
|
| 10 |
+
"github.com/charmbracelet/crush/internal/env"
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
var errDockerUnavailable = errors.New("docker unavailable")
|
| 15 |
+
|
| 16 |
+
func setDockerMCPVersionRunner(t *testing.T, runner func(context.Context) error) {
|
| 17 |
+
t.Helper()
|
| 18 |
+
orig := dockerMCPVersionRunner
|
| 19 |
+
dockerMCPVersionRunner = runner
|
| 20 |
+
t.Cleanup(func() {
|
| 21 |
+
dockerMCPVersionRunner = orig
|
| 22 |
+
})
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func TestIsDockerMCPEnabled(t *testing.T) {
|
| 26 |
+
t.Parallel()
|
| 27 |
+
|
| 28 |
+
t.Run("returns false when MCP is nil", func(t *testing.T) {
|
| 29 |
+
t.Parallel()
|
| 30 |
+
cfg := &Config{
|
| 31 |
+
MCP: nil,
|
| 32 |
+
}
|
| 33 |
+
require.False(t, cfg.IsDockerMCPEnabled())
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
t.Run("returns false when docker mcp not configured", func(t *testing.T) {
|
| 37 |
+
t.Parallel()
|
| 38 |
+
cfg := &Config{
|
| 39 |
+
MCP: make(map[string]MCPConfig),
|
| 40 |
+
}
|
| 41 |
+
require.False(t, cfg.IsDockerMCPEnabled())
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
t.Run("returns true when docker mcp is configured", func(t *testing.T) {
|
| 45 |
+
t.Parallel()
|
| 46 |
+
cfg := &Config{
|
| 47 |
+
MCP: map[string]MCPConfig{
|
| 48 |
+
DockerMCPName: {
|
| 49 |
+
Type: MCPStdio,
|
| 50 |
+
Command: "docker",
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
}
|
| 54 |
+
require.True(t, cfg.IsDockerMCPEnabled())
|
| 55 |
+
})
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
func TestEnableDockerMCP(t *testing.T) {
|
| 59 |
+
t.Run("adds docker mcp to config", func(t *testing.T) {
|
| 60 |
+
setDockerMCPVersionRunner(t, func(context.Context) error { return nil })
|
| 61 |
+
|
| 62 |
+
// Create a temporary directory for config.
|
| 63 |
+
tmpDir := t.TempDir()
|
| 64 |
+
configPath := filepath.Join(tmpDir, "crush.json")
|
| 65 |
+
|
| 66 |
+
cfg := &Config{
|
| 67 |
+
MCP: make(map[string]MCPConfig),
|
| 68 |
+
}
|
| 69 |
+
store := &ConfigStore{
|
| 70 |
+
config: cfg,
|
| 71 |
+
globalDataPath: configPath,
|
| 72 |
+
resolver: NewShellVariableResolver(env.New()),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
err := store.EnableDockerMCP()
|
| 76 |
+
require.NoError(t, err)
|
| 77 |
+
|
| 78 |
+
// Check in-memory config via the store (copy-on-write publishes
|
| 79 |
+
// a new Config; the captured cfg pointer stays unchanged).
|
| 80 |
+
require.True(t, store.Config().IsDockerMCPEnabled())
|
| 81 |
+
mcpConfig, exists := store.Config().MCP[DockerMCPName]
|
| 82 |
+
require.True(t, exists)
|
| 83 |
+
require.Equal(t, MCPStdio, mcpConfig.Type)
|
| 84 |
+
require.Equal(t, "docker", mcpConfig.Command)
|
| 85 |
+
require.Equal(t, []string{"mcp", "gateway", "run"}, mcpConfig.Args)
|
| 86 |
+
require.False(t, mcpConfig.Disabled)
|
| 87 |
+
|
| 88 |
+
// Check persisted config.
|
| 89 |
+
data, err := os.ReadFile(configPath)
|
| 90 |
+
require.NoError(t, err)
|
| 91 |
+
require.Contains(t, string(data), "docker")
|
| 92 |
+
require.Contains(t, string(data), "gateway")
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
t.Run("fails when docker mcp not available", func(t *testing.T) {
|
| 96 |
+
setDockerMCPVersionRunner(t, func(context.Context) error { return errDockerUnavailable })
|
| 97 |
+
|
| 98 |
+
// Create a temporary directory for config.
|
| 99 |
+
tmpDir := t.TempDir()
|
| 100 |
+
configPath := filepath.Join(tmpDir, "crush.json")
|
| 101 |
+
|
| 102 |
+
cfg := &Config{
|
| 103 |
+
MCP: make(map[string]MCPConfig),
|
| 104 |
+
}
|
| 105 |
+
store := &ConfigStore{
|
| 106 |
+
config: cfg,
|
| 107 |
+
globalDataPath: configPath,
|
| 108 |
+
resolver: NewShellVariableResolver(env.New()),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
err := store.EnableDockerMCP()
|
| 112 |
+
require.Error(t, err)
|
| 113 |
+
require.Contains(t, err.Error(), "docker mcp is not available")
|
| 114 |
+
})
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
func TestDisableDockerMCP(t *testing.T) {
|
| 118 |
+
t.Parallel()
|
| 119 |
+
|
| 120 |
+
t.Run("removes docker mcp from config", func(t *testing.T) {
|
| 121 |
+
t.Parallel()
|
| 122 |
+
|
| 123 |
+
// Create a temporary directory for config.
|
| 124 |
+
tmpDir := t.TempDir()
|
| 125 |
+
configPath := filepath.Join(tmpDir, "crush.json")
|
| 126 |
+
|
| 127 |
+
cfg := &Config{
|
| 128 |
+
MCP: map[string]MCPConfig{
|
| 129 |
+
DockerMCPName: {
|
| 130 |
+
Type: MCPStdio,
|
| 131 |
+
Command: "docker",
|
| 132 |
+
Args: []string{"mcp", "gateway", "run"},
|
| 133 |
+
Disabled: false,
|
| 134 |
+
},
|
| 135 |
+
},
|
| 136 |
+
}
|
| 137 |
+
store := &ConfigStore{
|
| 138 |
+
config: cfg,
|
| 139 |
+
globalDataPath: configPath,
|
| 140 |
+
resolver: NewShellVariableResolver(env.New()),
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// Verify it's enabled first.
|
| 144 |
+
require.True(t, cfg.IsDockerMCPEnabled())
|
| 145 |
+
|
| 146 |
+
err := store.DisableDockerMCP()
|
| 147 |
+
require.NoError(t, err)
|
| 148 |
+
|
| 149 |
+
// Check in-memory config via the store (copy-on-write publishes a
|
| 150 |
+
// new Config; the original cfg pointer is intentionally unchanged).
|
| 151 |
+
require.False(t, store.Config().IsDockerMCPEnabled())
|
| 152 |
+
_, exists := store.Config().MCP[DockerMCPName]
|
| 153 |
+
require.False(t, exists)
|
| 154 |
+
})
|
| 155 |
+
|
| 156 |
+
t.Run("does nothing when MCP is nil", func(t *testing.T) {
|
| 157 |
+
t.Parallel()
|
| 158 |
+
|
| 159 |
+
cfg := &Config{
|
| 160 |
+
MCP: nil,
|
| 161 |
+
}
|
| 162 |
+
store := &ConfigStore{
|
| 163 |
+
config: cfg,
|
| 164 |
+
globalDataPath: filepath.Join(t.TempDir(), "crush.json"),
|
| 165 |
+
resolver: NewShellVariableResolver(env.New()),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
err := store.DisableDockerMCP()
|
| 169 |
+
require.NoError(t, err)
|
| 170 |
+
})
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
func TestEnableDockerMCPWithRealDockerWhenAvailable(t *testing.T) {
|
| 174 |
+
t.Parallel()
|
| 175 |
+
|
| 176 |
+
if !IsDockerMCPAvailable() {
|
| 177 |
+
t.Skip("docker mcp not available on this machine")
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
tmpDir := t.TempDir()
|
| 181 |
+
configPath := filepath.Join(tmpDir, "crush.json")
|
| 182 |
+
|
| 183 |
+
cfg := &Config{
|
| 184 |
+
MCP: make(map[string]MCPConfig),
|
| 185 |
+
}
|
| 186 |
+
store := &ConfigStore{
|
| 187 |
+
config: cfg,
|
| 188 |
+
globalDataPath: configPath,
|
| 189 |
+
resolver: NewShellVariableResolver(env.New()),
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
err := store.EnableDockerMCP()
|
| 193 |
+
require.NoError(t, err)
|
| 194 |
+
require.True(t, store.Config().IsDockerMCPEnabled())
|
| 195 |
+
}
|
internal/config/init.go
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"slices"
|
| 8 |
+
"strings"
|
| 9 |
+
|
| 10 |
+
"github.com/charmbracelet/crush/internal/fsext"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
const (
|
| 14 |
+
InitFlagFilename = "init"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
type ProjectInitFlag struct {
|
| 18 |
+
Initialized bool `json:"initialized"`
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
|
| 22 |
+
store, err := Load(workingDir, dataDir, debug)
|
| 23 |
+
if err != nil {
|
| 24 |
+
return nil, err
|
| 25 |
+
}
|
| 26 |
+
return store, nil
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
func ProjectNeedsInitialization(store *ConfigStore) (bool, error) {
|
| 30 |
+
if store == nil {
|
| 31 |
+
return false, fmt.Errorf("config not loaded")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
cfg := store.Config()
|
| 35 |
+
flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
|
| 36 |
+
|
| 37 |
+
_, err := os.Stat(flagFilePath)
|
| 38 |
+
if err == nil {
|
| 39 |
+
return false, nil
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if !os.IsNotExist(err) {
|
| 43 |
+
return false, fmt.Errorf("failed to check init flag file: %w", err)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
someContextFileExists, err := contextPathsExist(store.WorkingDir())
|
| 47 |
+
if err != nil {
|
| 48 |
+
return false, fmt.Errorf("failed to check for context files: %w", err)
|
| 49 |
+
}
|
| 50 |
+
if someContextFileExists {
|
| 51 |
+
return false, nil
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// If the working directory has no non-ignored files, skip initialization step
|
| 55 |
+
empty, err := dirHasNoVisibleFiles(store.WorkingDir())
|
| 56 |
+
if err != nil {
|
| 57 |
+
return false, fmt.Errorf("failed to check if directory is empty: %w", err)
|
| 58 |
+
}
|
| 59 |
+
if empty {
|
| 60 |
+
return false, nil
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return true, nil
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
func contextPathsExist(dir string) (bool, error) {
|
| 67 |
+
entries, err := os.ReadDir(dir)
|
| 68 |
+
if err != nil {
|
| 69 |
+
return false, err
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Create a slice of lowercase filenames for lookup with slices.Contains
|
| 73 |
+
var files []string
|
| 74 |
+
for _, entry := range entries {
|
| 75 |
+
if !entry.IsDir() {
|
| 76 |
+
files = append(files, strings.ToLower(entry.Name()))
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Check if any of the default context paths exist in the directory
|
| 81 |
+
for _, path := range defaultContextPaths {
|
| 82 |
+
// Extract just the filename from the path
|
| 83 |
+
_, filename := filepath.Split(path)
|
| 84 |
+
filename = strings.ToLower(filename)
|
| 85 |
+
|
| 86 |
+
if slices.Contains(files, filename) {
|
| 87 |
+
return true, nil
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return false, nil
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules.
|
| 95 |
+
func dirHasNoVisibleFiles(dir string) (bool, error) {
|
| 96 |
+
files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
|
| 97 |
+
if err != nil {
|
| 98 |
+
return false, err
|
| 99 |
+
}
|
| 100 |
+
return len(files) == 0, nil
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
func MarkProjectInitialized(store *ConfigStore) error {
|
| 104 |
+
if store == nil {
|
| 105 |
+
return fmt.Errorf("config not loaded")
|
| 106 |
+
}
|
| 107 |
+
flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename)
|
| 108 |
+
|
| 109 |
+
file, err := os.Create(flagFilePath)
|
| 110 |
+
if err != nil {
|
| 111 |
+
return fmt.Errorf("failed to create init flag file: %w", err)
|
| 112 |
+
}
|
| 113 |
+
defer file.Close()
|
| 114 |
+
|
| 115 |
+
return nil
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func HasInitialDataConfig(store *ConfigStore) bool {
|
| 119 |
+
if store == nil {
|
| 120 |
+
return false
|
| 121 |
+
}
|
| 122 |
+
cfgPath := GlobalConfigData()
|
| 123 |
+
if _, err := os.Stat(cfgPath); err != nil {
|
| 124 |
+
return false
|
| 125 |
+
}
|
| 126 |
+
return store.Config().IsConfigured()
|
| 127 |
+
}
|
internal/config/load.go
ADDED
|
@@ -0,0 +1,1454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"cmp"
|
| 5 |
+
"context"
|
| 6 |
+
"encoding/json"
|
| 7 |
+
"fmt"
|
| 8 |
+
"log/slog"
|
| 9 |
+
"maps"
|
| 10 |
+
"os"
|
| 11 |
+
"os/exec"
|
| 12 |
+
"path/filepath"
|
| 13 |
+
"regexp"
|
| 14 |
+
"runtime"
|
| 15 |
+
"slices"
|
| 16 |
+
"strconv"
|
| 17 |
+
"strings"
|
| 18 |
+
"sync"
|
| 19 |
+
"testing"
|
| 20 |
+
"time"
|
| 21 |
+
|
| 22 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 23 |
+
"github.com/charmbracelet/crush/internal/agent/hyper"
|
| 24 |
+
"github.com/charmbracelet/crush/internal/csync"
|
| 25 |
+
"github.com/charmbracelet/crush/internal/discover"
|
| 26 |
+
"github.com/charmbracelet/crush/internal/env"
|
| 27 |
+
"github.com/charmbracelet/crush/internal/filepathext"
|
| 28 |
+
"github.com/charmbracelet/crush/internal/fsext"
|
| 29 |
+
"github.com/charmbracelet/crush/internal/home"
|
| 30 |
+
"github.com/charmbracelet/crush/internal/shellconfig"
|
| 31 |
+
powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
|
| 32 |
+
"github.com/qjebbs/go-jsons"
|
| 33 |
+
"github.com/tidwall/gjson"
|
| 34 |
+
"github.com/tidwall/sjson"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
const defaultCatwalkURL = "https://catwalk.charm.land"
|
| 38 |
+
|
| 39 |
+
// Load loads the configuration from the default paths and returns a
|
| 40 |
+
// ConfigStore that owns both the pure-data Config and all runtime state.
|
| 41 |
+
func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
|
| 42 |
+
// Migrate deprecated disable_notifications before loading config.
|
| 43 |
+
migrateDisableNotifications()
|
| 44 |
+
|
| 45 |
+
configPaths := lookupConfigs(workingDir)
|
| 46 |
+
|
| 47 |
+
cfg, loadedPaths, err := loadFromConfigPaths(context.Background(), configPaths)
|
| 48 |
+
if err != nil {
|
| 49 |
+
return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
cfg.setDefaults(workingDir, dataDir)
|
| 53 |
+
|
| 54 |
+
store := &ConfigStore{
|
| 55 |
+
config: cfg,
|
| 56 |
+
workingDir: workingDir,
|
| 57 |
+
globalDataPath: GlobalConfigData(),
|
| 58 |
+
workspacePath: filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)),
|
| 59 |
+
loadedPaths: loadedPaths,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if debug {
|
| 63 |
+
cfg.Options.Debug = true
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Load workspace config last so it has highest priority.
|
| 67 |
+
if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
|
| 68 |
+
if !json.Valid(wsData) {
|
| 69 |
+
return nil, fmt.Errorf("invalid JSON in config file %s", store.workspacePath)
|
| 70 |
+
}
|
| 71 |
+
merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
|
| 72 |
+
if mergeErr == nil {
|
| 73 |
+
// Preserve defaults that setDefaults already applied.
|
| 74 |
+
dataDir := cfg.Options.DataDirectory
|
| 75 |
+
*cfg = *merged
|
| 76 |
+
cfg.setDefaults(workingDir, dataDir)
|
| 77 |
+
store.config = cfg
|
| 78 |
+
store.loadedPaths = append(store.loadedPaths, store.workspacePath)
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Validate hooks after all config merging is complete so workspace
|
| 83 |
+
// hooks also get their matcher regexes compiled.
|
| 84 |
+
if err := cfg.ValidateHooks(); err != nil {
|
| 85 |
+
return nil, fmt.Errorf("invalid hook configuration: %w", err)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if !isInsideWorktree() {
|
| 89 |
+
const depth = 2
|
| 90 |
+
const items = 100
|
| 91 |
+
slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
|
| 92 |
+
assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
|
| 93 |
+
assignIfNil(&cfg.Tools.Ls.MaxItems, items)
|
| 94 |
+
assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
|
| 95 |
+
assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if isAppleTerminal() {
|
| 99 |
+
slog.Warn("Detected Apple Terminal, enabling transparent mode")
|
| 100 |
+
assignIfNil(&cfg.Options.TUI.Transparent, true)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Load known providers, this loads the config from catwalk. A failed
|
| 104 |
+
// refresh still yields the cached or embedded catalog, so only an empty
|
| 105 |
+
// list is fatal: starting up without providers is worse than starting
|
| 106 |
+
// up with slightly stale ones. Pass a Hyper token refresher so the
|
| 107 |
+
// catalog fetch can retry on 401.
|
| 108 |
+
providers, err := Providers(cfg, func(ctx context.Context) error {
|
| 109 |
+
return store.RefreshOAuthToken(ctx, ScopeGlobal, "hyper")
|
| 110 |
+
})
|
| 111 |
+
if err != nil {
|
| 112 |
+
if len(providers) == 0 {
|
| 113 |
+
return nil, err
|
| 114 |
+
}
|
| 115 |
+
slog.Warn("Continuing with the previously known providers", "error", err)
|
| 116 |
+
}
|
| 117 |
+
store.knownProviders = providers
|
| 118 |
+
|
| 119 |
+
env := env.New()
|
| 120 |
+
// Configure providers
|
| 121 |
+
valueResolver := NewShellVariableResolver(env)
|
| 122 |
+
store.resolver = valueResolver
|
| 123 |
+
|
| 124 |
+
// Hold writeMu during initial load to prevent configureProviders
|
| 125 |
+
// from triggering auto-reload via RemoveConfigField.
|
| 126 |
+
store.writeMu.Lock()
|
| 127 |
+
defer store.writeMu.Unlock()
|
| 128 |
+
|
| 129 |
+
// Apply top-level env vars before configuring providers so variables
|
| 130 |
+
// like AWS_PROFILE are visible to the AWS SDK credential chain.
|
| 131 |
+
cfg.applyEnv(valueResolver)
|
| 132 |
+
|
| 133 |
+
if err := cfg.configureProviders(context.Background(), store, env, valueResolver, store.knownProviders); err != nil {
|
| 134 |
+
return nil, fmt.Errorf("failed to configure providers: %w", err)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if !cfg.IsConfigured() {
|
| 138 |
+
slog.Warn("No providers configured")
|
| 139 |
+
return store, nil
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
resolved, err := resolveSelectedModels(cfg, store.knownProviders)
|
| 143 |
+
if err != nil {
|
| 144 |
+
return nil, fmt.Errorf("failed to configure selected models: %w", err)
|
| 145 |
+
}
|
| 146 |
+
cfg.Models[SelectedModelTypeLarge] = resolved.Large
|
| 147 |
+
cfg.Models[SelectedModelTypeSmall] = resolved.Small
|
| 148 |
+
|
| 149 |
+
// Persist any fallback corrections while we still hold writeMu.
|
| 150 |
+
if resolved.LargeFallback {
|
| 151 |
+
if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
|
| 152 |
+
return store.updatePreferredModelFields(c, SelectedModelTypeLarge, resolved.Large)
|
| 153 |
+
}); err != nil {
|
| 154 |
+
return nil, fmt.Errorf("failed to update preferred large model: %w", err)
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
if resolved.SmallFallback {
|
| 158 |
+
if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
|
| 159 |
+
return store.updatePreferredModelFields(c, SelectedModelTypeSmall, resolved.Small)
|
| 160 |
+
}); err != nil {
|
| 161 |
+
return nil, fmt.Errorf("failed to update preferred small model: %w", err)
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
store.SetupAgents()
|
| 165 |
+
|
| 166 |
+
// Capture initial staleness snapshot
|
| 167 |
+
// Capture initial staleness snapshot. Track every discovered config path,
|
| 168 |
+
// not just the ones that loaded, so a config file created after startup
|
| 169 |
+
// (e.g. a crushrc added mid-session) is detected as a change.
|
| 170 |
+
store.captureStalenessSnapshot(append(slices.Clone(configPaths), loadedPaths...))
|
| 171 |
+
|
| 172 |
+
return store, nil
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on
|
| 176 |
+
// error.
|
| 177 |
+
func mustMarshalConfig(cfg *Config) []byte {
|
| 178 |
+
data, err := json.Marshal(cfg)
|
| 179 |
+
if err != nil {
|
| 180 |
+
return []byte("{}")
|
| 181 |
+
}
|
| 182 |
+
return data
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
func PushPopCrushEnv() func() {
|
| 186 |
+
var found []string
|
| 187 |
+
for _, ev := range os.Environ() {
|
| 188 |
+
if strings.HasPrefix(ev, "CRUSH_") {
|
| 189 |
+
pair := strings.SplitN(ev, "=", 2)
|
| 190 |
+
if len(pair) != 2 {
|
| 191 |
+
continue
|
| 192 |
+
}
|
| 193 |
+
found = append(found, strings.TrimPrefix(pair[0], "CRUSH_"))
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
backups := make(map[string]string)
|
| 197 |
+
for _, ev := range found {
|
| 198 |
+
backups[ev] = os.Getenv(ev)
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
for _, ev := range found {
|
| 202 |
+
os.Setenv(ev, os.Getenv("CRUSH_"+ev))
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
restore := func() {
|
| 206 |
+
for k, v := range backups {
|
| 207 |
+
os.Setenv(k, v)
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
return restore
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
|
| 214 |
+
knownProviderNames := make(map[string]bool)
|
| 215 |
+
restore := PushPopCrushEnv()
|
| 216 |
+
defer restore()
|
| 217 |
+
|
| 218 |
+
// When disable_default_providers is enabled, skip all default/embedded
|
| 219 |
+
// providers entirely. Users must fully specify any providers they want.
|
| 220 |
+
// We skip to the custom provider validation loop which handles all
|
| 221 |
+
// user-configured providers uniformly.
|
| 222 |
+
if c.Options.DisableDefaultProviders {
|
| 223 |
+
knownProviders = nil
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
for _, p := range knownProviders {
|
| 227 |
+
knownProviderNames[string(p.ID)] = true
|
| 228 |
+
config, configExists := c.Providers.Get(string(p.ID))
|
| 229 |
+
// if the user configured a known provider we need to allow it to override a couple of parameters
|
| 230 |
+
if configExists {
|
| 231 |
+
if config.BaseURL != "" {
|
| 232 |
+
p.APIEndpoint = config.BaseURL
|
| 233 |
+
}
|
| 234 |
+
if config.APIKey != "" {
|
| 235 |
+
p.APIKey = config.APIKey
|
| 236 |
+
}
|
| 237 |
+
if len(config.Models) > 0 {
|
| 238 |
+
models := []catwalk.Model{}
|
| 239 |
+
seen := make(map[string]bool)
|
| 240 |
+
|
| 241 |
+
for _, model := range config.Models {
|
| 242 |
+
if seen[model.ID] {
|
| 243 |
+
continue
|
| 244 |
+
}
|
| 245 |
+
seen[model.ID] = true
|
| 246 |
+
if model.Name == "" {
|
| 247 |
+
model.Name = model.ID
|
| 248 |
+
}
|
| 249 |
+
models = append(models, model)
|
| 250 |
+
}
|
| 251 |
+
for _, model := range p.Models {
|
| 252 |
+
if seen[model.ID] {
|
| 253 |
+
continue
|
| 254 |
+
}
|
| 255 |
+
seen[model.ID] = true
|
| 256 |
+
if model.Name == "" {
|
| 257 |
+
model.Name = model.ID
|
| 258 |
+
}
|
| 259 |
+
models = append(models, model)
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
p.Models = models
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
headers := map[string]string{}
|
| 267 |
+
if len(p.DefaultHeaders) > 0 {
|
| 268 |
+
maps.Copy(headers, p.DefaultHeaders)
|
| 269 |
+
}
|
| 270 |
+
if len(config.ExtraHeaders) > 0 {
|
| 271 |
+
maps.Copy(headers, config.ExtraHeaders)
|
| 272 |
+
}
|
| 273 |
+
// Provider headers use the same error contract as MCP headers:
|
| 274 |
+
// a failing $(...) aborts the provider load with a clear
|
| 275 |
+
// message, and a header that resolves to the empty string
|
| 276 |
+
// (unset bare $VAR under lenient nounset, $(echo), or literal
|
| 277 |
+
// "") is dropped from the outgoing request.
|
| 278 |
+
for k, v := range headers {
|
| 279 |
+
resolved, err := resolver.ResolveValue(v)
|
| 280 |
+
if err != nil {
|
| 281 |
+
return fmt.Errorf("resolving provider %s header %q: %w", p.ID, k, err)
|
| 282 |
+
}
|
| 283 |
+
if resolved == "" {
|
| 284 |
+
delete(headers, k)
|
| 285 |
+
continue
|
| 286 |
+
}
|
| 287 |
+
headers[k] = resolved
|
| 288 |
+
}
|
| 289 |
+
// Start from user config so all user fields survive without
|
| 290 |
+
// explicit copying. Overlay catwalk identity/endpoint fields
|
| 291 |
+
// (already merged with user overrides above).
|
| 292 |
+
prepared := config
|
| 293 |
+
prepared.ID = string(p.ID)
|
| 294 |
+
prepared.Name = p.Name
|
| 295 |
+
prepared.BaseURL = p.APIEndpoint
|
| 296 |
+
prepared.APIKey = p.APIKey
|
| 297 |
+
prepared.APIKeyTemplate = p.APIKey // Store original template for re-resolution
|
| 298 |
+
prepared.Type = p.Type
|
| 299 |
+
prepared.Models = p.Models
|
| 300 |
+
prepared.ExtraHeaders = headers
|
| 301 |
+
if prepared.ExtraParams == nil {
|
| 302 |
+
prepared.ExtraParams = make(map[string]string)
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
switch {
|
| 306 |
+
case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
|
| 307 |
+
// Claude Code subscription is not supported anymore. Remove to show onboarding.
|
| 308 |
+
// RemoveConfigField persists the deletion to disk. The in-memory
|
| 309 |
+
// state is kept consistent by the Providers.Del call below; any
|
| 310 |
+
// concurrent reload that races with this write will also see the
|
| 311 |
+
// removal because it re-reads from disk.
|
| 312 |
+
store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
|
| 313 |
+
c.Providers.Del(string(p.ID))
|
| 314 |
+
continue
|
| 315 |
+
case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
|
| 316 |
+
prepared.SetupGitHubCopilot()
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
switch p.ID {
|
| 320 |
+
// Handle specific providers that require additional configuration
|
| 321 |
+
case catwalk.InferenceProviderVertexAI:
|
| 322 |
+
var (
|
| 323 |
+
project = env.Get("VERTEXAI_PROJECT")
|
| 324 |
+
location = env.Get("VERTEXAI_LOCATION")
|
| 325 |
+
)
|
| 326 |
+
if project == "" || location == "" {
|
| 327 |
+
if configExists {
|
| 328 |
+
slog.Warn("Skipping Vertex AI provider due to missing credentials")
|
| 329 |
+
c.Providers.Del(string(p.ID))
|
| 330 |
+
}
|
| 331 |
+
continue
|
| 332 |
+
}
|
| 333 |
+
prepared.ExtraParams["project"] = project
|
| 334 |
+
prepared.ExtraParams["location"] = location
|
| 335 |
+
case catwalk.InferenceProviderAzure:
|
| 336 |
+
endpoint, err := resolver.ResolveValue(p.APIEndpoint)
|
| 337 |
+
if err != nil || endpoint == "" {
|
| 338 |
+
if configExists {
|
| 339 |
+
slog.Warn("Skipping Azure provider due to missing API endpoint", "provider", p.ID, "error", err)
|
| 340 |
+
c.Providers.Del(string(p.ID))
|
| 341 |
+
}
|
| 342 |
+
continue
|
| 343 |
+
}
|
| 344 |
+
prepared.BaseURL = endpoint
|
| 345 |
+
prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
|
| 346 |
+
case catwalk.InferenceProviderBedrock, catwalk.InferenceProviderBedrockEurope:
|
| 347 |
+
if p.APIKey == "" && !hasAWSCredentials(env) {
|
| 348 |
+
if configExists {
|
| 349 |
+
slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
|
| 350 |
+
c.Providers.Del(string(p.ID))
|
| 351 |
+
}
|
| 352 |
+
continue
|
| 353 |
+
}
|
| 354 |
+
case catwalk.InferenceProvider("hyper"):
|
| 355 |
+
if apiKey := env.Get("HYPER_API_KEY"); apiKey != "" {
|
| 356 |
+
prepared.APIKey = apiKey
|
| 357 |
+
prepared.APIKeyTemplate = apiKey
|
| 358 |
+
} else {
|
| 359 |
+
v, err := resolver.ResolveValue(p.APIKey)
|
| 360 |
+
if v == "" || err != nil {
|
| 361 |
+
if configExists {
|
| 362 |
+
slog.Warn("Skipping Hyper provider due to missing API key", "provider", p.ID)
|
| 363 |
+
c.Providers.Del(string(p.ID))
|
| 364 |
+
}
|
| 365 |
+
continue
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
default:
|
| 369 |
+
// An OAuth login is a credential too: providers signed in
|
| 370 |
+
// through OAuth (e.g. OpenAI with a ChatGPT account) are
|
| 371 |
+
// configured even when no API key is present.
|
| 372 |
+
if config.OAuthToken != nil {
|
| 373 |
+
break
|
| 374 |
+
}
|
| 375 |
+
// if the provider api or endpoint are missing we skip them
|
| 376 |
+
v, err := resolver.ResolveValue(p.APIKey)
|
| 377 |
+
if v == "" || err != nil {
|
| 378 |
+
if configExists {
|
| 379 |
+
slog.Warn("Skipping provider due to missing API key", "provider", p.ID)
|
| 380 |
+
c.Providers.Del(string(p.ID))
|
| 381 |
+
}
|
| 382 |
+
continue
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
c.Providers.Set(string(p.ID), prepared)
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// Discover models concurrently for custom providers that need it.
|
| 389 |
+
// A provider needs discovery when discover_models is explicitly true,
|
| 390 |
+
// or when the models list is empty (auto-trigger, unless opted out).
|
| 391 |
+
type discoveryResult struct {
|
| 392 |
+
models []catwalk.Model
|
| 393 |
+
err error
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
discoveryResults := make(map[string]discoveryResult)
|
| 397 |
+
var mu sync.Mutex
|
| 398 |
+
var wg sync.WaitGroup
|
| 399 |
+
|
| 400 |
+
discoverCtx, discoverCancel := context.WithTimeout(ctx, 3*time.Second)
|
| 401 |
+
for id, pc := range c.Providers.Seq2() {
|
| 402 |
+
if knownProviderNames[id] {
|
| 403 |
+
continue
|
| 404 |
+
}
|
| 405 |
+
if pc.Disable || pc.BaseURL == "" {
|
| 406 |
+
continue
|
| 407 |
+
}
|
| 408 |
+
wantsDiscovery := pc.AutoDiscoverModels != nil && *pc.AutoDiscoverModels
|
| 409 |
+
autoTrigger := len(pc.Models) == 0 && (pc.AutoDiscoverModels == nil || *pc.AutoDiscoverModels)
|
| 410 |
+
if !wantsDiscovery && !autoTrigger {
|
| 411 |
+
continue
|
| 412 |
+
}
|
| 413 |
+
providerID := cmp.Or(pc.ID, id)
|
| 414 |
+
cfg := discover.Config{
|
| 415 |
+
ID: providerID,
|
| 416 |
+
BaseURL: pc.BaseURL,
|
| 417 |
+
APIKey: pc.APIKey,
|
| 418 |
+
ExtraHeaders: pc.ExtraHeaders,
|
| 419 |
+
ExistingModels: pc.Models,
|
| 420 |
+
}
|
| 421 |
+
providerType := cmp.Or(pc.Type, catwalk.TypeOpenAICompat)
|
| 422 |
+
wg.Go(func() {
|
| 423 |
+
models, err := discover.DiscoverModels(discoverCtx, cfg, resolver)
|
| 424 |
+
if err == nil && len(models) > 0 {
|
| 425 |
+
if enricher := discover.GetEnricher(string(providerType)); enricher != nil {
|
| 426 |
+
models, _ = enricher.EnrichModels(discoverCtx, cfg, resolver, models)
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
mu.Lock()
|
| 430 |
+
discoveryResults[id] = discoveryResult{models: models, err: err}
|
| 431 |
+
mu.Unlock()
|
| 432 |
+
})
|
| 433 |
+
}
|
| 434 |
+
wg.Wait()
|
| 435 |
+
discoverCancel()
|
| 436 |
+
|
| 437 |
+
// Validate the custom providers.
|
| 438 |
+
for id, providerConfig := range c.Providers.Seq2() {
|
| 439 |
+
if knownProviderNames[id] {
|
| 440 |
+
continue
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
// Make sure the provider ID is set.
|
| 444 |
+
providerConfig.ID = id
|
| 445 |
+
providerConfig.Name = cmp.Or(providerConfig.Name, id) // Use ID as name if not set
|
| 446 |
+
// Default to OpenAI if not set.
|
| 447 |
+
providerConfig.Type = cmp.Or(providerConfig.Type, catwalk.TypeOpenAICompat)
|
| 448 |
+
if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) &&
|
| 449 |
+
providerConfig.Type != hyper.Name &&
|
| 450 |
+
!discover.IsKnownCustomProvider(string(providerConfig.Type)) {
|
| 451 |
+
slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
|
| 452 |
+
c.Providers.Del(id)
|
| 453 |
+
continue
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
if providerConfig.Disable {
|
| 457 |
+
slog.Debug("Skipping custom provider due to disable flag", "provider", id)
|
| 458 |
+
c.Providers.Del(id)
|
| 459 |
+
continue
|
| 460 |
+
}
|
| 461 |
+
apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
|
| 462 |
+
if apiKey == "" || err != nil {
|
| 463 |
+
slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
|
| 464 |
+
}
|
| 465 |
+
baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
|
| 466 |
+
if baseURL == "" || err != nil {
|
| 467 |
+
slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id, "error", err)
|
| 468 |
+
c.Providers.Del(id)
|
| 469 |
+
continue
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// Apply discovery results if available.
|
| 473 |
+
if result, ok := discoveryResults[id]; ok {
|
| 474 |
+
if result.err != nil {
|
| 475 |
+
slog.Warn("Model discovery failed", "provider", id, "error", result.err)
|
| 476 |
+
if len(providerConfig.Models) == 0 {
|
| 477 |
+
slog.Warn("Skipping provider with no models after failed discovery", "provider", id)
|
| 478 |
+
c.Providers.Del(id)
|
| 479 |
+
continue
|
| 480 |
+
}
|
| 481 |
+
} else if len(result.models) > 0 {
|
| 482 |
+
providerConfig.Models = result.models
|
| 483 |
+
slog.Info("Discovered models for provider", "provider", id, "count", len(result.models))
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
if len(providerConfig.Models) == 0 {
|
| 488 |
+
slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
|
| 489 |
+
c.Providers.Del(id)
|
| 490 |
+
continue
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// Custom-provider headers share the MCP error contract; see
|
| 494 |
+
// the known-provider loop above.
|
| 495 |
+
for k, v := range providerConfig.ExtraHeaders {
|
| 496 |
+
resolved, err := resolver.ResolveValue(v)
|
| 497 |
+
if err != nil {
|
| 498 |
+
return fmt.Errorf("resolving provider %s header %q: %w", id, k, err)
|
| 499 |
+
}
|
| 500 |
+
if resolved == "" {
|
| 501 |
+
delete(providerConfig.ExtraHeaders, k)
|
| 502 |
+
continue
|
| 503 |
+
}
|
| 504 |
+
providerConfig.ExtraHeaders[k] = resolved
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
c.Providers.Set(id, providerConfig)
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders {
|
| 511 |
+
return fmt.Errorf("default providers are disabled and there are no custom providers are configured")
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
return nil
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// applyEnv sets top-level env vars from the config. Keys are sorted for
|
| 518 |
+
// deterministic ordering so that vars referencing other vars via the
|
| 519 |
+
// value resolver produce consistent results.
|
| 520 |
+
func (c *Config) applyEnv(resolver VariableResolver) {
|
| 521 |
+
keys := make([]string, 0, len(c.Env))
|
| 522 |
+
for k := range c.Env {
|
| 523 |
+
keys = append(keys, k)
|
| 524 |
+
}
|
| 525 |
+
slices.Sort(keys)
|
| 526 |
+
for _, k := range keys {
|
| 527 |
+
resolved, err := resolver.ResolveValue(c.Env[k])
|
| 528 |
+
if err != nil {
|
| 529 |
+
slog.Warn("Skipping env var due to resolution failure.", "key", k, "value", c.Env[k], "error", err)
|
| 530 |
+
continue
|
| 531 |
+
}
|
| 532 |
+
os.Setenv(k, resolved)
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
// NormalizeOptions allocates Options and Options.TUI and fills in the option
|
| 537 |
+
// defaults the UI relies on, so readers can dereference them without guarding.
|
| 538 |
+
// Configs loaded from disk get this via setDefaults; configs arriving over the
|
| 539 |
+
// wire from a Crush server need the same treatment before the UI reads them.
|
| 540 |
+
//
|
| 541 |
+
// DiffMode is deliberately left alone: the permissions dialog reads its zero
|
| 542 |
+
// value as "choose split or unified from the terminal width".
|
| 543 |
+
func (c *Config) NormalizeOptions() {
|
| 544 |
+
if c.Options == nil {
|
| 545 |
+
c.Options = &Options{}
|
| 546 |
+
}
|
| 547 |
+
if c.Options.TUI == nil {
|
| 548 |
+
c.Options.TUI = &TUIOptions{}
|
| 549 |
+
}
|
| 550 |
+
if c.Options.TUI.Scrollbar == "" {
|
| 551 |
+
c.Options.TUI.Scrollbar = ScrollbarDefault
|
| 552 |
+
}
|
| 553 |
+
if c.Options.TUI.ExitBanner == "" {
|
| 554 |
+
c.Options.TUI.ExitBanner = ExitBannerDefault
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
func (c *Config) setDefaults(workingDir, dataDir string) {
|
| 559 |
+
c.NormalizeOptions()
|
| 560 |
+
if len(c.Options.GlobalContextPaths) == 0 {
|
| 561 |
+
crushConfigDir := filepath.Dir(GlobalConfig())
|
| 562 |
+
c.Options.GlobalContextPaths = []string{
|
| 563 |
+
filepath.Join(crushConfigDir, "CRUSH.md"),
|
| 564 |
+
filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
slices.Sort(c.Options.GlobalContextPaths)
|
| 568 |
+
c.Options.GlobalContextPaths = slices.Compact(c.Options.GlobalContextPaths)
|
| 569 |
+
|
| 570 |
+
if dataDir != "" {
|
| 571 |
+
c.Options.DataDirectory = dataDir
|
| 572 |
+
} else if c.Options.DataDirectory == "" {
|
| 573 |
+
if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok {
|
| 574 |
+
c.Options.DataDirectory = path
|
| 575 |
+
} else {
|
| 576 |
+
c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
c.Options.DataDirectory = filepath.Clean(filepathext.SmartJoin(workingDir, c.Options.DataDirectory))
|
| 580 |
+
if c.Providers == nil {
|
| 581 |
+
c.Providers = csync.NewMap[string, ProviderConfig]()
|
| 582 |
+
}
|
| 583 |
+
if c.Models == nil {
|
| 584 |
+
c.Models = make(map[SelectedModelType]SelectedModel)
|
| 585 |
+
}
|
| 586 |
+
if c.RecentModels == nil {
|
| 587 |
+
c.RecentModels = make(map[SelectedModelType][]SelectedModel)
|
| 588 |
+
}
|
| 589 |
+
if c.MCP == nil {
|
| 590 |
+
c.MCP = make(map[string]MCPConfig)
|
| 591 |
+
}
|
| 592 |
+
// Drop orphaned OAuth token entries left behind when a user removes
|
| 593 |
+
// an MCP from crush.json. See MCPConfig.isOrphanedToken.
|
| 594 |
+
for name, m := range c.MCP {
|
| 595 |
+
if m.isOrphanedToken() {
|
| 596 |
+
delete(c.MCP, name)
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
if c.LSP == nil {
|
| 600 |
+
c.LSP = make(map[string]LSPConfig)
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
// Apply defaults to LSP configurations
|
| 604 |
+
c.applyLSPDefaults()
|
| 605 |
+
|
| 606 |
+
// Add the default context paths if they are not already present
|
| 607 |
+
c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
|
| 608 |
+
|
| 609 |
+
slices.Sort(c.Options.ContextPaths)
|
| 610 |
+
c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
|
| 611 |
+
|
| 612 |
+
// Add the default skills directories if not already present.
|
| 613 |
+
for _, dir := range GlobalSkillsDirs() {
|
| 614 |
+
if !slices.Contains(c.Options.SkillsPaths, dir) {
|
| 615 |
+
c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
// Project specific skills dirs.
|
| 620 |
+
c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
|
| 621 |
+
|
| 622 |
+
if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
|
| 623 |
+
c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
|
| 627 |
+
c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
if c.Options.Attribution == nil {
|
| 631 |
+
c.Options.Attribution = &Attribution{
|
| 632 |
+
TrailerStyle: TrailerStyleAssistedBy,
|
| 633 |
+
GeneratedWith: true,
|
| 634 |
+
}
|
| 635 |
+
} else if c.Options.Attribution.TrailerStyle == "" {
|
| 636 |
+
// Migrate deprecated co_authored_by or apply default
|
| 637 |
+
if c.Options.Attribution.CoAuthoredBy != nil {
|
| 638 |
+
if *c.Options.Attribution.CoAuthoredBy {
|
| 639 |
+
c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
|
| 640 |
+
} else {
|
| 641 |
+
c.Options.Attribution.TrailerStyle = TrailerStyleNone
|
| 642 |
+
}
|
| 643 |
+
} else {
|
| 644 |
+
c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
// powernapDefaults caches the powernap default LSP server catalog. The
|
| 652 |
+
// catalog is static and immutable for the life of the process, but
|
| 653 |
+
// building it (NewManager + LoadDefaults) is expensive and was previously
|
| 654 |
+
// repeated on every config reload. We load it once and only ever read from
|
| 655 |
+
// it via GetServer, so a shared instance is safe.
|
| 656 |
+
var (
|
| 657 |
+
powernapDefaultsOnce sync.Once
|
| 658 |
+
powernapDefaults *powernapConfig.Manager
|
| 659 |
+
)
|
| 660 |
+
|
| 661 |
+
func lspDefaultsManager() *powernapConfig.Manager {
|
| 662 |
+
powernapDefaultsOnce.Do(func() {
|
| 663 |
+
m := powernapConfig.NewManager()
|
| 664 |
+
// LoadDefaults only fails on malformed embedded defaults, which
|
| 665 |
+
// would be a build-time bug; treat the manager as usable either
|
| 666 |
+
// way so a transient error never wedges config loading.
|
| 667 |
+
_ = m.LoadDefaults()
|
| 668 |
+
powernapDefaults = m
|
| 669 |
+
})
|
| 670 |
+
return powernapDefaults
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
// applyLSPDefaults applies default values from powernap to LSP configurations
|
| 674 |
+
func (c *Config) applyLSPDefaults() {
|
| 675 |
+
// Reuse the process-wide default catalog; building it per reload was a
|
| 676 |
+
// significant chunk of reload latency.
|
| 677 |
+
configManager := lspDefaultsManager()
|
| 678 |
+
|
| 679 |
+
// Apply defaults to each LSP configuration
|
| 680 |
+
for name, cfg := range c.LSP {
|
| 681 |
+
// Try to get defaults from powernap based on name or command name.
|
| 682 |
+
base, ok := configManager.GetServer(name)
|
| 683 |
+
if !ok {
|
| 684 |
+
base, ok = configManager.GetServer(cfg.Command)
|
| 685 |
+
if !ok {
|
| 686 |
+
continue
|
| 687 |
+
}
|
| 688 |
+
}
|
| 689 |
+
if cfg.Options == nil {
|
| 690 |
+
cfg.Options = base.Settings
|
| 691 |
+
}
|
| 692 |
+
if cfg.InitOptions == nil {
|
| 693 |
+
cfg.InitOptions = base.InitOptions
|
| 694 |
+
}
|
| 695 |
+
if len(cfg.FileTypes) == 0 {
|
| 696 |
+
cfg.FileTypes = base.FileTypes
|
| 697 |
+
}
|
| 698 |
+
if len(cfg.RootMarkers) == 0 {
|
| 699 |
+
cfg.RootMarkers = base.RootMarkers
|
| 700 |
+
}
|
| 701 |
+
cfg.Command = cmp.Or(cfg.Command, base.Command)
|
| 702 |
+
if len(cfg.Args) == 0 {
|
| 703 |
+
cfg.Args = base.Args
|
| 704 |
+
}
|
| 705 |
+
if len(cfg.Env) == 0 {
|
| 706 |
+
cfg.Env = base.Environment
|
| 707 |
+
}
|
| 708 |
+
// Update the config in the map
|
| 709 |
+
c.LSP[name] = cfg
|
| 710 |
+
}
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
|
| 714 |
+
if len(knownProviders) == 0 && c.Providers.Len() == 0 {
|
| 715 |
+
err = fmt.Errorf("no providers configured, please configure at least one provider")
|
| 716 |
+
return largeModel, smallModel, err
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
// Use the first provider enabled based on the known providers order
|
| 720 |
+
// if no provider found that is known use the first provider configured
|
| 721 |
+
for _, p := range knownProviders {
|
| 722 |
+
providerConfig, ok := c.Providers.Get(string(p.ID))
|
| 723 |
+
if !ok || providerConfig.Disable {
|
| 724 |
+
continue
|
| 725 |
+
}
|
| 726 |
+
defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
|
| 727 |
+
if defaultLargeModel == nil {
|
| 728 |
+
slog.Warn("Default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
|
| 729 |
+
if len(providerConfig.Models) == 0 {
|
| 730 |
+
return largeModel, smallModel, fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
|
| 731 |
+
}
|
| 732 |
+
defaultLargeModel = &providerConfig.Models[0]
|
| 733 |
+
}
|
| 734 |
+
largeModel = SelectedModel{
|
| 735 |
+
Provider: string(p.ID),
|
| 736 |
+
Model: defaultLargeModel.ID,
|
| 737 |
+
MaxTokens: defaultLargeModel.DefaultMaxTokens,
|
| 738 |
+
ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
|
| 742 |
+
if defaultSmallModel == nil {
|
| 743 |
+
slog.Warn("Default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
|
| 744 |
+
if len(providerConfig.Models) == 0 {
|
| 745 |
+
return largeModel, smallModel, fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
|
| 746 |
+
}
|
| 747 |
+
defaultSmallModel = &providerConfig.Models[0]
|
| 748 |
+
}
|
| 749 |
+
smallModel = SelectedModel{
|
| 750 |
+
Provider: string(p.ID),
|
| 751 |
+
Model: defaultSmallModel.ID,
|
| 752 |
+
MaxTokens: defaultSmallModel.DefaultMaxTokens,
|
| 753 |
+
ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
|
| 754 |
+
}
|
| 755 |
+
return largeModel, smallModel, err
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
enabledProviders := c.EnabledProviders()
|
| 759 |
+
slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
|
| 760 |
+
return strings.Compare(a.ID, b.ID)
|
| 761 |
+
})
|
| 762 |
+
|
| 763 |
+
if len(enabledProviders) == 0 {
|
| 764 |
+
err = fmt.Errorf("no providers configured, please configure at least one provider")
|
| 765 |
+
return largeModel, smallModel, err
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
providerConfig := enabledProviders[0]
|
| 769 |
+
if len(providerConfig.Models) == 0 {
|
| 770 |
+
err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
|
| 771 |
+
return largeModel, smallModel, err
|
| 772 |
+
}
|
| 773 |
+
defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
|
| 774 |
+
largeModel = SelectedModel{
|
| 775 |
+
Provider: providerConfig.ID,
|
| 776 |
+
Model: defaultLargeModel.ID,
|
| 777 |
+
MaxTokens: defaultLargeModel.DefaultMaxTokens,
|
| 778 |
+
}
|
| 779 |
+
defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
|
| 780 |
+
smallModel = SelectedModel{
|
| 781 |
+
Provider: providerConfig.ID,
|
| 782 |
+
Model: defaultSmallModel.ID,
|
| 783 |
+
MaxTokens: defaultSmallModel.DefaultMaxTokens,
|
| 784 |
+
}
|
| 785 |
+
return largeModel, smallModel, err
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
// resolvedModels holds the result of resolving user-configured model
|
| 789 |
+
// selections against the provider catalog.
|
| 790 |
+
type resolvedModels struct {
|
| 791 |
+
Large SelectedModel
|
| 792 |
+
Small SelectedModel
|
| 793 |
+
LargeFallback bool // true if Large was corrected to a default
|
| 794 |
+
SmallFallback bool // true if Small was corrected to a default
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
// resolveSelectedModels validates the user's configured model selections
|
| 798 |
+
// against the provider catalog, falling back to defaults when a model ID is
|
| 799 |
+
// invalid. It is pure resolution logic: it does not mutate the store or
|
| 800 |
+
// touch disk. The caller assigns the results to c.Models and persists any
|
| 801 |
+
// fallback corrections as appropriate.
|
| 802 |
+
func resolveSelectedModels(cfg *Config, knownProviders []catwalk.Provider) (resolvedModels, error) {
|
| 803 |
+
var result resolvedModels
|
| 804 |
+
defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders)
|
| 805 |
+
if err != nil {
|
| 806 |
+
return result, fmt.Errorf("failed to select default models: %w", err)
|
| 807 |
+
}
|
| 808 |
+
large, small := defaultLarge, defaultSmall
|
| 809 |
+
|
| 810 |
+
largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge]
|
| 811 |
+
if largeModelConfigured {
|
| 812 |
+
if largeModelSelected.Model != "" {
|
| 813 |
+
large.Model = largeModelSelected.Model
|
| 814 |
+
}
|
| 815 |
+
if largeModelSelected.Provider != "" {
|
| 816 |
+
large.Provider = largeModelSelected.Provider
|
| 817 |
+
}
|
| 818 |
+
model := cfg.GetModel(large.Provider, large.Model)
|
| 819 |
+
if model == nil {
|
| 820 |
+
large = defaultLarge
|
| 821 |
+
result.LargeFallback = true
|
| 822 |
+
} else {
|
| 823 |
+
if largeModelSelected.MaxTokens > 0 {
|
| 824 |
+
large.MaxTokens = largeModelSelected.MaxTokens
|
| 825 |
+
} else {
|
| 826 |
+
large.MaxTokens = model.DefaultMaxTokens
|
| 827 |
+
}
|
| 828 |
+
if largeModelSelected.ReasoningEffort != "" {
|
| 829 |
+
large.ReasoningEffort = largeModelSelected.ReasoningEffort
|
| 830 |
+
} else {
|
| 831 |
+
large.ReasoningEffort = model.DefaultReasoningEffort
|
| 832 |
+
}
|
| 833 |
+
large.Think = largeModelSelected.Think
|
| 834 |
+
if largeModelSelected.Temperature != nil {
|
| 835 |
+
large.Temperature = largeModelSelected.Temperature
|
| 836 |
+
}
|
| 837 |
+
if largeModelSelected.TopP != nil {
|
| 838 |
+
large.TopP = largeModelSelected.TopP
|
| 839 |
+
}
|
| 840 |
+
if largeModelSelected.TopK != nil {
|
| 841 |
+
large.TopK = largeModelSelected.TopK
|
| 842 |
+
}
|
| 843 |
+
if largeModelSelected.FrequencyPenalty != nil {
|
| 844 |
+
large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
|
| 845 |
+
}
|
| 846 |
+
if largeModelSelected.PresencePenalty != nil {
|
| 847 |
+
large.PresencePenalty = largeModelSelected.PresencePenalty
|
| 848 |
+
}
|
| 849 |
+
if largeModelSelected.ProviderOptions != nil {
|
| 850 |
+
large.ProviderOptions = maps.Clone(largeModelSelected.ProviderOptions)
|
| 851 |
+
}
|
| 852 |
+
}
|
| 853 |
+
}
|
| 854 |
+
smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall]
|
| 855 |
+
if smallModelConfigured {
|
| 856 |
+
if smallModelSelected.Model != "" {
|
| 857 |
+
small.Model = smallModelSelected.Model
|
| 858 |
+
}
|
| 859 |
+
if smallModelSelected.Provider != "" {
|
| 860 |
+
small.Provider = smallModelSelected.Provider
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
model := cfg.GetModel(small.Provider, small.Model)
|
| 864 |
+
if model == nil {
|
| 865 |
+
small = defaultSmall
|
| 866 |
+
result.SmallFallback = true
|
| 867 |
+
} else {
|
| 868 |
+
if smallModelSelected.MaxTokens > 0 {
|
| 869 |
+
small.MaxTokens = smallModelSelected.MaxTokens
|
| 870 |
+
} else {
|
| 871 |
+
small.MaxTokens = model.DefaultMaxTokens
|
| 872 |
+
}
|
| 873 |
+
if smallModelSelected.ReasoningEffort != "" {
|
| 874 |
+
small.ReasoningEffort = smallModelSelected.ReasoningEffort
|
| 875 |
+
} else {
|
| 876 |
+
small.ReasoningEffort = model.DefaultReasoningEffort
|
| 877 |
+
}
|
| 878 |
+
if smallModelSelected.Temperature != nil {
|
| 879 |
+
small.Temperature = smallModelSelected.Temperature
|
| 880 |
+
}
|
| 881 |
+
if smallModelSelected.TopP != nil {
|
| 882 |
+
small.TopP = smallModelSelected.TopP
|
| 883 |
+
}
|
| 884 |
+
if smallModelSelected.TopK != nil {
|
| 885 |
+
small.TopK = smallModelSelected.TopK
|
| 886 |
+
}
|
| 887 |
+
if smallModelSelected.FrequencyPenalty != nil {
|
| 888 |
+
small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
|
| 889 |
+
}
|
| 890 |
+
if smallModelSelected.PresencePenalty != nil {
|
| 891 |
+
small.PresencePenalty = smallModelSelected.PresencePenalty
|
| 892 |
+
}
|
| 893 |
+
if smallModelSelected.ProviderOptions != nil {
|
| 894 |
+
small.ProviderOptions = maps.Clone(smallModelSelected.ProviderOptions)
|
| 895 |
+
}
|
| 896 |
+
small.Think = smallModelSelected.Think
|
| 897 |
+
}
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
// When small isn't explicitly configured and the provider isn't a
|
| 901 |
+
// known built-in, use the large model as the small model. This
|
| 902 |
+
// prevents two different models from being requested concurrently
|
| 903 |
+
// for local/openai-compat providers.
|
| 904 |
+
if !smallModelConfigured {
|
| 905 |
+
isKnownProvider := false
|
| 906 |
+
for _, kp := range knownProviders {
|
| 907 |
+
if string(kp.ID) == small.Provider {
|
| 908 |
+
isKnownProvider = true
|
| 909 |
+
break
|
| 910 |
+
}
|
| 911 |
+
}
|
| 912 |
+
if !isKnownProvider {
|
| 913 |
+
slog.Warn("Using large model as small model for unknown provider", "provider", large.Provider, "model", large.Model)
|
| 914 |
+
small = large
|
| 915 |
+
}
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
result.Large = large
|
| 919 |
+
result.Small = small
|
| 920 |
+
return result, nil
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
// lookupConfigs searches config files starting at cwd and walking up
|
| 924 |
+
// through the current project. The upward walk stops at the git
|
| 925 |
+
// working tree root when one can be detected, otherwise at cwd itself,
|
| 926 |
+
// so an unrelated crush.json placed above the project is never picked
|
| 927 |
+
// up. Global user-level config locations are always included
|
| 928 |
+
// regardless of the boundary.
|
| 929 |
+
func lookupConfigs(cwd string) []string {
|
| 930 |
+
// Prepend global user config and machine-owned data JSON. Only the user
|
| 931 |
+
// config directory contributes a crushrc; the data directory is writable
|
| 932 |
+
// machine state and must never be executed as Bash. Missing files are
|
| 933 |
+
// skipped when loaded.
|
| 934 |
+
configPaths := []string{
|
| 935 |
+
systemConfigPath,
|
| 936 |
+
GlobalConfig(),
|
| 937 |
+
shellConfigSibling(GlobalConfig()),
|
| 938 |
+
GlobalConfigData(),
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
// Ordered high-to-low priority within a directory. LookupBounded returns
|
| 942 |
+
// matches in this order, and the later reverse + merge make the earliest
|
| 943 |
+
// listed name win on conflict. So: .crushrc beats crushrc, both beat the
|
| 944 |
+
// JSON configs, and .crush.json beats crush.json.
|
| 945 |
+
configNames := []string{
|
| 946 |
+
"." + appName + "rc",
|
| 947 |
+
appName + "rc",
|
| 948 |
+
"." + appName + ".json",
|
| 949 |
+
appName + ".json",
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
|
| 953 |
+
if err != nil {
|
| 954 |
+
// returns at least default configs
|
| 955 |
+
return configPaths
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
// reverse order so last config has more priority
|
| 959 |
+
slices.Reverse(foundConfigs)
|
| 960 |
+
|
| 961 |
+
return append(configPaths, foundConfigs...)
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
func loadFromConfigPaths(ctx context.Context, configPaths []string) (*Config, []string, error) {
|
| 965 |
+
var configs [][]byte
|
| 966 |
+
var loaded []string
|
| 967 |
+
|
| 968 |
+
// Track directories that have both crush.json and crushrc to warn
|
| 969 |
+
// about potential confusion, along with the top-level keys each
|
| 970 |
+
// defines so we can report conflicts.
|
| 971 |
+
jsonDirKeys := make(map[string]map[string]bool)
|
| 972 |
+
shDirKeys := make(map[string]map[string]bool)
|
| 973 |
+
|
| 974 |
+
for _, path := range configPaths {
|
| 975 |
+
if path == "" {
|
| 976 |
+
continue
|
| 977 |
+
}
|
| 978 |
+
data, err := os.ReadFile(path)
|
| 979 |
+
if err != nil {
|
| 980 |
+
if os.IsNotExist(err) {
|
| 981 |
+
continue
|
| 982 |
+
}
|
| 983 |
+
return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
|
| 984 |
+
}
|
| 985 |
+
if len(data) == 0 {
|
| 986 |
+
continue
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
dir := filepath.Dir(path)
|
| 990 |
+
if isShellConfig(path) {
|
| 991 |
+
jsonBytes, err := shellconfig.LoadShellConfig(ctx, path, data)
|
| 992 |
+
if err != nil {
|
| 993 |
+
return nil, nil, fmt.Errorf("failed to load shell config %s: %w", path, err)
|
| 994 |
+
}
|
| 995 |
+
if len(jsonBytes) > 0 {
|
| 996 |
+
if !json.Valid(jsonBytes) {
|
| 997 |
+
return nil, nil, fmt.Errorf("shell config %s produced invalid JSON", path)
|
| 998 |
+
}
|
| 999 |
+
addTopLevelKeys(shDirKeys, dir, jsonBytes)
|
| 1000 |
+
configs = append(configs, jsonBytes)
|
| 1001 |
+
loaded = append(loaded, path)
|
| 1002 |
+
}
|
| 1003 |
+
} else {
|
| 1004 |
+
if !json.Valid(data) {
|
| 1005 |
+
return nil, nil, fmt.Errorf("invalid JSON in config file %s", path)
|
| 1006 |
+
}
|
| 1007 |
+
addTopLevelKeys(jsonDirKeys, dir, data)
|
| 1008 |
+
configs = append(configs, data)
|
| 1009 |
+
loaded = append(loaded, path)
|
| 1010 |
+
}
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
// Warn if both a JSON config and a crushrc exist in the same directory
|
| 1014 |
+
// and define overlapping top-level keys. Disjoint coexistence is
|
| 1015 |
+
// intentional and not worth warning about.
|
| 1016 |
+
for dir, jKeys := range jsonDirKeys {
|
| 1017 |
+
sKeys, ok := shDirKeys[dir]
|
| 1018 |
+
if !ok {
|
| 1019 |
+
continue
|
| 1020 |
+
}
|
| 1021 |
+
var conflicts []string
|
| 1022 |
+
for k := range jKeys {
|
| 1023 |
+
if sKeys[k] {
|
| 1024 |
+
conflicts = append(conflicts, k)
|
| 1025 |
+
}
|
| 1026 |
+
}
|
| 1027 |
+
if len(conflicts) > 0 {
|
| 1028 |
+
slices.Sort(conflicts)
|
| 1029 |
+
slog.Warn("Found both a JSON config and a crushrc in the same directory; merging with crushrc taking precedence",
|
| 1030 |
+
"dir", dir, "conflicting_keys", strings.Join(conflicts, ", "))
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
cfg, err := loadFromBytes(configs)
|
| 1035 |
+
if err != nil {
|
| 1036 |
+
return nil, nil, err
|
| 1037 |
+
}
|
| 1038 |
+
return cfg, loaded, nil
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
// addTopLevelKeys records the top-level JSON keys present in data into the
|
| 1042 |
+
// set for dir.
|
| 1043 |
+
func addTopLevelKeys(m map[string]map[string]bool, dir string, data []byte) {
|
| 1044 |
+
keys := m[dir]
|
| 1045 |
+
if keys == nil {
|
| 1046 |
+
keys = make(map[string]bool)
|
| 1047 |
+
m[dir] = keys
|
| 1048 |
+
}
|
| 1049 |
+
gjson.ParseBytes(data).ForEach(func(key, _ gjson.Result) bool {
|
| 1050 |
+
keys[key.String()] = true
|
| 1051 |
+
return true
|
| 1052 |
+
})
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
func loadFromBytes(configs [][]byte) (*Config, error) {
|
| 1056 |
+
if len(configs) == 0 {
|
| 1057 |
+
return &Config{}, nil
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
data, err := jsons.Merge(configs)
|
| 1061 |
+
if err != nil {
|
| 1062 |
+
return nil, err
|
| 1063 |
+
}
|
| 1064 |
+
var config Config
|
| 1065 |
+
if err := json.Unmarshal(data, &config); err != nil {
|
| 1066 |
+
return nil, err
|
| 1067 |
+
}
|
| 1068 |
+
return &config, nil
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
func hasAWSCredentials(env env.Env) bool {
|
| 1072 |
+
if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
|
| 1073 |
+
return true
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
|
| 1077 |
+
return true
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
|
| 1081 |
+
return true
|
| 1082 |
+
}
|
| 1083 |
+
|
| 1084 |
+
if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
|
| 1085 |
+
return true
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
|
| 1089 |
+
env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
|
| 1090 |
+
return true
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
// File-based credential discovery requires filesystem stats, so do it
|
| 1094 |
+
// last and skip it under test. Checking testing.Testing() before the
|
| 1095 |
+
// os.Stat call (rather than after, in the && tail) ensures the syscall
|
| 1096 |
+
// is never issued during tests, where it otherwise ran unconditionally
|
| 1097 |
+
// and only had its result discarded.
|
| 1098 |
+
if testing.Testing() {
|
| 1099 |
+
return false
|
| 1100 |
+
}
|
| 1101 |
+
if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil {
|
| 1102 |
+
return true
|
| 1103 |
+
}
|
| 1104 |
+
if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/login")); err == nil {
|
| 1105 |
+
return true
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
return false
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
// migrateDisableNotifications migrates the deprecated disable_notifications
|
| 1112 |
+
// and notification_style fields to the unified notifications field. It checks
|
| 1113 |
+
// both the user config (~/.config) and data config (~/.local) files. If
|
| 1114 |
+
// disable_notifications is true, it sets notifications to "disabled" in the
|
| 1115 |
+
// data file. If notification_style is set, it moves the value to notifications.
|
| 1116 |
+
// Regardless of value, it removes the deprecated fields from any file that
|
| 1117 |
+
// contains them.
|
| 1118 |
+
func migrateDisableNotifications() {
|
| 1119 |
+
globalConfig := GlobalConfig()
|
| 1120 |
+
dataConfig := GlobalConfigData()
|
| 1121 |
+
|
| 1122 |
+
var wasDisabled bool
|
| 1123 |
+
var styleValue string
|
| 1124 |
+
filesToClean := []string{}
|
| 1125 |
+
|
| 1126 |
+
for _, path := range []string{globalConfig, dataConfig} {
|
| 1127 |
+
data, err := os.ReadFile(path)
|
| 1128 |
+
if err != nil {
|
| 1129 |
+
continue
|
| 1130 |
+
}
|
| 1131 |
+
needsClean := false
|
| 1132 |
+
if gjson.Get(string(data), "options.disable_notifications").Exists() {
|
| 1133 |
+
needsClean = true
|
| 1134 |
+
if gjson.Get(string(data), "options.disable_notifications").Bool() {
|
| 1135 |
+
wasDisabled = true
|
| 1136 |
+
}
|
| 1137 |
+
}
|
| 1138 |
+
if v := gjson.Get(string(data), "options.notification_style"); v.Exists() {
|
| 1139 |
+
needsClean = true
|
| 1140 |
+
if styleValue == "" {
|
| 1141 |
+
styleValue = v.String()
|
| 1142 |
+
}
|
| 1143 |
+
}
|
| 1144 |
+
if needsClean {
|
| 1145 |
+
filesToClean = append(filesToClean, path)
|
| 1146 |
+
}
|
| 1147 |
+
}
|
| 1148 |
+
|
| 1149 |
+
if len(filesToClean) == 0 {
|
| 1150 |
+
return
|
| 1151 |
+
}
|
| 1152 |
+
|
| 1153 |
+
// Determine the value to persist: notification_style takes precedence,
|
| 1154 |
+
// then disable_notifications: true maps to "disabled".
|
| 1155 |
+
migratedValue := styleValue
|
| 1156 |
+
if migratedValue == "" && wasDisabled {
|
| 1157 |
+
migratedValue = "disabled"
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
if migratedValue != "" {
|
| 1161 |
+
data, err := os.ReadFile(dataConfig)
|
| 1162 |
+
if err == nil {
|
| 1163 |
+
if !gjson.Get(string(data), "options.notifications").Exists() {
|
| 1164 |
+
updated, err := sjson.Set(string(data), "options.notifications", migratedValue)
|
| 1165 |
+
if err == nil {
|
| 1166 |
+
if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
|
| 1167 |
+
slog.Warn("Failed to migrate to notifications field", "error", err)
|
| 1168 |
+
} else {
|
| 1169 |
+
slog.Info("Migrated notification settings to notifications field", "value", migratedValue)
|
| 1170 |
+
}
|
| 1171 |
+
}
|
| 1172 |
+
}
|
| 1173 |
+
}
|
| 1174 |
+
}
|
| 1175 |
+
|
| 1176 |
+
// Remove deprecated fields from all files that contain them.
|
| 1177 |
+
for _, path := range filesToClean {
|
| 1178 |
+
data, err := os.ReadFile(path)
|
| 1179 |
+
if err != nil {
|
| 1180 |
+
continue
|
| 1181 |
+
}
|
| 1182 |
+
updated := string(data)
|
| 1183 |
+
updated, _ = sjson.Delete(updated, "options.disable_notifications")
|
| 1184 |
+
updated, _ = sjson.Delete(updated, "options.notification_style")
|
| 1185 |
+
if updated == string(data) {
|
| 1186 |
+
continue
|
| 1187 |
+
}
|
| 1188 |
+
if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
|
| 1189 |
+
slog.Warn("Failed to write migrated config", "path", path, "error", err)
|
| 1190 |
+
}
|
| 1191 |
+
}
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
+
// GlobalConfig returns the global configuration file path for the application.
|
| 1195 |
+
func GlobalConfig() string {
|
| 1196 |
+
if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
|
| 1197 |
+
return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
|
| 1198 |
+
}
|
| 1199 |
+
return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
// shellConfigSibling returns the crushrc path that sits alongside a given
|
| 1203 |
+
// crush.json path (same directory). Used so global config locations pick up a
|
| 1204 |
+
// shell config, not just JSON.
|
| 1205 |
+
func shellConfigSibling(jsonPath string) string {
|
| 1206 |
+
return filepath.Join(filepath.Dir(jsonPath), appName+"rc")
|
| 1207 |
+
}
|
| 1208 |
+
|
| 1209 |
+
// isShellConfig reports whether a config path is a shell config (crushrc or
|
| 1210 |
+
// the hidden .crushrc), as opposed to a JSON config.
|
| 1211 |
+
func isShellConfig(path string) bool {
|
| 1212 |
+
base := filepath.Base(path)
|
| 1213 |
+
return base == appName+"rc" || base == "."+appName+"rc"
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
// GlobalCacheDir returns the path to the global cache directory for the
|
| 1217 |
+
// application.
|
| 1218 |
+
func GlobalCacheDir() string {
|
| 1219 |
+
if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
|
| 1220 |
+
return crushCache
|
| 1221 |
+
}
|
| 1222 |
+
if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
|
| 1223 |
+
return filepath.Join(xdgCacheHome, appName)
|
| 1224 |
+
}
|
| 1225 |
+
if runtime.GOOS == "windows" {
|
| 1226 |
+
localAppData := cmp.Or(
|
| 1227 |
+
os.Getenv("LOCALAPPDATA"),
|
| 1228 |
+
filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
|
| 1229 |
+
)
|
| 1230 |
+
return filepath.Join(localAppData, appName, "cache")
|
| 1231 |
+
}
|
| 1232 |
+
return filepath.Join(home.Dir(), ".cache", appName)
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
// ProjectConfigs returns list of current project configs paths.
|
| 1236 |
+
func ProjectConfigs(cwd string) []string {
|
| 1237 |
+
return lookupConfigs(cwd)
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
// GlobalConfigData returns the path to the main data directory for the application.
|
| 1241 |
+
// this config is used when the app overrides configurations instead of updating the global config.
|
| 1242 |
+
func GlobalConfigData() string {
|
| 1243 |
+
if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
|
| 1244 |
+
return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
|
| 1245 |
+
}
|
| 1246 |
+
if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
|
| 1247 |
+
return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
// return the path to the main data directory
|
| 1251 |
+
// for windows, it should be in `%LOCALAPPDATA%/crush/`
|
| 1252 |
+
// for linux and macOS, it should be in `$HOME/.local/share/crush/`
|
| 1253 |
+
if runtime.GOOS == "windows" {
|
| 1254 |
+
localAppData := cmp.Or(
|
| 1255 |
+
os.Getenv("LOCALAPPDATA"),
|
| 1256 |
+
filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
|
| 1257 |
+
)
|
| 1258 |
+
return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
|
| 1259 |
+
}
|
| 1260 |
+
|
| 1261 |
+
return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
// GlobalWorkspaceDir returns the path to the global server workspace
|
| 1265 |
+
// directory. This directory acts as a meta-workspace for the server
|
| 1266 |
+
// process, giving it a real workingDir so that config loading, scoped
|
| 1267 |
+
// writes, and provider resolution behave identically to project
|
| 1268 |
+
// workspaces.
|
| 1269 |
+
func GlobalWorkspaceDir() string {
|
| 1270 |
+
return filepath.Dir(GlobalConfigData())
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
func assignIfNil[T any](ptr **T, val T) {
|
| 1274 |
+
if *ptr == nil {
|
| 1275 |
+
*ptr = &val
|
| 1276 |
+
}
|
| 1277 |
+
}
|
| 1278 |
+
|
| 1279 |
+
func isInsideWorktree() bool {
|
| 1280 |
+
bts, err := exec.CommandContext(
|
| 1281 |
+
context.Background(),
|
| 1282 |
+
"git", "rev-parse",
|
| 1283 |
+
"--is-inside-work-tree",
|
| 1284 |
+
).CombinedOutput()
|
| 1285 |
+
return err == nil && strings.TrimSpace(string(bts)) == "true"
|
| 1286 |
+
}
|
| 1287 |
+
|
| 1288 |
+
// worktreeRoot returns the absolute path of the git working tree root for
|
| 1289 |
+
// dir, or the empty string if dir is not inside a working tree (bare
|
| 1290 |
+
// repositories, missing git binary, plain directories, or any other
|
| 1291 |
+
// failure mode). Linked worktrees and submodules each report their own
|
| 1292 |
+
// top-level, which is what callers want when bounding lookups.
|
| 1293 |
+
// worktreeRootCache memoizes the git worktree root per directory. The root
|
| 1294 |
+
// is stable for the life of the process, so we avoid re-shelling out to
|
| 1295 |
+
// "git rev-parse" on every config reload. Keyed by the requested dir; the
|
| 1296 |
+
// value is the resolved root ("" when dir is not in a git worktree).
|
| 1297 |
+
var worktreeRootCache sync.Map // map[string]string
|
| 1298 |
+
|
| 1299 |
+
func worktreeRoot(dir string) string {
|
| 1300 |
+
if cached, ok := worktreeRootCache.Load(dir); ok {
|
| 1301 |
+
return cached.(string)
|
| 1302 |
+
}
|
| 1303 |
+
root := computeWorktreeRoot(dir)
|
| 1304 |
+
worktreeRootCache.Store(dir, root)
|
| 1305 |
+
return root
|
| 1306 |
+
}
|
| 1307 |
+
|
| 1308 |
+
func computeWorktreeRoot(dir string) string {
|
| 1309 |
+
cmd := exec.CommandContext(
|
| 1310 |
+
context.Background(),
|
| 1311 |
+
"git", "rev-parse", "--show-toplevel",
|
| 1312 |
+
)
|
| 1313 |
+
cmd.Dir = dir
|
| 1314 |
+
out, err := cmd.Output()
|
| 1315 |
+
if err != nil {
|
| 1316 |
+
return ""
|
| 1317 |
+
}
|
| 1318 |
+
root := strings.TrimSpace(string(out))
|
| 1319 |
+
if root == "" {
|
| 1320 |
+
return ""
|
| 1321 |
+
}
|
| 1322 |
+
abs, err := filepath.Abs(root)
|
| 1323 |
+
if err != nil {
|
| 1324 |
+
return ""
|
| 1325 |
+
}
|
| 1326 |
+
return abs
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
// projectBoundary returns the directory at which an upward configuration
|
| 1330 |
+
// search rooted at dir should stop. It is the git working tree root when
|
| 1331 |
+
// one can be detected, otherwise dir itself. Returning dir as a
|
| 1332 |
+
// fallback keeps Crush from silently adopting state files placed above
|
| 1333 |
+
// the current project.
|
| 1334 |
+
func projectBoundary(dir string) string {
|
| 1335 |
+
if root := worktreeRoot(dir); root != "" {
|
| 1336 |
+
return root
|
| 1337 |
+
}
|
| 1338 |
+
abs, err := filepath.Abs(dir)
|
| 1339 |
+
if err != nil {
|
| 1340 |
+
return dir
|
| 1341 |
+
}
|
| 1342 |
+
return abs
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
// GlobalSkillsDirs returns the default directories for Agent Skills.
|
| 1346 |
+
// Skills in these directories are auto-discovered and their files can be read
|
| 1347 |
+
// without permission prompts.
|
| 1348 |
+
func GlobalSkillsDirs() []string {
|
| 1349 |
+
if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
|
| 1350 |
+
return []string{crushSkills}
|
| 1351 |
+
}
|
| 1352 |
+
|
| 1353 |
+
paths := []string{
|
| 1354 |
+
filepath.Join(home.Config(), appName, "skills"),
|
| 1355 |
+
filepath.Join(home.Config(), "agents", "skills"),
|
| 1356 |
+
// Per the Agent Skills spec, scan ~/.agents/skills
|
| 1357 |
+
filepath.Join(home.Dir(), ".agents", "skills"),
|
| 1358 |
+
filepath.Join(home.Dir(), ".claude", "skills"),
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
// On Windows, also load from app data on top of `$HOME/.config/crush`.
|
| 1362 |
+
// This is here mostly for backwards compatibility.
|
| 1363 |
+
if runtime.GOOS == "windows" {
|
| 1364 |
+
appData := cmp.Or(
|
| 1365 |
+
os.Getenv("LOCALAPPDATA"),
|
| 1366 |
+
filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
|
| 1367 |
+
)
|
| 1368 |
+
paths = append(
|
| 1369 |
+
paths,
|
| 1370 |
+
filepath.Join(appData, appName, "skills"),
|
| 1371 |
+
filepath.Join(appData, "agents", "skills"),
|
| 1372 |
+
)
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
return paths
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
// projectSkillSubdirs lists the conventional subdirectories where
|
| 1379 |
+
// project-level skills are discovered. Shared across working-dir and
|
| 1380 |
+
// git-root lookups to prevent drift when a new convention is added.
|
| 1381 |
+
var projectSkillSubdirs = []string{
|
| 1382 |
+
".agents/skills",
|
| 1383 |
+
".crush/skills",
|
| 1384 |
+
".claude/skills",
|
| 1385 |
+
".cursor/skills",
|
| 1386 |
+
}
|
| 1387 |
+
|
| 1388 |
+
// ProjectSkillsDir returns the default project directories for which Crush
|
| 1389 |
+
// will look for skills. In addition to the working directory, it also
|
| 1390 |
+
// checks the git working tree root so that monorepo-level skills are
|
| 1391 |
+
// discovered when the user is inside a subdirectory.
|
| 1392 |
+
// Working-directory paths come first so local skills take precedence
|
| 1393 |
+
// over monorepo-level ones.
|
| 1394 |
+
func ProjectSkillsDir(workingDir string) []string {
|
| 1395 |
+
dirs := make([]string, 0, len(projectSkillSubdirs)*2)
|
| 1396 |
+
for _, sub := range projectSkillSubdirs {
|
| 1397 |
+
dirs = append(dirs, filepath.Join(workingDir, sub))
|
| 1398 |
+
}
|
| 1399 |
+
|
| 1400 |
+
// When the working directory is inside a git repository, also look at
|
| 1401 |
+
// the repository root so monorepo-level .agents/skills are found.
|
| 1402 |
+
if root := worktreeRoot(workingDir); root != "" && root != workingDir {
|
| 1403 |
+
for _, sub := range projectSkillSubdirs {
|
| 1404 |
+
dirs = append(dirs, filepath.Join(root, sub))
|
| 1405 |
+
}
|
| 1406 |
+
}
|
| 1407 |
+
|
| 1408 |
+
return dirs
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
|
| 1412 |
+
|
| 1413 |
+
// normalizeHookEvent maps user-provided event names to their canonical
|
| 1414 |
+
// form. Matching is case-insensitive and accepts snake_case variants
|
| 1415 |
+
// (e.g. "pre_tool_use" → "PreToolUse").
|
| 1416 |
+
func normalizeHookEvent(name string) string {
|
| 1417 |
+
switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
|
| 1418 |
+
case "pretooluse":
|
| 1419 |
+
return "PreToolUse"
|
| 1420 |
+
default:
|
| 1421 |
+
return name
|
| 1422 |
+
}
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
+
// ValidateHooks normalizes event names and checks that every configured
|
| 1426 |
+
// hook has a command and a syntactically valid matcher regex. Matcher
|
| 1427 |
+
// compilation used for matching is owned by hooks.Runner; this function
|
| 1428 |
+
// only validates up front so the user sees config errors at load time
|
| 1429 |
+
// rather than on the first tool call.
|
| 1430 |
+
func (c *Config) ValidateHooks() error {
|
| 1431 |
+
// Normalize event name keys.
|
| 1432 |
+
for event, eventHooks := range c.Hooks {
|
| 1433 |
+
canonical := normalizeHookEvent(event)
|
| 1434 |
+
if canonical != event {
|
| 1435 |
+
c.Hooks[canonical] = append(c.Hooks[canonical], eventHooks...)
|
| 1436 |
+
delete(c.Hooks, event)
|
| 1437 |
+
}
|
| 1438 |
+
}
|
| 1439 |
+
|
| 1440 |
+
for event, eventHooks := range c.Hooks {
|
| 1441 |
+
for i, h := range eventHooks {
|
| 1442 |
+
if h.Command == "" {
|
| 1443 |
+
return fmt.Errorf("hook %s[%d]: command is required", event, i)
|
| 1444 |
+
}
|
| 1445 |
+
if h.Matcher == "" {
|
| 1446 |
+
continue
|
| 1447 |
+
}
|
| 1448 |
+
if _, err := regexp.Compile(h.Matcher); err != nil {
|
| 1449 |
+
return fmt.Errorf("hook %s[%d]: invalid matcher regex %q: %w", event, i, h.Matcher, err)
|
| 1450 |
+
}
|
| 1451 |
+
}
|
| 1452 |
+
}
|
| 1453 |
+
return nil
|
| 1454 |
+
}
|
internal/config/load_test.go
ADDED
|
@@ -0,0 +1,2536 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"io"
|
| 6 |
+
"log/slog"
|
| 7 |
+
"net/http"
|
| 8 |
+
"net/http/httptest"
|
| 9 |
+
"os"
|
| 10 |
+
"os/exec"
|
| 11 |
+
"path/filepath"
|
| 12 |
+
"runtime"
|
| 13 |
+
"strings"
|
| 14 |
+
"testing"
|
| 15 |
+
"time"
|
| 16 |
+
|
| 17 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 18 |
+
"github.com/charmbracelet/crush/internal/csync"
|
| 19 |
+
"github.com/charmbracelet/crush/internal/env"
|
| 20 |
+
"github.com/charmbracelet/crush/internal/oauth"
|
| 21 |
+
"github.com/stretchr/testify/assert"
|
| 22 |
+
"github.com/stretchr/testify/require"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
func TestMain(m *testing.M) {
|
| 26 |
+
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
| 27 |
+
|
| 28 |
+
exitVal := m.Run()
|
| 29 |
+
os.Exit(exitVal)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func TestConfig_LoadFromBytes(t *testing.T) {
|
| 33 |
+
data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
|
| 34 |
+
data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
|
| 35 |
+
data3 := []byte(`{"providers": {"openai": {}}}`)
|
| 36 |
+
|
| 37 |
+
loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3})
|
| 38 |
+
|
| 39 |
+
require.NoError(t, err)
|
| 40 |
+
require.NotNil(t, loadedConfig)
|
| 41 |
+
require.Equal(t, 1, loadedConfig.Providers.Len())
|
| 42 |
+
pc, _ := loadedConfig.Providers.Get("openai")
|
| 43 |
+
require.Equal(t, "key2", pc.APIKey)
|
| 44 |
+
require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
func TestLookupConfigs_BoundedByProject(t *testing.T) {
|
| 48 |
+
// Force GlobalConfig and GlobalConfigData to point at locations we
|
| 49 |
+
// control so they can be present in the result without polluting
|
| 50 |
+
// the developer's real config.
|
| 51 |
+
globalDir := t.TempDir()
|
| 52 |
+
dataDir := t.TempDir()
|
| 53 |
+
t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir)
|
| 54 |
+
t.Setenv("CRUSH_GLOBAL_DATA", dataDir)
|
| 55 |
+
|
| 56 |
+
t.Run("does not pick up crush.json above non-git project", func(t *testing.T) {
|
| 57 |
+
parent := t.TempDir()
|
| 58 |
+
|
| 59 |
+
// crush.json above the project must not be adopted.
|
| 60 |
+
require.NoError(t, os.WriteFile(
|
| 61 |
+
filepath.Join(parent, "crush.json"),
|
| 62 |
+
[]byte(`{}`),
|
| 63 |
+
0o644,
|
| 64 |
+
))
|
| 65 |
+
|
| 66 |
+
project := filepath.Join(parent, "project")
|
| 67 |
+
require.NoError(t, os.Mkdir(project, 0o755))
|
| 68 |
+
|
| 69 |
+
got := lookupConfigs(project)
|
| 70 |
+
for _, p := range got {
|
| 71 |
+
require.NotEqual(t, filepath.Join(parent, "crush.json"), p)
|
| 72 |
+
}
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) {
|
| 76 |
+
if _, err := exec.LookPath("git"); err != nil {
|
| 77 |
+
t.Skip("git not available")
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
parent := t.TempDir()
|
| 81 |
+
|
| 82 |
+
require.NoError(t, os.WriteFile(
|
| 83 |
+
filepath.Join(parent, "crush.json"),
|
| 84 |
+
[]byte(`{}`),
|
| 85 |
+
0o644,
|
| 86 |
+
))
|
| 87 |
+
|
| 88 |
+
worktree := filepath.Join(parent, "worktree")
|
| 89 |
+
require.NoError(t, os.Mkdir(worktree, 0o755))
|
| 90 |
+
gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
|
| 91 |
+
gitInit.Dir = worktree
|
| 92 |
+
require.NoError(t, gitInit.Run())
|
| 93 |
+
|
| 94 |
+
got := lookupConfigs(worktree)
|
| 95 |
+
strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json"))
|
| 96 |
+
require.NoError(t, err)
|
| 97 |
+
for _, p := range got {
|
| 98 |
+
pEval, err := filepath.EvalSymlinks(p)
|
| 99 |
+
if err != nil {
|
| 100 |
+
continue
|
| 101 |
+
}
|
| 102 |
+
require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json")
|
| 103 |
+
}
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
t.Run("picks up crush.json inside the project", func(t *testing.T) {
|
| 107 |
+
project := t.TempDir()
|
| 108 |
+
local := filepath.Join(project, "crush.json")
|
| 109 |
+
require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644))
|
| 110 |
+
|
| 111 |
+
got := lookupConfigs(project)
|
| 112 |
+
|
| 113 |
+
localEval, err := filepath.EvalSymlinks(local)
|
| 114 |
+
require.NoError(t, err)
|
| 115 |
+
var foundLocal bool
|
| 116 |
+
for _, p := range got {
|
| 117 |
+
pEval, err := filepath.EvalSymlinks(p)
|
| 118 |
+
if err != nil {
|
| 119 |
+
continue
|
| 120 |
+
}
|
| 121 |
+
if pEval == localEval {
|
| 122 |
+
foundLocal = true
|
| 123 |
+
break
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got)
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
t.Run("global config is always included regardless of boundary", func(t *testing.T) {
|
| 130 |
+
project := t.TempDir()
|
| 131 |
+
|
| 132 |
+
got := lookupConfigs(project)
|
| 133 |
+
// Global config and global data path are always prepended,
|
| 134 |
+
// even when no project file exists.
|
| 135 |
+
require.Contains(t, got, GlobalConfig())
|
| 136 |
+
require.Contains(t, got, GlobalConfigData())
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
t.Run("global shell config (crushrc) is included", func(t *testing.T) {
|
| 140 |
+
project := t.TempDir()
|
| 141 |
+
|
| 142 |
+
got := lookupConfigs(project)
|
| 143 |
+
// A global crushrc is discovered only beside the user config. The data
|
| 144 |
+
// directory is machine-owned state and must never execute a crushrc.
|
| 145 |
+
require.Contains(t, got, shellConfigSibling(GlobalConfig()))
|
| 146 |
+
require.NotContains(t, got, shellConfigSibling(GlobalConfigData()))
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
t.Run("project crushrc and .crushrc are discovered", func(t *testing.T) {
|
| 150 |
+
project := t.TempDir()
|
| 151 |
+
require.NoError(t, os.WriteFile(filepath.Join(project, "crushrc"), []byte(""), 0o644))
|
| 152 |
+
require.NoError(t, os.WriteFile(filepath.Join(project, ".crushrc"), []byte(""), 0o644))
|
| 153 |
+
|
| 154 |
+
got := lookupConfigs(project)
|
| 155 |
+
require.Contains(t, got, filepath.Join(project, "crushrc"))
|
| 156 |
+
require.Contains(t, got, filepath.Join(project, ".crushrc"))
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
t.Run("system config is loaded first", func(t *testing.T) {
|
| 160 |
+
if runtime.GOOS == "windows" {
|
| 161 |
+
t.Skip("system config not supported on Windows")
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
got := lookupConfigs(t.TempDir())
|
| 165 |
+
require.NotEmpty(t, got)
|
| 166 |
+
// The system-wide config must be first so it has the lowest
|
| 167 |
+
// priority when configs are merged.
|
| 168 |
+
require.Equal(t, "/etc/crush/crush.json", got[0])
|
| 169 |
+
})
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
|
| 173 |
+
t.Parallel()
|
| 174 |
+
|
| 175 |
+
t.Run("identifies the offending file", func(t *testing.T) {
|
| 176 |
+
t.Parallel()
|
| 177 |
+
tmpDir := t.TempDir()
|
| 178 |
+
good := filepath.Join(tmpDir, "good.json")
|
| 179 |
+
bad := filepath.Join(tmpDir, "bad.json")
|
| 180 |
+
require.NoError(t, os.WriteFile(good, []byte(`{"providers":{}}`), 0o644))
|
| 181 |
+
require.NoError(t, os.WriteFile(bad, []byte(`{not valid json}`), 0o644))
|
| 182 |
+
|
| 183 |
+
_, _, err := loadFromConfigPaths(context.Background(), []string{good, bad})
|
| 184 |
+
require.Error(t, err)
|
| 185 |
+
require.Contains(t, err.Error(), "invalid JSON in config file")
|
| 186 |
+
require.Contains(t, err.Error(), "bad.json")
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
t.Run("skips missing and empty files", func(t *testing.T) {
|
| 190 |
+
t.Parallel()
|
| 191 |
+
tmpDir := t.TempDir()
|
| 192 |
+
empty := filepath.Join(tmpDir, "empty.json")
|
| 193 |
+
require.NoError(t, os.WriteFile(empty, []byte(""), 0o644))
|
| 194 |
+
|
| 195 |
+
cfg, _, err := loadFromConfigPaths(context.Background(), []string{
|
| 196 |
+
filepath.Join(tmpDir, "nonexistent.json"),
|
| 197 |
+
empty,
|
| 198 |
+
})
|
| 199 |
+
require.NoError(t, err)
|
| 200 |
+
require.NotNil(t, cfg)
|
| 201 |
+
})
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// TestLoadFromConfigPaths_ConflictWarningNamesKeys verifies that when a JSON
|
| 205 |
+
// config and a crushrc coexist in the same directory, the merge warning names
|
| 206 |
+
// the overlapping top-level keys so incremental migrations can spot stale
|
| 207 |
+
// duplicates.
|
| 208 |
+
func TestLoadFromConfigPaths_ConflictWarningNamesKeys(t *testing.T) {
|
| 209 |
+
capture := func(t *testing.T) *strings.Builder {
|
| 210 |
+
t.Helper()
|
| 211 |
+
var buf strings.Builder
|
| 212 |
+
prev := slog.Default()
|
| 213 |
+
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
| 214 |
+
t.Cleanup(func() { slog.SetDefault(prev) })
|
| 215 |
+
return &buf
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
t.Run("names overlapping keys", func(t *testing.T) {
|
| 219 |
+
buf := capture(t)
|
| 220 |
+
tmpDir := t.TempDir()
|
| 221 |
+
jsonPath := filepath.Join(tmpDir, "crush.json")
|
| 222 |
+
rcPath := filepath.Join(tmpDir, "crushrc")
|
| 223 |
+
require.NoError(t, os.WriteFile(jsonPath, []byte(`{"options":{"debug":true},"providers":{}}`), 0o644))
|
| 224 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("option debug true\n"), 0o644))
|
| 225 |
+
|
| 226 |
+
_, _, err := loadFromConfigPaths(context.Background(), []string{jsonPath, rcPath})
|
| 227 |
+
require.NoError(t, err)
|
| 228 |
+
require.Contains(t, buf.String(), "crushrc taking precedence")
|
| 229 |
+
require.Contains(t, buf.String(), `"conflicting_keys":"options"`)
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
t.Run("no warning when nothing overlaps", func(t *testing.T) {
|
| 233 |
+
buf := capture(t)
|
| 234 |
+
tmpDir := t.TempDir()
|
| 235 |
+
jsonPath := filepath.Join(tmpDir, "crush.json")
|
| 236 |
+
rcPath := filepath.Join(tmpDir, "crushrc")
|
| 237 |
+
require.NoError(t, os.WriteFile(jsonPath, []byte(`{"providers":{}}`), 0o644))
|
| 238 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("option debug true\n"), 0o644))
|
| 239 |
+
|
| 240 |
+
_, _, err := loadFromConfigPaths(context.Background(), []string{jsonPath, rcPath})
|
| 241 |
+
require.NoError(t, err)
|
| 242 |
+
require.NotContains(t, buf.String(), "crushrc taking precedence",
|
| 243 |
+
"disjoint coexistence should not warn")
|
| 244 |
+
})
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// testStore wraps a Config in a minimal ConfigStore for testing.
|
| 248 |
+
func testStore(cfg *Config) *ConfigStore {
|
| 249 |
+
return &ConfigStore{config: cfg}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
func TestConfig_setDefaults(t *testing.T) {
|
| 253 |
+
t.Run("sets default data directory", func(t *testing.T) {
|
| 254 |
+
cfg := &Config{}
|
| 255 |
+
workingDir := t.TempDir()
|
| 256 |
+
|
| 257 |
+
cfg.setDefaults(workingDir, "")
|
| 258 |
+
|
| 259 |
+
require.NotNil(t, cfg.Options)
|
| 260 |
+
require.NotNil(t, cfg.Options.TUI)
|
| 261 |
+
require.NotNil(t, cfg.Options.ContextPaths)
|
| 262 |
+
require.NotNil(t, cfg.Providers)
|
| 263 |
+
require.NotNil(t, cfg.Models)
|
| 264 |
+
require.NotNil(t, cfg.LSP)
|
| 265 |
+
require.NotNil(t, cfg.MCP)
|
| 266 |
+
require.Equal(t, filepath.Join(workingDir, ".crush"), cfg.Options.DataDirectory)
|
| 267 |
+
require.Equal(t, "AGENTS.md", cfg.Options.InitializeAs)
|
| 268 |
+
// DiffMode is deliberately left empty: the permissions dialog treats
|
| 269 |
+
// the zero value as "pick split or unified based on terminal width".
|
| 270 |
+
require.Empty(t, cfg.Options.TUI.DiffMode)
|
| 271 |
+
require.Equal(t, ScrollbarDefault, cfg.Options.TUI.Scrollbar)
|
| 272 |
+
require.Equal(t, ExitBannerDefault, cfg.Options.TUI.ExitBanner)
|
| 273 |
+
for _, path := range defaultContextPaths {
|
| 274 |
+
require.Contains(t, cfg.Options.ContextPaths, path)
|
| 275 |
+
}
|
| 276 |
+
})
|
| 277 |
+
|
| 278 |
+
t.Run("sets TUI defaults only when unset", func(t *testing.T) {
|
| 279 |
+
cfg := &Config{}
|
| 280 |
+
workingDir := t.TempDir()
|
| 281 |
+
|
| 282 |
+
cfg.setDefaults(workingDir, "")
|
| 283 |
+
|
| 284 |
+
require.Empty(t, cfg.Options.TUI.DiffMode)
|
| 285 |
+
require.Equal(t, ScrollbarDefault, cfg.Options.TUI.Scrollbar)
|
| 286 |
+
require.Equal(t, ExitBannerDefault, cfg.Options.TUI.ExitBanner)
|
| 287 |
+
|
| 288 |
+
cfg.Options.TUI.DiffMode = DiffModeSplit
|
| 289 |
+
cfg.Options.TUI.Scrollbar = ScrollbarNever
|
| 290 |
+
cfg.Options.TUI.ExitBanner = ExitBannerCompact
|
| 291 |
+
cfg.setDefaults(workingDir, "")
|
| 292 |
+
|
| 293 |
+
require.Equal(t, DiffModeSplit, cfg.Options.TUI.DiffMode)
|
| 294 |
+
require.Equal(t, ScrollbarNever, cfg.Options.TUI.Scrollbar)
|
| 295 |
+
require.Equal(t, ExitBannerCompact, cfg.Options.TUI.ExitBanner)
|
| 296 |
+
})
|
| 297 |
+
|
| 298 |
+
t.Run("prunes orphaned OAuth token MCP entries but keeps real ones", func(t *testing.T) {
|
| 299 |
+
cfg := &Config{
|
| 300 |
+
MCP: map[string]MCPConfig{
|
| 301 |
+
"orphan": {OAuthToken: &oauth.Token{AccessToken: "stale"}},
|
| 302 |
+
"real-http": {Type: MCPHttp, URL: "https://example.com/mcp", OAuthToken: &oauth.Token{AccessToken: "live"}},
|
| 303 |
+
"real-stdio": {Type: MCPStdio, Command: "npx"},
|
| 304 |
+
"malformed": {Command: "npx"}, // missing type but has a command: surface the error, don't prune
|
| 305 |
+
},
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
cfg.setDefaults(t.TempDir(), "")
|
| 309 |
+
|
| 310 |
+
require.NotContains(t, cfg.MCP, "orphan", "orphaned token entry should be pruned")
|
| 311 |
+
require.Contains(t, cfg.MCP, "real-http")
|
| 312 |
+
require.Contains(t, cfg.MCP, "real-stdio")
|
| 313 |
+
require.Contains(t, cfg.MCP, "malformed", "malformed entry should survive so its error surfaces")
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
t.Run("resolves relative configured data directory from working directory", func(t *testing.T) {
|
| 317 |
+
cfg := &Config{Options: &Options{DataDirectory: "."}}
|
| 318 |
+
workingDir := filepath.Join(t.TempDir(), "worktree")
|
| 319 |
+
|
| 320 |
+
cfg.setDefaults(workingDir, "")
|
| 321 |
+
|
| 322 |
+
require.Equal(t, workingDir, cfg.Options.DataDirectory)
|
| 323 |
+
})
|
| 324 |
+
|
| 325 |
+
t.Run("resolves relative flag data directory from working directory", func(t *testing.T) {
|
| 326 |
+
cfg := &Config{}
|
| 327 |
+
workingDir := filepath.Join(t.TempDir(), "worktree")
|
| 328 |
+
|
| 329 |
+
cfg.setDefaults(workingDir, "./state")
|
| 330 |
+
|
| 331 |
+
require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
t.Run("preserves absolute configured data directory", func(t *testing.T) {
|
| 335 |
+
// Use a platform-appropriate absolute path so the test runs
|
| 336 |
+
// the same way on POSIX and Windows.
|
| 337 |
+
absDir := filepath.Join(t.TempDir(), "data")
|
| 338 |
+
cfg := &Config{Options: &Options{DataDirectory: absDir}}
|
| 339 |
+
|
| 340 |
+
cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "")
|
| 341 |
+
|
| 342 |
+
require.Equal(t, absDir, cfg.Options.DataDirectory)
|
| 343 |
+
})
|
| 344 |
+
|
| 345 |
+
t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) {
|
| 346 |
+
// Simulate the load and reload paths: defaults are applied
|
| 347 |
+
// twice with the data directory potentially carried through
|
| 348 |
+
// from an earlier merge as a relative string.
|
| 349 |
+
workingDir := filepath.Join(t.TempDir(), "worktree")
|
| 350 |
+
cfg := &Config{}
|
| 351 |
+
cfg.setDefaults(workingDir, "")
|
| 352 |
+
|
| 353 |
+
// Workspace JSON sets data_directory to a relative value; the
|
| 354 |
+
// merge replaces the struct, then setDefaults runs again.
|
| 355 |
+
cfg.Options.DataDirectory = "./state"
|
| 356 |
+
cfg.setDefaults(workingDir, "")
|
| 357 |
+
|
| 358 |
+
require.True(t, filepath.IsAbs(cfg.Options.DataDirectory),
|
| 359 |
+
"data directory must remain absolute after re-merge, got %q",
|
| 360 |
+
cfg.Options.DataDirectory)
|
| 361 |
+
require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
|
| 362 |
+
})
|
| 363 |
+
|
| 364 |
+
t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
|
| 365 |
+
parent := t.TempDir()
|
| 366 |
+
|
| 367 |
+
// .crush in the parent: it should not be reused by the child
|
| 368 |
+
// because there is no git context joining them.
|
| 369 |
+
require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
|
| 370 |
+
|
| 371 |
+
child := filepath.Join(parent, "child")
|
| 372 |
+
require.NoError(t, os.Mkdir(child, 0o755))
|
| 373 |
+
|
| 374 |
+
cfg := &Config{}
|
| 375 |
+
cfg.setDefaults(child, "")
|
| 376 |
+
|
| 377 |
+
require.Equal(
|
| 378 |
+
t,
|
| 379 |
+
filepath.Clean(filepath.Join(child, defaultDataDirectory)),
|
| 380 |
+
filepath.Clean(cfg.Options.DataDirectory),
|
| 381 |
+
)
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
+
t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) {
|
| 385 |
+
if _, err := exec.LookPath("git"); err != nil {
|
| 386 |
+
t.Skip("git not available")
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
parent := t.TempDir()
|
| 390 |
+
|
| 391 |
+
// Stray .crush above the worktree root.
|
| 392 |
+
require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
|
| 393 |
+
|
| 394 |
+
worktree := filepath.Join(parent, "worktree")
|
| 395 |
+
require.NoError(t, os.Mkdir(worktree, 0o755))
|
| 396 |
+
|
| 397 |
+
sub := filepath.Join(worktree, "pkg")
|
| 398 |
+
require.NoError(t, os.Mkdir(sub, 0o755))
|
| 399 |
+
|
| 400 |
+
// Make worktree a real git repo so the boundary detection
|
| 401 |
+
// resolves to it, mirroring what happens with linked worktrees
|
| 402 |
+
// in real usage.
|
| 403 |
+
gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
|
| 404 |
+
gitInit.Dir = worktree
|
| 405 |
+
require.NoError(t, gitInit.Run())
|
| 406 |
+
|
| 407 |
+
cfg := &Config{}
|
| 408 |
+
cfg.setDefaults(sub, "")
|
| 409 |
+
|
| 410 |
+
// Resolve symlinks because TempDir on macOS sits under /var
|
| 411 |
+
// which is a symlink to /private/var. The data directory has
|
| 412 |
+
// not been created yet, so resolve its parent and join.
|
| 413 |
+
gotDir, gotName := filepath.Split(cfg.Options.DataDirectory)
|
| 414 |
+
gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir))
|
| 415 |
+
require.NoError(t, err)
|
| 416 |
+
gotEval := filepath.Join(gotEvalDir, gotName)
|
| 417 |
+
|
| 418 |
+
strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory))
|
| 419 |
+
require.NoError(t, err)
|
| 420 |
+
require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush")
|
| 421 |
+
|
| 422 |
+
subEval, err := filepath.EvalSymlinks(sub)
|
| 423 |
+
require.NoError(t, err)
|
| 424 |
+
require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
|
| 425 |
+
})
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
func TestConfig_configureProviders(t *testing.T) {
|
| 429 |
+
knownProviders := []catwalk.Provider{
|
| 430 |
+
{
|
| 431 |
+
ID: "openai",
|
| 432 |
+
APIKey: "$OPENAI_API_KEY",
|
| 433 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 434 |
+
Models: []catwalk.Model{{
|
| 435 |
+
ID: "test-model",
|
| 436 |
+
}},
|
| 437 |
+
},
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
cfg := &Config{}
|
| 441 |
+
cfg.setDefaults("/tmp", "")
|
| 442 |
+
env := env.NewFromMap(map[string]string{
|
| 443 |
+
"OPENAI_API_KEY": "test-key",
|
| 444 |
+
})
|
| 445 |
+
resolver := NewShellVariableResolver(env)
|
| 446 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 447 |
+
require.NoError(t, err)
|
| 448 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 449 |
+
|
| 450 |
+
// We want to make sure that we keep the configured API key as a placeholder
|
| 451 |
+
pc, _ := cfg.Providers.Get("openai")
|
| 452 |
+
require.Equal(t, "$OPENAI_API_KEY", pc.APIKey)
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
func TestConfig_configureProvidersWithOverride(t *testing.T) {
|
| 456 |
+
knownProviders := []catwalk.Provider{
|
| 457 |
+
{
|
| 458 |
+
ID: "openai",
|
| 459 |
+
APIKey: "$OPENAI_API_KEY",
|
| 460 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 461 |
+
Models: []catwalk.Model{{
|
| 462 |
+
ID: "test-model",
|
| 463 |
+
}},
|
| 464 |
+
},
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
cfg := &Config{
|
| 468 |
+
Providers: csync.NewMap[string, ProviderConfig](),
|
| 469 |
+
}
|
| 470 |
+
cfg.Providers.Set("openai", ProviderConfig{
|
| 471 |
+
APIKey: "xyz",
|
| 472 |
+
BaseURL: "https://api.openai.com/v2",
|
| 473 |
+
Models: []catwalk.Model{
|
| 474 |
+
{
|
| 475 |
+
ID: "test-model",
|
| 476 |
+
Name: "Updated",
|
| 477 |
+
},
|
| 478 |
+
{
|
| 479 |
+
ID: "another-model",
|
| 480 |
+
},
|
| 481 |
+
},
|
| 482 |
+
})
|
| 483 |
+
cfg.setDefaults("/tmp", "")
|
| 484 |
+
|
| 485 |
+
env := env.NewFromMap(map[string]string{
|
| 486 |
+
"OPENAI_API_KEY": "test-key",
|
| 487 |
+
})
|
| 488 |
+
resolver := NewShellVariableResolver(env)
|
| 489 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 490 |
+
require.NoError(t, err)
|
| 491 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 492 |
+
|
| 493 |
+
// We want to make sure that we keep the configured API key as a placeholder
|
| 494 |
+
pc, _ := cfg.Providers.Get("openai")
|
| 495 |
+
require.Equal(t, "xyz", pc.APIKey)
|
| 496 |
+
require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
|
| 497 |
+
require.Len(t, pc.Models, 2)
|
| 498 |
+
require.Equal(t, "Updated", pc.Models[0].Name)
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
|
| 502 |
+
knownProviders := []catwalk.Provider{
|
| 503 |
+
{
|
| 504 |
+
ID: "openai",
|
| 505 |
+
APIKey: "$OPENAI_API_KEY",
|
| 506 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 507 |
+
Models: []catwalk.Model{{
|
| 508 |
+
ID: "test-model",
|
| 509 |
+
}},
|
| 510 |
+
},
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
cfg := &Config{
|
| 514 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 515 |
+
"custom": {
|
| 516 |
+
APIKey: "xyz",
|
| 517 |
+
BaseURL: "https://api.someendpoint.com/v2",
|
| 518 |
+
Models: []catwalk.Model{
|
| 519 |
+
{
|
| 520 |
+
ID: "test-model",
|
| 521 |
+
},
|
| 522 |
+
},
|
| 523 |
+
},
|
| 524 |
+
}),
|
| 525 |
+
}
|
| 526 |
+
cfg.setDefaults("/tmp", "")
|
| 527 |
+
env := env.NewFromMap(map[string]string{
|
| 528 |
+
"OPENAI_API_KEY": "test-key",
|
| 529 |
+
})
|
| 530 |
+
resolver := NewShellVariableResolver(env)
|
| 531 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 532 |
+
require.NoError(t, err)
|
| 533 |
+
// Should be to because of the env variable
|
| 534 |
+
require.Equal(t, cfg.Providers.Len(), 2)
|
| 535 |
+
|
| 536 |
+
// We want to make sure that we keep the configured API key as a placeholder
|
| 537 |
+
pc, _ := cfg.Providers.Get("custom")
|
| 538 |
+
require.Equal(t, "xyz", pc.APIKey)
|
| 539 |
+
// Make sure we set the ID correctly
|
| 540 |
+
require.Equal(t, "custom", pc.ID)
|
| 541 |
+
require.Equal(t, "https://api.someendpoint.com/v2", pc.BaseURL)
|
| 542 |
+
require.Len(t, pc.Models, 1)
|
| 543 |
+
|
| 544 |
+
_, ok := cfg.Providers.Get("openai")
|
| 545 |
+
require.True(t, ok, "OpenAI provider should still be present")
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
|
| 549 |
+
knownProviders := []catwalk.Provider{
|
| 550 |
+
{
|
| 551 |
+
ID: catwalk.InferenceProviderBedrock,
|
| 552 |
+
APIKey: "",
|
| 553 |
+
APIEndpoint: "",
|
| 554 |
+
Models: []catwalk.Model{{
|
| 555 |
+
ID: "anthropic.claude-sonnet-4-20250514-v1:0",
|
| 556 |
+
}},
|
| 557 |
+
},
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
cfg := &Config{}
|
| 561 |
+
cfg.setDefaults("/tmp", "")
|
| 562 |
+
env := env.NewFromMap(map[string]string{
|
| 563 |
+
"AWS_ACCESS_KEY_ID": "test-key-id",
|
| 564 |
+
"AWS_SECRET_ACCESS_KEY": "test-secret-key",
|
| 565 |
+
})
|
| 566 |
+
resolver := NewShellVariableResolver(env)
|
| 567 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 568 |
+
require.NoError(t, err)
|
| 569 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 570 |
+
|
| 571 |
+
bedrockProvider, ok := cfg.Providers.Get("bedrock")
|
| 572 |
+
require.True(t, ok, "Bedrock provider should be present")
|
| 573 |
+
require.Len(t, bedrockProvider.Models, 1)
|
| 574 |
+
require.Equal(t, "anthropic.claude-sonnet-4-20250514-v1:0", bedrockProvider.Models[0].ID)
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
|
| 578 |
+
knownProviders := []catwalk.Provider{
|
| 579 |
+
{
|
| 580 |
+
ID: catwalk.InferenceProviderBedrock,
|
| 581 |
+
APIKey: "",
|
| 582 |
+
APIEndpoint: "",
|
| 583 |
+
Models: []catwalk.Model{{
|
| 584 |
+
ID: "anthropic.claude-sonnet-4-20250514-v1:0",
|
| 585 |
+
}},
|
| 586 |
+
},
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
cfg := &Config{}
|
| 590 |
+
cfg.setDefaults("/tmp", "")
|
| 591 |
+
env := env.NewFromMap(map[string]string{})
|
| 592 |
+
resolver := NewShellVariableResolver(env)
|
| 593 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 594 |
+
require.NoError(t, err)
|
| 595 |
+
// Provider should not be configured without credentials
|
| 596 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
|
| 600 |
+
knownProviders := []catwalk.Provider{
|
| 601 |
+
{
|
| 602 |
+
ID: catwalk.InferenceProviderVertexAI,
|
| 603 |
+
APIKey: "",
|
| 604 |
+
APIEndpoint: "",
|
| 605 |
+
Models: []catwalk.Model{{
|
| 606 |
+
ID: "gemini-pro",
|
| 607 |
+
}},
|
| 608 |
+
},
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
cfg := &Config{}
|
| 612 |
+
cfg.setDefaults("/tmp", "")
|
| 613 |
+
env := env.NewFromMap(map[string]string{
|
| 614 |
+
"VERTEXAI_PROJECT": "test-project",
|
| 615 |
+
"VERTEXAI_LOCATION": "us-central1",
|
| 616 |
+
})
|
| 617 |
+
resolver := NewShellVariableResolver(env)
|
| 618 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 619 |
+
require.NoError(t, err)
|
| 620 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 621 |
+
|
| 622 |
+
vertexProvider, ok := cfg.Providers.Get("vertexai")
|
| 623 |
+
require.True(t, ok, "VertexAI provider should be present")
|
| 624 |
+
require.Len(t, vertexProvider.Models, 1)
|
| 625 |
+
require.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
|
| 626 |
+
require.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
|
| 627 |
+
require.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
|
| 631 |
+
knownProviders := []catwalk.Provider{
|
| 632 |
+
{
|
| 633 |
+
ID: catwalk.InferenceProviderVertexAI,
|
| 634 |
+
APIKey: "",
|
| 635 |
+
APIEndpoint: "",
|
| 636 |
+
Models: []catwalk.Model{{
|
| 637 |
+
ID: "gemini-pro",
|
| 638 |
+
}},
|
| 639 |
+
},
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
cfg := &Config{}
|
| 643 |
+
cfg.setDefaults("/tmp", "")
|
| 644 |
+
env := env.NewFromMap(map[string]string{
|
| 645 |
+
"GOOGLE_GENAI_USE_VERTEXAI": "false",
|
| 646 |
+
"GOOGLE_CLOUD_PROJECT": "test-project",
|
| 647 |
+
"GOOGLE_CLOUD_LOCATION": "us-central1",
|
| 648 |
+
})
|
| 649 |
+
resolver := NewShellVariableResolver(env)
|
| 650 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 651 |
+
require.NoError(t, err)
|
| 652 |
+
// Provider should not be configured without proper credentials
|
| 653 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
|
| 657 |
+
knownProviders := []catwalk.Provider{
|
| 658 |
+
{
|
| 659 |
+
ID: catwalk.InferenceProviderVertexAI,
|
| 660 |
+
APIKey: "",
|
| 661 |
+
APIEndpoint: "",
|
| 662 |
+
Models: []catwalk.Model{{
|
| 663 |
+
ID: "gemini-pro",
|
| 664 |
+
}},
|
| 665 |
+
},
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
cfg := &Config{}
|
| 669 |
+
cfg.setDefaults("/tmp", "")
|
| 670 |
+
env := env.NewFromMap(map[string]string{
|
| 671 |
+
"GOOGLE_GENAI_USE_VERTEXAI": "true",
|
| 672 |
+
"GOOGLE_CLOUD_LOCATION": "us-central1",
|
| 673 |
+
})
|
| 674 |
+
resolver := NewShellVariableResolver(env)
|
| 675 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 676 |
+
require.NoError(t, err)
|
| 677 |
+
// Provider should not be configured without project
|
| 678 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
func TestConfig_configureProvidersSetProviderID(t *testing.T) {
|
| 682 |
+
knownProviders := []catwalk.Provider{
|
| 683 |
+
{
|
| 684 |
+
ID: "openai",
|
| 685 |
+
APIKey: "$OPENAI_API_KEY",
|
| 686 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 687 |
+
Models: []catwalk.Model{{
|
| 688 |
+
ID: "test-model",
|
| 689 |
+
}},
|
| 690 |
+
},
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
cfg := &Config{}
|
| 694 |
+
cfg.setDefaults("/tmp", "")
|
| 695 |
+
env := env.NewFromMap(map[string]string{
|
| 696 |
+
"OPENAI_API_KEY": "test-key",
|
| 697 |
+
})
|
| 698 |
+
resolver := NewShellVariableResolver(env)
|
| 699 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 700 |
+
require.NoError(t, err)
|
| 701 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 702 |
+
|
| 703 |
+
// Provider ID should be set
|
| 704 |
+
pc, _ := cfg.Providers.Get("openai")
|
| 705 |
+
require.Equal(t, "openai", pc.ID)
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
func TestConfig_EnabledProviders(t *testing.T) {
|
| 709 |
+
t.Run("all providers enabled", func(t *testing.T) {
|
| 710 |
+
cfg := &Config{
|
| 711 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 712 |
+
"openai": {
|
| 713 |
+
ID: "openai",
|
| 714 |
+
APIKey: "key1",
|
| 715 |
+
Disable: false,
|
| 716 |
+
},
|
| 717 |
+
"anthropic": {
|
| 718 |
+
ID: "anthropic",
|
| 719 |
+
APIKey: "key2",
|
| 720 |
+
Disable: false,
|
| 721 |
+
},
|
| 722 |
+
}),
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
enabled := cfg.EnabledProviders()
|
| 726 |
+
require.Len(t, enabled, 2)
|
| 727 |
+
})
|
| 728 |
+
|
| 729 |
+
t.Run("some providers disabled", func(t *testing.T) {
|
| 730 |
+
cfg := &Config{
|
| 731 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 732 |
+
"openai": {
|
| 733 |
+
ID: "openai",
|
| 734 |
+
APIKey: "key1",
|
| 735 |
+
Disable: false,
|
| 736 |
+
},
|
| 737 |
+
"anthropic": {
|
| 738 |
+
ID: "anthropic",
|
| 739 |
+
APIKey: "key2",
|
| 740 |
+
Disable: true,
|
| 741 |
+
},
|
| 742 |
+
}),
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
enabled := cfg.EnabledProviders()
|
| 746 |
+
require.Len(t, enabled, 1)
|
| 747 |
+
require.Equal(t, "openai", enabled[0].ID)
|
| 748 |
+
})
|
| 749 |
+
|
| 750 |
+
t.Run("empty providers map", func(t *testing.T) {
|
| 751 |
+
cfg := &Config{
|
| 752 |
+
Providers: csync.NewMap[string, ProviderConfig](),
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
enabled := cfg.EnabledProviders()
|
| 756 |
+
require.Len(t, enabled, 0)
|
| 757 |
+
})
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
func TestConfig_IsConfigured(t *testing.T) {
|
| 761 |
+
t.Run("returns true when at least one provider is enabled", func(t *testing.T) {
|
| 762 |
+
cfg := &Config{
|
| 763 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 764 |
+
"openai": {
|
| 765 |
+
ID: "openai",
|
| 766 |
+
APIKey: "key1",
|
| 767 |
+
Disable: false,
|
| 768 |
+
},
|
| 769 |
+
}),
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
require.True(t, cfg.IsConfigured())
|
| 773 |
+
})
|
| 774 |
+
|
| 775 |
+
t.Run("returns false when no providers are configured", func(t *testing.T) {
|
| 776 |
+
cfg := &Config{
|
| 777 |
+
Providers: csync.NewMap[string, ProviderConfig](),
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
require.False(t, cfg.IsConfigured())
|
| 781 |
+
})
|
| 782 |
+
|
| 783 |
+
t.Run("returns false when all providers are disabled", func(t *testing.T) {
|
| 784 |
+
cfg := &Config{
|
| 785 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 786 |
+
"openai": {
|
| 787 |
+
ID: "openai",
|
| 788 |
+
APIKey: "key1",
|
| 789 |
+
Disable: true,
|
| 790 |
+
},
|
| 791 |
+
"anthropic": {
|
| 792 |
+
ID: "anthropic",
|
| 793 |
+
APIKey: "key2",
|
| 794 |
+
Disable: true,
|
| 795 |
+
},
|
| 796 |
+
}),
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
require.False(t, cfg.IsConfigured())
|
| 800 |
+
})
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
|
| 804 |
+
cfg := &Config{
|
| 805 |
+
Options: &Options{
|
| 806 |
+
DisabledTools: []string{},
|
| 807 |
+
},
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
cfg.SetupAgents()
|
| 811 |
+
coderAgent, ok := cfg.Agents[AgentCoder]
|
| 812 |
+
require.True(t, ok)
|
| 813 |
+
assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
|
| 814 |
+
|
| 815 |
+
taskAgent, ok := cfg.Agents[AgentTask]
|
| 816 |
+
require.True(t, ok)
|
| 817 |
+
assert.Equal(t, []string{"lsp_symbols", "lsp_definition", "lsp_call_hierarchy", "glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
|
| 821 |
+
cfg := &Config{
|
| 822 |
+
Options: &Options{
|
| 823 |
+
DisabledTools: []string{
|
| 824 |
+
"edit",
|
| 825 |
+
"download",
|
| 826 |
+
"grep",
|
| 827 |
+
},
|
| 828 |
+
},
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
cfg.SetupAgents()
|
| 832 |
+
coderAgent, ok := cfg.Agents[AgentCoder]
|
| 833 |
+
require.True(t, ok)
|
| 834 |
+
|
| 835 |
+
assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "lsp_symbols", "lsp_definition", "lsp_call_hierarchy", "lsp_rename", "lsp_replace_symbol", "fetch", "agentic_fetch", "glob", "ls", "question", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
|
| 836 |
+
|
| 837 |
+
taskAgent, ok := cfg.Agents[AgentTask]
|
| 838 |
+
require.True(t, ok)
|
| 839 |
+
assert.Equal(t, []string{"lsp_symbols", "lsp_definition", "lsp_call_hierarchy", "glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
|
| 843 |
+
cfg := &Config{
|
| 844 |
+
Options: &Options{
|
| 845 |
+
DisabledTools: []string{
|
| 846 |
+
"glob",
|
| 847 |
+
"grep",
|
| 848 |
+
"ls",
|
| 849 |
+
"lsp_call_hierarchy",
|
| 850 |
+
"lsp_definition",
|
| 851 |
+
"lsp_symbols",
|
| 852 |
+
"sourcegraph",
|
| 853 |
+
"view",
|
| 854 |
+
},
|
| 855 |
+
},
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
cfg.SetupAgents()
|
| 859 |
+
coderAgent, ok := cfg.Agents[AgentCoder]
|
| 860 |
+
require.True(t, ok)
|
| 861 |
+
assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "lsp_rename", "lsp_replace_symbol", "fetch", "agentic_fetch", "question", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
|
| 862 |
+
|
| 863 |
+
taskAgent, ok := cfg.Agents[AgentTask]
|
| 864 |
+
require.True(t, ok)
|
| 865 |
+
assert.Len(t, taskAgent.AllowedTools, 0)
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
|
| 869 |
+
knownProviders := []catwalk.Provider{
|
| 870 |
+
{
|
| 871 |
+
ID: "openai",
|
| 872 |
+
APIKey: "$OPENAI_API_KEY",
|
| 873 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 874 |
+
Models: []catwalk.Model{{
|
| 875 |
+
ID: "test-model",
|
| 876 |
+
}},
|
| 877 |
+
},
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
cfg := &Config{
|
| 881 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 882 |
+
"openai": {
|
| 883 |
+
Disable: true,
|
| 884 |
+
},
|
| 885 |
+
}),
|
| 886 |
+
}
|
| 887 |
+
cfg.setDefaults("/tmp", "")
|
| 888 |
+
|
| 889 |
+
env := env.NewFromMap(map[string]string{
|
| 890 |
+
"OPENAI_API_KEY": "test-key",
|
| 891 |
+
})
|
| 892 |
+
resolver := NewShellVariableResolver(env)
|
| 893 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 894 |
+
require.NoError(t, err)
|
| 895 |
+
|
| 896 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 897 |
+
prov, exists := cfg.Providers.Get("openai")
|
| 898 |
+
require.True(t, exists)
|
| 899 |
+
require.True(t, prov.Disable)
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
|
| 903 |
+
t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
|
| 904 |
+
cfg := &Config{
|
| 905 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 906 |
+
"custom": {
|
| 907 |
+
BaseURL: "https://api.custom.com/v1",
|
| 908 |
+
Models: []catwalk.Model{{
|
| 909 |
+
ID: "test-model",
|
| 910 |
+
}},
|
| 911 |
+
},
|
| 912 |
+
"openai": {
|
| 913 |
+
APIKey: "$MISSING",
|
| 914 |
+
},
|
| 915 |
+
}),
|
| 916 |
+
}
|
| 917 |
+
cfg.setDefaults("/tmp", "")
|
| 918 |
+
|
| 919 |
+
env := env.NewFromMap(map[string]string{})
|
| 920 |
+
resolver := NewShellVariableResolver(env)
|
| 921 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 922 |
+
require.NoError(t, err)
|
| 923 |
+
|
| 924 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 925 |
+
_, exists := cfg.Providers.Get("custom")
|
| 926 |
+
require.True(t, exists)
|
| 927 |
+
})
|
| 928 |
+
|
| 929 |
+
t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
|
| 930 |
+
cfg := &Config{
|
| 931 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 932 |
+
"custom": {
|
| 933 |
+
APIKey: "test-key",
|
| 934 |
+
Models: []catwalk.Model{{
|
| 935 |
+
ID: "test-model",
|
| 936 |
+
}},
|
| 937 |
+
},
|
| 938 |
+
}),
|
| 939 |
+
}
|
| 940 |
+
cfg.setDefaults("/tmp", "")
|
| 941 |
+
|
| 942 |
+
env := env.NewFromMap(map[string]string{})
|
| 943 |
+
resolver := NewShellVariableResolver(env)
|
| 944 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 945 |
+
require.NoError(t, err)
|
| 946 |
+
|
| 947 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 948 |
+
_, exists := cfg.Providers.Get("custom")
|
| 949 |
+
require.False(t, exists)
|
| 950 |
+
})
|
| 951 |
+
|
| 952 |
+
t.Run("custom provider with shell-expanded BaseURL is kept", func(t *testing.T) {
|
| 953 |
+
cfg := &Config{
|
| 954 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 955 |
+
"custom": {
|
| 956 |
+
APIKey: "test-key",
|
| 957 |
+
BaseURL: "$(echo https://api.custom.com/v1)",
|
| 958 |
+
Models: []catwalk.Model{{
|
| 959 |
+
ID: "test-model",
|
| 960 |
+
}},
|
| 961 |
+
},
|
| 962 |
+
}),
|
| 963 |
+
}
|
| 964 |
+
cfg.setDefaults("/tmp", "")
|
| 965 |
+
|
| 966 |
+
env := env.NewFromMap(map[string]string{})
|
| 967 |
+
resolver := NewShellVariableResolver(env)
|
| 968 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 969 |
+
require.NoError(t, err)
|
| 970 |
+
|
| 971 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 972 |
+
_, exists := cfg.Providers.Get("custom")
|
| 973 |
+
require.True(t, exists, "a BaseURL supplied via shell expansion must pass endpoint validation")
|
| 974 |
+
})
|
| 975 |
+
|
| 976 |
+
t.Run("custom provider with env var BaseURL is kept", func(t *testing.T) {
|
| 977 |
+
cfg := &Config{
|
| 978 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 979 |
+
"custom": {
|
| 980 |
+
APIKey: "test-key",
|
| 981 |
+
BaseURL: "$CUSTOM_API_URL",
|
| 982 |
+
Models: []catwalk.Model{{
|
| 983 |
+
ID: "test-model",
|
| 984 |
+
}},
|
| 985 |
+
},
|
| 986 |
+
}),
|
| 987 |
+
}
|
| 988 |
+
cfg.setDefaults("/tmp", "")
|
| 989 |
+
|
| 990 |
+
env := env.NewFromMap(map[string]string{
|
| 991 |
+
"CUSTOM_API_URL": "https://api.custom.com/v1",
|
| 992 |
+
})
|
| 993 |
+
resolver := NewShellVariableResolver(env)
|
| 994 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 995 |
+
require.NoError(t, err)
|
| 996 |
+
|
| 997 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 998 |
+
_, exists := cfg.Providers.Get("custom")
|
| 999 |
+
require.True(t, exists, "a BaseURL supplied via an env variable must pass endpoint validation")
|
| 1000 |
+
})
|
| 1001 |
+
|
| 1002 |
+
t.Run("custom provider whose BaseURL resolves to empty is removed", func(t *testing.T) {
|
| 1003 |
+
cfg := &Config{
|
| 1004 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1005 |
+
"custom": {
|
| 1006 |
+
APIKey: "test-key",
|
| 1007 |
+
BaseURL: "$MISSING_API_URL",
|
| 1008 |
+
Models: []catwalk.Model{{
|
| 1009 |
+
ID: "test-model",
|
| 1010 |
+
}},
|
| 1011 |
+
},
|
| 1012 |
+
}),
|
| 1013 |
+
}
|
| 1014 |
+
cfg.setDefaults("/tmp", "")
|
| 1015 |
+
|
| 1016 |
+
env := env.NewFromMap(map[string]string{})
|
| 1017 |
+
resolver := NewShellVariableResolver(env)
|
| 1018 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1019 |
+
require.NoError(t, err)
|
| 1020 |
+
|
| 1021 |
+
require.Equal(t, 0, cfg.Providers.Len())
|
| 1022 |
+
_, exists := cfg.Providers.Get("custom")
|
| 1023 |
+
require.False(t, exists, "a raw-non-empty BaseURL that resolves to empty must be rejected")
|
| 1024 |
+
})
|
| 1025 |
+
|
| 1026 |
+
t.Run("BaseURL resolving to empty with disable_default_providers returns an error", func(t *testing.T) {
|
| 1027 |
+
cfg := &Config{
|
| 1028 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1029 |
+
"custom": {
|
| 1030 |
+
APIKey: "test-key",
|
| 1031 |
+
BaseURL: "$MISSING_API_URL",
|
| 1032 |
+
Models: []catwalk.Model{{
|
| 1033 |
+
ID: "test-model",
|
| 1034 |
+
}},
|
| 1035 |
+
},
|
| 1036 |
+
}),
|
| 1037 |
+
}
|
| 1038 |
+
cfg.setDefaults("/tmp", "")
|
| 1039 |
+
cfg.Options.DisableDefaultProviders = true
|
| 1040 |
+
|
| 1041 |
+
env := env.NewFromMap(map[string]string{})
|
| 1042 |
+
resolver := NewShellVariableResolver(env)
|
| 1043 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1044 |
+
require.Error(t, err, "the sole custom provider is invalid after resolution, so configuration must fail")
|
| 1045 |
+
})
|
| 1046 |
+
|
| 1047 |
+
t.Run("custom provider with no models attempts discovery and is removed on failure", func(t *testing.T) {
|
| 1048 |
+
cfg := &Config{
|
| 1049 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1050 |
+
"custom": {
|
| 1051 |
+
APIKey: "test-key",
|
| 1052 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1053 |
+
Models: []catwalk.Model{},
|
| 1054 |
+
},
|
| 1055 |
+
}),
|
| 1056 |
+
}
|
| 1057 |
+
cfg.setDefaults("/tmp", "")
|
| 1058 |
+
|
| 1059 |
+
env := env.NewFromMap(map[string]string{})
|
| 1060 |
+
resolver := NewShellVariableResolver(env)
|
| 1061 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1062 |
+
require.NoError(t, err)
|
| 1063 |
+
|
| 1064 |
+
// Discovery fails (unreachable URL) so provider is removed.
|
| 1065 |
+
require.Equal(t, 0, cfg.Providers.Len())
|
| 1066 |
+
_, exists := cfg.Providers.Get("custom")
|
| 1067 |
+
require.False(t, exists)
|
| 1068 |
+
})
|
| 1069 |
+
|
| 1070 |
+
t.Run("custom provider with no models and discover_models:false is removed", func(t *testing.T) {
|
| 1071 |
+
discoverFalse := false
|
| 1072 |
+
cfg := &Config{
|
| 1073 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1074 |
+
"custom": {
|
| 1075 |
+
APIKey: "test-key",
|
| 1076 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1077 |
+
Models: []catwalk.Model{},
|
| 1078 |
+
AutoDiscoverModels: &discoverFalse,
|
| 1079 |
+
},
|
| 1080 |
+
}),
|
| 1081 |
+
}
|
| 1082 |
+
cfg.setDefaults("/tmp", "")
|
| 1083 |
+
|
| 1084 |
+
env := env.NewFromMap(map[string]string{})
|
| 1085 |
+
resolver := NewShellVariableResolver(env)
|
| 1086 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1087 |
+
require.NoError(t, err)
|
| 1088 |
+
|
| 1089 |
+
require.Equal(t, 0, cfg.Providers.Len())
|
| 1090 |
+
_, exists := cfg.Providers.Get("custom")
|
| 1091 |
+
require.False(t, exists)
|
| 1092 |
+
})
|
| 1093 |
+
|
| 1094 |
+
t.Run("custom provider with models and discover_models:true merges discovered models", func(t *testing.T) {
|
| 1095 |
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 1096 |
+
w.Header().Set("Content-Type", "application/json")
|
| 1097 |
+
_, _ = w.Write([]byte(`{"data": [
|
| 1098 |
+
{"id": "existing-model", "object": "model"},
|
| 1099 |
+
{"id": "discovered-model", "object": "model"}
|
| 1100 |
+
]}`))
|
| 1101 |
+
}))
|
| 1102 |
+
defer server.Close()
|
| 1103 |
+
|
| 1104 |
+
discoverTrue := true
|
| 1105 |
+
cfg := &Config{
|
| 1106 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1107 |
+
"custom": {
|
| 1108 |
+
APIKey: "test-key",
|
| 1109 |
+
BaseURL: server.URL + "/v1",
|
| 1110 |
+
Models: []catwalk.Model{
|
| 1111 |
+
{ID: "existing-model", Name: "My Custom Name", ContextWindow: 200000},
|
| 1112 |
+
},
|
| 1113 |
+
AutoDiscoverModels: &discoverTrue,
|
| 1114 |
+
},
|
| 1115 |
+
}),
|
| 1116 |
+
}
|
| 1117 |
+
cfg.setDefaults("/tmp", "")
|
| 1118 |
+
|
| 1119 |
+
env := env.NewFromMap(map[string]string{})
|
| 1120 |
+
resolver := NewShellVariableResolver(env)
|
| 1121 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1122 |
+
require.NoError(t, err)
|
| 1123 |
+
|
| 1124 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 1125 |
+
p, exists := cfg.Providers.Get("custom")
|
| 1126 |
+
require.True(t, exists)
|
| 1127 |
+
require.Len(t, p.Models, 2)
|
| 1128 |
+
|
| 1129 |
+
// User-specified model keeps its custom fields.
|
| 1130 |
+
require.Equal(t, "existing-model", p.Models[0].ID)
|
| 1131 |
+
require.Equal(t, "My Custom Name", p.Models[0].Name)
|
| 1132 |
+
require.Equal(t, int64(200000), p.Models[0].ContextWindow)
|
| 1133 |
+
|
| 1134 |
+
// Discovered model is appended.
|
| 1135 |
+
require.Equal(t, "discovered-model", p.Models[1].ID)
|
| 1136 |
+
})
|
| 1137 |
+
|
| 1138 |
+
t.Run("custom provider with models and no discover_models uses only listed models", func(t *testing.T) {
|
| 1139 |
+
cfg := &Config{
|
| 1140 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1141 |
+
"custom": {
|
| 1142 |
+
APIKey: "test-key",
|
| 1143 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1144 |
+
Models: []catwalk.Model{
|
| 1145 |
+
{ID: "my-model", Name: "My Model"},
|
| 1146 |
+
},
|
| 1147 |
+
},
|
| 1148 |
+
}),
|
| 1149 |
+
}
|
| 1150 |
+
cfg.setDefaults("/tmp", "")
|
| 1151 |
+
|
| 1152 |
+
env := env.NewFromMap(map[string]string{})
|
| 1153 |
+
resolver := NewShellVariableResolver(env)
|
| 1154 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1155 |
+
require.NoError(t, err)
|
| 1156 |
+
|
| 1157 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 1158 |
+
p, exists := cfg.Providers.Get("custom")
|
| 1159 |
+
require.True(t, exists)
|
| 1160 |
+
require.Len(t, p.Models, 1)
|
| 1161 |
+
require.Equal(t, "my-model", p.Models[0].ID)
|
| 1162 |
+
})
|
| 1163 |
+
|
| 1164 |
+
t.Run("custom provider with no models auto-discovers successfully", func(t *testing.T) {
|
| 1165 |
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 1166 |
+
w.Header().Set("Content-Type", "application/json")
|
| 1167 |
+
_, _ = w.Write([]byte(`{"data": [
|
| 1168 |
+
{"id": "auto-model-a", "object": "model"},
|
| 1169 |
+
{"id": "auto-model-b", "object": "model"}
|
| 1170 |
+
]}`))
|
| 1171 |
+
}))
|
| 1172 |
+
defer server.Close()
|
| 1173 |
+
|
| 1174 |
+
cfg := &Config{
|
| 1175 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1176 |
+
"custom": {
|
| 1177 |
+
APIKey: "test-key",
|
| 1178 |
+
BaseURL: server.URL + "/v1",
|
| 1179 |
+
},
|
| 1180 |
+
}),
|
| 1181 |
+
}
|
| 1182 |
+
cfg.setDefaults("/tmp", "")
|
| 1183 |
+
|
| 1184 |
+
env := env.NewFromMap(map[string]string{})
|
| 1185 |
+
resolver := NewShellVariableResolver(env)
|
| 1186 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1187 |
+
require.NoError(t, err)
|
| 1188 |
+
|
| 1189 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 1190 |
+
p, exists := cfg.Providers.Get("custom")
|
| 1191 |
+
require.True(t, exists)
|
| 1192 |
+
require.Len(t, p.Models, 2)
|
| 1193 |
+
require.Equal(t, "auto-model-a", p.Models[0].ID)
|
| 1194 |
+
require.Equal(t, "auto-model-b", p.Models[1].ID)
|
| 1195 |
+
})
|
| 1196 |
+
|
| 1197 |
+
t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
|
| 1198 |
+
cfg := &Config{
|
| 1199 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1200 |
+
"custom": {
|
| 1201 |
+
APIKey: "test-key",
|
| 1202 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1203 |
+
Type: "unsupported",
|
| 1204 |
+
Models: []catwalk.Model{{
|
| 1205 |
+
ID: "test-model",
|
| 1206 |
+
}},
|
| 1207 |
+
},
|
| 1208 |
+
}),
|
| 1209 |
+
}
|
| 1210 |
+
cfg.setDefaults("/tmp", "")
|
| 1211 |
+
|
| 1212 |
+
env := env.NewFromMap(map[string]string{})
|
| 1213 |
+
resolver := NewShellVariableResolver(env)
|
| 1214 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1215 |
+
require.NoError(t, err)
|
| 1216 |
+
|
| 1217 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 1218 |
+
_, exists := cfg.Providers.Get("custom")
|
| 1219 |
+
require.False(t, exists)
|
| 1220 |
+
})
|
| 1221 |
+
|
| 1222 |
+
t.Run("valid custom provider is kept and ID is set", func(t *testing.T) {
|
| 1223 |
+
cfg := &Config{
|
| 1224 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1225 |
+
"custom": {
|
| 1226 |
+
APIKey: "test-key",
|
| 1227 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1228 |
+
Type: catwalk.TypeOpenAI,
|
| 1229 |
+
Models: []catwalk.Model{{
|
| 1230 |
+
ID: "test-model",
|
| 1231 |
+
}},
|
| 1232 |
+
},
|
| 1233 |
+
}),
|
| 1234 |
+
}
|
| 1235 |
+
cfg.setDefaults("/tmp", "")
|
| 1236 |
+
|
| 1237 |
+
env := env.NewFromMap(map[string]string{})
|
| 1238 |
+
resolver := NewShellVariableResolver(env)
|
| 1239 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1240 |
+
require.NoError(t, err)
|
| 1241 |
+
|
| 1242 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 1243 |
+
customProvider, exists := cfg.Providers.Get("custom")
|
| 1244 |
+
require.True(t, exists)
|
| 1245 |
+
require.Equal(t, "custom", customProvider.ID)
|
| 1246 |
+
require.Equal(t, "test-key", customProvider.APIKey)
|
| 1247 |
+
require.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
|
| 1248 |
+
})
|
| 1249 |
+
|
| 1250 |
+
t.Run("custom anthropic provider is supported", func(t *testing.T) {
|
| 1251 |
+
cfg := &Config{
|
| 1252 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1253 |
+
"custom-anthropic": {
|
| 1254 |
+
APIKey: "test-key",
|
| 1255 |
+
BaseURL: "https://api.anthropic.com/v1",
|
| 1256 |
+
Type: catwalk.TypeAnthropic,
|
| 1257 |
+
Models: []catwalk.Model{{
|
| 1258 |
+
ID: "claude-3-sonnet",
|
| 1259 |
+
}},
|
| 1260 |
+
},
|
| 1261 |
+
}),
|
| 1262 |
+
}
|
| 1263 |
+
cfg.setDefaults("/tmp", "")
|
| 1264 |
+
|
| 1265 |
+
env := env.NewFromMap(map[string]string{})
|
| 1266 |
+
resolver := NewShellVariableResolver(env)
|
| 1267 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1268 |
+
require.NoError(t, err)
|
| 1269 |
+
|
| 1270 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 1271 |
+
customProvider, exists := cfg.Providers.Get("custom-anthropic")
|
| 1272 |
+
require.True(t, exists)
|
| 1273 |
+
require.Equal(t, "custom-anthropic", customProvider.ID)
|
| 1274 |
+
require.Equal(t, "test-key", customProvider.APIKey)
|
| 1275 |
+
require.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
|
| 1276 |
+
require.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
|
| 1277 |
+
})
|
| 1278 |
+
|
| 1279 |
+
t.Run("disabled custom provider is removed", func(t *testing.T) {
|
| 1280 |
+
cfg := &Config{
|
| 1281 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1282 |
+
"custom": {
|
| 1283 |
+
APIKey: "test-key",
|
| 1284 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1285 |
+
Type: catwalk.TypeOpenAI,
|
| 1286 |
+
Disable: true,
|
| 1287 |
+
Models: []catwalk.Model{{
|
| 1288 |
+
ID: "test-model",
|
| 1289 |
+
}},
|
| 1290 |
+
},
|
| 1291 |
+
}),
|
| 1292 |
+
}
|
| 1293 |
+
cfg.setDefaults("/tmp", "")
|
| 1294 |
+
|
| 1295 |
+
env := env.NewFromMap(map[string]string{})
|
| 1296 |
+
resolver := NewShellVariableResolver(env)
|
| 1297 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1298 |
+
require.NoError(t, err)
|
| 1299 |
+
|
| 1300 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 1301 |
+
_, exists := cfg.Providers.Get("custom")
|
| 1302 |
+
require.False(t, exists)
|
| 1303 |
+
})
|
| 1304 |
+
}
|
| 1305 |
+
|
| 1306 |
+
func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
|
| 1307 |
+
t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
|
| 1308 |
+
knownProviders := []catwalk.Provider{
|
| 1309 |
+
{
|
| 1310 |
+
ID: catwalk.InferenceProviderVertexAI,
|
| 1311 |
+
APIKey: "",
|
| 1312 |
+
APIEndpoint: "",
|
| 1313 |
+
Models: []catwalk.Model{{
|
| 1314 |
+
ID: "gemini-pro",
|
| 1315 |
+
}},
|
| 1316 |
+
},
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
cfg := &Config{
|
| 1320 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1321 |
+
"vertexai": {
|
| 1322 |
+
BaseURL: "custom-url",
|
| 1323 |
+
},
|
| 1324 |
+
}),
|
| 1325 |
+
}
|
| 1326 |
+
cfg.setDefaults("/tmp", "")
|
| 1327 |
+
|
| 1328 |
+
env := env.NewFromMap(map[string]string{
|
| 1329 |
+
"GOOGLE_GENAI_USE_VERTEXAI": "false",
|
| 1330 |
+
})
|
| 1331 |
+
resolver := NewShellVariableResolver(env)
|
| 1332 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1333 |
+
require.NoError(t, err)
|
| 1334 |
+
|
| 1335 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 1336 |
+
_, exists := cfg.Providers.Get("vertexai")
|
| 1337 |
+
require.False(t, exists)
|
| 1338 |
+
})
|
| 1339 |
+
|
| 1340 |
+
t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
|
| 1341 |
+
knownProviders := []catwalk.Provider{
|
| 1342 |
+
{
|
| 1343 |
+
ID: catwalk.InferenceProviderBedrock,
|
| 1344 |
+
APIKey: "",
|
| 1345 |
+
APIEndpoint: "",
|
| 1346 |
+
Models: []catwalk.Model{{
|
| 1347 |
+
ID: "anthropic.claude-sonnet-4-20250514-v1:0",
|
| 1348 |
+
}},
|
| 1349 |
+
},
|
| 1350 |
+
}
|
| 1351 |
+
|
| 1352 |
+
cfg := &Config{
|
| 1353 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1354 |
+
"bedrock": {
|
| 1355 |
+
BaseURL: "custom-url",
|
| 1356 |
+
},
|
| 1357 |
+
}),
|
| 1358 |
+
}
|
| 1359 |
+
cfg.setDefaults("/tmp", "")
|
| 1360 |
+
|
| 1361 |
+
env := env.NewFromMap(map[string]string{})
|
| 1362 |
+
resolver := NewShellVariableResolver(env)
|
| 1363 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1364 |
+
require.NoError(t, err)
|
| 1365 |
+
|
| 1366 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 1367 |
+
_, exists := cfg.Providers.Get("bedrock")
|
| 1368 |
+
require.False(t, exists)
|
| 1369 |
+
})
|
| 1370 |
+
|
| 1371 |
+
t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
|
| 1372 |
+
knownProviders := []catwalk.Provider{
|
| 1373 |
+
{
|
| 1374 |
+
ID: "openai",
|
| 1375 |
+
APIKey: "$MISSING_API_KEY",
|
| 1376 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 1377 |
+
Models: []catwalk.Model{{
|
| 1378 |
+
ID: "test-model",
|
| 1379 |
+
}},
|
| 1380 |
+
},
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
cfg := &Config{
|
| 1384 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1385 |
+
"openai": {
|
| 1386 |
+
BaseURL: "custom-url",
|
| 1387 |
+
},
|
| 1388 |
+
}),
|
| 1389 |
+
}
|
| 1390 |
+
cfg.setDefaults("/tmp", "")
|
| 1391 |
+
|
| 1392 |
+
env := env.NewFromMap(map[string]string{})
|
| 1393 |
+
resolver := NewShellVariableResolver(env)
|
| 1394 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1395 |
+
require.NoError(t, err)
|
| 1396 |
+
|
| 1397 |
+
require.Equal(t, cfg.Providers.Len(), 0)
|
| 1398 |
+
_, exists := cfg.Providers.Get("openai")
|
| 1399 |
+
require.False(t, exists)
|
| 1400 |
+
})
|
| 1401 |
+
|
| 1402 |
+
t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
|
| 1403 |
+
knownProviders := []catwalk.Provider{
|
| 1404 |
+
{
|
| 1405 |
+
ID: "openai",
|
| 1406 |
+
APIKey: "$OPENAI_API_KEY",
|
| 1407 |
+
APIEndpoint: "$MISSING_ENDPOINT",
|
| 1408 |
+
Models: []catwalk.Model{{
|
| 1409 |
+
ID: "test-model",
|
| 1410 |
+
}},
|
| 1411 |
+
},
|
| 1412 |
+
}
|
| 1413 |
+
|
| 1414 |
+
cfg := &Config{
|
| 1415 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1416 |
+
"openai": {
|
| 1417 |
+
APIKey: "test-key",
|
| 1418 |
+
},
|
| 1419 |
+
}),
|
| 1420 |
+
}
|
| 1421 |
+
cfg.setDefaults("/tmp", "")
|
| 1422 |
+
|
| 1423 |
+
env := env.NewFromMap(map[string]string{
|
| 1424 |
+
"OPENAI_API_KEY": "test-key",
|
| 1425 |
+
})
|
| 1426 |
+
resolver := NewShellVariableResolver(env)
|
| 1427 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1428 |
+
require.NoError(t, err)
|
| 1429 |
+
|
| 1430 |
+
require.Equal(t, cfg.Providers.Len(), 1)
|
| 1431 |
+
_, exists := cfg.Providers.Get("openai")
|
| 1432 |
+
require.True(t, exists)
|
| 1433 |
+
})
|
| 1434 |
+
}
|
| 1435 |
+
|
| 1436 |
+
func TestConfig_defaultModelSelection(t *testing.T) {
|
| 1437 |
+
t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
|
| 1438 |
+
knownProviders := []catwalk.Provider{
|
| 1439 |
+
{
|
| 1440 |
+
ID: "openai",
|
| 1441 |
+
APIKey: "abc",
|
| 1442 |
+
DefaultLargeModelID: "large-model",
|
| 1443 |
+
DefaultSmallModelID: "small-model",
|
| 1444 |
+
Models: []catwalk.Model{
|
| 1445 |
+
{
|
| 1446 |
+
ID: "large-model",
|
| 1447 |
+
DefaultMaxTokens: 1000,
|
| 1448 |
+
},
|
| 1449 |
+
{
|
| 1450 |
+
ID: "small-model",
|
| 1451 |
+
DefaultMaxTokens: 500,
|
| 1452 |
+
},
|
| 1453 |
+
},
|
| 1454 |
+
},
|
| 1455 |
+
}
|
| 1456 |
+
|
| 1457 |
+
cfg := &Config{}
|
| 1458 |
+
cfg.setDefaults("/tmp", "")
|
| 1459 |
+
env := env.NewFromMap(map[string]string{})
|
| 1460 |
+
resolver := NewShellVariableResolver(env)
|
| 1461 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1462 |
+
require.NoError(t, err)
|
| 1463 |
+
|
| 1464 |
+
large, small, err := cfg.defaultModelSelection(knownProviders)
|
| 1465 |
+
require.NoError(t, err)
|
| 1466 |
+
require.Equal(t, "large-model", large.Model)
|
| 1467 |
+
require.Equal(t, "openai", large.Provider)
|
| 1468 |
+
require.Equal(t, int64(1000), large.MaxTokens)
|
| 1469 |
+
require.Equal(t, "small-model", small.Model)
|
| 1470 |
+
require.Equal(t, "openai", small.Provider)
|
| 1471 |
+
require.Equal(t, int64(500), small.MaxTokens)
|
| 1472 |
+
})
|
| 1473 |
+
t.Run("should error if no providers configured", func(t *testing.T) {
|
| 1474 |
+
knownProviders := []catwalk.Provider{
|
| 1475 |
+
{
|
| 1476 |
+
ID: "openai",
|
| 1477 |
+
APIKey: "$MISSING_KEY",
|
| 1478 |
+
DefaultLargeModelID: "large-model",
|
| 1479 |
+
DefaultSmallModelID: "small-model",
|
| 1480 |
+
Models: []catwalk.Model{
|
| 1481 |
+
{
|
| 1482 |
+
ID: "large-model",
|
| 1483 |
+
DefaultMaxTokens: 1000,
|
| 1484 |
+
},
|
| 1485 |
+
{
|
| 1486 |
+
ID: "small-model",
|
| 1487 |
+
DefaultMaxTokens: 500,
|
| 1488 |
+
},
|
| 1489 |
+
},
|
| 1490 |
+
},
|
| 1491 |
+
}
|
| 1492 |
+
|
| 1493 |
+
cfg := &Config{}
|
| 1494 |
+
cfg.setDefaults("/tmp", "")
|
| 1495 |
+
env := env.NewFromMap(map[string]string{})
|
| 1496 |
+
resolver := NewShellVariableResolver(env)
|
| 1497 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1498 |
+
require.NoError(t, err)
|
| 1499 |
+
|
| 1500 |
+
_, _, err = cfg.defaultModelSelection(knownProviders)
|
| 1501 |
+
require.Error(t, err)
|
| 1502 |
+
})
|
| 1503 |
+
t.Run("should not error if model is missing", func(t *testing.T) {
|
| 1504 |
+
knownProviders := []catwalk.Provider{
|
| 1505 |
+
{
|
| 1506 |
+
ID: "openai",
|
| 1507 |
+
APIKey: "abc",
|
| 1508 |
+
DefaultLargeModelID: "large-model",
|
| 1509 |
+
DefaultSmallModelID: "small-model",
|
| 1510 |
+
Models: []catwalk.Model{
|
| 1511 |
+
{
|
| 1512 |
+
ID: "not-large-model",
|
| 1513 |
+
DefaultMaxTokens: 1000,
|
| 1514 |
+
},
|
| 1515 |
+
{
|
| 1516 |
+
ID: "small-model",
|
| 1517 |
+
DefaultMaxTokens: 500,
|
| 1518 |
+
},
|
| 1519 |
+
},
|
| 1520 |
+
},
|
| 1521 |
+
}
|
| 1522 |
+
|
| 1523 |
+
cfg := &Config{}
|
| 1524 |
+
cfg.setDefaults("/tmp", "")
|
| 1525 |
+
env := env.NewFromMap(map[string]string{})
|
| 1526 |
+
resolver := NewShellVariableResolver(env)
|
| 1527 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1528 |
+
require.NoError(t, err)
|
| 1529 |
+
_, _, err = cfg.defaultModelSelection(knownProviders)
|
| 1530 |
+
require.NoError(t, err)
|
| 1531 |
+
})
|
| 1532 |
+
|
| 1533 |
+
t.Run("should configure the default models with a custom provider", func(t *testing.T) {
|
| 1534 |
+
knownProviders := []catwalk.Provider{
|
| 1535 |
+
{
|
| 1536 |
+
ID: "openai",
|
| 1537 |
+
APIKey: "$MISSING", // will not be included in the config
|
| 1538 |
+
DefaultLargeModelID: "large-model",
|
| 1539 |
+
DefaultSmallModelID: "small-model",
|
| 1540 |
+
Models: []catwalk.Model{
|
| 1541 |
+
{
|
| 1542 |
+
ID: "not-large-model",
|
| 1543 |
+
DefaultMaxTokens: 1000,
|
| 1544 |
+
},
|
| 1545 |
+
{
|
| 1546 |
+
ID: "small-model",
|
| 1547 |
+
DefaultMaxTokens: 500,
|
| 1548 |
+
},
|
| 1549 |
+
},
|
| 1550 |
+
},
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
cfg := &Config{
|
| 1554 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1555 |
+
"custom": {
|
| 1556 |
+
APIKey: "test-key",
|
| 1557 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1558 |
+
Models: []catwalk.Model{
|
| 1559 |
+
{
|
| 1560 |
+
ID: "model",
|
| 1561 |
+
DefaultMaxTokens: 600,
|
| 1562 |
+
},
|
| 1563 |
+
},
|
| 1564 |
+
},
|
| 1565 |
+
}),
|
| 1566 |
+
}
|
| 1567 |
+
cfg.setDefaults("/tmp", "")
|
| 1568 |
+
env := env.NewFromMap(map[string]string{})
|
| 1569 |
+
resolver := NewShellVariableResolver(env)
|
| 1570 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1571 |
+
require.NoError(t, err)
|
| 1572 |
+
large, small, err := cfg.defaultModelSelection(knownProviders)
|
| 1573 |
+
require.NoError(t, err)
|
| 1574 |
+
require.Equal(t, "model", large.Model)
|
| 1575 |
+
require.Equal(t, "custom", large.Provider)
|
| 1576 |
+
require.Equal(t, int64(600), large.MaxTokens)
|
| 1577 |
+
require.Equal(t, "model", small.Model)
|
| 1578 |
+
require.Equal(t, "custom", small.Provider)
|
| 1579 |
+
require.Equal(t, int64(600), small.MaxTokens)
|
| 1580 |
+
})
|
| 1581 |
+
|
| 1582 |
+
t.Run("should fail if no model configured", func(t *testing.T) {
|
| 1583 |
+
knownProviders := []catwalk.Provider{
|
| 1584 |
+
{
|
| 1585 |
+
ID: "openai",
|
| 1586 |
+
APIKey: "$MISSING", // will not be included in the config
|
| 1587 |
+
DefaultLargeModelID: "large-model",
|
| 1588 |
+
DefaultSmallModelID: "small-model",
|
| 1589 |
+
Models: []catwalk.Model{
|
| 1590 |
+
{
|
| 1591 |
+
ID: "not-large-model",
|
| 1592 |
+
DefaultMaxTokens: 1000,
|
| 1593 |
+
},
|
| 1594 |
+
{
|
| 1595 |
+
ID: "small-model",
|
| 1596 |
+
DefaultMaxTokens: 500,
|
| 1597 |
+
},
|
| 1598 |
+
},
|
| 1599 |
+
},
|
| 1600 |
+
}
|
| 1601 |
+
|
| 1602 |
+
cfg := &Config{
|
| 1603 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1604 |
+
"custom": {
|
| 1605 |
+
APIKey: "test-key",
|
| 1606 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1607 |
+
Models: []catwalk.Model{},
|
| 1608 |
+
},
|
| 1609 |
+
}),
|
| 1610 |
+
}
|
| 1611 |
+
cfg.setDefaults("/tmp", "")
|
| 1612 |
+
env := env.NewFromMap(map[string]string{})
|
| 1613 |
+
resolver := NewShellVariableResolver(env)
|
| 1614 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1615 |
+
require.NoError(t, err)
|
| 1616 |
+
_, _, err = cfg.defaultModelSelection(knownProviders)
|
| 1617 |
+
require.Error(t, err)
|
| 1618 |
+
})
|
| 1619 |
+
t.Run("should use the default provider first", func(t *testing.T) {
|
| 1620 |
+
knownProviders := []catwalk.Provider{
|
| 1621 |
+
{
|
| 1622 |
+
ID: "openai",
|
| 1623 |
+
APIKey: "set",
|
| 1624 |
+
DefaultLargeModelID: "large-model",
|
| 1625 |
+
DefaultSmallModelID: "small-model",
|
| 1626 |
+
Models: []catwalk.Model{
|
| 1627 |
+
{
|
| 1628 |
+
ID: "large-model",
|
| 1629 |
+
DefaultMaxTokens: 1000,
|
| 1630 |
+
},
|
| 1631 |
+
{
|
| 1632 |
+
ID: "small-model",
|
| 1633 |
+
DefaultMaxTokens: 500,
|
| 1634 |
+
},
|
| 1635 |
+
},
|
| 1636 |
+
},
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
cfg := &Config{
|
| 1640 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1641 |
+
"custom": {
|
| 1642 |
+
APIKey: "test-key",
|
| 1643 |
+
BaseURL: "https://api.custom.com/v1",
|
| 1644 |
+
Models: []catwalk.Model{
|
| 1645 |
+
{
|
| 1646 |
+
ID: "large-model",
|
| 1647 |
+
DefaultMaxTokens: 1000,
|
| 1648 |
+
},
|
| 1649 |
+
},
|
| 1650 |
+
},
|
| 1651 |
+
}),
|
| 1652 |
+
}
|
| 1653 |
+
cfg.setDefaults("/tmp", "")
|
| 1654 |
+
env := env.NewFromMap(map[string]string{})
|
| 1655 |
+
resolver := NewShellVariableResolver(env)
|
| 1656 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1657 |
+
require.NoError(t, err)
|
| 1658 |
+
large, small, err := cfg.defaultModelSelection(knownProviders)
|
| 1659 |
+
require.NoError(t, err)
|
| 1660 |
+
require.Equal(t, "large-model", large.Model)
|
| 1661 |
+
require.Equal(t, "openai", large.Provider)
|
| 1662 |
+
require.Equal(t, int64(1000), large.MaxTokens)
|
| 1663 |
+
require.Equal(t, "small-model", small.Model)
|
| 1664 |
+
require.Equal(t, "openai", small.Provider)
|
| 1665 |
+
require.Equal(t, int64(500), small.MaxTokens)
|
| 1666 |
+
})
|
| 1667 |
+
}
|
| 1668 |
+
|
| 1669 |
+
func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
|
| 1670 |
+
t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
|
| 1671 |
+
knownProviders := []catwalk.Provider{
|
| 1672 |
+
{
|
| 1673 |
+
ID: "openai",
|
| 1674 |
+
APIKey: "$OPENAI_API_KEY",
|
| 1675 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 1676 |
+
Models: []catwalk.Model{{
|
| 1677 |
+
ID: "gpt-4",
|
| 1678 |
+
}},
|
| 1679 |
+
},
|
| 1680 |
+
}
|
| 1681 |
+
|
| 1682 |
+
// User references openai but doesn't fully specify it (no base_url, no
|
| 1683 |
+
// models). This should be rejected because disable_default_providers
|
| 1684 |
+
// treats all providers as custom.
|
| 1685 |
+
cfg := &Config{
|
| 1686 |
+
Options: &Options{
|
| 1687 |
+
DisableDefaultProviders: true,
|
| 1688 |
+
},
|
| 1689 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1690 |
+
"openai": {
|
| 1691 |
+
APIKey: "$OPENAI_API_KEY",
|
| 1692 |
+
},
|
| 1693 |
+
}),
|
| 1694 |
+
}
|
| 1695 |
+
cfg.setDefaults("/tmp", "")
|
| 1696 |
+
|
| 1697 |
+
env := env.NewFromMap(map[string]string{
|
| 1698 |
+
"OPENAI_API_KEY": "test-key",
|
| 1699 |
+
})
|
| 1700 |
+
resolver := NewShellVariableResolver(env)
|
| 1701 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1702 |
+
require.ErrorContains(t, err, "no custom providers")
|
| 1703 |
+
|
| 1704 |
+
// openai should NOT be present because it lacks base_url and models.
|
| 1705 |
+
require.Equal(t, 0, cfg.Providers.Len())
|
| 1706 |
+
_, exists := cfg.Providers.Get("openai")
|
| 1707 |
+
require.False(t, exists, "openai should not be present without full specification")
|
| 1708 |
+
})
|
| 1709 |
+
|
| 1710 |
+
t.Run("when enabled, fully specified providers work", func(t *testing.T) {
|
| 1711 |
+
knownProviders := []catwalk.Provider{
|
| 1712 |
+
{
|
| 1713 |
+
ID: "openai",
|
| 1714 |
+
APIKey: "$OPENAI_API_KEY",
|
| 1715 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 1716 |
+
Models: []catwalk.Model{{
|
| 1717 |
+
ID: "gpt-4",
|
| 1718 |
+
}},
|
| 1719 |
+
},
|
| 1720 |
+
}
|
| 1721 |
+
|
| 1722 |
+
// User fully specifies their provider.
|
| 1723 |
+
cfg := &Config{
|
| 1724 |
+
Options: &Options{
|
| 1725 |
+
DisableDefaultProviders: true,
|
| 1726 |
+
},
|
| 1727 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1728 |
+
"my-llm": {
|
| 1729 |
+
APIKey: "$MY_API_KEY",
|
| 1730 |
+
BaseURL: "https://my-llm.example.com/v1",
|
| 1731 |
+
Models: []catwalk.Model{{
|
| 1732 |
+
ID: "my-model",
|
| 1733 |
+
}},
|
| 1734 |
+
},
|
| 1735 |
+
}),
|
| 1736 |
+
}
|
| 1737 |
+
cfg.setDefaults("/tmp", "")
|
| 1738 |
+
|
| 1739 |
+
env := env.NewFromMap(map[string]string{
|
| 1740 |
+
"MY_API_KEY": "test-key",
|
| 1741 |
+
"OPENAI_API_KEY": "test-key",
|
| 1742 |
+
})
|
| 1743 |
+
resolver := NewShellVariableResolver(env)
|
| 1744 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1745 |
+
require.NoError(t, err)
|
| 1746 |
+
|
| 1747 |
+
// Only fully specified provider should be present.
|
| 1748 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 1749 |
+
provider, exists := cfg.Providers.Get("my-llm")
|
| 1750 |
+
require.True(t, exists, "my-llm should be present")
|
| 1751 |
+
require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
|
| 1752 |
+
require.Len(t, provider.Models, 1)
|
| 1753 |
+
|
| 1754 |
+
// Default openai should NOT be present.
|
| 1755 |
+
_, exists = cfg.Providers.Get("openai")
|
| 1756 |
+
require.False(t, exists, "openai should not be present")
|
| 1757 |
+
})
|
| 1758 |
+
|
| 1759 |
+
t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
|
| 1760 |
+
knownProviders := []catwalk.Provider{
|
| 1761 |
+
{
|
| 1762 |
+
ID: "openai",
|
| 1763 |
+
APIKey: "$OPENAI_API_KEY",
|
| 1764 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 1765 |
+
Models: []catwalk.Model{{
|
| 1766 |
+
ID: "gpt-4",
|
| 1767 |
+
}},
|
| 1768 |
+
},
|
| 1769 |
+
{
|
| 1770 |
+
ID: "anthropic",
|
| 1771 |
+
APIKey: "$ANTHROPIC_API_KEY",
|
| 1772 |
+
APIEndpoint: "https://api.anthropic.com/v1",
|
| 1773 |
+
Models: []catwalk.Model{{
|
| 1774 |
+
ID: "claude-3",
|
| 1775 |
+
}},
|
| 1776 |
+
},
|
| 1777 |
+
}
|
| 1778 |
+
|
| 1779 |
+
// User only configures openai, both API keys are available, but option
|
| 1780 |
+
// is disabled.
|
| 1781 |
+
cfg := &Config{
|
| 1782 |
+
Options: &Options{
|
| 1783 |
+
DisableDefaultProviders: false,
|
| 1784 |
+
},
|
| 1785 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1786 |
+
"openai": {
|
| 1787 |
+
APIKey: "$OPENAI_API_KEY",
|
| 1788 |
+
},
|
| 1789 |
+
}),
|
| 1790 |
+
}
|
| 1791 |
+
cfg.setDefaults("/tmp", "")
|
| 1792 |
+
|
| 1793 |
+
env := env.NewFromMap(map[string]string{
|
| 1794 |
+
"OPENAI_API_KEY": "test-key",
|
| 1795 |
+
"ANTHROPIC_API_KEY": "test-key",
|
| 1796 |
+
})
|
| 1797 |
+
resolver := NewShellVariableResolver(env)
|
| 1798 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1799 |
+
require.NoError(t, err)
|
| 1800 |
+
|
| 1801 |
+
// Both providers should be present.
|
| 1802 |
+
require.Equal(t, 2, cfg.Providers.Len())
|
| 1803 |
+
_, exists := cfg.Providers.Get("openai")
|
| 1804 |
+
require.True(t, exists, "openai should be present")
|
| 1805 |
+
_, exists = cfg.Providers.Get("anthropic")
|
| 1806 |
+
require.True(t, exists, "anthropic should be present")
|
| 1807 |
+
})
|
| 1808 |
+
|
| 1809 |
+
t.Run("when enabled, provider missing models attempts discovery but still triggers no-custom-providers error", func(t *testing.T) {
|
| 1810 |
+
cfg := &Config{
|
| 1811 |
+
Options: &Options{
|
| 1812 |
+
DisableDefaultProviders: true,
|
| 1813 |
+
},
|
| 1814 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1815 |
+
"my-llm": {
|
| 1816 |
+
APIKey: "test-key",
|
| 1817 |
+
BaseURL: "https://my-llm.example.com/v1",
|
| 1818 |
+
Models: []catwalk.Model{}, // No models.
|
| 1819 |
+
},
|
| 1820 |
+
}),
|
| 1821 |
+
}
|
| 1822 |
+
cfg.setDefaults("/tmp", "")
|
| 1823 |
+
|
| 1824 |
+
env := env.NewFromMap(map[string]string{})
|
| 1825 |
+
resolver := NewShellVariableResolver(env)
|
| 1826 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1827 |
+
require.ErrorContains(t, err, "no custom providers")
|
| 1828 |
+
|
| 1829 |
+
// Discovery fails (unreachable URL) so provider is removed.
|
| 1830 |
+
require.Equal(t, 0, cfg.Providers.Len())
|
| 1831 |
+
})
|
| 1832 |
+
|
| 1833 |
+
t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
|
| 1834 |
+
cfg := &Config{
|
| 1835 |
+
Options: &Options{
|
| 1836 |
+
DisableDefaultProviders: true,
|
| 1837 |
+
},
|
| 1838 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 1839 |
+
"my-llm": {
|
| 1840 |
+
APIKey: "test-key",
|
| 1841 |
+
Models: []catwalk.Model{{ID: "model"}},
|
| 1842 |
+
// No BaseURL.
|
| 1843 |
+
},
|
| 1844 |
+
}),
|
| 1845 |
+
}
|
| 1846 |
+
cfg.setDefaults("/tmp", "")
|
| 1847 |
+
|
| 1848 |
+
env := env.NewFromMap(map[string]string{})
|
| 1849 |
+
resolver := NewShellVariableResolver(env)
|
| 1850 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
|
| 1851 |
+
require.ErrorContains(t, err, "no custom providers")
|
| 1852 |
+
|
| 1853 |
+
// Provider should be rejected for missing base_url.
|
| 1854 |
+
require.Equal(t, 0, cfg.Providers.Len())
|
| 1855 |
+
})
|
| 1856 |
+
}
|
| 1857 |
+
|
| 1858 |
+
func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
|
| 1859 |
+
t.Run("sets option from environment variable", func(t *testing.T) {
|
| 1860 |
+
t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
|
| 1861 |
+
|
| 1862 |
+
cfg := &Config{}
|
| 1863 |
+
cfg.setDefaults("/tmp", "")
|
| 1864 |
+
|
| 1865 |
+
require.True(t, cfg.Options.DisableDefaultProviders)
|
| 1866 |
+
})
|
| 1867 |
+
|
| 1868 |
+
t.Run("does not override when env var is not set", func(t *testing.T) {
|
| 1869 |
+
cfg := &Config{
|
| 1870 |
+
Options: &Options{
|
| 1871 |
+
DisableDefaultProviders: true,
|
| 1872 |
+
},
|
| 1873 |
+
}
|
| 1874 |
+
cfg.setDefaults("/tmp", "")
|
| 1875 |
+
|
| 1876 |
+
require.True(t, cfg.Options.DisableDefaultProviders)
|
| 1877 |
+
})
|
| 1878 |
+
}
|
| 1879 |
+
|
| 1880 |
+
func TestConfig_configureSelectedModels(t *testing.T) {
|
| 1881 |
+
t.Run("reload mode should not persist fallback defaults", func(t *testing.T) {
|
| 1882 |
+
dir := t.TempDir()
|
| 1883 |
+
globalPath := filepath.Join(dir, "crush.json")
|
| 1884 |
+
require.NoError(t, os.WriteFile(globalPath, []byte(`{"models":{"large":{"provider":"ghost","model":"missing"}}}`), 0o600))
|
| 1885 |
+
|
| 1886 |
+
knownProviders := []catwalk.Provider{
|
| 1887 |
+
{
|
| 1888 |
+
ID: "openai",
|
| 1889 |
+
APIKey: "abc",
|
| 1890 |
+
DefaultLargeModelID: "large-model",
|
| 1891 |
+
DefaultSmallModelID: "small-model",
|
| 1892 |
+
Models: []catwalk.Model{
|
| 1893 |
+
{ID: "large-model", DefaultMaxTokens: 1000},
|
| 1894 |
+
{ID: "small-model", DefaultMaxTokens: 500},
|
| 1895 |
+
},
|
| 1896 |
+
},
|
| 1897 |
+
}
|
| 1898 |
+
|
| 1899 |
+
cfg := &Config{
|
| 1900 |
+
Models: map[SelectedModelType]SelectedModel{
|
| 1901 |
+
SelectedModelTypeLarge: {Provider: "ghost", Model: "missing"},
|
| 1902 |
+
},
|
| 1903 |
+
}
|
| 1904 |
+
cfg.setDefaults(dir, "")
|
| 1905 |
+
store := &ConfigStore{config: cfg, globalDataPath: globalPath}
|
| 1906 |
+
env := env.NewFromMap(map[string]string{})
|
| 1907 |
+
resolver := NewShellVariableResolver(env)
|
| 1908 |
+
err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
|
| 1909 |
+
require.NoError(t, err)
|
| 1910 |
+
|
| 1911 |
+
resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
|
| 1912 |
+
require.NoError(t, resolveErr)
|
| 1913 |
+
cfg.Models[SelectedModelTypeLarge] = resolved.Large
|
| 1914 |
+
cfg.Models[SelectedModelTypeSmall] = resolved.Small
|
| 1915 |
+
|
| 1916 |
+
// In-memory falls back to default.
|
| 1917 |
+
require.True(t, resolved.LargeFallback)
|
| 1918 |
+
require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
|
| 1919 |
+
require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
|
| 1920 |
+
|
| 1921 |
+
// Disk remains unchanged (resolveSelectedModels never persists).
|
| 1922 |
+
data, readErr := os.ReadFile(globalPath)
|
| 1923 |
+
require.NoError(t, readErr)
|
| 1924 |
+
require.Contains(t, string(data), `"provider":"ghost"`)
|
| 1925 |
+
require.Contains(t, string(data), `"model":"missing"`)
|
| 1926 |
+
})
|
| 1927 |
+
t.Run("should override defaults", func(t *testing.T) {
|
| 1928 |
+
knownProviders := []catwalk.Provider{
|
| 1929 |
+
{
|
| 1930 |
+
ID: "openai",
|
| 1931 |
+
APIKey: "abc",
|
| 1932 |
+
DefaultLargeModelID: "large-model",
|
| 1933 |
+
DefaultSmallModelID: "small-model",
|
| 1934 |
+
Models: []catwalk.Model{
|
| 1935 |
+
{
|
| 1936 |
+
ID: "larger-model",
|
| 1937 |
+
DefaultMaxTokens: 2000,
|
| 1938 |
+
},
|
| 1939 |
+
{
|
| 1940 |
+
ID: "large-model",
|
| 1941 |
+
DefaultMaxTokens: 1000,
|
| 1942 |
+
},
|
| 1943 |
+
{
|
| 1944 |
+
ID: "small-model",
|
| 1945 |
+
DefaultMaxTokens: 500,
|
| 1946 |
+
},
|
| 1947 |
+
},
|
| 1948 |
+
},
|
| 1949 |
+
}
|
| 1950 |
+
|
| 1951 |
+
cfg := &Config{
|
| 1952 |
+
Models: map[SelectedModelType]SelectedModel{
|
| 1953 |
+
"large": {
|
| 1954 |
+
Model: "larger-model",
|
| 1955 |
+
},
|
| 1956 |
+
},
|
| 1957 |
+
}
|
| 1958 |
+
cfg.setDefaults("/tmp", "")
|
| 1959 |
+
env := env.NewFromMap(map[string]string{})
|
| 1960 |
+
resolver := NewShellVariableResolver(env)
|
| 1961 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 1962 |
+
require.NoError(t, err)
|
| 1963 |
+
|
| 1964 |
+
resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
|
| 1965 |
+
require.NoError(t, resolveErr)
|
| 1966 |
+
cfg.Models[SelectedModelTypeLarge] = resolved.Large
|
| 1967 |
+
cfg.Models[SelectedModelTypeSmall] = resolved.Small
|
| 1968 |
+
large := cfg.Models[SelectedModelTypeLarge]
|
| 1969 |
+
small := cfg.Models[SelectedModelTypeSmall]
|
| 1970 |
+
require.Equal(t, "larger-model", large.Model)
|
| 1971 |
+
require.Equal(t, "openai", large.Provider)
|
| 1972 |
+
require.Equal(t, int64(2000), large.MaxTokens)
|
| 1973 |
+
require.Equal(t, "small-model", small.Model)
|
| 1974 |
+
require.Equal(t, "openai", small.Provider)
|
| 1975 |
+
require.Equal(t, int64(500), small.MaxTokens)
|
| 1976 |
+
})
|
| 1977 |
+
t.Run("should be possible to use multiple providers", func(t *testing.T) {
|
| 1978 |
+
knownProviders := []catwalk.Provider{
|
| 1979 |
+
{
|
| 1980 |
+
ID: "openai",
|
| 1981 |
+
APIKey: "abc",
|
| 1982 |
+
DefaultLargeModelID: "large-model",
|
| 1983 |
+
DefaultSmallModelID: "small-model",
|
| 1984 |
+
Models: []catwalk.Model{
|
| 1985 |
+
{
|
| 1986 |
+
ID: "large-model",
|
| 1987 |
+
DefaultMaxTokens: 1000,
|
| 1988 |
+
},
|
| 1989 |
+
{
|
| 1990 |
+
ID: "small-model",
|
| 1991 |
+
DefaultMaxTokens: 500,
|
| 1992 |
+
},
|
| 1993 |
+
},
|
| 1994 |
+
},
|
| 1995 |
+
{
|
| 1996 |
+
ID: "anthropic",
|
| 1997 |
+
APIKey: "abc",
|
| 1998 |
+
DefaultLargeModelID: "a-large-model",
|
| 1999 |
+
DefaultSmallModelID: "a-small-model",
|
| 2000 |
+
Models: []catwalk.Model{
|
| 2001 |
+
{
|
| 2002 |
+
ID: "a-large-model",
|
| 2003 |
+
DefaultMaxTokens: 1000,
|
| 2004 |
+
},
|
| 2005 |
+
{
|
| 2006 |
+
ID: "a-small-model",
|
| 2007 |
+
DefaultMaxTokens: 200,
|
| 2008 |
+
},
|
| 2009 |
+
},
|
| 2010 |
+
},
|
| 2011 |
+
}
|
| 2012 |
+
|
| 2013 |
+
cfg := &Config{
|
| 2014 |
+
Models: map[SelectedModelType]SelectedModel{
|
| 2015 |
+
"small": {
|
| 2016 |
+
Model: "a-small-model",
|
| 2017 |
+
Provider: "anthropic",
|
| 2018 |
+
MaxTokens: 300,
|
| 2019 |
+
},
|
| 2020 |
+
},
|
| 2021 |
+
}
|
| 2022 |
+
cfg.setDefaults("/tmp", "")
|
| 2023 |
+
env := env.NewFromMap(map[string]string{})
|
| 2024 |
+
resolver := NewShellVariableResolver(env)
|
| 2025 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 2026 |
+
require.NoError(t, err)
|
| 2027 |
+
|
| 2028 |
+
resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
|
| 2029 |
+
require.NoError(t, resolveErr)
|
| 2030 |
+
cfg.Models[SelectedModelTypeLarge] = resolved.Large
|
| 2031 |
+
cfg.Models[SelectedModelTypeSmall] = resolved.Small
|
| 2032 |
+
large := cfg.Models[SelectedModelTypeLarge]
|
| 2033 |
+
small := cfg.Models[SelectedModelTypeSmall]
|
| 2034 |
+
require.Equal(t, "large-model", large.Model)
|
| 2035 |
+
require.Equal(t, "openai", large.Provider)
|
| 2036 |
+
require.Equal(t, int64(1000), large.MaxTokens)
|
| 2037 |
+
require.Equal(t, "a-small-model", small.Model)
|
| 2038 |
+
require.Equal(t, "anthropic", small.Provider)
|
| 2039 |
+
require.Equal(t, int64(300), small.MaxTokens)
|
| 2040 |
+
})
|
| 2041 |
+
|
| 2042 |
+
t.Run("should override the max tokens only", func(t *testing.T) {
|
| 2043 |
+
knownProviders := []catwalk.Provider{
|
| 2044 |
+
{
|
| 2045 |
+
ID: "openai",
|
| 2046 |
+
APIKey: "abc",
|
| 2047 |
+
DefaultLargeModelID: "large-model",
|
| 2048 |
+
DefaultSmallModelID: "small-model",
|
| 2049 |
+
Models: []catwalk.Model{
|
| 2050 |
+
{
|
| 2051 |
+
ID: "large-model",
|
| 2052 |
+
DefaultMaxTokens: 1000,
|
| 2053 |
+
},
|
| 2054 |
+
{
|
| 2055 |
+
ID: "small-model",
|
| 2056 |
+
DefaultMaxTokens: 500,
|
| 2057 |
+
},
|
| 2058 |
+
},
|
| 2059 |
+
},
|
| 2060 |
+
}
|
| 2061 |
+
|
| 2062 |
+
cfg := &Config{
|
| 2063 |
+
Models: map[SelectedModelType]SelectedModel{
|
| 2064 |
+
"large": {
|
| 2065 |
+
MaxTokens: 100,
|
| 2066 |
+
},
|
| 2067 |
+
},
|
| 2068 |
+
}
|
| 2069 |
+
cfg.setDefaults("/tmp", "")
|
| 2070 |
+
env := env.NewFromMap(map[string]string{})
|
| 2071 |
+
resolver := NewShellVariableResolver(env)
|
| 2072 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 2073 |
+
require.NoError(t, err)
|
| 2074 |
+
|
| 2075 |
+
resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
|
| 2076 |
+
require.NoError(t, resolveErr)
|
| 2077 |
+
cfg.Models[SelectedModelTypeLarge] = resolved.Large
|
| 2078 |
+
cfg.Models[SelectedModelTypeSmall] = resolved.Small
|
| 2079 |
+
large := cfg.Models[SelectedModelTypeLarge]
|
| 2080 |
+
require.Equal(t, "large-model", large.Model)
|
| 2081 |
+
require.Equal(t, "openai", large.Provider)
|
| 2082 |
+
require.Equal(t, int64(100), large.MaxTokens)
|
| 2083 |
+
})
|
| 2084 |
+
t.Run("resolve and persist fallback under writeMu does not deadlock", func(t *testing.T) {
|
| 2085 |
+
dir := t.TempDir()
|
| 2086 |
+
globalPath := filepath.Join(dir, "crush.json")
|
| 2087 |
+
require.NoError(t, os.WriteFile(globalPath, []byte(`{}`), 0o600))
|
| 2088 |
+
|
| 2089 |
+
knownProviders := []catwalk.Provider{
|
| 2090 |
+
{
|
| 2091 |
+
ID: "openai",
|
| 2092 |
+
APIKey: "abc",
|
| 2093 |
+
DefaultLargeModelID: "large-model",
|
| 2094 |
+
DefaultSmallModelID: "small-model",
|
| 2095 |
+
Models: []catwalk.Model{
|
| 2096 |
+
{ID: "large-model", DefaultMaxTokens: 1000},
|
| 2097 |
+
{ID: "small-model", DefaultMaxTokens: 500},
|
| 2098 |
+
},
|
| 2099 |
+
},
|
| 2100 |
+
}
|
| 2101 |
+
|
| 2102 |
+
cfg := &Config{
|
| 2103 |
+
Models: map[SelectedModelType]SelectedModel{
|
| 2104 |
+
SelectedModelTypeLarge: {Provider: "openai", Model: "this-model-does-not-exist"},
|
| 2105 |
+
SelectedModelTypeSmall: {Provider: "openai", Model: "also-does-not-exist"},
|
| 2106 |
+
},
|
| 2107 |
+
}
|
| 2108 |
+
cfg.setDefaults(dir, "")
|
| 2109 |
+
store := &ConfigStore{config: cfg, globalDataPath: globalPath}
|
| 2110 |
+
env := env.NewFromMap(map[string]string{})
|
| 2111 |
+
resolver := NewShellVariableResolver(env)
|
| 2112 |
+
err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
|
| 2113 |
+
require.NoError(t, err)
|
| 2114 |
+
|
| 2115 |
+
// Simulate the Load path: resolve (pure), then persist fallbacks
|
| 2116 |
+
// under writeMu using updateLocked. Before the refactor, the
|
| 2117 |
+
// combined configureSelectedModels(persist=true) self-deadlocked
|
| 2118 |
+
// because UpdatePreferredModel re-acquired writeMu.
|
| 2119 |
+
done := make(chan error, 1)
|
| 2120 |
+
go func() {
|
| 2121 |
+
resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
|
| 2122 |
+
if resolveErr != nil {
|
| 2123 |
+
done <- resolveErr
|
| 2124 |
+
return
|
| 2125 |
+
}
|
| 2126 |
+
cfg.Models[SelectedModelTypeLarge] = resolved.Large
|
| 2127 |
+
cfg.Models[SelectedModelTypeSmall] = resolved.Small
|
| 2128 |
+
|
| 2129 |
+
store.writeMu.Lock()
|
| 2130 |
+
defer store.writeMu.Unlock()
|
| 2131 |
+
if resolved.LargeFallback {
|
| 2132 |
+
if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
|
| 2133 |
+
return store.updatePreferredModelFields(c, SelectedModelTypeLarge, resolved.Large)
|
| 2134 |
+
}); err != nil {
|
| 2135 |
+
done <- err
|
| 2136 |
+
return
|
| 2137 |
+
}
|
| 2138 |
+
}
|
| 2139 |
+
if resolved.SmallFallback {
|
| 2140 |
+
if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
|
| 2141 |
+
return store.updatePreferredModelFields(c, SelectedModelTypeSmall, resolved.Small)
|
| 2142 |
+
}); err != nil {
|
| 2143 |
+
done <- err
|
| 2144 |
+
return
|
| 2145 |
+
}
|
| 2146 |
+
}
|
| 2147 |
+
done <- nil
|
| 2148 |
+
}()
|
| 2149 |
+
|
| 2150 |
+
select {
|
| 2151 |
+
case err := <-done:
|
| 2152 |
+
require.NoError(t, err)
|
| 2153 |
+
// Should have fallen back to defaults.
|
| 2154 |
+
require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
|
| 2155 |
+
require.Equal(t, "small-model", cfg.Models[SelectedModelTypeSmall].Model)
|
| 2156 |
+
case <-time.After(5 * time.Second):
|
| 2157 |
+
t.Fatal("resolve + persist deadlocked under writeMu")
|
| 2158 |
+
}
|
| 2159 |
+
})
|
| 2160 |
+
}
|
| 2161 |
+
|
| 2162 |
+
func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
|
| 2163 |
+
// Test that HYPER_API_KEY environment variable works without config
|
| 2164 |
+
knownProviders := []catwalk.Provider{
|
| 2165 |
+
{
|
| 2166 |
+
ID: "hyper",
|
| 2167 |
+
APIKey: "", // No API key in provider definition
|
| 2168 |
+
DefaultLargeModelID: "large-model",
|
| 2169 |
+
DefaultSmallModelID: "small-model",
|
| 2170 |
+
Models: []catwalk.Model{
|
| 2171 |
+
{
|
| 2172 |
+
ID: "large-model",
|
| 2173 |
+
DefaultMaxTokens: 1000,
|
| 2174 |
+
},
|
| 2175 |
+
{
|
| 2176 |
+
ID: "small-model",
|
| 2177 |
+
DefaultMaxTokens: 500,
|
| 2178 |
+
},
|
| 2179 |
+
},
|
| 2180 |
+
},
|
| 2181 |
+
}
|
| 2182 |
+
|
| 2183 |
+
cfg := &Config{}
|
| 2184 |
+
cfg.setDefaults("/tmp", "")
|
| 2185 |
+
env := env.NewFromMap(map[string]string{
|
| 2186 |
+
"HYPER_API_KEY": "env-api-key",
|
| 2187 |
+
})
|
| 2188 |
+
resolver := NewShellVariableResolver(env)
|
| 2189 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 2190 |
+
require.NoError(t, err)
|
| 2191 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 2192 |
+
|
| 2193 |
+
// Verify Hyper provider is configured with the env var API key
|
| 2194 |
+
pc, ok := cfg.Providers.Get("hyper")
|
| 2195 |
+
require.True(t, ok, "Hyper provider should be configured")
|
| 2196 |
+
require.Equal(t, "env-api-key", pc.APIKey)
|
| 2197 |
+
require.Equal(t, "env-api-key", pc.APIKeyTemplate)
|
| 2198 |
+
}
|
| 2199 |
+
|
| 2200 |
+
func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T) {
|
| 2201 |
+
// Test that config API key takes precedence when HYPER_API_KEY is also set
|
| 2202 |
+
knownProviders := []catwalk.Provider{
|
| 2203 |
+
{
|
| 2204 |
+
ID: "hyper",
|
| 2205 |
+
APIKey: "provider-api-key",
|
| 2206 |
+
DefaultLargeModelID: "large-model",
|
| 2207 |
+
DefaultSmallModelID: "small-model",
|
| 2208 |
+
Models: []catwalk.Model{
|
| 2209 |
+
{
|
| 2210 |
+
ID: "large-model",
|
| 2211 |
+
DefaultMaxTokens: 1000,
|
| 2212 |
+
},
|
| 2213 |
+
{
|
| 2214 |
+
ID: "small-model",
|
| 2215 |
+
DefaultMaxTokens: 500,
|
| 2216 |
+
},
|
| 2217 |
+
},
|
| 2218 |
+
},
|
| 2219 |
+
}
|
| 2220 |
+
|
| 2221 |
+
// User has Hyper configured with an API key
|
| 2222 |
+
cfg := &Config{
|
| 2223 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 2224 |
+
"hyper": {
|
| 2225 |
+
APIKey: "config-api-key",
|
| 2226 |
+
},
|
| 2227 |
+
}),
|
| 2228 |
+
}
|
| 2229 |
+
cfg.setDefaults("/tmp", "")
|
| 2230 |
+
|
| 2231 |
+
// But they also have HYPER_API_KEY set - env var should take precedence
|
| 2232 |
+
env := env.NewFromMap(map[string]string{
|
| 2233 |
+
"HYPER_API_KEY": "env-api-key",
|
| 2234 |
+
})
|
| 2235 |
+
resolver := NewShellVariableResolver(env)
|
| 2236 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
|
| 2237 |
+
require.NoError(t, err)
|
| 2238 |
+
require.Equal(t, 1, cfg.Providers.Len())
|
| 2239 |
+
|
| 2240 |
+
// Verify env var takes precedence (as per requirements)
|
| 2241 |
+
pc, ok := cfg.Providers.Get("hyper")
|
| 2242 |
+
require.True(t, ok, "Hyper provider should be configured")
|
| 2243 |
+
require.Equal(t, "env-api-key", pc.APIKey)
|
| 2244 |
+
}
|
| 2245 |
+
|
| 2246 |
+
// TestConfig_configureProviders_ProviderHeaderResolveError verifies
|
| 2247 |
+
// that a failing $(cmd) in a provider header fails the provider load
|
| 2248 |
+
// with a clear message that names the offending header. Provider
|
| 2249 |
+
// headers share the MCP error contract.
|
| 2250 |
+
func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
|
| 2251 |
+
knownProviders := []catwalk.Provider{
|
| 2252 |
+
{
|
| 2253 |
+
ID: "openai",
|
| 2254 |
+
APIKey: "$OPENAI_API_KEY",
|
| 2255 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 2256 |
+
Models: []catwalk.Model{{ID: "test-model"}},
|
| 2257 |
+
},
|
| 2258 |
+
}
|
| 2259 |
+
|
| 2260 |
+
cfg := &Config{
|
| 2261 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 2262 |
+
"openai": {
|
| 2263 |
+
ExtraHeaders: map[string]string{
|
| 2264 |
+
// Failing $(...) — inner command exits 1. Must
|
| 2265 |
+
// propagate as an error, not a silent truncation.
|
| 2266 |
+
"X-Broken": "$(false)",
|
| 2267 |
+
},
|
| 2268 |
+
},
|
| 2269 |
+
}),
|
| 2270 |
+
}
|
| 2271 |
+
cfg.setDefaults("/tmp", "")
|
| 2272 |
+
|
| 2273 |
+
testEnv := env.NewFromMap(map[string]string{
|
| 2274 |
+
"OPENAI_API_KEY": "test-key",
|
| 2275 |
+
"PATH": os.Getenv("PATH"),
|
| 2276 |
+
})
|
| 2277 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 2278 |
+
|
| 2279 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
|
| 2280 |
+
require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
|
| 2281 |
+
require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
|
| 2282 |
+
}
|
| 2283 |
+
|
| 2284 |
+
// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads
|
| 2285 |
+
// verifies that a Catwalk-style default header like
|
| 2286 |
+
// "OpenAI-Organization": "$OPENAI_ORG_ID" loads cleanly under lenient
|
| 2287 |
+
// nounset (unset → "" → header dropped), and does not fail the load
|
| 2288 |
+
// or leave the literal template on the wire.
|
| 2289 |
+
func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
|
| 2290 |
+
knownProviders := []catwalk.Provider{
|
| 2291 |
+
{
|
| 2292 |
+
ID: "openai",
|
| 2293 |
+
APIKey: "$OPENAI_API_KEY",
|
| 2294 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 2295 |
+
Models: []catwalk.Model{{ID: "test-model"}},
|
| 2296 |
+
DefaultHeaders: map[string]string{
|
| 2297 |
+
"OpenAI-Organization": "$OPENAI_ORG_ID",
|
| 2298 |
+
},
|
| 2299 |
+
},
|
| 2300 |
+
}
|
| 2301 |
+
|
| 2302 |
+
cfg := &Config{}
|
| 2303 |
+
cfg.setDefaults("/tmp", "")
|
| 2304 |
+
|
| 2305 |
+
testEnv := env.NewFromMap(map[string]string{
|
| 2306 |
+
"OPENAI_API_KEY": "test-key",
|
| 2307 |
+
"PATH": os.Getenv("PATH"),
|
| 2308 |
+
})
|
| 2309 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 2310 |
+
|
| 2311 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
|
| 2312 |
+
require.NoError(t, err, "optional env-gated header must not fail the load")
|
| 2313 |
+
|
| 2314 |
+
pc, ok := cfg.Providers.Get("openai")
|
| 2315 |
+
require.True(t, ok, "openai provider must still be configured")
|
| 2316 |
+
_, present := pc.ExtraHeaders["OpenAI-Organization"]
|
| 2317 |
+
require.False(t, present, "header whose value resolves to empty must be absent")
|
| 2318 |
+
}
|
| 2319 |
+
|
| 2320 |
+
// TestConfig_configureProviders_LiteralEmptyHeaderDropped pins design
|
| 2321 |
+
// decision #18 for the literal case: a user-authored
|
| 2322 |
+
// "X-Custom": "" in extra_headers is absent from the resolved map.
|
| 2323 |
+
// Applies to both known- and custom-provider paths; this test
|
| 2324 |
+
// exercises the custom-provider loop.
|
| 2325 |
+
func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
|
| 2326 |
+
cfg := &Config{
|
| 2327 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 2328 |
+
"my-llm": {
|
| 2329 |
+
APIKey: "test-key",
|
| 2330 |
+
BaseURL: "https://my-llm.example.com/v1",
|
| 2331 |
+
Type: catwalk.TypeOpenAI,
|
| 2332 |
+
Models: []catwalk.Model{{ID: "m"}},
|
| 2333 |
+
ExtraHeaders: map[string]string{
|
| 2334 |
+
"X-Custom": "",
|
| 2335 |
+
"X-Kept": "present",
|
| 2336 |
+
},
|
| 2337 |
+
},
|
| 2338 |
+
}),
|
| 2339 |
+
}
|
| 2340 |
+
cfg.setDefaults("/tmp", "")
|
| 2341 |
+
|
| 2342 |
+
testEnv := env.NewFromMap(map[string]string{
|
| 2343 |
+
"PATH": os.Getenv("PATH"),
|
| 2344 |
+
})
|
| 2345 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 2346 |
+
|
| 2347 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, []catwalk.Provider{})
|
| 2348 |
+
require.NoError(t, err)
|
| 2349 |
+
|
| 2350 |
+
pc, ok := cfg.Providers.Get("my-llm")
|
| 2351 |
+
require.True(t, ok)
|
| 2352 |
+
_, present := pc.ExtraHeaders["X-Custom"]
|
| 2353 |
+
require.False(t, present, "literal empty-string header must be dropped")
|
| 2354 |
+
require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
|
| 2355 |
+
}
|
| 2356 |
+
|
| 2357 |
+
// TestConfig_configureProviders_EchoEmptyHeaderDropped pins design
|
| 2358 |
+
// decision #18 for the non-failing empty case: $(echo) exits 0 with
|
| 2359 |
+
// empty output, resolves cleanly to "", and must be dropped the same
|
| 2360 |
+
// way an unset bare $VAR is. Exercises the known-provider loop.
|
| 2361 |
+
func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
|
| 2362 |
+
knownProviders := []catwalk.Provider{
|
| 2363 |
+
{
|
| 2364 |
+
ID: "openai",
|
| 2365 |
+
APIKey: "$OPENAI_API_KEY",
|
| 2366 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 2367 |
+
Models: []catwalk.Model{{ID: "test-model"}},
|
| 2368 |
+
DefaultHeaders: map[string]string{
|
| 2369 |
+
"X-Empty": "$(echo)",
|
| 2370 |
+
"X-Kept": "present",
|
| 2371 |
+
},
|
| 2372 |
+
},
|
| 2373 |
+
}
|
| 2374 |
+
|
| 2375 |
+
cfg := &Config{}
|
| 2376 |
+
cfg.setDefaults("/tmp", "")
|
| 2377 |
+
|
| 2378 |
+
testEnv := env.NewFromMap(map[string]string{
|
| 2379 |
+
"OPENAI_API_KEY": "test-key",
|
| 2380 |
+
"PATH": os.Getenv("PATH"),
|
| 2381 |
+
})
|
| 2382 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 2383 |
+
|
| 2384 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
|
| 2385 |
+
require.NoError(t, err)
|
| 2386 |
+
|
| 2387 |
+
pc, ok := cfg.Providers.Get("openai")
|
| 2388 |
+
require.True(t, ok)
|
| 2389 |
+
_, present := pc.ExtraHeaders["X-Empty"]
|
| 2390 |
+
require.False(t, present, "$(echo) → empty → header must be dropped")
|
| 2391 |
+
require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
|
| 2392 |
+
}
|
| 2393 |
+
|
| 2394 |
+
// TestConfig_configureProviders_UnsetAPIKeySkipsProvider verifies that
|
| 2395 |
+
// under the lenient-nounset shell resolver, $UNSET_API_KEY expands to
|
| 2396 |
+
// ("", nil) rather than ("", err), and the existing
|
| 2397 |
+
// `v == "" || err != nil` skip path at load.go:331 still drops the
|
| 2398 |
+
// provider. The slog.Warn line is emitted on the same
|
| 2399 |
+
// path but is not asserted here — internal/config/load_test.go's
|
| 2400 |
+
// TestMain replaces the default slog handler with an io.Discard
|
| 2401 |
+
// writer, so capturing that log line would require mid-test handler
|
| 2402 |
+
// swapping and a sync.Mutex dance that adds more flake surface than
|
| 2403 |
+
// signal. The observable outcome (provider absent from the map) is
|
| 2404 |
+
// what downstream code — model picker, agent wiring — actually reads,
|
| 2405 |
+
// so that's what we pin.
|
| 2406 |
+
func TestConfig_configureProviders_UnsetAPIKeySkipsProvider(t *testing.T) {
|
| 2407 |
+
knownProviders := []catwalk.Provider{
|
| 2408 |
+
{
|
| 2409 |
+
ID: "openai",
|
| 2410 |
+
APIKey: "$SOMETHING_UNSET",
|
| 2411 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 2412 |
+
Models: []catwalk.Model{{ID: "test-model"}},
|
| 2413 |
+
},
|
| 2414 |
+
}
|
| 2415 |
+
|
| 2416 |
+
// Existing user config for this known provider so the load.go:332
|
| 2417 |
+
// `if configExists` branch fires and actually calls Providers.Del.
|
| 2418 |
+
// Without it the provider was never in the map to begin with and
|
| 2419 |
+
// the test would pass trivially.
|
| 2420 |
+
cfg := &Config{
|
| 2421 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 2422 |
+
"openai": {BaseURL: "custom-url"},
|
| 2423 |
+
}),
|
| 2424 |
+
}
|
| 2425 |
+
cfg.setDefaults("/tmp", "")
|
| 2426 |
+
|
| 2427 |
+
testEnv := env.NewFromMap(map[string]string{
|
| 2428 |
+
"PATH": os.Getenv("PATH"),
|
| 2429 |
+
})
|
| 2430 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 2431 |
+
|
| 2432 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
|
| 2433 |
+
require.NoError(t, err, "skip path must not surface as a load error")
|
| 2434 |
+
|
| 2435 |
+
require.Equal(t, 0, cfg.Providers.Len(), "provider with unset API key must be skipped")
|
| 2436 |
+
_, exists := cfg.Providers.Get("openai")
|
| 2437 |
+
require.False(t, exists)
|
| 2438 |
+
}
|
| 2439 |
+
|
| 2440 |
+
// TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider pins
|
| 2441 |
+
// that the two failure modes for APIKey — ("", nil) from an unset var
|
| 2442 |
+
// under lenient nounset and ("", err) from a failing $(cmd) — are
|
| 2443 |
+
// equivalent for the skip outcome at load.go:331. The `v == "" ||
|
| 2444 |
+
// err != nil` check fires on either branch; this test locks in that
|
| 2445 |
+
// equivalence so a future refactor that splits the check into two
|
| 2446 |
+
// paths doesn't accidentally start propagating $(false) as a load
|
| 2447 |
+
// error while keeping unset-var as a silent skip (or vice versa).
|
| 2448 |
+
func TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider(t *testing.T) {
|
| 2449 |
+
knownProviders := []catwalk.Provider{
|
| 2450 |
+
{
|
| 2451 |
+
ID: "openai",
|
| 2452 |
+
APIKey: "$(false)",
|
| 2453 |
+
APIEndpoint: "https://api.openai.com/v1",
|
| 2454 |
+
Models: []catwalk.Model{{ID: "test-model"}},
|
| 2455 |
+
},
|
| 2456 |
+
}
|
| 2457 |
+
|
| 2458 |
+
cfg := &Config{
|
| 2459 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 2460 |
+
"openai": {BaseURL: "custom-url"},
|
| 2461 |
+
}),
|
| 2462 |
+
}
|
| 2463 |
+
cfg.setDefaults("/tmp", "")
|
| 2464 |
+
|
| 2465 |
+
testEnv := env.NewFromMap(map[string]string{
|
| 2466 |
+
"PATH": os.Getenv("PATH"),
|
| 2467 |
+
})
|
| 2468 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 2469 |
+
|
| 2470 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
|
| 2471 |
+
require.NoError(t, err, "failing $(cmd) in API key must skip provider, not fail load")
|
| 2472 |
+
|
| 2473 |
+
require.Equal(t, 0, cfg.Providers.Len(), "provider with failing $(cmd) API key must be skipped")
|
| 2474 |
+
_, exists := cfg.Providers.Get("openai")
|
| 2475 |
+
require.False(t, exists)
|
| 2476 |
+
}
|
| 2477 |
+
|
| 2478 |
+
// TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider pins
|
| 2479 |
+
// the same contract on the Azure path at load.go:287 — APIEndpoint is
|
| 2480 |
+
// the field that gates Azure and goes through the same
|
| 2481 |
+
// `v == "" || err != nil` skip check. Covered here so both branches
|
| 2482 |
+
// of the shared skip pattern (APIKey default path and APIEndpoint
|
| 2483 |
+
// Azure path) are tested; a future refactor that unifies them can
|
| 2484 |
+
// rely on these two tests to catch drift.
|
| 2485 |
+
func TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider(t *testing.T) {
|
| 2486 |
+
knownProviders := []catwalk.Provider{
|
| 2487 |
+
{
|
| 2488 |
+
ID: catwalk.InferenceProviderAzure,
|
| 2489 |
+
APIKey: "test-key",
|
| 2490 |
+
APIEndpoint: "$UNSET_AZURE_ENDPOINT",
|
| 2491 |
+
Models: []catwalk.Model{{ID: "test-model"}},
|
| 2492 |
+
},
|
| 2493 |
+
}
|
| 2494 |
+
|
| 2495 |
+
cfg := &Config{
|
| 2496 |
+
Providers: csync.NewMapFrom(map[string]ProviderConfig{
|
| 2497 |
+
"azure": {BaseURL: ""},
|
| 2498 |
+
}),
|
| 2499 |
+
}
|
| 2500 |
+
cfg.setDefaults("/tmp", "")
|
| 2501 |
+
|
| 2502 |
+
testEnv := env.NewFromMap(map[string]string{
|
| 2503 |
+
"PATH": os.Getenv("PATH"),
|
| 2504 |
+
})
|
| 2505 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 2506 |
+
|
| 2507 |
+
err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
|
| 2508 |
+
require.NoError(t, err)
|
| 2509 |
+
|
| 2510 |
+
require.Equal(t, 0, cfg.Providers.Len(), "azure provider with unset endpoint must be skipped")
|
| 2511 |
+
_, exists := cfg.Providers.Get("azure")
|
| 2512 |
+
require.False(t, exists)
|
| 2513 |
+
}
|
| 2514 |
+
|
| 2515 |
+
func TestConfig_LoadFromBytes_Env(t *testing.T) {
|
| 2516 |
+
data := []byte(`{"env": {"AWS_PROFILE": "my-profile", "AWS_REGION": "us-west-2"}}`)
|
| 2517 |
+
|
| 2518 |
+
loadedConfig, err := loadFromBytes([][]byte{data})
|
| 2519 |
+
|
| 2520 |
+
require.NoError(t, err)
|
| 2521 |
+
require.NotNil(t, loadedConfig.Env)
|
| 2522 |
+
require.Equal(t, "my-profile", loadedConfig.Env["AWS_PROFILE"])
|
| 2523 |
+
require.Equal(t, "us-west-2", loadedConfig.Env["AWS_REGION"])
|
| 2524 |
+
}
|
| 2525 |
+
|
| 2526 |
+
func TestConfig_LoadFromBytes_EnvMerge(t *testing.T) {
|
| 2527 |
+
data1 := []byte(`{"env": {"AWS_PROFILE": "first", "AWS_REGION": "us-east-1"}}`)
|
| 2528 |
+
data2 := []byte(`{"env": {"AWS_PROFILE": "second"}}`)
|
| 2529 |
+
|
| 2530 |
+
loadedConfig, err := loadFromBytes([][]byte{data1, data2})
|
| 2531 |
+
|
| 2532 |
+
require.NoError(t, err)
|
| 2533 |
+
require.NotNil(t, loadedConfig.Env)
|
| 2534 |
+
require.Equal(t, "second", loadedConfig.Env["AWS_PROFILE"])
|
| 2535 |
+
require.Equal(t, "us-east-1", loadedConfig.Env["AWS_REGION"])
|
| 2536 |
+
}
|
internal/config/lsp_defaults_test.go
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"github.com/stretchr/testify/require"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func TestApplyLSPDefaults(t *testing.T) {
|
| 10 |
+
t.Parallel()
|
| 11 |
+
|
| 12 |
+
// Create a config with an LSP that should get defaults
|
| 13 |
+
config := &Config{
|
| 14 |
+
LSP: map[string]LSPConfig{
|
| 15 |
+
"gopls": {
|
| 16 |
+
Command: "gopls", // This should get defaults from powernap
|
| 17 |
+
},
|
| 18 |
+
"custom": {
|
| 19 |
+
Command: "custom-lsp",
|
| 20 |
+
RootMarkers: []string{"custom.toml"}, // This should keep its explicit config
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Apply defaults
|
| 26 |
+
config.applyLSPDefaults()
|
| 27 |
+
|
| 28 |
+
// Check that gopls got defaults (it should have some root markers now)
|
| 29 |
+
goplsConfig := config.LSP["gopls"]
|
| 30 |
+
require.NotEmpty(t, goplsConfig.RootMarkers, "gopls should have received default root markers")
|
| 31 |
+
|
| 32 |
+
// Check that custom LSP kept its explicit config
|
| 33 |
+
customConfig := config.LSP["custom"]
|
| 34 |
+
require.Equal(t, []string{"custom.toml"}, customConfig.RootMarkers, "custom LSP should keep its explicit root markers")
|
| 35 |
+
}
|
internal/config/openai_test.go
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"strings"
|
| 9 |
+
"testing"
|
| 10 |
+
"time"
|
| 11 |
+
|
| 12 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/csync"
|
| 14 |
+
"github.com/charmbracelet/crush/internal/oauth"
|
| 15 |
+
"github.com/stretchr/testify/require"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
func TestGetModelIncludesChatGPTModels(t *testing.T) {
|
| 19 |
+
t.Parallel()
|
| 20 |
+
|
| 21 |
+
providers := csync.NewMap[string, ProviderConfig]()
|
| 22 |
+
providers.Set("openai", ProviderConfig{
|
| 23 |
+
Models: []catwalk.Model{{ID: "gpt-5.1"}},
|
| 24 |
+
ChatGPTModels: []catwalk.Model{{ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex"}},
|
| 25 |
+
})
|
| 26 |
+
cfg := &Config{Providers: providers}
|
| 27 |
+
|
| 28 |
+
model := cfg.GetModel("openai", "gpt-5.1-codex")
|
| 29 |
+
require.NotNil(t, model)
|
| 30 |
+
require.Equal(t, "GPT-5.1 Codex", model.Name)
|
| 31 |
+
|
| 32 |
+
require.NotNil(t, cfg.GetModel("openai", "gpt-5.1"))
|
| 33 |
+
require.Nil(t, cfg.GetModel("openai", "missing"))
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func TestTokenFields(t *testing.T) {
|
| 37 |
+
t.Parallel()
|
| 38 |
+
|
| 39 |
+
t.Run("openai keeps the api key", func(t *testing.T) {
|
| 40 |
+
t.Parallel()
|
| 41 |
+
|
| 42 |
+
fields := tokenFields("openai", &oauth.Token{AccessToken: "at"})
|
| 43 |
+
require.Contains(t, fields, "providers.openai.oauth")
|
| 44 |
+
require.NotContains(t, fields, "providers.openai.api_key")
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
t.Run("other providers mirror the access token", func(t *testing.T) {
|
| 48 |
+
t.Parallel()
|
| 49 |
+
|
| 50 |
+
fields := tokenFields("hyper", &oauth.Token{AccessToken: "at"})
|
| 51 |
+
require.Contains(t, fields, "providers.hyper.oauth")
|
| 52 |
+
require.Equal(t, "at", fields["providers.hyper.api_key"])
|
| 53 |
+
})
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestTokenAccountIDRoundTrip(t *testing.T) {
|
| 57 |
+
t.Parallel()
|
| 58 |
+
|
| 59 |
+
// The account ID is persisted with the token in crush.json; verify
|
| 60 |
+
// it survives the JSON round trip loadTokenFromDisk performs.
|
| 61 |
+
token := &oauth.Token{
|
| 62 |
+
AccessToken: "at",
|
| 63 |
+
RefreshToken: "rt",
|
| 64 |
+
IDToken: "idt",
|
| 65 |
+
AccountID: "acct-42",
|
| 66 |
+
ExpiresIn: 3600,
|
| 67 |
+
ExpiresAt: 4102444800,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
data, err := json.Marshal(token)
|
| 71 |
+
require.NoError(t, err)
|
| 72 |
+
|
| 73 |
+
var restored oauth.Token
|
| 74 |
+
require.NoError(t, json.Unmarshal(data, &restored))
|
| 75 |
+
require.Equal(t, "acct-42", restored.AccountID)
|
| 76 |
+
require.Equal(t, "idt", restored.IDToken)
|
| 77 |
+
require.Equal(t, "rt", restored.RefreshToken)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// testResolver mimics the shell resolver for templates: variables
|
| 81 |
+
// resolve to empty when unset, literals pass through.
|
| 82 |
+
type testResolver struct{}
|
| 83 |
+
|
| 84 |
+
func (testResolver) ResolveValue(v string) (string, error) {
|
| 85 |
+
if strings.HasPrefix(v, "$") {
|
| 86 |
+
return "", nil
|
| 87 |
+
}
|
| 88 |
+
return v, nil
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
func TestHasAPIKey(t *testing.T) {
|
| 92 |
+
t.Parallel()
|
| 93 |
+
|
| 94 |
+
t.Run("empty", func(t *testing.T) {
|
| 95 |
+
t.Parallel()
|
| 96 |
+
|
| 97 |
+
require.False(t, (&ProviderConfig{}).HasAPIKey(testResolver{}))
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
t.Run("literal key", func(t *testing.T) {
|
| 101 |
+
t.Parallel()
|
| 102 |
+
|
| 103 |
+
require.True(t, (&ProviderConfig{APIKey: "sk-key"}).HasAPIKey(testResolver{}))
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
t.Run("unresolved template", func(t *testing.T) {
|
| 107 |
+
t.Parallel()
|
| 108 |
+
|
| 109 |
+
require.False(t, (&ProviderConfig{APIKey: "$OPENAI_API_KEY"}).HasAPIKey(testResolver{}))
|
| 110 |
+
})
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// TestSetProviderAPIKeyOpenAIIsEitherOr proves the OpenAI provider holds
|
| 114 |
+
// exactly one credential: a ChatGPT login retires a previously entered
|
| 115 |
+
// API key, and entering an API key retires a previous ChatGPT login.
|
| 116 |
+
// Copilot keeps mirroring the access token into api_key as before.
|
| 117 |
+
func TestSetProviderAPIKeyOpenAIIsEitherOr(t *testing.T) {
|
| 118 |
+
// Not parallel: t.Setenv below.
|
| 119 |
+
|
| 120 |
+
// Point config discovery at the test sandbox: the write below
|
| 121 |
+
// triggers an auto-reload, which must not pick up the developer's
|
| 122 |
+
// real crush.json.
|
| 123 |
+
t.Setenv("CRUSH_GLOBAL_CONFIG", t.TempDir())
|
| 124 |
+
t.Setenv("CRUSH_GLOBAL_DATA", t.TempDir())
|
| 125 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 126 |
+
|
| 127 |
+
token := &oauth.Token{
|
| 128 |
+
AccessToken: "chatgpt-at",
|
| 129 |
+
RefreshToken: "chatgpt-rt",
|
| 130 |
+
ExpiresIn: 3600,
|
| 131 |
+
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
newStore := func(t *testing.T, providerID string, initialConfig string) *ConfigStore {
|
| 135 |
+
t.Helper()
|
| 136 |
+
dir := t.TempDir()
|
| 137 |
+
configPath := filepath.Join(dir, "crush.json")
|
| 138 |
+
require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
|
| 139 |
+
|
| 140 |
+
// The in-memory config mirrors what a real load would produce.
|
| 141 |
+
// SetConfigFields auto-reloads from configPath, so any state the
|
| 142 |
+
// assertions expect to survive must be on disk.
|
| 143 |
+
providers := csync.NewMap[string, ProviderConfig]()
|
| 144 |
+
require.NoError(t, json.Unmarshal([]byte(initialConfig), &struct {
|
| 145 |
+
Providers *csync.Map[string, ProviderConfig] `json:"providers"`
|
| 146 |
+
}{Providers: providers}))
|
| 147 |
+
|
| 148 |
+
return &ConfigStore{
|
| 149 |
+
config: &Config{Providers: providers},
|
| 150 |
+
globalDataPath: configPath,
|
| 151 |
+
workingDir: dir,
|
| 152 |
+
fetchOpenAIModels: func(context.Context, *oauth.Token) ([]catwalk.Model, error) {
|
| 153 |
+
return []catwalk.Model{{ID: "gpt-5.1-codex"}}, nil
|
| 154 |
+
},
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
t.Run("chatgpt login retires the api key", func(t *testing.T) {
|
| 159 |
+
store := newStore(t, "openai", `{
|
| 160 |
+
"providers": {
|
| 161 |
+
"openai": {
|
| 162 |
+
"id": "openai",
|
| 163 |
+
"api_key": "sk-keep",
|
| 164 |
+
"models": [{"id": "gpt-5.1", "name": "GPT-5.1"}]
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
}`)
|
| 168 |
+
|
| 169 |
+
require.NoError(t, store.SetProviderAPIKey(ScopeGlobal, "openai", token))
|
| 170 |
+
|
| 171 |
+
pc, ok := store.Config().Providers.Get("openai")
|
| 172 |
+
require.True(t, ok)
|
| 173 |
+
require.Empty(t, pc.APIKey, "the ChatGPT login replaces the API key")
|
| 174 |
+
require.Equal(t, token, pc.OAuthToken)
|
| 175 |
+
require.Equal(t, "gpt-5.1-codex", pc.ChatGPTModels[0].ID, "the subscription catalog lands in its own field")
|
| 176 |
+
require.Equal(t, "gpt-5.1", pc.Models[0].ID, "the API catalog is untouched")
|
| 177 |
+
|
| 178 |
+
disk, err := os.ReadFile(store.globalDataPath)
|
| 179 |
+
require.NoError(t, err)
|
| 180 |
+
require.NotContains(t, string(disk), "sk-keep", "the retired key is gone from the config file")
|
| 181 |
+
require.Contains(t, string(disk), "chatgpt-rt", "the login is persisted")
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
t.Run("api key retires the chatgpt login", func(t *testing.T) {
|
| 185 |
+
store := newStore(t, "openai", `{
|
| 186 |
+
"providers": {
|
| 187 |
+
"openai": {
|
| 188 |
+
"id": "openai",
|
| 189 |
+
"api_key": "",
|
| 190 |
+
"oauth": {"access_token": "old-at", "refresh_token": "old-rt"},
|
| 191 |
+
"chatgpt_models": [{"id": "gpt-5.6-luna"}]
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
}`)
|
| 195 |
+
|
| 196 |
+
require.NoError(t, store.SetProviderAPIKey(ScopeGlobal, "openai", "sk-new"))
|
| 197 |
+
|
| 198 |
+
pc, ok := store.Config().Providers.Get("openai")
|
| 199 |
+
require.True(t, ok)
|
| 200 |
+
require.Equal(t, "sk-new", pc.APIKey)
|
| 201 |
+
require.Nil(t, pc.OAuthToken, "the API key replaces the ChatGPT login")
|
| 202 |
+
require.Empty(t, pc.ChatGPTModels, "the subscription catalog goes with it")
|
| 203 |
+
|
| 204 |
+
disk, err := os.ReadFile(store.globalDataPath)
|
| 205 |
+
require.NoError(t, err)
|
| 206 |
+
require.NotContains(t, string(disk), "old-rt", "the retired login is gone from the config file")
|
| 207 |
+
require.NotContains(t, string(disk), "gpt-5.6-luna")
|
| 208 |
+
})
|
| 209 |
+
|
| 210 |
+
t.Run("copilot", func(t *testing.T) {
|
| 211 |
+
store := newStore(t, "copilot", `{"providers":{"copilot":{"id":"copilot"}}}`)
|
| 212 |
+
|
| 213 |
+
require.NoError(t, store.SetProviderAPIKey(ScopeGlobal, "copilot", token))
|
| 214 |
+
|
| 215 |
+
pc, ok := store.Config().Providers.Get("copilot")
|
| 216 |
+
require.True(t, ok)
|
| 217 |
+
require.Equal(t, "chatgpt-at", pc.APIKey, "copilot still mirrors the access token into api_key")
|
| 218 |
+
require.Equal(t, token, pc.OAuthToken)
|
| 219 |
+
})
|
| 220 |
+
}
|
internal/config/options_request_timeout_test.go
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"testing"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"github.com/stretchr/testify/require"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func ptr[T any](v T) *T { return &v }
|
| 12 |
+
|
| 13 |
+
func TestOptionsGetRequestTimeout(t *testing.T) {
|
| 14 |
+
t.Parallel()
|
| 15 |
+
|
| 16 |
+
tests := []struct {
|
| 17 |
+
name string
|
| 18 |
+
options *Options
|
| 19 |
+
expected time.Duration
|
| 20 |
+
}{
|
| 21 |
+
{
|
| 22 |
+
name: "nil options",
|
| 23 |
+
options: nil,
|
| 24 |
+
expected: DefaultRequestTimeout,
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
name: "unset field",
|
| 28 |
+
options: &Options{},
|
| 29 |
+
expected: DefaultRequestTimeout,
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
name: "disabled",
|
| 33 |
+
options: &Options{RequestTimeout: ptr(0)},
|
| 34 |
+
expected: 0,
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
name: "negative disables too",
|
| 38 |
+
options: &Options{RequestTimeout: ptr(-1)},
|
| 39 |
+
expected: 0,
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
name: "seconds to duration",
|
| 43 |
+
options: &Options{RequestTimeout: ptr(300)},
|
| 44 |
+
expected: 5 * time.Minute,
|
| 45 |
+
},
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
for _, tt := range tests {
|
| 49 |
+
t.Run(tt.name, func(t *testing.T) {
|
| 50 |
+
t.Parallel()
|
| 51 |
+
require.Equal(t, tt.expected, tt.options.GetRequestTimeout())
|
| 52 |
+
})
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestOptionsRequestTimeoutFromJSON(t *testing.T) {
|
| 57 |
+
t.Parallel()
|
| 58 |
+
|
| 59 |
+
var cfg Config
|
| 60 |
+
require.NoError(t, json.Unmarshal([]byte(`{"options":{"request_timeout":300}}`), &cfg))
|
| 61 |
+
require.Equal(t, 300, *cfg.Options.RequestTimeout)
|
| 62 |
+
require.Equal(t, 5*time.Minute, cfg.Options.GetRequestTimeout())
|
| 63 |
+
}
|
internal/config/provider_empty_test.go
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"os"
|
| 6 |
+
"testing"
|
| 7 |
+
|
| 8 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 9 |
+
"github.com/stretchr/testify/require"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type emptyProviderClient struct{}
|
| 13 |
+
|
| 14 |
+
func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
|
| 15 |
+
return []catwalk.Provider{}, nil
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// TestCatwalkSync_GetEmptyResultFromClient tests that when the client returns
|
| 19 |
+
// an empty list, we fall back to cached providers and return an error.
|
| 20 |
+
func TestCatwalkSync_GetEmptyResultFromClient(t *testing.T) {
|
| 21 |
+
t.Parallel()
|
| 22 |
+
|
| 23 |
+
tmpDir := t.TempDir()
|
| 24 |
+
path := tmpDir + "/providers.json"
|
| 25 |
+
|
| 26 |
+
syncer := &catwalkSync{}
|
| 27 |
+
client := &emptyProviderClient{}
|
| 28 |
+
|
| 29 |
+
syncer.Init(client, path, true)
|
| 30 |
+
|
| 31 |
+
providers, err := syncer.Get(t.Context())
|
| 32 |
+
require.Error(t, err)
|
| 33 |
+
require.Contains(t, err.Error(), "empty providers list from catwalk")
|
| 34 |
+
require.NotEmpty(t, providers) // Should have embedded providers as fallback.
|
| 35 |
+
|
| 36 |
+
// Check that no cache file was created for empty results.
|
| 37 |
+
_, statErr := os.Stat(path)
|
| 38 |
+
require.True(t, os.IsNotExist(statErr), "Cache file should not exist for empty results")
|
| 39 |
+
}
|
internal/config/recent_models_test.go
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"io/fs"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"testing"
|
| 9 |
+
|
| 10 |
+
"github.com/stretchr/testify/require"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
// readConfigJSON reads and unmarshals the JSON config file at path.
|
| 14 |
+
func readConfigJSON(t *testing.T, path string) map[string]any {
|
| 15 |
+
t.Helper()
|
| 16 |
+
baseDir := filepath.Dir(path)
|
| 17 |
+
fileName := filepath.Base(path)
|
| 18 |
+
b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
|
| 19 |
+
require.NoError(t, err)
|
| 20 |
+
var out map[string]any
|
| 21 |
+
require.NoError(t, json.Unmarshal(b, &out))
|
| 22 |
+
return out
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// readRecentModels reads the recent_models section from the config file.
|
| 26 |
+
func readRecentModels(t *testing.T, path string) map[string]any {
|
| 27 |
+
t.Helper()
|
| 28 |
+
out := readConfigJSON(t, path)
|
| 29 |
+
rm, ok := out["recent_models"].(map[string]any)
|
| 30 |
+
require.True(t, ok)
|
| 31 |
+
return rm
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// testStoreWithPath creates a ConfigStore backed by a Config for recent model tests.
|
| 35 |
+
func testStoreWithPath(cfg *Config, dir string) *ConfigStore {
|
| 36 |
+
return &ConfigStore{
|
| 37 |
+
config: cfg,
|
| 38 |
+
globalDataPath: filepath.Join(dir, "config.json"),
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// configWithRecents builds a Config seeded with the given recent models for
|
| 43 |
+
// the large type, for exercising the pure nextRecentModels helper.
|
| 44 |
+
func configWithRecents(recents ...SelectedModel) *Config {
|
| 45 |
+
return &Config{
|
| 46 |
+
RecentModels: map[SelectedModelType][]SelectedModel{
|
| 47 |
+
SelectedModelTypeLarge: recents,
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
func TestNextRecentModels_AddsToFront(t *testing.T) {
|
| 53 |
+
t.Parallel()
|
| 54 |
+
|
| 55 |
+
cfg := configWithRecents()
|
| 56 |
+
updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
|
| 57 |
+
require.True(t, changed)
|
| 58 |
+
require.Equal(t, []SelectedModel{{Provider: "openai", Model: "gpt-4o"}}, updated)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func TestNextRecentModels_DedupeAndMoveToFront(t *testing.T) {
|
| 62 |
+
t.Parallel()
|
| 63 |
+
|
| 64 |
+
cfg := configWithRecents(
|
| 65 |
+
SelectedModel{Provider: "anthropic", Model: "claude"},
|
| 66 |
+
SelectedModel{Provider: "openai", Model: "gpt-4o"},
|
| 67 |
+
)
|
| 68 |
+
updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
|
| 69 |
+
require.True(t, changed)
|
| 70 |
+
require.Equal(t, []SelectedModel{
|
| 71 |
+
{Provider: "openai", Model: "gpt-4o"},
|
| 72 |
+
{Provider: "anthropic", Model: "claude"},
|
| 73 |
+
}, updated)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
func TestNextRecentModels_TrimsToMax(t *testing.T) {
|
| 77 |
+
t.Parallel()
|
| 78 |
+
|
| 79 |
+
var seed []SelectedModel
|
| 80 |
+
for _, id := range []string{"m5", "m4", "m3", "m2", "m1"} {
|
| 81 |
+
seed = append(seed, SelectedModel{Provider: "p", Model: id})
|
| 82 |
+
}
|
| 83 |
+
cfg := configWithRecents(seed...)
|
| 84 |
+
|
| 85 |
+
updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: "m6"})
|
| 86 |
+
require.True(t, changed)
|
| 87 |
+
require.Len(t, updated, maxRecentModelsPerType)
|
| 88 |
+
require.Equal(t, SelectedModel{Provider: "p", Model: "m6"}, updated[0])
|
| 89 |
+
require.Equal(t, SelectedModel{Provider: "p", Model: "m2"}, updated[maxRecentModelsPerType-1])
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
func TestNextRecentModels_SkipsEmptyValues(t *testing.T) {
|
| 93 |
+
t.Parallel()
|
| 94 |
+
|
| 95 |
+
cfg := configWithRecents()
|
| 96 |
+
_, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"})
|
| 97 |
+
require.False(t, changed)
|
| 98 |
+
_, changed = nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""})
|
| 99 |
+
require.False(t, changed)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
func TestNextRecentModels_NoChangeWhenAlreadyFront(t *testing.T) {
|
| 103 |
+
t.Parallel()
|
| 104 |
+
|
| 105 |
+
entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
|
| 106 |
+
cfg := configWithRecents(entry)
|
| 107 |
+
_, changed := nextRecentModels(cfg, SelectedModelTypeLarge, entry)
|
| 108 |
+
require.False(t, changed)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
func TestUpdatePreferredModel_PersistsModelAndRecents(t *testing.T) {
|
| 112 |
+
t.Parallel()
|
| 113 |
+
|
| 114 |
+
dir := t.TempDir()
|
| 115 |
+
cfg := &Config{}
|
| 116 |
+
cfg.setDefaults(dir, "")
|
| 117 |
+
store := testStoreWithPath(cfg, dir)
|
| 118 |
+
|
| 119 |
+
sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
|
| 120 |
+
require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, sel))
|
| 121 |
+
|
| 122 |
+
// in-memory state (read through the store; copy-on-write publishes a
|
| 123 |
+
// new Config, so the seed cfg pointer is intentionally unchanged).
|
| 124 |
+
require.Equal(t, sel, store.Config().Models[SelectedModelTypeLarge])
|
| 125 |
+
require.Len(t, store.Config().RecentModels[SelectedModelTypeLarge], 1)
|
| 126 |
+
|
| 127 |
+
// persisted state
|
| 128 |
+
rm := readRecentModels(t, store.globalDataPath)
|
| 129 |
+
large, ok := rm[string(SelectedModelTypeLarge)].([]any)
|
| 130 |
+
require.True(t, ok)
|
| 131 |
+
require.Len(t, large, 1)
|
| 132 |
+
item := large[0].(map[string]any)
|
| 133 |
+
require.Equal(t, "openai", item["provider"])
|
| 134 |
+
require.Equal(t, "gpt-4o", item["model"])
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
func TestUpdatePreferredModel_TypeIsolation(t *testing.T) {
|
| 138 |
+
t.Parallel()
|
| 139 |
+
|
| 140 |
+
dir := t.TempDir()
|
| 141 |
+
cfg := &Config{}
|
| 142 |
+
cfg.setDefaults(dir, "")
|
| 143 |
+
store := testStoreWithPath(cfg, dir)
|
| 144 |
+
|
| 145 |
+
largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
|
| 146 |
+
smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
|
| 147 |
+
require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
|
| 148 |
+
require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
|
| 149 |
+
|
| 150 |
+
// Adding to large leaves small untouched.
|
| 151 |
+
anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
|
| 152 |
+
require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
|
| 153 |
+
|
| 154 |
+
require.Len(t, store.Config().RecentModels[SelectedModelTypeLarge], 2)
|
| 155 |
+
require.Equal(t, anotherLarge, store.Config().RecentModels[SelectedModelTypeLarge][0])
|
| 156 |
+
require.Len(t, store.Config().RecentModels[SelectedModelTypeSmall], 1)
|
| 157 |
+
require.Equal(t, smallModel, store.Config().RecentModels[SelectedModelTypeSmall][0])
|
| 158 |
+
}
|
internal/config/reload_crushrc_test.go
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
// isolateReloadEnv points HOME/XDG/CRUSH_* at a throwaway directory so a
|
| 15 |
+
// reload only sees the config files the test writes. No t.Parallel(): these
|
| 16 |
+
// tests Setenv.
|
| 17 |
+
func isolateReloadEnv(t *testing.T) (workDir, dataDir string) {
|
| 18 |
+
t.Helper()
|
| 19 |
+
isolated := t.TempDir()
|
| 20 |
+
t.Setenv("HOME", isolated)
|
| 21 |
+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
|
| 22 |
+
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
|
| 23 |
+
t.Setenv("CRUSH_GLOBAL_CONFIG", filepath.Join(isolated, ".config", "crush"))
|
| 24 |
+
t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(isolated, ".local", "share", "crush"))
|
| 25 |
+
return t.TempDir(), t.TempDir()
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// TestReloadFromDisk_PicksUpEditedCrushrc verifies that a crushrc edit on
|
| 29 |
+
// disk is reflected in memory after a reload, matching how JSON config
|
| 30 |
+
// already behaves.
|
| 31 |
+
func TestReloadFromDisk_PicksUpEditedCrushrc(t *testing.T) {
|
| 32 |
+
workDir, dataDir := isolateReloadEnv(t)
|
| 33 |
+
rcPath := filepath.Join(workDir, "crushrc")
|
| 34 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("option notifications bell\n"), 0o644))
|
| 35 |
+
|
| 36 |
+
store, err := config.Load(workDir, dataDir, false)
|
| 37 |
+
require.NoError(t, err)
|
| 38 |
+
require.Equal(t, "bell", store.Config().Options.Notifications)
|
| 39 |
+
|
| 40 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("option notifications osc\n"), 0o644))
|
| 41 |
+
require.NoError(t, store.ReloadFromDisk(context.Background()))
|
| 42 |
+
require.Equal(t, "osc", store.Config().Options.Notifications,
|
| 43 |
+
"reload must re-execute the crushrc and pick up the new value")
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// TestReloadFromDisk_FailingCrushrcKeepsOldConfig verifies that a crushrc
|
| 47 |
+
// which fails to load during a reload does not clobber the in-memory config.
|
| 48 |
+
// The reload must return an error and leave the previously-loaded config
|
| 49 |
+
// intact (the swap happens only after a successful parse).
|
| 50 |
+
func TestReloadFromDisk_FailingCrushrcKeepsOldConfig(t *testing.T) {
|
| 51 |
+
workDir, dataDir := isolateReloadEnv(t)
|
| 52 |
+
rcPath := filepath.Join(workDir, "crushrc")
|
| 53 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("option notifications bell\n"), 0o644))
|
| 54 |
+
|
| 55 |
+
store, err := config.Load(workDir, dataDir, false)
|
| 56 |
+
require.NoError(t, err)
|
| 57 |
+
require.Equal(t, "bell", store.Config().Options.Notifications)
|
| 58 |
+
|
| 59 |
+
// Overwrite with a crushrc that errors at load time (unknown option key).
|
| 60 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("option totally-bogus-key value\n"), 0o644))
|
| 61 |
+
|
| 62 |
+
err = store.ReloadFromDisk(context.Background())
|
| 63 |
+
require.Error(t, err, "a failing crushrc must surface a reload error")
|
| 64 |
+
require.Equal(t, "bell", store.Config().Options.Notifications,
|
| 65 |
+
"in-memory config must be preserved when a reload fails")
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// TestReloadFromDisk_HangingCrushrcIsInterruptible is the core regression test
|
| 69 |
+
// for the reload-path blocker: config reloads run while holding the store's
|
| 70 |
+
// write lock, so a crushrc that blocks (busy loop, hung command substitution)
|
| 71 |
+
// must be interruptible via the reload context rather than wedging the store
|
| 72 |
+
// forever. A cancelled reload must return an error and leave the old config
|
| 73 |
+
// in place. The test bounds its own wait so a regression can't hang CI.
|
| 74 |
+
func TestReloadFromDisk_HangingCrushrcIsInterruptible(t *testing.T) {
|
| 75 |
+
workDir, dataDir := isolateReloadEnv(t)
|
| 76 |
+
rcPath := filepath.Join(workDir, "crushrc")
|
| 77 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("option notifications bell\n"), 0o644))
|
| 78 |
+
|
| 79 |
+
store, err := config.Load(workDir, dataDir, false)
|
| 80 |
+
require.NoError(t, err)
|
| 81 |
+
require.Equal(t, "bell", store.Config().Options.Notifications)
|
| 82 |
+
|
| 83 |
+
// Overwrite with a crushrc that never terminates.
|
| 84 |
+
require.NoError(t, os.WriteFile(rcPath, []byte("while true; do :; done\n"), 0o644))
|
| 85 |
+
|
| 86 |
+
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
| 87 |
+
t.Cleanup(cancel)
|
| 88 |
+
|
| 89 |
+
done := make(chan error, 1)
|
| 90 |
+
go func() {
|
| 91 |
+
done <- store.ReloadFromDisk(ctx)
|
| 92 |
+
}()
|
| 93 |
+
|
| 94 |
+
select {
|
| 95 |
+
case err := <-done:
|
| 96 |
+
require.Error(t, err, "a cancelled reload must fail, not succeed")
|
| 97 |
+
case <-time.After(3 * time.Second):
|
| 98 |
+
t.Fatal("ReloadFromDisk did not return after context cancellation; the store would be wedged")
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
require.Equal(t, "bell", store.Config().Options.Notifications,
|
| 102 |
+
"in-memory config must be preserved when a reload is cancelled")
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// TestLoad_TracksNotYetCreatedGlobalCrushrc verifies that config.Load tracks
|
| 106 |
+
// the global crushrc path even when the file does not exist yet, so a crushrc
|
| 107 |
+
// created after startup is detected as a staleness change. Previously only
|
| 108 |
+
// successfully-loaded paths were tracked, so a mid-session global crushrc went
|
| 109 |
+
// unnoticed until something else triggered a reload.
|
| 110 |
+
//
|
| 111 |
+
// Project-level crushrc files are discovered by walking the tree for existing
|
| 112 |
+
// files, so a not-yet-created project crushrc is still only picked up on the
|
| 113 |
+
// next reload; the global path is the common case this covers.
|
| 114 |
+
func TestLoad_TracksNotYetCreatedGlobalCrushrc(t *testing.T) {
|
| 115 |
+
workDir, dataDir := isolateReloadEnv(t)
|
| 116 |
+
globalRC := filepath.Join(t.TempDir(), "crushrc")
|
| 117 |
+
t.Setenv("CRUSH_GLOBAL_CONFIG", filepath.Dir(globalRC))
|
| 118 |
+
|
| 119 |
+
// A provider must be configured so Load runs past its early
|
| 120 |
+
// "not configured" return and reaches the staleness snapshot capture.
|
| 121 |
+
require.NoError(t, os.WriteFile(
|
| 122 |
+
filepath.Join(workDir, "crushrc"),
|
| 123 |
+
[]byte("provider add openai --api-key k\n"), 0o644,
|
| 124 |
+
))
|
| 125 |
+
|
| 126 |
+
// Load with no global crushrc present.
|
| 127 |
+
store, err := config.Load(workDir, dataDir, false)
|
| 128 |
+
require.NoError(t, err)
|
| 129 |
+
require.False(t, store.ConfigStaleness().Dirty, "fresh load should be clean")
|
| 130 |
+
|
| 131 |
+
// Create the global crushrc after startup.
|
| 132 |
+
require.NoError(t, os.WriteFile(globalRC, []byte("option debug true\n"), 0o644))
|
| 133 |
+
|
| 134 |
+
staleness := store.ConfigStaleness()
|
| 135 |
+
require.True(t, staleness.Dirty, "creating a global crushrc must be detected")
|
| 136 |
+
require.Contains(t, staleness.Changed, globalRC)
|
| 137 |
+
}
|
internal/config/reload_hooks_test.go
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"testing"
|
| 8 |
+
|
| 9 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/hooks"
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
// TestReloadFromDisk_CompilesHookMatchers is a regression test for a bug
|
| 15 |
+
// where ReloadFromDisk dropped the compiled matcher regex on every hook,
|
| 16 |
+
// causing a matcher like "^bash$" to match every tool call after any
|
| 17 |
+
// SetConfigField-triggered reload.
|
| 18 |
+
//
|
| 19 |
+
// The assertion is phrased in terms of observable Runner behavior (not
|
| 20 |
+
// internal field presence) so it stays valid if the Runner later owns
|
| 21 |
+
// matcher compilation itself.
|
| 22 |
+
func TestReloadFromDisk_CompilesHookMatchers(t *testing.T) {
|
| 23 |
+
// No t.Parallel(): we Setenv HOME/XDG_CONFIG_HOME to isolate from the
|
| 24 |
+
// developer's real global config, which may define its own hooks.
|
| 25 |
+
isolated := t.TempDir()
|
| 26 |
+
t.Setenv("HOME", isolated)
|
| 27 |
+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
|
| 28 |
+
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
|
| 29 |
+
|
| 30 |
+
workDir := t.TempDir()
|
| 31 |
+
dataDir := t.TempDir()
|
| 32 |
+
configPath := filepath.Join(workDir, "crush.json")
|
| 33 |
+
cfgJSON := `{
|
| 34 |
+
"hooks": {
|
| 35 |
+
"PreToolUse": [
|
| 36 |
+
{"matcher": "^bash$", "command": "exit 0"}
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
}`
|
| 40 |
+
require.NoError(t, os.WriteFile(configPath, []byte(cfgJSON), 0o600))
|
| 41 |
+
|
| 42 |
+
store, err := config.Load(workDir, dataDir, false)
|
| 43 |
+
require.NoError(t, err)
|
| 44 |
+
|
| 45 |
+
// Sanity: hook filtering works immediately after Load.
|
| 46 |
+
assertHookFilters(t, store)
|
| 47 |
+
|
| 48 |
+
require.NoError(t, store.ReloadFromDisk(context.Background()))
|
| 49 |
+
|
| 50 |
+
// The actual regression check: filtering must still work after a
|
| 51 |
+
// reload, not silently collapse to match-everything.
|
| 52 |
+
assertHookFilters(t, store)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// assertHookFilters builds a Runner from the store's current hooks and
|
| 56 |
+
// verifies the "^bash$" matcher rejects a non-bash tool while accepting
|
| 57 |
+
// bash.
|
| 58 |
+
func assertHookFilters(t *testing.T, store *config.ConfigStore) {
|
| 59 |
+
t.Helper()
|
| 60 |
+
preHooks := store.Config().Hooks[hooks.EventPreToolUse]
|
| 61 |
+
require.Len(t, preHooks, 1)
|
| 62 |
+
|
| 63 |
+
runner := hooks.NewRunner(preHooks, t.TempDir(), t.TempDir())
|
| 64 |
+
|
| 65 |
+
nonMatch, err := runner.Run(context.Background(), hooks.EventPreToolUse, "sess", "view", `{}`)
|
| 66 |
+
require.NoError(t, err)
|
| 67 |
+
require.Equal(t, 0, nonMatch.HookCount, "view must not match ^bash$ matcher")
|
| 68 |
+
|
| 69 |
+
match, err := runner.Run(context.Background(), hooks.EventPreToolUse, "sess", "bash", `{}`)
|
| 70 |
+
require.NoError(t, err)
|
| 71 |
+
require.Equal(t, 1, match.HookCount, "bash must match ^bash$ matcher")
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// TestSetConfigField_AutoReload_PreservesHookMatcherFiltering verifies the
|
| 75 |
+
// dominant real-world trigger path: config writes call autoReload,
|
| 76 |
+
// autoReload calls ReloadFromDisk, and hook matching must remain correct.
|
| 77 |
+
func TestSetConfigField_AutoReload_PreservesHookMatcherFiltering(t *testing.T) {
|
| 78 |
+
isolated := t.TempDir()
|
| 79 |
+
t.Setenv("HOME", isolated)
|
| 80 |
+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
|
| 81 |
+
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
|
| 82 |
+
|
| 83 |
+
workDir := t.TempDir()
|
| 84 |
+
dataDir := t.TempDir()
|
| 85 |
+
configPath := filepath.Join(workDir, "crush.json")
|
| 86 |
+
cfgJSON := `{
|
| 87 |
+
"hooks": {
|
| 88 |
+
"PreToolUse": [
|
| 89 |
+
{"matcher": "^bash$", "command": "exit 0"}
|
| 90 |
+
]
|
| 91 |
+
}
|
| 92 |
+
}`
|
| 93 |
+
require.NoError(t, os.WriteFile(configPath, []byte(cfgJSON), 0o600))
|
| 94 |
+
|
| 95 |
+
store, err := config.Load(workDir, dataDir, false)
|
| 96 |
+
require.NoError(t, err)
|
| 97 |
+
assertHookFilters(t, store)
|
| 98 |
+
|
| 99 |
+
require.NoError(t, store.SetConfigField(config.ScopeGlobal, "options.debug", true))
|
| 100 |
+
|
| 101 |
+
assertHookFilters(t, store)
|
| 102 |
+
}
|
internal/config/resolve_real_test.go
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"maps"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"slices"
|
| 9 |
+
"strings"
|
| 10 |
+
"testing"
|
| 11 |
+
|
| 12 |
+
"github.com/charmbracelet/crush/internal/env"
|
| 13 |
+
"github.com/stretchr/testify/require"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// These tests exercise the full shell-expansion path (no mocks,
|
| 17 |
+
// no injected Expander) to catch regressions that only surface when
|
| 18 |
+
// internal/shell actually runs the value. Table-level unit tests with
|
| 19 |
+
// fake expanders live in resolve_test.go.
|
| 20 |
+
|
| 21 |
+
// realShellResolver builds a resolver backed by a shell env that
|
| 22 |
+
// contains PATH + the caller-supplied overrides. Production callers
|
| 23 |
+
// get PATH for free via env.New(); these tests need it so $(cat ...)
|
| 24 |
+
// and similar inner commands can resolve.
|
| 25 |
+
func realShellResolver(vars map[string]string) VariableResolver {
|
| 26 |
+
m := map[string]string{"PATH": os.Getenv("PATH")}
|
| 27 |
+
maps.Copy(m, vars)
|
| 28 |
+
return NewShellVariableResolver(env.NewFromMap(m))
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func writeTempFile(t *testing.T, content string) string {
|
| 32 |
+
t.Helper()
|
| 33 |
+
dir := t.TempDir()
|
| 34 |
+
p := filepath.Join(dir, "secret")
|
| 35 |
+
require.NoError(t, os.WriteFile(p, []byte(content), 0o600))
|
| 36 |
+
return p
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// TestResolvedEnv_RealShell_Success covers the shell constructs the
|
| 40 |
+
// PLAN calls out: $(cat tempfile) with and without trailing newline,
|
| 41 |
+
// ${VAR:-default} for unset vars, literal-space preservation around
|
| 42 |
+
// $(...), nested parens, quoted args inside $(echo ...), and a
|
| 43 |
+
// glob-like literal round-tripping unchanged.
|
| 44 |
+
func TestResolvedEnv_RealShell_Success(t *testing.T) {
|
| 45 |
+
t.Parallel()
|
| 46 |
+
|
| 47 |
+
// filepath.ToSlash so Windows temp paths (C:\Users\...) survive
|
| 48 |
+
// being injected into a shell command string — the embedded shell
|
| 49 |
+
// treats backslashes as escapes, forward slashes work on every OS.
|
| 50 |
+
withNL := filepath.ToSlash(writeTempFile(t, "token-with-nl\n"))
|
| 51 |
+
noNL := filepath.ToSlash(writeTempFile(t, "token-no-nl"))
|
| 52 |
+
|
| 53 |
+
m := MCPConfig{
|
| 54 |
+
Env: map[string]string{
|
| 55 |
+
// POSIX strips exactly one trailing newline from $(...)
|
| 56 |
+
// output, so both forms land on the same value.
|
| 57 |
+
"TOK_NL": fmt.Sprintf("$(cat %s)", withNL),
|
| 58 |
+
"TOK_NO": fmt.Sprintf("$(cat %s)", noNL),
|
| 59 |
+
|
| 60 |
+
// ${VAR:-default} must not error on unset: this is the
|
| 61 |
+
// opt-in escape hatch for "empty is fine".
|
| 62 |
+
"FALLBACK": "${MCP_MISSING:-fallback}",
|
| 63 |
+
|
| 64 |
+
// Leading/trailing literal spaces around $(...) must be
|
| 65 |
+
// preserved — single-value contract, no field splitting.
|
| 66 |
+
"PADDED": " $(echo v) ",
|
| 67 |
+
|
| 68 |
+
// ")" inside a quoted arg to echo is a regression guard
|
| 69 |
+
// for the old hand-rolled paren matcher.
|
| 70 |
+
"PAREN": `$(echo ")")`,
|
| 71 |
+
|
| 72 |
+
// Embedded space inside a quoted arg must survive
|
| 73 |
+
// verbatim; no word-splitting side effect.
|
| 74 |
+
"SPACEY": `$(echo "a b")`,
|
| 75 |
+
|
| 76 |
+
// Glob-like literals must not expand.
|
| 77 |
+
"GLOB": "*.go",
|
| 78 |
+
},
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
got, err := m.ResolvedEnv(realShellResolver(nil))
|
| 82 |
+
require.NoError(t, err)
|
| 83 |
+
|
| 84 |
+
// ResolvedEnv returns "KEY=value" sorted by key.
|
| 85 |
+
want := []string{
|
| 86 |
+
"FALLBACK=fallback",
|
| 87 |
+
"GLOB=*.go",
|
| 88 |
+
"PADDED= v ",
|
| 89 |
+
"PAREN=)",
|
| 90 |
+
"SPACEY=a b",
|
| 91 |
+
"TOK_NL=token-with-nl",
|
| 92 |
+
"TOK_NO=token-no-nl",
|
| 93 |
+
}
|
| 94 |
+
require.Equal(t, want, got)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// TestResolvedEnv_RealShell_DoesNotMutate pins that both success and
|
| 98 |
+
// failure paths leave m.Env untouched. Prior behaviour rewrote the
|
| 99 |
+
// value in place on error; that was the exact mechanism that shipped
|
| 100 |
+
// empty credentials to MCP servers.
|
| 101 |
+
func TestResolvedEnv_RealShell_DoesNotMutate(t *testing.T) {
|
| 102 |
+
t.Parallel()
|
| 103 |
+
|
| 104 |
+
t.Run("success path leaves Env untouched", func(t *testing.T) {
|
| 105 |
+
t.Parallel()
|
| 106 |
+
m := MCPConfig{Env: map[string]string{"TOKEN": "$(echo shh)"}}
|
| 107 |
+
orig := maps.Clone(m.Env)
|
| 108 |
+
|
| 109 |
+
_, err := m.ResolvedEnv(realShellResolver(nil))
|
| 110 |
+
require.NoError(t, err)
|
| 111 |
+
require.Equal(t, orig, m.Env)
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
t.Run("failure path leaves Env untouched", func(t *testing.T) {
|
| 115 |
+
t.Parallel()
|
| 116 |
+
m := MCPConfig{Env: map[string]string{"BROKEN": "$(false)"}}
|
| 117 |
+
orig := maps.Clone(m.Env)
|
| 118 |
+
|
| 119 |
+
_, err := m.ResolvedEnv(realShellResolver(nil))
|
| 120 |
+
require.Error(t, err)
|
| 121 |
+
require.Equal(t, orig, m.Env, "map must be preserved on error")
|
| 122 |
+
})
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// TestResolvedEnv_RealShell_Idempotent pins the pure-function contract:
|
| 126 |
+
// two calls on the same config return deeply-equal slices.
|
| 127 |
+
func TestResolvedEnv_RealShell_Idempotent(t *testing.T) {
|
| 128 |
+
t.Parallel()
|
| 129 |
+
|
| 130 |
+
m := MCPConfig{
|
| 131 |
+
Env: map[string]string{
|
| 132 |
+
"A": "$(echo one)",
|
| 133 |
+
"B": "$(echo two)",
|
| 134 |
+
"C": "literal",
|
| 135 |
+
},
|
| 136 |
+
}
|
| 137 |
+
r := realShellResolver(nil)
|
| 138 |
+
|
| 139 |
+
first, err := m.ResolvedEnv(r)
|
| 140 |
+
require.NoError(t, err)
|
| 141 |
+
second, err := m.ResolvedEnv(r)
|
| 142 |
+
require.NoError(t, err)
|
| 143 |
+
require.Equal(t, first, second)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// TestResolvedEnv_RealShell_Deterministic guards against Go's
|
| 147 |
+
// randomized map iteration leaking into the returned slice order.
|
| 148 |
+
func TestResolvedEnv_RealShell_Deterministic(t *testing.T) {
|
| 149 |
+
t.Parallel()
|
| 150 |
+
|
| 151 |
+
m := MCPConfig{Env: map[string]string{
|
| 152 |
+
"Z": "z",
|
| 153 |
+
"A": "a",
|
| 154 |
+
"M": "m",
|
| 155 |
+
}}
|
| 156 |
+
|
| 157 |
+
got, err := m.ResolvedEnv(realShellResolver(nil))
|
| 158 |
+
require.NoError(t, err)
|
| 159 |
+
require.True(t, slices.IsSorted(got), "env slice must be sorted; got %v", got)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// TestResolvedEnv_RealShell_UnsetExpandsEmpty pins the lenient
|
| 163 |
+
// default: an unset bare $VAR expands to the empty string, matching
|
| 164 |
+
// bash. The silent-empty-credential class of bug is prevented by the
|
| 165 |
+
// pure-function error-returning contract of ResolvedEnv, so we don't
|
| 166 |
+
// rely on nounset to catch typo'd variable names. Users who want
|
| 167 |
+
// strict behaviour for a required credential opt in per-reference
|
| 168 |
+
// with ${VAR:?msg}; see TestResolvedEnv_RealShell_ColonQuestionIsStrict.
|
| 169 |
+
func TestResolvedEnv_RealShell_UnsetExpandsEmpty(t *testing.T) {
|
| 170 |
+
t.Parallel()
|
| 171 |
+
|
| 172 |
+
m := MCPConfig{Env: map[string]string{
|
| 173 |
+
// Intentional typo: user meant $FORGEJO_TOKEN. Under the
|
| 174 |
+
// lenient default this expands to "" rather than erroring,
|
| 175 |
+
// matching bash's behaviour on bare $VAR.
|
| 176 |
+
"FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
|
| 177 |
+
}}
|
| 178 |
+
got, err := m.ResolvedEnv(realShellResolver(nil))
|
| 179 |
+
require.NoError(t, err, "unset var must expand to empty, not error")
|
| 180 |
+
require.Equal(t, []string{"FORGEJO_ACCESS_TOKEN="}, got)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// TestResolvedEnv_RealShell_ColonQuestionIsStrict pins the opt-in
|
| 184 |
+
// strictness contract: ${VAR:?msg} must hard-error when VAR is unset,
|
| 185 |
+
// regardless of the global NoUnset toggle. This is the recommended
|
| 186 |
+
// mechanism for required credentials under the lenient default, so a
|
| 187 |
+
// future refactor that accidentally swallows ${VAR:?...} errors would
|
| 188 |
+
// silently ship empty tokens to MCP servers again.
|
| 189 |
+
func TestResolvedEnv_RealShell_ColonQuestionIsStrict(t *testing.T) {
|
| 190 |
+
t.Parallel()
|
| 191 |
+
|
| 192 |
+
m := MCPConfig{Env: map[string]string{
|
| 193 |
+
"FORGEJO_ACCESS_TOKEN": "${FORGJO_TOKEN:?set FORGJO_TOKEN}",
|
| 194 |
+
}}
|
| 195 |
+
got, err := m.ResolvedEnv(realShellResolver(nil))
|
| 196 |
+
require.Error(t, err, "${VAR:?msg} must error when VAR is unset")
|
| 197 |
+
require.Nil(t, got)
|
| 198 |
+
// The resolver wraps with the env key and the user-written
|
| 199 |
+
// template; the inner shell error carries the :? message so
|
| 200 |
+
// users learn which credential is missing and why.
|
| 201 |
+
msg := err.Error()
|
| 202 |
+
require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN")
|
| 203 |
+
require.Contains(t, msg, "${FORGJO_TOKEN:?set FORGJO_TOKEN}")
|
| 204 |
+
require.Contains(t, msg, "set FORGJO_TOKEN")
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// TestResolvedEnv_RealShell_FailureDetail pins that a failing inner
|
| 208 |
+
// command surfaces enough detail (exit code + stderr on POSIX, the
|
| 209 |
+
// underlying OS error on Windows where coreutils runs in-process) to
|
| 210 |
+
// diagnose without forcing the user to re-run the command by hand.
|
| 211 |
+
// Also verifies the template is included so they know which Env
|
| 212 |
+
// entry blew up.
|
| 213 |
+
func TestResolvedEnv_RealShell_FailureDetail(t *testing.T) {
|
| 214 |
+
t.Parallel()
|
| 215 |
+
|
| 216 |
+
// Forward slashes so the path survives shell-string injection on
|
| 217 |
+
// Windows; see TestResolvedEnv_RealShell_Success for the same note.
|
| 218 |
+
missing := filepath.ToSlash(filepath.Join(t.TempDir(), "definitely-not-here"))
|
| 219 |
+
m := MCPConfig{Env: map[string]string{
|
| 220 |
+
"FORGEJO_ACCESS_TOKEN": fmt.Sprintf("$(cat %s)", missing),
|
| 221 |
+
}}
|
| 222 |
+
|
| 223 |
+
_, err := m.ResolvedEnv(realShellResolver(nil))
|
| 224 |
+
require.Error(t, err)
|
| 225 |
+
msg := err.Error()
|
| 226 |
+
require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN", "must identify the failing env var")
|
| 227 |
+
require.Contains(t, msg, missing, "must include the template so users see what failed")
|
| 228 |
+
|
| 229 |
+
// Inner diagnostic detail must survive. POSIX surfaces "exit
|
| 230 |
+
// status N" + stderr; Windows' in-process coreutils surfaces the
|
| 231 |
+
// Go OS error instead. Accept either shape so the test is
|
| 232 |
+
// portable without weakening the intent.
|
| 233 |
+
lower := strings.ToLower(msg)
|
| 234 |
+
hasDetail := strings.Contains(lower, "exit status") ||
|
| 235 |
+
strings.Contains(lower, "no such file") ||
|
| 236 |
+
strings.Contains(lower, "cannot find")
|
| 237 |
+
require.True(t, hasDetail, "must surface inner error detail: %s", msg)
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// TestResolvedHeaders_RealShell_FailurePreservesOriginal pins two
|
| 241 |
+
// invariants simultaneously: on failure the returned map is nil (not
|
| 242 |
+
// a partially-populated map) and the receiver's Headers map is
|
| 243 |
+
// unchanged. A test that only asserted on the returned value could
|
| 244 |
+
// hide an in-place mutation regression.
|
| 245 |
+
func TestResolvedHeaders_RealShell_FailurePreservesOriginal(t *testing.T) {
|
| 246 |
+
t.Parallel()
|
| 247 |
+
|
| 248 |
+
m := MCPConfig{Headers: map[string]string{
|
| 249 |
+
"Authorization": "Bearer $(false)",
|
| 250 |
+
"X-Static": "kept",
|
| 251 |
+
}}
|
| 252 |
+
orig := maps.Clone(m.Headers)
|
| 253 |
+
|
| 254 |
+
got, err := m.ResolvedHeaders(realShellResolver(nil))
|
| 255 |
+
require.Error(t, err)
|
| 256 |
+
require.Nil(t, got, "headers map must be nil on failure")
|
| 257 |
+
require.Contains(t, err.Error(), "header Authorization")
|
| 258 |
+
require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// TestResolvedHeaders_RealShell_DropEmpty verifies that a header
|
| 262 |
+
// whose value resolves to the empty string is omitted from the
|
| 263 |
+
// returned map. Covers the three ways a value can legitimately land
|
| 264 |
+
// on empty — unset bare $VAR under lenient nounset, a literal "",
|
| 265 |
+
// and a non-failing command whose stdout is empty — and also pins
|
| 266 |
+
// that a failing $(cmd) still errors rather than silently dropping.
|
| 267 |
+
func TestResolvedHeaders_RealShell_DropEmpty(t *testing.T) {
|
| 268 |
+
t.Parallel()
|
| 269 |
+
|
| 270 |
+
t.Run("unset $VAR is absent", func(t *testing.T) {
|
| 271 |
+
t.Parallel()
|
| 272 |
+
m := MCPConfig{Headers: map[string]string{
|
| 273 |
+
"X-Missing": "$MCP_HEADER_NEVER_SET",
|
| 274 |
+
"X-Kept": "present",
|
| 275 |
+
}}
|
| 276 |
+
got, err := m.ResolvedHeaders(realShellResolver(nil))
|
| 277 |
+
require.NoError(t, err)
|
| 278 |
+
_, present := got["X-Missing"]
|
| 279 |
+
require.False(t, present, "unset bare $VAR → empty → header dropped")
|
| 280 |
+
require.Equal(t, "present", got["X-Kept"])
|
| 281 |
+
})
|
| 282 |
+
|
| 283 |
+
t.Run("literal empty string is absent", func(t *testing.T) {
|
| 284 |
+
t.Parallel()
|
| 285 |
+
m := MCPConfig{Headers: map[string]string{
|
| 286 |
+
"X-Custom": "",
|
| 287 |
+
"X-Kept": "present",
|
| 288 |
+
}}
|
| 289 |
+
got, err := m.ResolvedHeaders(realShellResolver(nil))
|
| 290 |
+
require.NoError(t, err)
|
| 291 |
+
_, present := got["X-Custom"]
|
| 292 |
+
require.False(t, present, "literal empty-string header must be dropped")
|
| 293 |
+
require.Equal(t, "present", got["X-Kept"])
|
| 294 |
+
})
|
| 295 |
+
|
| 296 |
+
t.Run("$(echo) is absent", func(t *testing.T) {
|
| 297 |
+
t.Parallel()
|
| 298 |
+
m := MCPConfig{Headers: map[string]string{
|
| 299 |
+
"X-Empty": "$(echo)",
|
| 300 |
+
"X-Kept": "present",
|
| 301 |
+
}}
|
| 302 |
+
got, err := m.ResolvedHeaders(realShellResolver(nil))
|
| 303 |
+
require.NoError(t, err)
|
| 304 |
+
_, present := got["X-Empty"]
|
| 305 |
+
require.False(t, present, "$(echo) → empty → header dropped")
|
| 306 |
+
require.Equal(t, "present", got["X-Kept"])
|
| 307 |
+
})
|
| 308 |
+
|
| 309 |
+
t.Run("$(false) errors and does not mutate", func(t *testing.T) {
|
| 310 |
+
t.Parallel()
|
| 311 |
+
m := MCPConfig{Headers: map[string]string{
|
| 312 |
+
"X-Broken": "$(false)",
|
| 313 |
+
"X-Kept": "present",
|
| 314 |
+
}}
|
| 315 |
+
orig := maps.Clone(m.Headers)
|
| 316 |
+
|
| 317 |
+
got, err := m.ResolvedHeaders(realShellResolver(nil))
|
| 318 |
+
require.Error(t, err)
|
| 319 |
+
require.Empty(t, got, "map must be nil/empty on failure, not a partial")
|
| 320 |
+
require.Contains(t, err.Error(), "header X-Broken")
|
| 321 |
+
require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
|
| 322 |
+
})
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
// TestResolvedArgs_RealShell exercises both success and failure for
|
| 326 |
+
// m.Args symmetrically with Env. Args are ordered so error messages
|
| 327 |
+
// must identify a positional index, not a key.
|
| 328 |
+
func TestResolvedArgs_RealShell(t *testing.T) {
|
| 329 |
+
t.Parallel()
|
| 330 |
+
|
| 331 |
+
t.Run("success expands each element", func(t *testing.T) {
|
| 332 |
+
t.Parallel()
|
| 333 |
+
m := MCPConfig{Args: []string{"--token", "$(echo shh)", "--host", "example.com"}}
|
| 334 |
+
got, err := m.ResolvedArgs(realShellResolver(nil))
|
| 335 |
+
require.NoError(t, err)
|
| 336 |
+
require.Equal(t, []string{"--token", "shh", "--host", "example.com"}, got)
|
| 337 |
+
})
|
| 338 |
+
|
| 339 |
+
t.Run("failure identifies offending index", func(t *testing.T) {
|
| 340 |
+
t.Parallel()
|
| 341 |
+
m := MCPConfig{Args: []string{"--token", "$(false)"}}
|
| 342 |
+
orig := slices.Clone(m.Args)
|
| 343 |
+
|
| 344 |
+
got, err := m.ResolvedArgs(realShellResolver(nil))
|
| 345 |
+
require.Error(t, err)
|
| 346 |
+
require.Nil(t, got)
|
| 347 |
+
require.Contains(t, err.Error(), "arg 1")
|
| 348 |
+
require.Equal(t, orig, m.Args, "receiver Args must be preserved")
|
| 349 |
+
})
|
| 350 |
+
|
| 351 |
+
t.Run("nil args returns nil, no error", func(t *testing.T) {
|
| 352 |
+
t.Parallel()
|
| 353 |
+
m := MCPConfig{}
|
| 354 |
+
got, err := m.ResolvedArgs(realShellResolver(nil))
|
| 355 |
+
require.NoError(t, err)
|
| 356 |
+
require.Nil(t, got)
|
| 357 |
+
})
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// TestLSPConfig_ResolvedArgs_RealShell exercises both success and
|
| 361 |
+
// failure for l.Args symmetrically with MCP args. Args are ordered so
|
| 362 |
+
// error messages must identify a positional index, not a key.
|
| 363 |
+
func TestLSPConfig_ResolvedArgs_RealShell(t *testing.T) {
|
| 364 |
+
t.Parallel()
|
| 365 |
+
|
| 366 |
+
t.Run("success expands $VAR in each element", func(t *testing.T) {
|
| 367 |
+
t.Parallel()
|
| 368 |
+
l := LSPConfig{Args: []string{"--root", "$HOME", "--flag", "literal"}}
|
| 369 |
+
r := realShellResolver(map[string]string{"HOME": "/home/tester"})
|
| 370 |
+
got, err := l.ResolvedArgs(r)
|
| 371 |
+
require.NoError(t, err)
|
| 372 |
+
require.Equal(t, []string{"--root", "/home/tester", "--flag", "literal"}, got)
|
| 373 |
+
})
|
| 374 |
+
|
| 375 |
+
t.Run("failure identifies offending index", func(t *testing.T) {
|
| 376 |
+
t.Parallel()
|
| 377 |
+
l := LSPConfig{Args: []string{"--root", "$(false)"}}
|
| 378 |
+
orig := slices.Clone(l.Args)
|
| 379 |
+
|
| 380 |
+
got, err := l.ResolvedArgs(realShellResolver(nil))
|
| 381 |
+
require.Error(t, err)
|
| 382 |
+
require.Nil(t, got)
|
| 383 |
+
require.Contains(t, err.Error(), "arg 1")
|
| 384 |
+
require.Equal(t, orig, l.Args, "receiver Args must be preserved")
|
| 385 |
+
})
|
| 386 |
+
|
| 387 |
+
t.Run("nil args returns nil, no error", func(t *testing.T) {
|
| 388 |
+
t.Parallel()
|
| 389 |
+
l := LSPConfig{}
|
| 390 |
+
got, err := l.ResolvedArgs(realShellResolver(nil))
|
| 391 |
+
require.NoError(t, err)
|
| 392 |
+
require.Nil(t, got)
|
| 393 |
+
})
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
// TestLSPConfig_ResolvedEnv_RealShell pins the LSP env contract:
|
| 397 |
+
// success expands $VAR, failure wraps with the key name, and the
|
| 398 |
+
// receiver map is never mutated. The shape is map[string]string
|
| 399 |
+
// (not the MCP []string form) because powernap.ClientConfig.Environment
|
| 400 |
+
// takes a map directly.
|
| 401 |
+
func TestLSPConfig_ResolvedEnv_RealShell(t *testing.T) {
|
| 402 |
+
t.Parallel()
|
| 403 |
+
|
| 404 |
+
t.Run("success expands $VAR", func(t *testing.T) {
|
| 405 |
+
t.Parallel()
|
| 406 |
+
l := LSPConfig{Env: map[string]string{"GOPATH": "$HOME/go"}}
|
| 407 |
+
r := realShellResolver(map[string]string{"HOME": "/home/tester"})
|
| 408 |
+
got, err := l.ResolvedEnv(r)
|
| 409 |
+
require.NoError(t, err)
|
| 410 |
+
require.Equal(t, map[string]string{"GOPATH": "/home/tester/go"}, got)
|
| 411 |
+
})
|
| 412 |
+
|
| 413 |
+
t.Run("failure identifies offending key", func(t *testing.T) {
|
| 414 |
+
t.Parallel()
|
| 415 |
+
l := LSPConfig{Env: map[string]string{
|
| 416 |
+
"GOPATH": "$(false)",
|
| 417 |
+
"OTHER": "literal",
|
| 418 |
+
}}
|
| 419 |
+
orig := maps.Clone(l.Env)
|
| 420 |
+
|
| 421 |
+
got, err := l.ResolvedEnv(realShellResolver(nil))
|
| 422 |
+
require.Error(t, err)
|
| 423 |
+
require.Nil(t, got)
|
| 424 |
+
require.Contains(t, err.Error(), `env "GOPATH"`)
|
| 425 |
+
require.Equal(t, orig, l.Env, "receiver Env must be preserved")
|
| 426 |
+
})
|
| 427 |
+
|
| 428 |
+
t.Run("idempotent and non-mutating", func(t *testing.T) {
|
| 429 |
+
t.Parallel()
|
| 430 |
+
l := LSPConfig{Env: map[string]string{
|
| 431 |
+
"A": "$(echo one)",
|
| 432 |
+
"B": "literal",
|
| 433 |
+
}}
|
| 434 |
+
orig := maps.Clone(l.Env)
|
| 435 |
+
r := realShellResolver(nil)
|
| 436 |
+
|
| 437 |
+
first, err := l.ResolvedEnv(r)
|
| 438 |
+
require.NoError(t, err)
|
| 439 |
+
second, err := l.ResolvedEnv(r)
|
| 440 |
+
require.NoError(t, err)
|
| 441 |
+
require.Equal(t, first, second)
|
| 442 |
+
require.Equal(t, orig, l.Env, "receiver Env must be preserved")
|
| 443 |
+
})
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
// TestLSPConfig_IdentityResolver pins the client-mode contract: both
|
| 447 |
+
// ResolvedArgs and ResolvedEnv round-trip the template verbatim under
|
| 448 |
+
// IdentityResolver and never error on unset variables. Local
|
| 449 |
+
// expansion would double-expand when the server does its own — this
|
| 450 |
+
// has to stay a pure pass-through.
|
| 451 |
+
func TestLSPConfig_IdentityResolver(t *testing.T) {
|
| 452 |
+
t.Parallel()
|
| 453 |
+
|
| 454 |
+
l := LSPConfig{
|
| 455 |
+
Args: []string{"--root", "$LSP_ROOT", "$(vault read -f lsp)"},
|
| 456 |
+
Env: map[string]string{
|
| 457 |
+
"GOPATH": "$HOME/go",
|
| 458 |
+
"TOKEN": "$(cat /run/secrets/x)",
|
| 459 |
+
},
|
| 460 |
+
}
|
| 461 |
+
r := IdentityResolver()
|
| 462 |
+
|
| 463 |
+
args, err := l.ResolvedArgs(r)
|
| 464 |
+
require.NoError(t, err)
|
| 465 |
+
require.Equal(t, l.Args, args)
|
| 466 |
+
|
| 467 |
+
envs, err := l.ResolvedEnv(r)
|
| 468 |
+
require.NoError(t, err)
|
| 469 |
+
require.Equal(t, l.Env, envs)
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// TestMCPConfig_IdentityResolver pins the client-mode contract: every
|
| 473 |
+
// Resolved* method round-trips the template verbatim and never errors
|
| 474 |
+
// on unset variables. Local expansion would double-expand when the
|
| 475 |
+
// server does its own — this has to stay a pure pass-through.
|
| 476 |
+
func TestMCPConfig_IdentityResolver(t *testing.T) {
|
| 477 |
+
t.Parallel()
|
| 478 |
+
|
| 479 |
+
m := MCPConfig{
|
| 480 |
+
Command: "$CMD",
|
| 481 |
+
Args: []string{"--token", "$MCP_MISSING_TOKEN", "$(vault read -f secret)"},
|
| 482 |
+
Env: map[string]string{
|
| 483 |
+
"TOKEN": "$(cat /run/secrets/x)",
|
| 484 |
+
"HOST": "$MCP_MISSING_HOST",
|
| 485 |
+
},
|
| 486 |
+
Headers: map[string]string{
|
| 487 |
+
"Authorization": "Bearer $(vault read -f token)",
|
| 488 |
+
},
|
| 489 |
+
URL: "https://$MCP_HOST/$(vault read -f path)",
|
| 490 |
+
}
|
| 491 |
+
r := IdentityResolver()
|
| 492 |
+
|
| 493 |
+
args, err := m.ResolvedArgs(r)
|
| 494 |
+
require.NoError(t, err)
|
| 495 |
+
require.Equal(t, m.Args, args)
|
| 496 |
+
|
| 497 |
+
envs, err := m.ResolvedEnv(r)
|
| 498 |
+
require.NoError(t, err)
|
| 499 |
+
// Sorted "KEY=value".
|
| 500 |
+
require.Equal(t, []string{
|
| 501 |
+
"HOST=$MCP_MISSING_HOST",
|
| 502 |
+
"TOKEN=$(cat /run/secrets/x)",
|
| 503 |
+
}, envs)
|
| 504 |
+
|
| 505 |
+
headers, err := m.ResolvedHeaders(r)
|
| 506 |
+
require.NoError(t, err)
|
| 507 |
+
require.Equal(t, m.Headers, headers)
|
| 508 |
+
|
| 509 |
+
u, err := m.ResolvedURL(r)
|
| 510 |
+
require.NoError(t, err)
|
| 511 |
+
require.Equal(t, m.URL, u)
|
| 512 |
+
}
|
internal/config/resolve_test.go
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"strings"
|
| 7 |
+
"testing"
|
| 8 |
+
|
| 9 |
+
"github.com/charmbracelet/crush/internal/env"
|
| 10 |
+
"github.com/stretchr/testify/require"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
// fakeExpander returns a canned value/error for the last passed value and
|
| 14 |
+
// records the context, raw value, and env slice it was called with. It
|
| 15 |
+
// lets the config-layer tests assert on delegation behaviour without
|
| 16 |
+
// spinning up a real interpreter — real-shell coverage lives in
|
| 17 |
+
// internal/shell/expand_test.go and resolve_real_test.go.
|
| 18 |
+
type fakeExpander struct {
|
| 19 |
+
expand func(ctx context.Context, value string, env []string) (string, error)
|
| 20 |
+
lastValue string
|
| 21 |
+
lastEnv []string
|
| 22 |
+
calls int
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func (f *fakeExpander) Expand(ctx context.Context, value string, env []string) (string, error) {
|
| 26 |
+
f.calls++
|
| 27 |
+
f.lastValue = value
|
| 28 |
+
f.lastEnv = env
|
| 29 |
+
if f.expand == nil {
|
| 30 |
+
return value, nil
|
| 31 |
+
}
|
| 32 |
+
return f.expand(ctx, value, env)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func TestShellVariableResolver_DelegatesToExpander(t *testing.T) {
|
| 36 |
+
t.Parallel()
|
| 37 |
+
|
| 38 |
+
fe := &fakeExpander{
|
| 39 |
+
expand: func(_ context.Context, value string, _ []string) (string, error) {
|
| 40 |
+
if value == "hello $FOO" {
|
| 41 |
+
return "hello bar", nil
|
| 42 |
+
}
|
| 43 |
+
return value, nil
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
e := env.NewFromMap(map[string]string{"FOO": "bar"})
|
| 48 |
+
r := NewShellVariableResolver(e, WithExpander(fe.Expand))
|
| 49 |
+
|
| 50 |
+
got, err := r.ResolveValue("hello $FOO")
|
| 51 |
+
require.NoError(t, err)
|
| 52 |
+
require.Equal(t, "hello bar", got)
|
| 53 |
+
require.Equal(t, 1, fe.calls)
|
| 54 |
+
require.Equal(t, "hello $FOO", fe.lastValue)
|
| 55 |
+
require.Contains(t, fe.lastEnv, "FOO=bar")
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
func TestShellVariableResolver_LoneDollarIsError(t *testing.T) {
|
| 59 |
+
t.Parallel()
|
| 60 |
+
|
| 61 |
+
// Lone "$" must short-circuit before reaching the expander: the
|
| 62 |
+
// underlying shell parser would accept it as a literal, but this
|
| 63 |
+
// resolver has historically rejected it and callers depend on
|
| 64 |
+
// that early-fail behaviour.
|
| 65 |
+
fe := &fakeExpander{}
|
| 66 |
+
r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
|
| 67 |
+
|
| 68 |
+
_, err := r.ResolveValue("$")
|
| 69 |
+
require.Error(t, err)
|
| 70 |
+
require.Equal(t, 0, fe.calls, "expander must not be called for lone $")
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
func TestShellVariableResolver_PassesThroughLiterals(t *testing.T) {
|
| 74 |
+
t.Parallel()
|
| 75 |
+
|
| 76 |
+
fe := &fakeExpander{
|
| 77 |
+
expand: func(_ context.Context, value string, _ []string) (string, error) {
|
| 78 |
+
return value, nil
|
| 79 |
+
},
|
| 80 |
+
}
|
| 81 |
+
r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
|
| 82 |
+
|
| 83 |
+
got, err := r.ResolveValue("plain-string")
|
| 84 |
+
require.NoError(t, err)
|
| 85 |
+
require.Equal(t, "plain-string", got)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
func TestShellVariableResolver_WrapsErrorsWithTemplate(t *testing.T) {
|
| 89 |
+
t.Parallel()
|
| 90 |
+
|
| 91 |
+
inner := errors.New("cat: /run/secrets/x: permission denied")
|
| 92 |
+
fe := &fakeExpander{
|
| 93 |
+
expand: func(_ context.Context, _ string, _ []string) (string, error) {
|
| 94 |
+
return "", inner
|
| 95 |
+
},
|
| 96 |
+
}
|
| 97 |
+
r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
|
| 98 |
+
|
| 99 |
+
_, err := r.ResolveValue("$(cat /run/secrets/x)")
|
| 100 |
+
require.Error(t, err)
|
| 101 |
+
require.ErrorIs(t, err, inner)
|
| 102 |
+
require.Contains(t, err.Error(), "$(cat /run/secrets/x)")
|
| 103 |
+
require.Contains(t, err.Error(), "permission denied")
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
func TestSanitizeResolveError(t *testing.T) {
|
| 107 |
+
t.Parallel()
|
| 108 |
+
|
| 109 |
+
t.Run("nil passes through", func(t *testing.T) {
|
| 110 |
+
t.Parallel()
|
| 111 |
+
require.NoError(t, sanitizeResolveError("anything", nil))
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
t.Run("includes template and wraps inner", func(t *testing.T) {
|
| 115 |
+
t.Parallel()
|
| 116 |
+
inner := errors.New("cat: /run/secrets/x: permission denied")
|
| 117 |
+
got := sanitizeResolveError("$(cat /run/secrets/x)", inner)
|
| 118 |
+
require.Error(t, got)
|
| 119 |
+
require.ErrorIs(t, got, inner)
|
| 120 |
+
require.Contains(t, got.Error(), "$(cat /run/secrets/x)")
|
| 121 |
+
require.Contains(t, got.Error(), "permission denied")
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
t.Run("unwrap preserves original for errors.Is", func(t *testing.T) {
|
| 125 |
+
t.Parallel()
|
| 126 |
+
inner := errors.New("sentinel")
|
| 127 |
+
got := sanitizeResolveError("$FOO", inner)
|
| 128 |
+
require.ErrorIs(t, got, inner)
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
t.Run("truncates over-budget inner message", func(t *testing.T) {
|
| 132 |
+
t.Parallel()
|
| 133 |
+
// Inner message holds far more than the budget. After
|
| 134 |
+
// sanitization the rendered inner portion must not exceed
|
| 135 |
+
// maxResolveErrBytes, and the characters beyond the budget
|
| 136 |
+
// (marked by a distinct tail sentinel) must be gone.
|
| 137 |
+
const tailSentinel = "TAIL_SENTINEL_BEYOND_BUDGET"
|
| 138 |
+
body := strings.Repeat("x", maxResolveErrBytes)
|
| 139 |
+
inner := errors.New(body + tailSentinel)
|
| 140 |
+
|
| 141 |
+
got := sanitizeResolveError("$TEMPLATE", inner)
|
| 142 |
+
require.Error(t, got)
|
| 143 |
+
|
| 144 |
+
prefix := `resolving "$TEMPLATE": `
|
| 145 |
+
rendered := got.Error()
|
| 146 |
+
require.True(
|
| 147 |
+
t,
|
| 148 |
+
strings.HasPrefix(rendered, prefix),
|
| 149 |
+
"rendered error must start with template prefix",
|
| 150 |
+
)
|
| 151 |
+
innerRendered := strings.TrimPrefix(rendered, prefix)
|
| 152 |
+
require.LessOrEqual(
|
| 153 |
+
t,
|
| 154 |
+
len(innerRendered),
|
| 155 |
+
maxResolveErrBytes,
|
| 156 |
+
"inner message must be bounded to maxResolveErrBytes",
|
| 157 |
+
)
|
| 158 |
+
require.NotContains(
|
| 159 |
+
t,
|
| 160 |
+
rendered,
|
| 161 |
+
tailSentinel,
|
| 162 |
+
"content past the budget must not leak",
|
| 163 |
+
)
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
t.Run("replaces non-printable bytes", func(t *testing.T) {
|
| 167 |
+
t.Parallel()
|
| 168 |
+
// NUL, BEL, ESC, DEL, and a UTF-8 high byte should all be
|
| 169 |
+
// scrubbed to '?'. Tab and newline are preserved because
|
| 170 |
+
// they show up legitimately in command stderr.
|
| 171 |
+
inner := errors.New("ok\x00bad\x07\x1b\x7f\xffend\ttab\nline")
|
| 172 |
+
got := sanitizeResolveError("$T", inner)
|
| 173 |
+
rendered := got.Error()
|
| 174 |
+
|
| 175 |
+
require.NotContains(t, rendered, "\x00")
|
| 176 |
+
require.NotContains(t, rendered, "\x07")
|
| 177 |
+
require.NotContains(t, rendered, "\x1b")
|
| 178 |
+
require.NotContains(t, rendered, "\x7f")
|
| 179 |
+
require.NotContains(t, rendered, "\xff")
|
| 180 |
+
require.Contains(t, rendered, "ok?bad????end\ttab\nline")
|
| 181 |
+
})
|
| 182 |
+
|
| 183 |
+
t.Run("scrubbing does not depend on shell.ExpandValue upstream", func(t *testing.T) {
|
| 184 |
+
t.Parallel()
|
| 185 |
+
// A custom Expander can inject arbitrary error text. The
|
| 186 |
+
// config-layer helper is the single chokepoint; it must
|
| 187 |
+
// bound + scrub regardless of the error source.
|
| 188 |
+
nasty := strings.Repeat("A", maxResolveErrBytes+64) + "\x00BEYOND"
|
| 189 |
+
fe := &fakeExpander{
|
| 190 |
+
expand: func(_ context.Context, _ string, _ []string) (string, error) {
|
| 191 |
+
return "", errors.New(nasty)
|
| 192 |
+
},
|
| 193 |
+
}
|
| 194 |
+
r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
|
| 195 |
+
|
| 196 |
+
_, err := r.ResolveValue("$T")
|
| 197 |
+
require.Error(t, err)
|
| 198 |
+
require.NotContains(t, err.Error(), "BEYOND", "over-budget tail must not leak")
|
| 199 |
+
require.NotContains(t, err.Error(), "\x00", "non-printables must be scrubbed")
|
| 200 |
+
})
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
func TestScrubErrorMessage(t *testing.T) {
|
| 204 |
+
t.Parallel()
|
| 205 |
+
|
| 206 |
+
t.Run("bounds output to maxResolveErrBytes", func(t *testing.T) {
|
| 207 |
+
t.Parallel()
|
| 208 |
+
got := scrubErrorMessage(strings.Repeat("a", maxResolveErrBytes*3))
|
| 209 |
+
require.Len(t, got, maxResolveErrBytes)
|
| 210 |
+
})
|
| 211 |
+
|
| 212 |
+
t.Run("preserves printable ASCII tab and newline", func(t *testing.T) {
|
| 213 |
+
t.Parallel()
|
| 214 |
+
require.Equal(t, "a\tb\nc d!", scrubErrorMessage("a\tb\nc d!"))
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
t.Run("replaces control and non-ASCII bytes", func(t *testing.T) {
|
| 218 |
+
t.Parallel()
|
| 219 |
+
require.Equal(t, "a?b??c", scrubErrorMessage("a\x01b\x1b\xe2c"))
|
| 220 |
+
})
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
func TestNewShellVariableResolver(t *testing.T) {
|
| 224 |
+
testEnv := env.NewFromMap(map[string]string{"TEST": "value"})
|
| 225 |
+
resolver := NewShellVariableResolver(testEnv)
|
| 226 |
+
|
| 227 |
+
require.NotNil(t, resolver)
|
| 228 |
+
require.Implements(t, (*VariableResolver)(nil), resolver)
|
| 229 |
+
}
|
internal/config/scope.go
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import "fmt"
|
| 4 |
+
|
| 5 |
+
// Scope determines which config file is targeted for read/write operations.
|
| 6 |
+
type Scope int
|
| 7 |
+
|
| 8 |
+
const (
|
| 9 |
+
// ScopeGlobal targets the global data config (~/.local/share/crush/crush.json).
|
| 10 |
+
ScopeGlobal Scope = iota
|
| 11 |
+
// ScopeWorkspace targets the workspace config (.crush/crush.json).
|
| 12 |
+
ScopeWorkspace
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// String returns a human-readable label for the scope.
|
| 16 |
+
func (s Scope) String() string {
|
| 17 |
+
switch s {
|
| 18 |
+
case ScopeGlobal:
|
| 19 |
+
return "global"
|
| 20 |
+
case ScopeWorkspace:
|
| 21 |
+
return "workspace"
|
| 22 |
+
default:
|
| 23 |
+
return fmt.Sprintf("Scope(%d)", int(s))
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// ErrNoWorkspaceConfig is returned when a workspace-scoped write is
|
| 28 |
+
// attempted on a ConfigStore that has no workspace config path.
|
| 29 |
+
var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured")
|
internal/config/scopeb_race_test.go
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"path/filepath"
|
| 6 |
+
"sync"
|
| 7 |
+
"testing"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
// TestScopeB_InPlaceMutationRace probes whether the store's in-place field
|
| 11 |
+
// mutators (e.g. SetCompactMode) race with concurrent Config() readers that
|
| 12 |
+
// walk the same field. This is the "Scope B" race left open after the
|
| 13 |
+
// pointer-swap fix. Run with -race.
|
| 14 |
+
// TestScopeB_InPlaceMutationRace verifies that the store's typed field
|
| 15 |
+
// mutators (e.g. SetCompactMode) no longer race concurrent Config()
|
| 16 |
+
// readers walking the same field. Copy-on-write publishing means the
|
| 17 |
+
// mutator swaps in a fresh Config rather than writing through the live
|
| 18 |
+
// pointer, so a reader always sees an immutable snapshot. Run with -race.
|
| 19 |
+
func TestScopeB_InPlaceMutationRace(t *testing.T) {
|
| 20 |
+
dir := t.TempDir()
|
| 21 |
+
configPath := filepath.Join(dir, "crush.json")
|
| 22 |
+
|
| 23 |
+
t.Setenv("CRUSH_GLOBAL_CONFIG", dir)
|
| 24 |
+
t.Setenv("CRUSH_GLOBAL_DATA", dir)
|
| 25 |
+
resetProviderState()
|
| 26 |
+
t.Cleanup(resetProviderState)
|
| 27 |
+
|
| 28 |
+
cfg := `{
|
| 29 |
+
"models": {"large": {"provider": "openai", "model": "gpt-4"}},
|
| 30 |
+
"providers": {"openai": {"api_key": "k", "models": [{"id": "gpt-4", "name": "GPT-4"}]}},
|
| 31 |
+
"options": {"tui": {"compact_mode": false}}
|
| 32 |
+
}`
|
| 33 |
+
if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil {
|
| 34 |
+
t.Fatal(err)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
store, err := Load(dir, dir, false)
|
| 38 |
+
if err != nil {
|
| 39 |
+
t.Fatal(err)
|
| 40 |
+
}
|
| 41 |
+
store.globalDataPath = configPath
|
| 42 |
+
|
| 43 |
+
var wg sync.WaitGroup
|
| 44 |
+
stop := make(chan struct{})
|
| 45 |
+
|
| 46 |
+
// Reader: walks Options.TUI.CompactMode, the field SetCompactMode writes.
|
| 47 |
+
wg.Add(1)
|
| 48 |
+
go func() {
|
| 49 |
+
defer wg.Done()
|
| 50 |
+
for {
|
| 51 |
+
select {
|
| 52 |
+
case <-stop:
|
| 53 |
+
return
|
| 54 |
+
default:
|
| 55 |
+
c := store.Config()
|
| 56 |
+
if c != nil && c.Options != nil {
|
| 57 |
+
_ = c.Options.TUI.CompactMode
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}()
|
| 62 |
+
|
| 63 |
+
// Writer: flips compact mode, mutating Options in place.
|
| 64 |
+
wg.Add(1)
|
| 65 |
+
go func() {
|
| 66 |
+
defer wg.Done()
|
| 67 |
+
for i := 0; i < 50; i++ {
|
| 68 |
+
_ = store.SetCompactMode(ScopeGlobal, i%2 == 0)
|
| 69 |
+
}
|
| 70 |
+
close(stop)
|
| 71 |
+
}()
|
| 72 |
+
|
| 73 |
+
wg.Wait()
|
| 74 |
+
}
|
internal/config/shellconfig_hook_test.go
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"github.com/charmbracelet/crush/internal/hooks"
|
| 7 |
+
"github.com/stretchr/testify/require"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func TestShellConfigHookAdd(t *testing.T) {
|
| 11 |
+
store := loadCrushSh(t, `hook add PreToolUse --matcher "^bash$" --command "echo hi" --name greet --timeout 10`)
|
| 12 |
+
|
| 13 |
+
hs := store.Config().Hooks[hooks.EventPreToolUse]
|
| 14 |
+
require.Len(t, hs, 1)
|
| 15 |
+
require.Equal(t, "greet", hs[0].Name)
|
| 16 |
+
require.Equal(t, "^bash$", hs[0].Matcher)
|
| 17 |
+
require.Equal(t, "echo hi", hs[0].Command)
|
| 18 |
+
require.Equal(t, 10, hs[0].Timeout)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func TestShellConfigHookRemoveByName(t *testing.T) {
|
| 22 |
+
store := loadCrushSh(t, `hook add PreToolUse --command a --name keep
|
| 23 |
+
hook add PreToolUse --command b --name drop
|
| 24 |
+
hook remove PreToolUse --name drop`)
|
| 25 |
+
|
| 26 |
+
hs := store.Config().Hooks[hooks.EventPreToolUse]
|
| 27 |
+
require.Len(t, hs, 1)
|
| 28 |
+
require.Equal(t, "keep", hs[0].Name)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func TestShellConfigHookClearEvent(t *testing.T) {
|
| 32 |
+
store := loadCrushSh(t, `hook add PreToolUse --command a --name a
|
| 33 |
+
hook add PreToolUse --command b --name b
|
| 34 |
+
hook remove PreToolUse`)
|
| 35 |
+
|
| 36 |
+
require.Empty(t, store.Config().Hooks[hooks.EventPreToolUse])
|
| 37 |
+
}
|
internal/config/shellconfig_lsp_test.go
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"github.com/stretchr/testify/require"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func TestShellConfigLSPAdd(t *testing.T) {
|
| 10 |
+
store := loadCrushSh(t, `lsp add gopls --command gopls --filetypes go --filetypes mod --root-markers go.mod --timeout 60`)
|
| 11 |
+
|
| 12 |
+
l, ok := store.Config().LSP["gopls"]
|
| 13 |
+
require.True(t, ok, "gopls LSP should be configured")
|
| 14 |
+
require.Equal(t, "gopls", l.Command)
|
| 15 |
+
require.Subset(t, l.FileTypes, []string{"go", "mod"})
|
| 16 |
+
require.Contains(t, l.RootMarkers, "go.mod")
|
| 17 |
+
require.Equal(t, 60, l.Timeout)
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func TestShellConfigLSPRemove(t *testing.T) {
|
| 21 |
+
store := loadCrushSh(t, `lsp add keepls --command keep-server
|
| 22 |
+
lsp add dropls --command drop-server
|
| 23 |
+
lsp remove dropls`)
|
| 24 |
+
|
| 25 |
+
lsps := store.Config().LSP
|
| 26 |
+
require.Contains(t, lsps, "keepls")
|
| 27 |
+
require.NotContains(t, lsps, "dropls")
|
| 28 |
+
}
|
internal/config/shellconfig_option_test.go
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"github.com/stretchr/testify/require"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func TestShellConfigOptionBooleans(t *testing.T) {
|
| 10 |
+
store := loadCrushSh(t, `option debug true
|
| 11 |
+
option progress false
|
| 12 |
+
option auto-lsp false`)
|
| 13 |
+
|
| 14 |
+
opts := store.Config().Options
|
| 15 |
+
require.True(t, opts.Debug, "debug should be on")
|
| 16 |
+
require.NotNil(t, opts.Progress)
|
| 17 |
+
require.False(t, *opts.Progress, "progress should be off")
|
| 18 |
+
require.NotNil(t, opts.AutoLSP)
|
| 19 |
+
require.False(t, *opts.AutoLSP, "auto-lsp should be off")
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Config phrases this field negatively (disable_metrics) but the command
|
| 23 |
+
// exposes it positively. "metrics false" must land as disable_metrics = true.
|
| 24 |
+
func TestShellConfigOptionPositiveMetricsFalse(t *testing.T) {
|
| 25 |
+
store := loadCrushSh(t, `option metrics false`)
|
| 26 |
+
require.True(t, store.Config().Options.DisableMetrics, "metrics off => disable_metrics true")
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// The bare positive form defaults to true, which inverts to disable = false.
|
| 30 |
+
func TestShellConfigOptionPositiveMetricsBare(t *testing.T) {
|
| 31 |
+
store := loadCrushSh(t, `option metrics`)
|
| 32 |
+
require.False(t, store.Config().Options.DisableMetrics, "metrics on => disable_metrics false")
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func TestShellConfigOptionUI(t *testing.T) {
|
| 36 |
+
store := loadCrushSh(t, `option ui compact true
|
| 37 |
+
option ui diff split
|
| 38 |
+
option ui transparent false
|
| 39 |
+
option ui mouse false
|
| 40 |
+
option ui scrollbar always
|
| 41 |
+
option ui completions-max-depth 4
|
| 42 |
+
option ui completions-max-items 200`)
|
| 43 |
+
|
| 44 |
+
ui := store.Config().Options.TUI
|
| 45 |
+
require.NotNil(t, ui)
|
| 46 |
+
require.True(t, ui.CompactMode)
|
| 47 |
+
require.Equal(t, "split", ui.DiffMode)
|
| 48 |
+
require.NotNil(t, ui.Transparent)
|
| 49 |
+
require.False(t, *ui.Transparent)
|
| 50 |
+
require.NotNil(t, ui.Mouse)
|
| 51 |
+
require.False(t, *ui.Mouse)
|
| 52 |
+
require.Equal(t, "always", ui.Scrollbar)
|
| 53 |
+
require.NotNil(t, ui.Completions.MaxDepth)
|
| 54 |
+
require.Equal(t, 4, *ui.Completions.MaxDepth)
|
| 55 |
+
require.NotNil(t, ui.Completions.MaxItems)
|
| 56 |
+
require.Equal(t, 200, *ui.Completions.MaxItems)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
func TestShellConfigOptionUIRejectsInvalidValue(t *testing.T) {
|
| 60 |
+
_, err := loadCrushShErr(t, `option ui diff side-by-side`)
|
| 61 |
+
require.Error(t, err)
|
| 62 |
+
require.Contains(t, err.Error(), "expects unified or split")
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func TestShellConfigOptionAttribution(t *testing.T) {
|
| 66 |
+
store := loadCrushSh(t, `option attribution-trailer-style none
|
| 67 |
+
option attribution-generated-with false`)
|
| 68 |
+
|
| 69 |
+
attribution := store.Config().Options.Attribution
|
| 70 |
+
require.NotNil(t, attribution)
|
| 71 |
+
require.Equal(t, "none", string(attribution.TrailerStyle))
|
| 72 |
+
require.False(t, attribution.GeneratedWith)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
func TestShellConfigOptionAttributionTrailerStylePreservesGeneratedWithDefault(t *testing.T) {
|
| 76 |
+
store := loadCrushSh(t, `option attribution-trailer-style co-authored-by`)
|
| 77 |
+
|
| 78 |
+
attribution := store.Config().Options.Attribution
|
| 79 |
+
require.NotNil(t, attribution)
|
| 80 |
+
require.Equal(t, "co-authored-by", string(attribution.TrailerStyle))
|
| 81 |
+
require.True(t, attribution.GeneratedWith)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
func TestShellConfigOptionAttributionGeneratedWithCaseInsensitive(t *testing.T) {
|
| 85 |
+
store := loadCrushSh(t, `option attribution-generated-with YES`)
|
| 86 |
+
|
| 87 |
+
require.NotNil(t, store.Config().Options.Attribution)
|
| 88 |
+
require.True(t, store.Config().Options.Attribution.GeneratedWith)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
func TestShellConfigOptionAttributionRejectsInvalidStyle(t *testing.T) {
|
| 92 |
+
_, err := loadCrushShErr(t, `option attribution-trailer-style bogus`)
|
| 93 |
+
require.Error(t, err)
|
| 94 |
+
require.Contains(t, err.Error(), "expects none, co-authored-by, or assisted-by")
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
func TestShellConfigOptionListAppends(t *testing.T) {
|
| 98 |
+
store := loadCrushSh(t, `option disable-skill crush-config
|
| 99 |
+
option disable-skill jq`)
|
| 100 |
+
|
| 101 |
+
require.Subset(t, store.Config().Options.DisabledSkills, []string{"crush-config", "jq"})
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// reset wipes values added earlier (or via source) while keeping anything
|
| 105 |
+
// added after it — observable in the effective config.
|
| 106 |
+
func TestShellConfigOptionReset(t *testing.T) {
|
| 107 |
+
store := loadCrushSh(t, `option skill-path ./inherited-a
|
| 108 |
+
option skill-path ./inherited-b
|
| 109 |
+
option reset skill-path
|
| 110 |
+
option skill-path ./mine`)
|
| 111 |
+
|
| 112 |
+
paths := store.Config().Options.SkillsPaths
|
| 113 |
+
require.Contains(t, paths, "./mine")
|
| 114 |
+
require.NotContains(t, paths, "./inherited-a")
|
| 115 |
+
require.NotContains(t, paths, "./inherited-b")
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func TestShellConfigOptionUnknownKeyFails(t *testing.T) {
|
| 119 |
+
_, err := loadCrushShErr(t, `option bogus-key value`)
|
| 120 |
+
require.Error(t, err)
|
| 121 |
+
require.Contains(t, err.Error(), "unknown key")
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
func TestShellConfigOptionDisableToolRemoved(t *testing.T) {
|
| 125 |
+
_, err := loadCrushShErr(t, `option disable-tool bash`)
|
| 126 |
+
require.Error(t, err)
|
| 127 |
+
require.Contains(t, err.Error(), "unknown key")
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
func TestShellConfigOptionResetRejectsNonList(t *testing.T) {
|
| 131 |
+
_, err := loadCrushShErr(t, `option reset debug`)
|
| 132 |
+
require.Error(t, err)
|
| 133 |
+
require.Contains(t, err.Error(), "not one")
|
| 134 |
+
}
|
internal/config/shellconfig_precedence_test.go
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"path/filepath"
|
| 6 |
+
"testing"
|
| 7 |
+
|
| 8 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 9 |
+
"github.com/stretchr/testify/require"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
// TestShellConfigDotCrushrcTakesPrecedence verifies that a project-local
|
| 13 |
+
// .crushrc overrides crushrc in the same directory on conflicting settings.
|
| 14 |
+
func TestShellConfigDotCrushrcTakesPrecedence(t *testing.T) {
|
| 15 |
+
isolated := t.TempDir()
|
| 16 |
+
t.Setenv("HOME", isolated)
|
| 17 |
+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
|
| 18 |
+
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
|
| 19 |
+
|
| 20 |
+
workDir := t.TempDir()
|
| 21 |
+
dataDir := t.TempDir()
|
| 22 |
+
require.NoError(t, os.WriteFile(
|
| 23 |
+
filepath.Join(workDir, "crushrc"),
|
| 24 |
+
[]byte("option notifications bell\n"), 0o644,
|
| 25 |
+
))
|
| 26 |
+
require.NoError(t, os.WriteFile(
|
| 27 |
+
filepath.Join(workDir, ".crushrc"),
|
| 28 |
+
[]byte("option notifications osc\n"), 0o644,
|
| 29 |
+
))
|
| 30 |
+
|
| 31 |
+
store, err := config.Load(workDir, dataDir, false)
|
| 32 |
+
require.NoError(t, err)
|
| 33 |
+
require.Equal(t, "osc", store.Config().Options.Notifications,
|
| 34 |
+
".crushrc should win over crushrc")
|
| 35 |
+
}
|
internal/config/shellconfig_provider_test.go
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"slices"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 8 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 9 |
+
"github.com/stretchr/testify/require"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
func TestShellConfigProviderAddAndModel(t *testing.T) {
|
| 13 |
+
store := loadCrushSh(t, `provider add myllm \
|
| 14 |
+
--type openai-compat \
|
| 15 |
+
--base-url "http://localhost:1234/v1" \
|
| 16 |
+
--api-key "sk-test" \
|
| 17 |
+
--discover-models false \
|
| 18 |
+
--extra-body '{"service_tier":"flex"}' \
|
| 19 |
+
--provider-options '{"region":"local"}'
|
| 20 |
+
provider add myllm \
|
| 21 |
+
--extra-body '{"stream":true}' \
|
| 22 |
+
--provider-options '{"mode":"test"}'
|
| 23 |
+
model add myllm/foo-1 --name "Foo 1" --context-window 8000 \
|
| 24 |
+
--price-input 1.25 --price-output 5 \
|
| 25 |
+
--price-cache-create 2 --price-cache-hit 0.25
|
| 26 |
+
model large myllm/foo-1 \
|
| 27 |
+
--top-p 0.9 --top-k 40 \
|
| 28 |
+
--frequency-penalty 0.2 --presence-penalty 0.1 \
|
| 29 |
+
--provider-options '{"routing":{"tier":"fast"}}'
|
| 30 |
+
model large myllm/foo-1 --provider-options '{"timeout":30}'`)
|
| 31 |
+
|
| 32 |
+
cfg := store.Config()
|
| 33 |
+
|
| 34 |
+
p, ok := cfg.Providers.Get("myllm")
|
| 35 |
+
require.True(t, ok, "myllm provider should be configured")
|
| 36 |
+
require.Equal(t, "sk-test", p.APIKey)
|
| 37 |
+
require.Equal(t, "http://localhost:1234/v1", p.BaseURL)
|
| 38 |
+
require.NotNil(t, p.AutoDiscoverModels)
|
| 39 |
+
require.False(t, *p.AutoDiscoverModels)
|
| 40 |
+
require.Equal(t, "flex", p.ExtraBody["service_tier"])
|
| 41 |
+
require.Equal(t, true, p.ExtraBody["stream"])
|
| 42 |
+
require.Equal(t, "local", p.ProviderOptions["region"])
|
| 43 |
+
require.Equal(t, "test", p.ProviderOptions["mode"])
|
| 44 |
+
require.True(
|
| 45 |
+
t,
|
| 46 |
+
slices.ContainsFunc(p.Models, func(m catwalk.Model) bool { return m.ID == "foo-1" }),
|
| 47 |
+
"custom model foo-1 should be in the provider catalog",
|
| 48 |
+
)
|
| 49 |
+
model := p.Models[0]
|
| 50 |
+
require.Equal(t, 1.25, model.CostPer1MIn)
|
| 51 |
+
require.Equal(t, 5.0, model.CostPer1MOut)
|
| 52 |
+
require.Equal(t, 2.0, model.CostPer1MInCached)
|
| 53 |
+
require.Equal(t, 0.25, model.CostPer1MOutCached)
|
| 54 |
+
|
| 55 |
+
large := cfg.Models[config.SelectedModelTypeLarge]
|
| 56 |
+
require.Equal(t, "myllm", large.Provider)
|
| 57 |
+
require.Equal(t, "foo-1", large.Model)
|
| 58 |
+
require.NotNil(t, large.TopP)
|
| 59 |
+
require.Equal(t, 0.9, *large.TopP)
|
| 60 |
+
require.NotNil(t, large.TopK)
|
| 61 |
+
require.Equal(t, int64(40), *large.TopK)
|
| 62 |
+
require.NotNil(t, large.FrequencyPenalty)
|
| 63 |
+
require.Equal(t, 0.2, *large.FrequencyPenalty)
|
| 64 |
+
require.NotNil(t, large.PresencePenalty)
|
| 65 |
+
require.Equal(t, 0.1, *large.PresencePenalty)
|
| 66 |
+
require.Equal(t, map[string]any{
|
| 67 |
+
"routing": map[string]any{"tier": "fast"},
|
| 68 |
+
"timeout": float64(30),
|
| 69 |
+
}, large.ProviderOptions)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
func TestShellConfigProviderRemove(t *testing.T) {
|
| 73 |
+
// Both providers get a model so they survive provider configuration
|
| 74 |
+
// (model-less providers are dropped); the only difference is the remove.
|
| 75 |
+
store := loadCrushSh(t, `provider add keepme --type openai-compat --base-url "http://localhost:1/v1" --api-key k
|
| 76 |
+
model add keepme/m1 --name M1
|
| 77 |
+
provider add dropme --type openai-compat --base-url "http://localhost:2/v1" --api-key k
|
| 78 |
+
model add dropme/m2 --name M2
|
| 79 |
+
provider remove dropme`)
|
| 80 |
+
|
| 81 |
+
_, keep := store.Config().Providers.Get("keepme")
|
| 82 |
+
_, drop := store.Config().Providers.Get("dropme")
|
| 83 |
+
require.True(t, keep, "keepme should remain")
|
| 84 |
+
require.False(t, drop, "dropme should be gone after remove")
|
| 85 |
+
}
|
internal/config/store.go
ADDED
|
@@ -0,0 +1,1430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"fmt"
|
| 7 |
+
"log/slog"
|
| 8 |
+
"maps"
|
| 9 |
+
"os"
|
| 10 |
+
"path/filepath"
|
| 11 |
+
"slices"
|
| 12 |
+
"sync"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 16 |
+
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
|
| 17 |
+
"github.com/charmbracelet/crush/internal/env"
|
| 18 |
+
"github.com/charmbracelet/crush/internal/lock"
|
| 19 |
+
"github.com/charmbracelet/crush/internal/oauth"
|
| 20 |
+
"github.com/charmbracelet/crush/internal/oauth/copilot"
|
| 21 |
+
"github.com/charmbracelet/crush/internal/oauth/hyper"
|
| 22 |
+
"github.com/charmbracelet/crush/internal/oauth/openai"
|
| 23 |
+
"github.com/tidwall/gjson"
|
| 24 |
+
"github.com/tidwall/sjson"
|
| 25 |
+
"golang.org/x/sync/singleflight"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
// configLockDeadline bounds how long lockConfig waits for the
|
| 29 |
+
// cross-process flock before giving up. A few seconds is plenty for
|
| 30 |
+
// honest contention; longer suggests something is wedged.
|
| 31 |
+
const configLockDeadline = 5 * time.Second
|
| 32 |
+
|
| 33 |
+
// refreshLockDeadline bounds how long RefreshOAuthToken waits for the
|
| 34 |
+
// per-provider cross-process refresh lock. It must exceed the token
|
| 35 |
+
// exchange HTTP timeout (30s) so that a peer mid-exchange is given time
|
| 36 |
+
// to finish and publish its result, which we then adopt instead of
|
| 37 |
+
// running our own exchange. Running our own would reuse an
|
| 38 |
+
// already-rotated refresh token and trip the provider's reuse detection,
|
| 39 |
+
// revoking the whole token family.
|
| 40 |
+
const refreshLockDeadline = 45 * time.Second
|
| 41 |
+
|
| 42 |
+
// credentialWriteLockDeadline bounds how long a credential write (e.g.
|
| 43 |
+
// storing the token from a fresh interactive login) waits for the
|
| 44 |
+
// per-provider refresh lock. It is deliberately shorter than
|
| 45 |
+
// refreshLockDeadline because a user is watching: if a peer is wedged we
|
| 46 |
+
// would rather write and risk a rare clobber than hang the UI.
|
| 47 |
+
const credentialWriteLockDeadline = 10 * time.Second
|
| 48 |
+
|
| 49 |
+
// fileSnapshot captures metadata about a config file at a point in time.
|
| 50 |
+
type fileSnapshot struct {
|
| 51 |
+
Path string
|
| 52 |
+
Exists bool
|
| 53 |
+
Size int64
|
| 54 |
+
ModTime int64 // UnixNano
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// RuntimeOverrides holds per-session settings that are never persisted to
|
| 58 |
+
// disk. They are applied on top of the loaded Config and survive only for
|
| 59 |
+
// the lifetime of the process (or workspace).
|
| 60 |
+
type RuntimeOverrides struct {
|
| 61 |
+
SkipPermissionRequests bool
|
| 62 |
+
// EnabledChannels lists the MCP servers opted in as channels for this
|
| 63 |
+
// session (via the --channels flag). A server present in MCP config only
|
| 64 |
+
// pushes channel events when it also appears here. Entries may be written
|
| 65 |
+
// as "server:<name>" or as a bare "<name>".
|
| 66 |
+
EnabledChannels []string
|
| 67 |
+
// Models records the model choices made in this instance, whether
|
| 68 |
+
// persisted or not. They are reapplied after a config reload so that a
|
| 69 |
+
// selection made here always outranks whatever the shared config file
|
| 70 |
+
// happens to hold — see pinPreferredModelLocked.
|
| 71 |
+
Models map[SelectedModelType]SelectedModel
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// ConfigStore is the single entry point for all config access. It owns the
|
| 75 |
+
// pure-data Config, runtime state (working directory, resolver, known
|
| 76 |
+
// providers), and persistence to both global and workspace config files.
|
| 77 |
+
//
|
| 78 |
+
// mu serialises all config file mutations (SetConfigFields,
|
| 79 |
+
// RemoveConfigField, RefreshOAuthToken) to prevent both in-process
|
| 80 |
+
// goroutine races and, together with the shared lock.File, cross-process
|
| 81 |
+
// races on the config file.
|
| 82 |
+
//
|
| 83 |
+
// writeMu serialises every operation that produces a new in-memory Config:
|
| 84 |
+
// the typed copy-on-write mutators (SetCompactMode, UpdatePreferredModel,
|
| 85 |
+
// ...) and ReloadFromDisk. Typed mutators take Lock; autoReload takes
|
| 86 |
+
// TryLock so a write triggered re-entrantly during a reload (e.g.
|
| 87 |
+
// configureProviders calling RemoveConfigField) skips the nested reload
|
| 88 |
+
// instead of deadlocking. This is what lets published Configs be treated
|
| 89 |
+
// as immutable: a mutator clones, mutates the clone, and swaps it in under
|
| 90 |
+
// writeMu rather than mutating the live Config in place.
|
| 91 |
+
type ConfigStore struct {
|
| 92 |
+
config *Config
|
| 93 |
+
workingDir string
|
| 94 |
+
resolver VariableResolver
|
| 95 |
+
globalDataPath string // ~/.local/share/crush/crush.json
|
| 96 |
+
workspacePath string // .crush/crush.json
|
| 97 |
+
loadedPaths []string // config files that were successfully loaded
|
| 98 |
+
knownProviders []catwalk.Provider
|
| 99 |
+
overrides RuntimeOverrides
|
| 100 |
+
trackedConfigPaths []string // unique, normalized config file paths
|
| 101 |
+
snapshots map[string]fileSnapshot // path -> snapshot at last capture
|
| 102 |
+
|
| 103 |
+
// configMu guards the config pointer field against concurrent
|
| 104 |
+
// readers (Config) and the writeMu-serialised swap (setConfig). It
|
| 105 |
+
// protects the pointer word only; the pointed-to Config is treated
|
| 106 |
+
// as immutable once published, since both reloads and typed mutators
|
| 107 |
+
// build a fresh Config rather than mutating the live one.
|
| 108 |
+
configMu sync.RWMutex
|
| 109 |
+
|
| 110 |
+
mu sync.Mutex // serialises config file writes
|
| 111 |
+
writeMu sync.RWMutex // serialises in-memory config production (mutators + reload); RLock for readers
|
| 112 |
+
|
| 113 |
+
// refreshSF collapses concurrent in-process OAuth refreshes for the
|
| 114 |
+
// same provider into a single attempt. Combined with the per-provider
|
| 115 |
+
// cross-process refresh lock, it ensures only one token exchange runs
|
| 116 |
+
// at a time. See RefreshOAuthToken.
|
| 117 |
+
refreshSF singleflight.Group
|
| 118 |
+
|
| 119 |
+
// exchangeToken performs the provider-specific OAuth token exchange.
|
| 120 |
+
// It is a field so tests can substitute a fake exchange without making
|
| 121 |
+
// real network calls. Production code leaves it nil, and exchange falls
|
| 122 |
+
// back to the real provider clients.
|
| 123 |
+
exchangeToken func(ctx context.Context, providerID, refreshToken string) (*oauth.Token, error)
|
| 124 |
+
|
| 125 |
+
// fetchOpenAIModels fetches the ChatGPT model catalog. A field for the
|
| 126 |
+
// same reason as exchangeToken: tests stub it to avoid network calls,
|
| 127 |
+
// production leaves it nil and refetchOpenAIModels calls the real one.
|
| 128 |
+
fetchOpenAIModels func(ctx context.Context, token *oauth.Token) ([]catwalk.Model, error)
|
| 129 |
+
|
| 130 |
+
// authSignalMu guards authSignals, which maps provider IDs to
|
| 131 |
+
// channels that WaitForTokenChange blocks on. SignalAuthComplete
|
| 132 |
+
// closes the channel to unblock waiters; a new channel is created
|
| 133 |
+
// on the next wait.
|
| 134 |
+
authSignalMu sync.Mutex
|
| 135 |
+
authSignals map[string]chan struct{}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Config returns the pure-data config struct (read-only after load).
|
| 139 |
+
//
|
| 140 |
+
// The pointer read is guarded by configMu so it can never tear against
|
| 141 |
+
// the reload swap in reloadFromDiskLocked. Reloads build a brand-new
|
| 142 |
+
// Config and swap it in rather than mutating the live one, so holding the
|
| 143 |
+
// returned pointer stays safe even across a concurrent reload — the reader
|
| 144 |
+
// keeps reading its (now immutable) snapshot.
|
| 145 |
+
func (s *ConfigStore) Config() *Config {
|
| 146 |
+
s.configMu.RLock()
|
| 147 |
+
defer s.configMu.RUnlock()
|
| 148 |
+
return s.config
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// setConfig atomically swaps the active config pointer under configMu.
|
| 152 |
+
// Used by the reload path; in-place field mutators leave the pointer
|
| 153 |
+
// untouched and run under mu instead.
|
| 154 |
+
func (s *ConfigStore) setConfig(cfg *Config) {
|
| 155 |
+
s.configMu.Lock()
|
| 156 |
+
defer s.configMu.Unlock()
|
| 157 |
+
s.config = cfg
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// WorkingDir returns the current working directory.
|
| 161 |
+
func (s *ConfigStore) WorkingDir() string {
|
| 162 |
+
return s.workingDir
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
// Resolver returns the variable resolver.
|
| 166 |
+
func (s *ConfigStore) Resolver() VariableResolver {
|
| 167 |
+
s.writeMu.RLock()
|
| 168 |
+
defer s.writeMu.RUnlock()
|
| 169 |
+
return s.resolver
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Resolve resolves a variable reference using the configured resolver.
|
| 173 |
+
func (s *ConfigStore) Resolve(key string) (string, error) {
|
| 174 |
+
s.writeMu.RLock()
|
| 175 |
+
r := s.resolver
|
| 176 |
+
s.writeMu.RUnlock()
|
| 177 |
+
if r == nil {
|
| 178 |
+
return "", fmt.Errorf("no variable resolver configured")
|
| 179 |
+
}
|
| 180 |
+
return r.ResolveValue(key)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// KnownProviders returns the list of known providers.
|
| 184 |
+
func (s *ConfigStore) KnownProviders() []catwalk.Provider {
|
| 185 |
+
s.writeMu.RLock()
|
| 186 |
+
defer s.writeMu.RUnlock()
|
| 187 |
+
return s.knownProviders
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// RefetchHyperProvider re-fetches the Hyper provider catalog from the
|
| 191 |
+
// remote API and updates the in-memory known providers list and config.
|
| 192 |
+
// This is called after OAuth authentication completes so the latest
|
| 193 |
+
// models are available without restarting.
|
| 194 |
+
func (s *ConfigStore) RefetchHyperProvider(ctx context.Context) error {
|
| 195 |
+
// Build a fresh client that reads the API key from the live config,
|
| 196 |
+
// not the stale snapshot captured at startup. The syncer's original
|
| 197 |
+
// client closes over the startup config and would send an expired
|
| 198 |
+
// token after OAuth re-authentication.
|
| 199 |
+
freshClient := realHyperClient{
|
| 200 |
+
baseURL: hyperp.BaseURL(),
|
| 201 |
+
resolveKey: func() string { return resolveHyperAPIKey(s.Config()) },
|
| 202 |
+
}
|
| 203 |
+
hyperSyncer.SetClient(freshClient)
|
| 204 |
+
|
| 205 |
+
hyperProvider, err := hyperSyncer.Refetch(ctx)
|
| 206 |
+
if err != nil {
|
| 207 |
+
return fmt.Errorf("failed to refetch Hyper provider: %w", err)
|
| 208 |
+
}
|
| 209 |
+
if hyperProvider.ID == "" {
|
| 210 |
+
return nil
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
s.writeMu.Lock()
|
| 214 |
+
defer s.writeMu.Unlock()
|
| 215 |
+
|
| 216 |
+
// Replace or insert the Hyper entry in knownProviders.
|
| 217 |
+
found := false
|
| 218 |
+
for i, p := range s.knownProviders {
|
| 219 |
+
if string(p.ID) == string(hyperProvider.ID) {
|
| 220 |
+
s.knownProviders[i] = hyperProvider
|
| 221 |
+
found = true
|
| 222 |
+
break
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
if !found {
|
| 226 |
+
s.knownProviders = append([]catwalk.Provider{hyperProvider}, s.knownProviders...)
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// Update the Hyper provider config with the refreshed model list
|
| 230 |
+
// and endpoint. Use cloneForWrite so readers always see a consistent
|
| 231 |
+
// snapshot (the store's contract forbids in-place config mutation).
|
| 232 |
+
nc := s.config.cloneForWrite()
|
| 233 |
+
if pc, ok := nc.Providers.Get(string(hyperProvider.ID)); ok {
|
| 234 |
+
pc.Models = hyperProvider.Models
|
| 235 |
+
if hyperProvider.APIEndpoint != "" {
|
| 236 |
+
pc.BaseURL = hyperProvider.APIEndpoint
|
| 237 |
+
}
|
| 238 |
+
nc.Providers.Set(string(hyperProvider.ID), pc)
|
| 239 |
+
}
|
| 240 |
+
s.setConfig(nc)
|
| 241 |
+
|
| 242 |
+
// Also update the memoized provider list so callers of
|
| 243 |
+
// config.Providers() (e.g. the models dialog) see fresh data.
|
| 244 |
+
UpdateProviderInList(hyperProvider)
|
| 245 |
+
|
| 246 |
+
s.SetupAgents()
|
| 247 |
+
return nil
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// SetupAgents configures the coder and task agents on the config.
|
| 251 |
+
func (s *ConfigStore) SetupAgents() {
|
| 252 |
+
s.Config().SetupAgents()
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// Overrides returns the runtime overrides for this store.
|
| 256 |
+
func (s *ConfigStore) Overrides() *RuntimeOverrides {
|
| 257 |
+
s.writeMu.RLock()
|
| 258 |
+
defer s.writeMu.RUnlock()
|
| 259 |
+
return &s.overrides
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// LoadedPaths returns the config file paths that were successfully loaded.
|
| 263 |
+
func (s *ConfigStore) LoadedPaths() []string {
|
| 264 |
+
s.writeMu.RLock()
|
| 265 |
+
defer s.writeMu.RUnlock()
|
| 266 |
+
return slices.Clone(s.loadedPaths)
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// lockConfig acquires both the in-process mutex and a cross-process flock
|
| 270 |
+
// on the config file for the given scope. Callers that need to do I/O
|
| 271 |
+
// between reading and writing (e.g. an HTTP token exchange) must use
|
| 272 |
+
// lockConfig explicitly rather than atomicWrite.
|
| 273 |
+
//
|
| 274 |
+
// The returned release function drops both locks. Callers must call it
|
| 275 |
+
// as soon as the file access is complete — no I/O should be performed
|
| 276 |
+
// while the lock is held.
|
| 277 |
+
func (s *ConfigStore) lockConfig(scope Scope) (func(), error) {
|
| 278 |
+
s.mu.Lock()
|
| 279 |
+
path, err := s.configPath(scope)
|
| 280 |
+
if err != nil {
|
| 281 |
+
s.mu.Unlock()
|
| 282 |
+
return nil, err
|
| 283 |
+
}
|
| 284 |
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
| 285 |
+
s.mu.Unlock()
|
| 286 |
+
return nil, fmt.Errorf("create config directory: %w", err)
|
| 287 |
+
}
|
| 288 |
+
ctx, cancel := context.WithTimeout(context.Background(), configLockDeadline)
|
| 289 |
+
defer cancel()
|
| 290 |
+
release, err := lock.File(ctx, path+".lock")
|
| 291 |
+
if err != nil {
|
| 292 |
+
s.mu.Unlock()
|
| 293 |
+
return nil, fmt.Errorf("acquire config lock: %w", err)
|
| 294 |
+
}
|
| 295 |
+
return func() {
|
| 296 |
+
release()
|
| 297 |
+
s.mu.Unlock()
|
| 298 |
+
}, nil
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// atomicWrite handles the lock-read-transform-write-unlock cycle for
|
| 302 |
+
// config file mutations. The fn callback receives the current file
|
| 303 |
+
// contents (raw bytes, or {} if the file is missing) and must return the
|
| 304 |
+
// new contents. fn must be pure — no I/O, no network calls.
|
| 305 |
+
func (s *ConfigStore) atomicWrite(scope Scope, fn func(current []byte) ([]byte, error)) error {
|
| 306 |
+
unlock, err := s.lockConfig(scope)
|
| 307 |
+
if err != nil {
|
| 308 |
+
return err
|
| 309 |
+
}
|
| 310 |
+
defer unlock()
|
| 311 |
+
|
| 312 |
+
path, err := s.configPath(scope)
|
| 313 |
+
if err != nil {
|
| 314 |
+
return err
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
data, err := os.ReadFile(path)
|
| 318 |
+
if err != nil {
|
| 319 |
+
if os.IsNotExist(err) {
|
| 320 |
+
data = []byte("{}")
|
| 321 |
+
} else {
|
| 322 |
+
return fmt.Errorf("read config file: %w", err)
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
newData, err := fn(data)
|
| 327 |
+
if err != nil {
|
| 328 |
+
return err
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
return atomicWriteFile(path, newData, 0o600)
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// configPath returns the file path for the given scope.
|
| 335 |
+
func (s *ConfigStore) configPath(scope Scope) (string, error) {
|
| 336 |
+
switch scope {
|
| 337 |
+
case ScopeWorkspace:
|
| 338 |
+
if s.workspacePath == "" {
|
| 339 |
+
return "", ErrNoWorkspaceConfig
|
| 340 |
+
}
|
| 341 |
+
return s.workspacePath, nil
|
| 342 |
+
default:
|
| 343 |
+
return s.globalDataPath, nil
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// HasConfigField checks whether a key exists in the config file for the given
|
| 348 |
+
// scope.
|
| 349 |
+
func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
|
| 350 |
+
path, err := s.configPath(scope)
|
| 351 |
+
if err != nil {
|
| 352 |
+
return false
|
| 353 |
+
}
|
| 354 |
+
data, err := os.ReadFile(path)
|
| 355 |
+
if err != nil {
|
| 356 |
+
return false
|
| 357 |
+
}
|
| 358 |
+
return gjson.Get(string(data), key).Exists()
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// SetConfigField sets a key/value pair in the config file for the given scope.
|
| 362 |
+
// After a successful write, it automatically reloads config to keep in-memory
|
| 363 |
+
// state fresh.
|
| 364 |
+
func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
|
| 365 |
+
return s.SetConfigFields(scope, map[string]any{key: value})
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// SetConfigFields sets multiple key/value pairs in the config file for the
|
| 369 |
+
// given scope in a single write, then reloads in-memory state from disk.
|
| 370 |
+
//
|
| 371 |
+
// Use this for arbitrary external edits where the in-memory effect of the
|
| 372 |
+
// change is not known ahead of time. The typed mutators (which know exactly
|
| 373 |
+
// what changed) go through update instead and skip the reload.
|
| 374 |
+
//
|
| 375 |
+
// The write is protected by an in-process mutex and a cross-process flock
|
| 376 |
+
// to prevent races between concurrent writers in different processes.
|
| 377 |
+
func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
|
| 378 |
+
if err := s.writeConfigFields(scope, kv); err != nil {
|
| 379 |
+
return err
|
| 380 |
+
}
|
| 381 |
+
// Auto-reload to keep in-memory state fresh after config edits.
|
| 382 |
+
// We use context.Background() since this is an internal operation that
|
| 383 |
+
// shouldn't be cancelled by user context.
|
| 384 |
+
if err := s.autoReload(context.Background()); err != nil {
|
| 385 |
+
// Log warning but don't fail the write - disk is already updated.
|
| 386 |
+
slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
|
| 387 |
+
}
|
| 388 |
+
return nil
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
// writeConfigFields persists key/value pairs to the config file. It does not
|
| 392 |
+
// touch in-memory config state or the staleness snapshot: callers either
|
| 393 |
+
// reload (SetConfigFields, whose reload recaptures the snapshot) or have
|
| 394 |
+
// already published an updated clone and capture the snapshot themselves
|
| 395 |
+
// (update). Both of those run under writeMu, which is what keeps the
|
| 396 |
+
// snapshot map free of concurrent writers.
|
| 397 |
+
func (s *ConfigStore) writeConfigFields(scope Scope, kv map[string]any) error {
|
| 398 |
+
// Sort keys for deterministic output regardless of map iteration
|
| 399 |
+
// order. This also ensures consistent results when callers pass
|
| 400 |
+
// overlapping JSONPath keys (e.g. "a" and "a.b").
|
| 401 |
+
keys := make([]string, 0, len(kv))
|
| 402 |
+
for k := range kv {
|
| 403 |
+
keys = append(keys, k)
|
| 404 |
+
}
|
| 405 |
+
slices.Sort(keys)
|
| 406 |
+
|
| 407 |
+
return s.atomicWrite(scope, func(data []byte) ([]byte, error) {
|
| 408 |
+
v := string(data)
|
| 409 |
+
for _, key := range keys {
|
| 410 |
+
var sErr error
|
| 411 |
+
if v, sErr = sjson.Set(v, key, kv[key]); sErr != nil {
|
| 412 |
+
return nil, fmt.Errorf("failed to set config field %s: %w", key, sErr)
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
return []byte(v), nil
|
| 416 |
+
})
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
// mutateInMemory applies a copy-on-write change to the config without
|
| 420 |
+
// persisting. Under writeMu it clones the live config, lets mutate edit the
|
| 421 |
+
// clone, and publishes it. This is the single primitive every in-memory
|
| 422 |
+
// config change goes through, so a published Config is never mutated in
|
| 423 |
+
// place and readers always see a consistent snapshot.
|
| 424 |
+
func (s *ConfigStore) mutateInMemory(mutate func(*Config)) {
|
| 425 |
+
s.writeMu.Lock()
|
| 426 |
+
defer s.writeMu.Unlock()
|
| 427 |
+
|
| 428 |
+
nc := s.Config().cloneForWrite()
|
| 429 |
+
mutate(nc)
|
| 430 |
+
s.setConfig(nc)
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// update applies a copy-on-write change and persists the reported fields.
|
| 434 |
+
// mutate edits the clone and returns the JSON-path fields to write to disk;
|
| 435 |
+
// because the clone already reflects the change, no reload is needed.
|
| 436 |
+
// Returning an empty map publishes the clone without a disk write.
|
| 437 |
+
func (s *ConfigStore) update(scope Scope, mutate func(*Config) map[string]any) error {
|
| 438 |
+
s.writeMu.Lock()
|
| 439 |
+
defer s.writeMu.Unlock()
|
| 440 |
+
return s.updateLocked(scope, mutate)
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
// updateLocked is the lock-free core of update. Caller must hold writeMu.
|
| 444 |
+
func (s *ConfigStore) updateLocked(scope Scope, mutate func(*Config) map[string]any) error {
|
| 445 |
+
nc := s.Config().cloneForWrite()
|
| 446 |
+
fields := mutate(nc)
|
| 447 |
+
s.setConfig(nc)
|
| 448 |
+
if len(fields) == 0 {
|
| 449 |
+
return nil
|
| 450 |
+
}
|
| 451 |
+
if err := s.writeConfigFields(scope, fields); err != nil {
|
| 452 |
+
return err
|
| 453 |
+
}
|
| 454 |
+
// Refresh the staleness snapshot so the file watcher does not treat
|
| 455 |
+
// our own write as an external change. Safe to touch the snapshot map
|
| 456 |
+
// here because we hold writeMu.
|
| 457 |
+
if path, err := s.configPath(scope); err == nil {
|
| 458 |
+
s.captureStalenessSnapshot(append(slices.Clone(s.loadedPaths), path))
|
| 459 |
+
}
|
| 460 |
+
return nil
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
// OverridePreferredModel sets the preferred model for the given type in
|
| 464 |
+
// memory only, without persisting. It is for per-run overrides (such as the
|
| 465 |
+
// non-interactive --model flags) that must not be written to the user's
|
| 466 |
+
// config file.
|
| 467 |
+
func (s *ConfigStore) OverridePreferredModel(modelType SelectedModelType, model SelectedModel) {
|
| 468 |
+
s.mutateInMemory(func(c *Config) {
|
| 469 |
+
if c.Models == nil {
|
| 470 |
+
c.Models = make(map[SelectedModelType]SelectedModel)
|
| 471 |
+
}
|
| 472 |
+
c.Models[modelType] = model
|
| 473 |
+
s.pinPreferredModelLocked(modelType, model)
|
| 474 |
+
})
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
// pinPreferredModelLocked records a model choice made in this instance so
|
| 478 |
+
// that a later config reload cannot replace it with a choice made
|
| 479 |
+
// somewhere else. Several Crush instances share one global config file, so
|
| 480 |
+
// a reload triggered by an unrelated write (a token refresh, say) would
|
| 481 |
+
// otherwise import whichever model a sibling instance last selected and
|
| 482 |
+
// switch models out from under the user mid-session.
|
| 483 |
+
//
|
| 484 |
+
// Caller must hold writeMu.
|
| 485 |
+
func (s *ConfigStore) pinPreferredModelLocked(modelType SelectedModelType, model SelectedModel) {
|
| 486 |
+
if s.overrides.Models == nil {
|
| 487 |
+
s.overrides.Models = make(map[SelectedModelType]SelectedModel)
|
| 488 |
+
}
|
| 489 |
+
s.overrides.Models[modelType] = model
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
// RemoveConfigField removes a key from the config file for the given scope.
|
| 493 |
+
// After a successful write, it automatically reloads config to keep in-memory
|
| 494 |
+
// state fresh.
|
| 495 |
+
//
|
| 496 |
+
// The write is protected by an in-process mutex and a cross-process flock.
|
| 497 |
+
func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
|
| 498 |
+
err := s.atomicWrite(scope, func(data []byte) ([]byte, error) {
|
| 499 |
+
v, sErr := sjson.Delete(string(data), key)
|
| 500 |
+
if sErr != nil {
|
| 501 |
+
return nil, fmt.Errorf("failed to delete config field %s: %w", key, sErr)
|
| 502 |
+
}
|
| 503 |
+
return []byte(v), nil
|
| 504 |
+
})
|
| 505 |
+
if err != nil {
|
| 506 |
+
return err
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
if err := s.autoReload(context.Background()); err != nil {
|
| 510 |
+
slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
return nil
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// UpdatePreferredModel updates the preferred model for the given type and
|
| 517 |
+
// persists it to the config file at the given scope. The selected model and
|
| 518 |
+
// the recent-models list are written together in a single config write.
|
| 519 |
+
//
|
| 520 |
+
// The write skips the full disk reparse/reload (which would rebuild the
|
| 521 |
+
// provider catalog and agents on every model switch and dominate selection
|
| 522 |
+
// latency); agents are refreshed separately by the caller (see
|
| 523 |
+
// UpdateAgentModel).
|
| 524 |
+
func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
|
| 525 |
+
return s.update(scope, func(c *Config) map[string]any {
|
| 526 |
+
return s.updatePreferredModelFields(c, modelType, model)
|
| 527 |
+
})
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// updatePreferredModelFields builds the fields map for persisting a preferred
|
| 531 |
+
// model change. Shared between UpdatePreferredModel and direct updateLocked
|
| 532 |
+
// callers (e.g. Load). Caller must hold writeMu.
|
| 533 |
+
func (s *ConfigStore) updatePreferredModelFields(c *Config, modelType SelectedModelType, model SelectedModel) map[string]any {
|
| 534 |
+
if c.Models == nil {
|
| 535 |
+
c.Models = make(map[SelectedModelType]SelectedModel)
|
| 536 |
+
}
|
| 537 |
+
c.Models[modelType] = model
|
| 538 |
+
s.pinPreferredModelLocked(modelType, model)
|
| 539 |
+
|
| 540 |
+
fields := map[string]any{
|
| 541 |
+
fmt.Sprintf("models.%s", modelType): model,
|
| 542 |
+
}
|
| 543 |
+
if updated, changed := nextRecentModels(c, modelType, model); changed {
|
| 544 |
+
if c.RecentModels == nil {
|
| 545 |
+
c.RecentModels = make(map[SelectedModelType][]SelectedModel)
|
| 546 |
+
}
|
| 547 |
+
c.RecentModels[modelType] = updated
|
| 548 |
+
fields[fmt.Sprintf("recent_models.%s", modelType)] = updated
|
| 549 |
+
}
|
| 550 |
+
return fields
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// SetCompactMode sets the compact mode setting and persists it.
|
| 554 |
+
func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
|
| 555 |
+
return s.update(scope, func(c *Config) map[string]any {
|
| 556 |
+
c.ensureTUI().CompactMode = enabled
|
| 557 |
+
return map[string]any{"options.tui.compact_mode": enabled}
|
| 558 |
+
})
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
// SetTransparentBackground sets the transparent background setting and persists it.
|
| 562 |
+
func (s *ConfigStore) SetTransparentBackground(scope Scope, enabled bool) error {
|
| 563 |
+
return s.update(scope, func(c *Config) map[string]any {
|
| 564 |
+
c.ensureTUI().Transparent = &enabled
|
| 565 |
+
return map[string]any{"options.tui.transparent": enabled}
|
| 566 |
+
})
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
// SetProviderAPIKey sets the API key for a provider and persists it.
|
| 570 |
+
// The OpenAI provider holds exactly one credential: storing a ChatGPT
|
| 571 |
+
// token removes a previously entered API key, and storing an API key
|
| 572 |
+
// removes a previous ChatGPT login.
|
| 573 |
+
func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
|
| 574 |
+
var providerConfig ProviderConfig
|
| 575 |
+
var exists bool
|
| 576 |
+
var setKeyOrToken func()
|
| 577 |
+
isToken := false
|
| 578 |
+
|
| 579 |
+
switch v := apiKey.(type) {
|
| 580 |
+
case string:
|
| 581 |
+
if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
|
| 582 |
+
return fmt.Errorf("failed to save api key to config file: %w", err)
|
| 583 |
+
}
|
| 584 |
+
setKeyOrToken = func() {
|
| 585 |
+
providerConfig.APIKey = v
|
| 586 |
+
if providerID == string(catwalk.InferenceProviderOpenAI) {
|
| 587 |
+
// Either OAuth or an API key, never both: the login
|
| 588 |
+
// leaves nothing usable on the API-key side behind.
|
| 589 |
+
providerConfig.OAuthToken = nil
|
| 590 |
+
providerConfig.ChatGPTModels = nil
|
| 591 |
+
}
|
| 592 |
+
}
|
| 593 |
+
if providerID == string(catwalk.InferenceProviderOpenAI) {
|
| 594 |
+
// Either OAuth or an API key, never both: the new key
|
| 595 |
+
// leaves nothing usable on the ChatGPT side behind.
|
| 596 |
+
if err := s.RemoveConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID)); err != nil {
|
| 597 |
+
return err
|
| 598 |
+
}
|
| 599 |
+
if err := s.RemoveConfigField(scope, fmt.Sprintf("providers.%s.chatgpt_models", providerID)); err != nil {
|
| 600 |
+
return err
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
case *oauth.Token:
|
| 604 |
+
// Hold the refresh lock across the write so a peer's in-flight
|
| 605 |
+
// token exchange cannot land on top of a credential the user just
|
| 606 |
+
// obtained interactively — which would silently invalidate the
|
| 607 |
+
// login they only just completed.
|
| 608 |
+
fields := map[string]any{
|
| 609 |
+
fmt.Sprintf("providers.%s.oauth", providerID): v,
|
| 610 |
+
}
|
| 611 |
+
if providerID != string(catwalk.InferenceProviderOpenAI) {
|
| 612 |
+
fields[fmt.Sprintf("providers.%s.api_key", providerID)] = v.AccessToken
|
| 613 |
+
}
|
| 614 |
+
if err := s.withRefreshLock(providerID, func() error {
|
| 615 |
+
return s.SetConfigFields(scope, fields)
|
| 616 |
+
}); err != nil {
|
| 617 |
+
return err
|
| 618 |
+
}
|
| 619 |
+
if providerID == string(catwalk.InferenceProviderOpenAI) {
|
| 620 |
+
// Either OAuth or an API key, never both: the login retires
|
| 621 |
+
// any key that came before it.
|
| 622 |
+
if err := s.RemoveConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID)); err != nil {
|
| 623 |
+
return err
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
setKeyOrToken = func() {
|
| 627 |
+
providerConfig.OAuthToken = v
|
| 628 |
+
if providerID == string(catwalk.InferenceProviderOpenAI) {
|
| 629 |
+
isToken = true
|
| 630 |
+
providerConfig.APIKey = ""
|
| 631 |
+
return
|
| 632 |
+
}
|
| 633 |
+
providerConfig.APIKey = v.AccessToken
|
| 634 |
+
if providerID == string(catwalk.InferenceProviderCopilot) {
|
| 635 |
+
providerConfig.SetupGitHubCopilot()
|
| 636 |
+
}
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
cfg := s.Config()
|
| 641 |
+
providerConfig, exists = cfg.Providers.Get(providerID)
|
| 642 |
+
if exists {
|
| 643 |
+
setKeyOrToken()
|
| 644 |
+
cfg.Providers.Set(providerID, providerConfig)
|
| 645 |
+
} else {
|
| 646 |
+
var foundProvider *catwalk.Provider
|
| 647 |
+
for _, p := range s.knownProviders {
|
| 648 |
+
if string(p.ID) == providerID {
|
| 649 |
+
foundProvider = &p
|
| 650 |
+
break
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
if foundProvider != nil {
|
| 655 |
+
providerConfig = ProviderConfig{
|
| 656 |
+
ID: providerID,
|
| 657 |
+
Name: foundProvider.Name,
|
| 658 |
+
BaseURL: foundProvider.APIEndpoint,
|
| 659 |
+
Type: foundProvider.Type,
|
| 660 |
+
Disable: false,
|
| 661 |
+
ExtraHeaders: make(map[string]string),
|
| 662 |
+
ExtraParams: make(map[string]string),
|
| 663 |
+
Models: foundProvider.Models,
|
| 664 |
+
}
|
| 665 |
+
setKeyOrToken()
|
| 666 |
+
} else {
|
| 667 |
+
return fmt.Errorf("provider with ID %s not found in known providers", providerID)
|
| 668 |
+
}
|
| 669 |
+
cfg.Providers.Set(providerID, providerConfig)
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
// After authenticating with Hyper, re-fetch the provider catalog so
|
| 673 |
+
// the latest models are available without restarting.
|
| 674 |
+
if providerID == "hyper" {
|
| 675 |
+
if refetchErr := s.RefetchHyperProvider(context.Background()); refetchErr != nil {
|
| 676 |
+
slog.Warn("Failed to refetch Hyper provider after auth", "error", refetchErr)
|
| 677 |
+
}
|
| 678 |
+
}
|
| 679 |
+
// After authenticating with a ChatGPT account, fetch the Codex model
|
| 680 |
+
// catalog the subscription grants and persist it so the models
|
| 681 |
+
// dialog can offer it beside the API-key catalog.
|
| 682 |
+
if providerID == string(catwalk.InferenceProviderOpenAI) && isToken {
|
| 683 |
+
s.refetchOpenAIModels(context.Background(), scope)
|
| 684 |
+
}
|
| 685 |
+
return nil
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
// refetchOpenAIModels stores the Codex model catalog the ChatGPT plan
|
| 689 |
+
// grants next to the provider's API-key models. Best effort: a failure
|
| 690 |
+
// leaves the existing catalog in place and the login still succeeds.
|
| 691 |
+
func (s *ConfigStore) refetchOpenAIModels(ctx context.Context, scope Scope) {
|
| 692 |
+
cfg := s.Config()
|
| 693 |
+
pc, ok := cfg.Providers.Get(string(catwalk.InferenceProviderOpenAI))
|
| 694 |
+
if !ok || pc.OAuthToken == nil {
|
| 695 |
+
return
|
| 696 |
+
}
|
| 697 |
+
fetchModels := s.fetchOpenAIModels
|
| 698 |
+
if fetchModels == nil {
|
| 699 |
+
fetchModels = openai.Models
|
| 700 |
+
}
|
| 701 |
+
models, err := fetchModels(ctx, pc.OAuthToken)
|
| 702 |
+
if err != nil {
|
| 703 |
+
slog.Warn("Failed to fetch ChatGPT model catalog after auth", "error", err)
|
| 704 |
+
return
|
| 705 |
+
}
|
| 706 |
+
if err := s.update(scope, func(c *Config) map[string]any {
|
| 707 |
+
p, ok := c.Providers.Get(string(catwalk.InferenceProviderOpenAI))
|
| 708 |
+
if !ok {
|
| 709 |
+
return nil
|
| 710 |
+
}
|
| 711 |
+
p.ChatGPTModels = models
|
| 712 |
+
c.Providers.Set(string(catwalk.InferenceProviderOpenAI), p)
|
| 713 |
+
return map[string]any{
|
| 714 |
+
"providers.openai.chatgpt_models": models,
|
| 715 |
+
}
|
| 716 |
+
}); err != nil {
|
| 717 |
+
slog.Warn("Failed to persist ChatGPT model catalog", "error", err)
|
| 718 |
+
}
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
// RefetchOpenAIChatGPTModels fills in the ChatGPT model catalog when the
|
| 722 |
+
// OpenAI provider is signed in but has none — because the fetch at login
|
| 723 |
+
// time failed, or the credentials predate the catalog. A no-op once the
|
| 724 |
+
// catalog exists, so callers can invoke it freely on model updates.
|
| 725 |
+
func (s *ConfigStore) RefetchOpenAIChatGPTModels(ctx context.Context) {
|
| 726 |
+
cfg := s.Config()
|
| 727 |
+
pc, ok := cfg.Providers.Get(string(catwalk.InferenceProviderOpenAI))
|
| 728 |
+
if !ok || pc.OAuthToken == nil || len(pc.ChatGPTModels) > 0 {
|
| 729 |
+
return
|
| 730 |
+
}
|
| 731 |
+
s.refetchOpenAIModels(ctx, ScopeGlobal)
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
// RefreshOAuthToken refreshes the OAuth token for the given provider.
|
| 735 |
+
//
|
| 736 |
+
// Providers like Hyper rotate refresh tokens: each exchange consumes the
|
| 737 |
+
// caller's refresh token, issues a new pair, and revokes the old one. If
|
| 738 |
+
// two crush instances (or two goroutines) refresh concurrently with the
|
| 739 |
+
// same stored refresh token, the second exchange reuses an already-revoked
|
| 740 |
+
// token, trips the provider's reuse detection, and revokes the entire
|
| 741 |
+
// token family — leaving both with dead tokens even though each refresh
|
| 742 |
+
// "succeeded".
|
| 743 |
+
//
|
| 744 |
+
// To prevent that, refreshes are single-flighted at two levels:
|
| 745 |
+
//
|
| 746 |
+
// - In-process: refreshSF collapses concurrent goroutines for the same
|
| 747 |
+
// provider into one attempt.
|
| 748 |
+
// - Cross-process: a per-provider advisory lock is held across the whole
|
| 749 |
+
// read-decide-exchange-write cycle, so only one process exchanges at a
|
| 750 |
+
// time. A process that acquires the lock after a peer rotated finds the
|
| 751 |
+
// peer's fresh token on disk and adopts it instead of exchanging.
|
| 752 |
+
func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
|
| 753 |
+
key := fmt.Sprintf("%d\x00%s", scope, providerID)
|
| 754 |
+
_, err, _ := s.refreshSF.Do(key, func() (any, error) {
|
| 755 |
+
return nil, s.refreshOAuthTokenLocked(ctx, scope, providerID)
|
| 756 |
+
})
|
| 757 |
+
return err
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
// refreshOAuthTokenLocked performs the cross-process single-flighted
|
| 761 |
+
// refresh. It is invoked through refreshSF, so at most one goroutine per
|
| 762 |
+
// provider runs it at a time within this process.
|
| 763 |
+
func (s *ConfigStore) refreshOAuthTokenLocked(ctx context.Context, scope Scope, providerID string) error {
|
| 764 |
+
cfg := s.Config()
|
| 765 |
+
providerConfig, exists := cfg.Providers.Get(providerID)
|
| 766 |
+
if !exists {
|
| 767 |
+
return fmt.Errorf("provider %s not found", providerID)
|
| 768 |
+
}
|
| 769 |
+
if providerConfig.OAuthToken == nil {
|
| 770 |
+
return fmt.Errorf("provider %s does not have an OAuth token", providerID)
|
| 771 |
+
}
|
| 772 |
+
entryToken := providerConfig.OAuthToken
|
| 773 |
+
|
| 774 |
+
// Acquire the per-provider cross-process refresh lock. This is a
|
| 775 |
+
// dedicated lock file, not the config-write lock, and it does not take
|
| 776 |
+
// s.mu — so the network exchange below cannot stall unrelated config
|
| 777 |
+
// operations. The deadline exceeds the exchange timeout so that a peer
|
| 778 |
+
// mid-exchange has time to publish a token we can adopt. Lock ordering:
|
| 779 |
+
// the refresh lock is always taken before the config-write lock (via
|
| 780 |
+
// SetConfigFields), never the reverse, so no deadlock is possible.
|
| 781 |
+
lockCtx, cancel := context.WithTimeout(ctx, refreshLockDeadline)
|
| 782 |
+
defer cancel()
|
| 783 |
+
release, lockErr := lock.File(lockCtx, s.refreshLockPath(providerID))
|
| 784 |
+
if lockErr != nil {
|
| 785 |
+
// Could not acquire the lock (peer wedged or deadline hit). Prefer a
|
| 786 |
+
// usable token already on disk over forcing our own exchange, which
|
| 787 |
+
// would risk reusing a rotated refresh token.
|
| 788 |
+
if diskToken := s.usableDiskToken(scope, providerID, entryToken); diskToken != nil {
|
| 789 |
+
slog.Warn("Refresh lock unavailable; adopting token from disk", "provider", providerID, "error", lockErr)
|
| 790 |
+
return s.applyToken(providerConfig, diskToken, providerID)
|
| 791 |
+
}
|
| 792 |
+
return fmt.Errorf("acquire refresh lock for provider %s: %w", providerID, lockErr)
|
| 793 |
+
}
|
| 794 |
+
defer release()
|
| 795 |
+
|
| 796 |
+
// Now that we hold the lock, disk is the authority on which credential
|
| 797 |
+
// is current: a peer may have rotated ours away while we waited. Adopt
|
| 798 |
+
// a newer token outright when it is still usable, and otherwise switch
|
| 799 |
+
// to its refresh token for the exchange below. Presenting our own
|
| 800 |
+
// already-rotated refresh token would trip the provider's reuse
|
| 801 |
+
// detection and revoke the whole family, forcing an interactive login.
|
| 802 |
+
if diskToken := s.newerDiskToken(scope, providerID, entryToken); diskToken != nil {
|
| 803 |
+
if !diskToken.IsExpired() {
|
| 804 |
+
slog.Info("Adopting token refreshed by another session", "provider", providerID)
|
| 805 |
+
return s.applyToken(providerConfig, diskToken, providerID)
|
| 806 |
+
}
|
| 807 |
+
slog.Info("Exchanging with refresh token rotated by another session", "provider", providerID)
|
| 808 |
+
entryToken = diskToken
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
// Disk still holds our token (or no newer peer token exists) and we hold
|
| 812 |
+
// the lock, so we are the sole exchanger. Perform the exchange.
|
| 813 |
+
refreshedToken, refreshErr := s.exchange(ctx, providerID, entryToken.RefreshToken)
|
| 814 |
+
if refreshErr != nil {
|
| 815 |
+
// The exchange may have failed because a peer rotated the refresh
|
| 816 |
+
// token in a window we did not cover. Re-check disk: adopt a usable
|
| 817 |
+
// token, or retry once with the peer's newer refresh token.
|
| 818 |
+
if diskToken := s.newerDiskToken(scope, providerID, entryToken); diskToken != nil {
|
| 819 |
+
if !diskToken.IsExpired() {
|
| 820 |
+
slog.Info("Adopting token refreshed by another session after exchange failure", "provider", providerID)
|
| 821 |
+
return s.applyToken(providerConfig, diskToken, providerID)
|
| 822 |
+
}
|
| 823 |
+
slog.Info("Retrying exchange with refresh token rotated by another session", "provider", providerID)
|
| 824 |
+
refreshedToken, refreshErr = s.exchange(ctx, providerID, diskToken.RefreshToken)
|
| 825 |
+
}
|
| 826 |
+
}
|
| 827 |
+
if refreshErr != nil {
|
| 828 |
+
return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
slog.Info("Successfully refreshed OAuth token", "provider", providerID)
|
| 832 |
+
if err := s.applyToken(providerConfig, refreshedToken, providerID); err != nil {
|
| 833 |
+
return err
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
if err := s.SetConfigFields(scope, tokenFields(providerID, refreshedToken)); err != nil {
|
| 837 |
+
return fmt.Errorf("failed to persist refreshed token: %w", err)
|
| 838 |
+
}
|
| 839 |
+
return nil
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
// tokenFields builds the config fields that persist an OAuth token. The
|
| 843 |
+
// OpenAI provider does not mirror the access token into api_key so a
|
| 844 |
+
// manually entered API key can coexist with the ChatGPT login.
|
| 845 |
+
func tokenFields(providerID string, token *oauth.Token) map[string]any {
|
| 846 |
+
fields := map[string]any{
|
| 847 |
+
fmt.Sprintf("providers.%s.oauth", providerID): token,
|
| 848 |
+
}
|
| 849 |
+
if providerID != string(catwalk.InferenceProviderOpenAI) {
|
| 850 |
+
fields[fmt.Sprintf("providers.%s.api_key", providerID)] = token.AccessToken
|
| 851 |
+
}
|
| 852 |
+
return fields
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
// WaitForTokenChange blocks until SignalAuthComplete is called for the
|
| 856 |
+
// given provider or the context is cancelled. It is used by OnAuthRefresh
|
| 857 |
+
// callbacks to wait for interactive re-authentication to complete before
|
| 858 |
+
// retrying a failed request. The channel is created atomically with the
|
| 859 |
+
// wait registration so a concurrent SignalAuthComplete cannot miss it.
|
| 860 |
+
func (s *ConfigStore) WaitForTokenChange(ctx context.Context, providerID string) error {
|
| 861 |
+
s.authSignalMu.Lock()
|
| 862 |
+
ch, ok := s.authSignals[providerID]
|
| 863 |
+
if !ok {
|
| 864 |
+
ch = make(chan struct{})
|
| 865 |
+
if s.authSignals == nil {
|
| 866 |
+
s.authSignals = make(map[string]chan struct{})
|
| 867 |
+
}
|
| 868 |
+
s.authSignals[providerID] = ch
|
| 869 |
+
}
|
| 870 |
+
s.authSignalMu.Unlock()
|
| 871 |
+
|
| 872 |
+
select {
|
| 873 |
+
case <-ch:
|
| 874 |
+
// Remove the consumed signal so a subsequent
|
| 875 |
+
// SignalAuthComplete does not close an already-closed
|
| 876 |
+
// channel.
|
| 877 |
+
s.authSignalMu.Lock()
|
| 878 |
+
if s.authSignals[providerID] == ch {
|
| 879 |
+
delete(s.authSignals, providerID)
|
| 880 |
+
}
|
| 881 |
+
s.authSignalMu.Unlock()
|
| 882 |
+
return nil
|
| 883 |
+
case <-ctx.Done():
|
| 884 |
+
return ctx.Err()
|
| 885 |
+
}
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
// SignalAuthComplete unblocks any goroutine waiting in WaitForTokenChange
|
| 889 |
+
// for the given provider. If no waiter exists yet, it pre-creates and
|
| 890 |
+
// immediately closes the channel so a subsequent WaitForTokenChange
|
| 891 |
+
// returns without blocking. This eliminates the race where the signal
|
| 892 |
+
// fires before the waiter registers.
|
| 893 |
+
func (s *ConfigStore) SignalAuthComplete(providerID string) {
|
| 894 |
+
s.authSignalMu.Lock()
|
| 895 |
+
defer s.authSignalMu.Unlock()
|
| 896 |
+
if ch, ok := s.authSignals[providerID]; ok {
|
| 897 |
+
delete(s.authSignals, providerID)
|
| 898 |
+
select {
|
| 899 |
+
case <-ch:
|
| 900 |
+
// Already closed by a previous signal; nothing to do.
|
| 901 |
+
default:
|
| 902 |
+
close(ch)
|
| 903 |
+
}
|
| 904 |
+
} else {
|
| 905 |
+
// No waiter yet. Pre-create a closed channel so the next
|
| 906 |
+
// WaitForTokenChange returns immediately.
|
| 907 |
+
if s.authSignals == nil {
|
| 908 |
+
s.authSignals = make(map[string]chan struct{})
|
| 909 |
+
}
|
| 910 |
+
ch := make(chan struct{})
|
| 911 |
+
close(ch)
|
| 912 |
+
s.authSignals[providerID] = ch
|
| 913 |
+
}
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
// newerDiskToken returns the on-disk token for the provider when it is
|
| 917 |
+
// newer than entryToken — i.e. another session (possibly in another
|
| 918 |
+
// process) has already rotated the credential. It returns nil when disk
|
| 919 |
+
// holds nothing newer than what we started with.
|
| 920 |
+
//
|
| 921 |
+
// Newness is judged by expiry as well as identity, so a config file that
|
| 922 |
+
// somehow holds an older token cannot drag us backwards. The result may
|
| 923 |
+
// itself be expired: providers that rotate refresh tokens invalidate ours
|
| 924 |
+
// the moment a peer refreshes, so the peer's refresh token is the only one
|
| 925 |
+
// the provider will still accept even after its access token ages out.
|
| 926 |
+
// Callers decide whether to adopt the token wholesale or merely borrow its
|
| 927 |
+
// refresh token.
|
| 928 |
+
func (s *ConfigStore) newerDiskToken(scope Scope, providerID string, entryToken *oauth.Token) *oauth.Token {
|
| 929 |
+
diskToken, err := s.loadTokenFromDisk(scope, providerID)
|
| 930 |
+
if err != nil {
|
| 931 |
+
slog.Warn("Failed to read token from config file", "provider", providerID, "error", err)
|
| 932 |
+
return nil
|
| 933 |
+
}
|
| 934 |
+
if diskToken == nil {
|
| 935 |
+
return nil
|
| 936 |
+
}
|
| 937 |
+
if diskToken.AccessToken == entryToken.AccessToken {
|
| 938 |
+
// Same token we started with; nobody rotated since.
|
| 939 |
+
return nil
|
| 940 |
+
}
|
| 941 |
+
if diskToken.RefreshToken == "" && entryToken.RefreshToken != "" {
|
| 942 |
+
// Adopting would strand us with no way to refresh later, and
|
| 943 |
+
// there is nothing to borrow for an exchange.
|
| 944 |
+
return nil
|
| 945 |
+
}
|
| 946 |
+
if diskToken.ExpiresAt < entryToken.ExpiresAt {
|
| 947 |
+
// Older than ours; nothing to gain from adopting it.
|
| 948 |
+
return nil
|
| 949 |
+
}
|
| 950 |
+
return diskToken
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
// usableDiskToken returns the on-disk token only when it is both newer
|
| 954 |
+
// than entryToken and still valid, meaning it can be adopted as-is with
|
| 955 |
+
// no exchange at all.
|
| 956 |
+
func (s *ConfigStore) usableDiskToken(scope Scope, providerID string, entryToken *oauth.Token) *oauth.Token {
|
| 957 |
+
diskToken := s.newerDiskToken(scope, providerID, entryToken)
|
| 958 |
+
if diskToken == nil || diskToken.IsExpired() {
|
| 959 |
+
return nil
|
| 960 |
+
}
|
| 961 |
+
return diskToken
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
// exchange performs the provider-specific OAuth token exchange. Tests may
|
| 965 |
+
// override it via the exchangeToken field; production uses the real
|
| 966 |
+
// provider clients.
|
| 967 |
+
func (s *ConfigStore) exchange(ctx context.Context, providerID, refreshToken string) (*oauth.Token, error) {
|
| 968 |
+
if s.exchangeToken != nil {
|
| 969 |
+
return s.exchangeToken(ctx, providerID, refreshToken)
|
| 970 |
+
}
|
| 971 |
+
switch providerID {
|
| 972 |
+
case string(catwalk.InferenceProviderCopilot):
|
| 973 |
+
return copilot.RefreshToken(ctx, refreshToken)
|
| 974 |
+
case string(catwalk.InferenceProviderOpenAI):
|
| 975 |
+
return openai.RefreshToken(ctx, refreshToken)
|
| 976 |
+
case hyperp.Name:
|
| 977 |
+
return hyper.ExchangeToken(ctx, refreshToken)
|
| 978 |
+
default:
|
| 979 |
+
return nil, fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
|
| 980 |
+
}
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
// withRefreshLock runs fn while holding the per-provider cross-process
|
| 984 |
+
// refresh lock, so a credential write cannot interleave with a peer's
|
| 985 |
+
// token exchange. Acquisition is best effort: when the lock cannot be
|
| 986 |
+
// taken in time, fn runs anyway rather than blocking a write the user is
|
| 987 |
+
// waiting on.
|
| 988 |
+
func (s *ConfigStore) withRefreshLock(providerID string, fn func() error) error {
|
| 989 |
+
ctx, cancel := context.WithTimeout(context.Background(), credentialWriteLockDeadline)
|
| 990 |
+
defer cancel()
|
| 991 |
+
release, err := lock.File(ctx, s.refreshLockPath(providerID))
|
| 992 |
+
if err != nil {
|
| 993 |
+
slog.Warn("Writing credentials without the refresh lock", "provider", providerID, "error", err)
|
| 994 |
+
return fn()
|
| 995 |
+
}
|
| 996 |
+
defer release()
|
| 997 |
+
return fn()
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
// refreshLockPath returns the path to the per-provider cross-process refresh
|
| 1001 |
+
// lock file. Lock files live under a dedicated locks/ subdirectory of the
|
| 1002 |
+
// data dir so they do not clutter the config directory. The file is created
|
| 1003 |
+
// on demand by lock.File and is never removed (flock keys on inode, not
|
| 1004 |
+
// path).
|
| 1005 |
+
func (s *ConfigStore) refreshLockPath(providerID string) string {
|
| 1006 |
+
dir := filepath.Join(filepath.Dir(s.globalDataPath), "locks")
|
| 1007 |
+
_ = os.MkdirAll(dir, 0o755)
|
| 1008 |
+
return filepath.Join(dir, fmt.Sprintf("%s.refresh.lock", providerID))
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
// applyToken updates the in-memory provider config with the given token.
|
| 1012 |
+
func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
|
| 1013 |
+
providerConfig.OAuthToken = token
|
| 1014 |
+
// The OpenAI provider holds exactly one credential, so a ChatGPT
|
| 1015 |
+
// token means there is no API key side by side with it.
|
| 1016 |
+
if providerID != string(catwalk.InferenceProviderOpenAI) {
|
| 1017 |
+
providerConfig.APIKey = token.AccessToken
|
| 1018 |
+
}
|
| 1019 |
+
if providerID == string(catwalk.InferenceProviderCopilot) {
|
| 1020 |
+
providerConfig.SetupGitHubCopilot()
|
| 1021 |
+
}
|
| 1022 |
+
s.Config().Providers.Set(providerID, providerConfig)
|
| 1023 |
+
return nil
|
| 1024 |
+
}
|
| 1025 |
+
|
| 1026 |
+
// loadTokenFromDisk reads the OAuth token for the given provider from the
|
| 1027 |
+
// config file on disk. Returns nil if the token is not found or matches the
|
| 1028 |
+
// current in-memory token.
|
| 1029 |
+
func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.Token, error) {
|
| 1030 |
+
path, err := s.configPath(scope)
|
| 1031 |
+
if err != nil {
|
| 1032 |
+
return nil, err
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
data, err := os.ReadFile(path)
|
| 1036 |
+
if err != nil {
|
| 1037 |
+
if os.IsNotExist(err) {
|
| 1038 |
+
return nil, nil
|
| 1039 |
+
}
|
| 1040 |
+
return nil, err
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
oauthKey := fmt.Sprintf("providers.%s.oauth", providerID)
|
| 1044 |
+
oauthResult := gjson.Get(string(data), oauthKey)
|
| 1045 |
+
if !oauthResult.Exists() {
|
| 1046 |
+
return nil, nil
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
var token oauth.Token
|
| 1050 |
+
if err := json.Unmarshal([]byte(oauthResult.Raw), &token); err != nil {
|
| 1051 |
+
return nil, err
|
| 1052 |
+
}
|
| 1053 |
+
|
| 1054 |
+
if token.AccessToken == "" {
|
| 1055 |
+
return nil, nil
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
return &token, nil
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
// nextRecentModels computes the recent-models list for the given type
|
| 1062 |
+
// after recording the supplied model at the front, operating on the
|
| 1063 |
+
// provided config without persisting anything. It returns the new slice
|
| 1064 |
+
// and whether it differs from cfg's current list. Callers fold the result
|
| 1065 |
+
// into a clone they are about to publish.
|
| 1066 |
+
func nextRecentModels(cfg *Config, modelType SelectedModelType, model SelectedModel) ([]SelectedModel, bool) {
|
| 1067 |
+
if model.Provider == "" || model.Model == "" {
|
| 1068 |
+
return nil, false
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
eq := func(a, b SelectedModel) bool {
|
| 1072 |
+
return a.Provider == b.Provider && a.Model == b.Model
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
entry := SelectedModel{
|
| 1076 |
+
Provider: model.Provider,
|
| 1077 |
+
Model: model.Model,
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
current := cfg.RecentModels[modelType]
|
| 1081 |
+
withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
|
| 1082 |
+
return eq(existing, entry)
|
| 1083 |
+
})
|
| 1084 |
+
|
| 1085 |
+
updated := append([]SelectedModel{entry}, withoutCurrent...)
|
| 1086 |
+
if len(updated) > maxRecentModelsPerType {
|
| 1087 |
+
updated = updated[:maxRecentModelsPerType]
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
if slices.EqualFunc(current, updated, eq) {
|
| 1091 |
+
return current, false
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
return updated, true
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
// NewTestStore creates a ConfigStore for testing purposes.
|
| 1098 |
+
func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
|
| 1099 |
+
return &ConfigStore{
|
| 1100 |
+
config: cfg,
|
| 1101 |
+
loadedPaths: loadedPaths,
|
| 1102 |
+
resolver: NewShellVariableResolver(env.New()),
|
| 1103 |
+
}
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
// ImportCopilot attempts to import a GitHub Copilot token from disk.
|
| 1107 |
+
func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
|
| 1108 |
+
if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
|
| 1109 |
+
return nil, false
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
|
| 1113 |
+
if !hasDiskToken {
|
| 1114 |
+
return nil, false
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
|
| 1118 |
+
token, err := copilot.RefreshToken(context.TODO(), diskToken)
|
| 1119 |
+
if err != nil {
|
| 1120 |
+
slog.Error("Unable to import GitHub Copilot token", "error", err)
|
| 1121 |
+
return nil, false
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
|
| 1125 |
+
return token, false
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
if err := s.SetConfigFields(ScopeGlobal, map[string]any{
|
| 1129 |
+
"providers.copilot.api_key": token.AccessToken,
|
| 1130 |
+
"providers.copilot.oauth": token,
|
| 1131 |
+
}); err != nil {
|
| 1132 |
+
slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
slog.Info("GitHub Copilot successfully imported")
|
| 1136 |
+
return token, true
|
| 1137 |
+
}
|
| 1138 |
+
|
| 1139 |
+
// StalenessResult contains the result of a staleness check.
|
| 1140 |
+
type StalenessResult struct {
|
| 1141 |
+
Dirty bool
|
| 1142 |
+
Changed []string
|
| 1143 |
+
Missing []string
|
| 1144 |
+
Errors map[string]error // stat errors by path
|
| 1145 |
+
}
|
| 1146 |
+
|
| 1147 |
+
// ConfigStaleness checks whether any tracked config files have changed on disk
|
| 1148 |
+
// since the last snapshot. Returns dirty=true if any files changed or went
|
| 1149 |
+
// missing, along with sorted lists of affected paths. Stat errors are
|
| 1150 |
+
// captured in Errors map but still treated as non-existence for dirty detection.
|
| 1151 |
+
func (s *ConfigStore) ConfigStaleness() StalenessResult {
|
| 1152 |
+
var result StalenessResult
|
| 1153 |
+
result.Errors = make(map[string]error)
|
| 1154 |
+
|
| 1155 |
+
for _, path := range s.trackedConfigPaths {
|
| 1156 |
+
snapshot, hadSnapshot := s.snapshots[path]
|
| 1157 |
+
|
| 1158 |
+
info, err := os.Stat(path)
|
| 1159 |
+
exists := err == nil && !info.IsDir()
|
| 1160 |
+
|
| 1161 |
+
if err != nil && !os.IsNotExist(err) {
|
| 1162 |
+
// Capture permission/IO errors separately from non-existence
|
| 1163 |
+
result.Errors[path] = err
|
| 1164 |
+
result.Dirty = true
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
if !exists {
|
| 1168 |
+
if hadSnapshot && snapshot.Exists {
|
| 1169 |
+
// File existed before but now missing
|
| 1170 |
+
result.Missing = append(result.Missing, path)
|
| 1171 |
+
result.Dirty = true
|
| 1172 |
+
}
|
| 1173 |
+
continue
|
| 1174 |
+
}
|
| 1175 |
+
|
| 1176 |
+
// File exists now
|
| 1177 |
+
if !hadSnapshot || !snapshot.Exists {
|
| 1178 |
+
// File didn't exist before but does now
|
| 1179 |
+
result.Changed = append(result.Changed, path)
|
| 1180 |
+
result.Dirty = true
|
| 1181 |
+
continue
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
// Check for content or metadata changes
|
| 1185 |
+
if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
|
| 1186 |
+
result.Changed = append(result.Changed, path)
|
| 1187 |
+
result.Dirty = true
|
| 1188 |
+
}
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
// Sort for deterministic output
|
| 1192 |
+
slices.Sort(result.Changed)
|
| 1193 |
+
slices.Sort(result.Missing)
|
| 1194 |
+
|
| 1195 |
+
return result
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
// RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
|
| 1199 |
+
// Call this after reloading config to clear dirty state.
|
| 1200 |
+
func (s *ConfigStore) RefreshStalenessSnapshot() error {
|
| 1201 |
+
if s.snapshots == nil {
|
| 1202 |
+
s.snapshots = make(map[string]fileSnapshot)
|
| 1203 |
+
}
|
| 1204 |
+
|
| 1205 |
+
for _, path := range s.trackedConfigPaths {
|
| 1206 |
+
info, err := os.Stat(path)
|
| 1207 |
+
exists := err == nil && !info.IsDir()
|
| 1208 |
+
|
| 1209 |
+
snapshot := fileSnapshot{
|
| 1210 |
+
Path: path,
|
| 1211 |
+
Exists: exists,
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
if exists {
|
| 1215 |
+
snapshot.Size = info.Size()
|
| 1216 |
+
snapshot.ModTime = info.ModTime().UnixNano()
|
| 1217 |
+
}
|
| 1218 |
+
|
| 1219 |
+
s.snapshots[path] = snapshot
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
return nil
|
| 1223 |
+
}
|
| 1224 |
+
|
| 1225 |
+
// CaptureStalenessSnapshot captures snapshots for the given paths, building the
|
| 1226 |
+
// tracked config paths list. Paths are deduplicated and normalized.
|
| 1227 |
+
func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
|
| 1228 |
+
// Build unique set of normalized paths
|
| 1229 |
+
seen := make(map[string]struct{})
|
| 1230 |
+
for _, p := range paths {
|
| 1231 |
+
if p == "" {
|
| 1232 |
+
continue
|
| 1233 |
+
}
|
| 1234 |
+
// Normalize path
|
| 1235 |
+
abs, err := filepath.Abs(p)
|
| 1236 |
+
if err != nil {
|
| 1237 |
+
abs = p
|
| 1238 |
+
}
|
| 1239 |
+
seen[abs] = struct{}{}
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
// Also track workspace and global config paths if set
|
| 1243 |
+
if s.workspacePath != "" {
|
| 1244 |
+
abs, err := filepath.Abs(s.workspacePath)
|
| 1245 |
+
if err == nil {
|
| 1246 |
+
seen[abs] = struct{}{}
|
| 1247 |
+
}
|
| 1248 |
+
}
|
| 1249 |
+
if s.globalDataPath != "" {
|
| 1250 |
+
abs, err := filepath.Abs(s.globalDataPath)
|
| 1251 |
+
if err == nil {
|
| 1252 |
+
seen[abs] = struct{}{}
|
| 1253 |
+
}
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
// Build sorted list for deterministic ordering
|
| 1257 |
+
s.trackedConfigPaths = make([]string, 0, len(seen))
|
| 1258 |
+
for p := range seen {
|
| 1259 |
+
s.trackedConfigPaths = append(s.trackedConfigPaths, p)
|
| 1260 |
+
}
|
| 1261 |
+
slices.Sort(s.trackedConfigPaths)
|
| 1262 |
+
|
| 1263 |
+
// Capture initial snapshots
|
| 1264 |
+
s.RefreshStalenessSnapshot()
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
|
| 1268 |
+
func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
|
| 1269 |
+
s.CaptureStalenessSnapshot(paths)
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
|
| 1273 |
+
// config atomically. It rebuilds the staleness snapshot after successful reload.
|
| 1274 |
+
// On failure, the store state is rolled back to its previous state.
|
| 1275 |
+
// Concurrent calls are serialised via writeMu.
|
| 1276 |
+
func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
|
| 1277 |
+
if s.workingDir == "" {
|
| 1278 |
+
return fmt.Errorf("cannot reload: working directory not set")
|
| 1279 |
+
}
|
| 1280 |
+
s.writeMu.Lock()
|
| 1281 |
+
defer s.writeMu.Unlock()
|
| 1282 |
+
return s.reloadFromDiskLocked(ctx)
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
// reloadFromDiskLocked performs the actual reload. Caller must hold writeMu.
|
| 1286 |
+
func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
|
| 1287 |
+
// Migrate deprecated disable_notifications before reloading config.
|
| 1288 |
+
migrateDisableNotifications()
|
| 1289 |
+
|
| 1290 |
+
configPaths := lookupConfigs(s.workingDir)
|
| 1291 |
+
cfg, loadedPaths, err := loadFromConfigPaths(ctx, configPaths)
|
| 1292 |
+
if err != nil {
|
| 1293 |
+
return fmt.Errorf("failed to reload config: %w", err)
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
// Apply defaults (using existing data directory if set)
|
| 1297 |
+
var dataDir string
|
| 1298 |
+
if cur := s.Config(); cur != nil && cur.Options != nil {
|
| 1299 |
+
dataDir = cur.Options.DataDirectory
|
| 1300 |
+
}
|
| 1301 |
+
cfg.setDefaults(s.workingDir, dataDir)
|
| 1302 |
+
|
| 1303 |
+
// Merge workspace config if present
|
| 1304 |
+
workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
|
| 1305 |
+
if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
|
| 1306 |
+
if !json.Valid(wsData) {
|
| 1307 |
+
return fmt.Errorf("invalid JSON in config file %s", workspacePath)
|
| 1308 |
+
}
|
| 1309 |
+
merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
|
| 1310 |
+
if mergeErr == nil {
|
| 1311 |
+
dataDir := cfg.Options.DataDirectory
|
| 1312 |
+
*cfg = *merged
|
| 1313 |
+
cfg.setDefaults(s.workingDir, dataDir)
|
| 1314 |
+
loadedPaths = append(loadedPaths, workspacePath)
|
| 1315 |
+
}
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
// Validate hooks after all config merging is complete so matcher
|
| 1319 |
+
// regexes are recompiled on the reloaded config (mirrors Load).
|
| 1320 |
+
if err := cfg.ValidateHooks(); err != nil {
|
| 1321 |
+
return fmt.Errorf("invalid hook configuration on reload: %w", err)
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
// Save current state for potential rollback BEFORE configureProviders,
|
| 1325 |
+
// which may write to disk via RemoveConfigField (e.g. removing stale
|
| 1326 |
+
// OAuth providers). Capturing after would snapshot a config that has
|
| 1327 |
+
// already been mutated, and the rollback would restore corrupted state.
|
| 1328 |
+
oldConfig := s.Config()
|
| 1329 |
+
oldLoadedPaths := s.loadedPaths
|
| 1330 |
+
oldResolver := s.resolver
|
| 1331 |
+
oldKnownProviders := s.knownProviders
|
| 1332 |
+
oldOverrides := s.overrides
|
| 1333 |
+
oldWorkspacePath := s.workspacePath
|
| 1334 |
+
|
| 1335 |
+
// Preserve runtime overrides
|
| 1336 |
+
overrides := s.overrides
|
| 1337 |
+
|
| 1338 |
+
// Reapply model choices made in this instance. The global config file is
|
| 1339 |
+
// shared, so it may now name a model a sibling instance selected; a
|
| 1340 |
+
// reload triggered by an unrelated write must not swap the user's model
|
| 1341 |
+
// mid-session. An external edit to the config still takes effect for any
|
| 1342 |
+
// model type this instance never chose.
|
| 1343 |
+
maps.Copy(cfg.Models, overrides.Models)
|
| 1344 |
+
|
| 1345 |
+
// Reconfigure providers
|
| 1346 |
+
env := env.New()
|
| 1347 |
+
resolver := NewShellVariableResolver(env)
|
| 1348 |
+
|
| 1349 |
+
// Apply top-level env vars before configuring providers so variables
|
| 1350 |
+
// like AWS_PROFILE are visible to the AWS SDK credential chain.
|
| 1351 |
+
cfg.applyEnv(resolver)
|
| 1352 |
+
|
| 1353 |
+
providers, err := Providers(cfg)
|
| 1354 |
+
if err != nil {
|
| 1355 |
+
if len(providers) == 0 {
|
| 1356 |
+
return fmt.Errorf("failed to load providers during reload: %w", err)
|
| 1357 |
+
}
|
| 1358 |
+
slog.Warn("Reload continuing with the previously known providers", "error", err)
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
if err := cfg.configureProviders(ctx, s, env, resolver, providers); err != nil {
|
| 1362 |
+
return fmt.Errorf("failed to configure providers during reload: %w", err)
|
| 1363 |
+
}
|
| 1364 |
+
|
| 1365 |
+
// Update store state BEFORE running model/agent setup (so they see new config)
|
| 1366 |
+
s.setConfig(cfg)
|
| 1367 |
+
s.loadedPaths = loadedPaths
|
| 1368 |
+
s.resolver = resolver
|
| 1369 |
+
s.knownProviders = providers
|
| 1370 |
+
s.overrides = overrides
|
| 1371 |
+
s.workspacePath = workspacePath
|
| 1372 |
+
|
| 1373 |
+
// Mirror startup flow: setup models and agents against NEW config.
|
| 1374 |
+
var setupErr error
|
| 1375 |
+
if !cfg.IsConfigured() {
|
| 1376 |
+
slog.Warn("No providers configured after reload")
|
| 1377 |
+
} else {
|
| 1378 |
+
resolved, resolveErr := resolveSelectedModels(cfg, providers)
|
| 1379 |
+
if resolveErr != nil {
|
| 1380 |
+
setupErr = fmt.Errorf("failed to configure selected models during reload: %w", resolveErr)
|
| 1381 |
+
} else {
|
| 1382 |
+
cfg.Models[SelectedModelTypeLarge] = resolved.Large
|
| 1383 |
+
cfg.Models[SelectedModelTypeSmall] = resolved.Small
|
| 1384 |
+
s.SetupAgents()
|
| 1385 |
+
}
|
| 1386 |
+
}
|
| 1387 |
+
|
| 1388 |
+
// Rollback on setup failure
|
| 1389 |
+
if setupErr != nil {
|
| 1390 |
+
s.setConfig(oldConfig)
|
| 1391 |
+
s.loadedPaths = oldLoadedPaths
|
| 1392 |
+
s.resolver = oldResolver
|
| 1393 |
+
s.knownProviders = oldKnownProviders
|
| 1394 |
+
s.overrides = oldOverrides
|
| 1395 |
+
s.workspacePath = oldWorkspacePath
|
| 1396 |
+
return setupErr
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
// Rebuild staleness tracking. Track every discovered config path, not
|
| 1400 |
+
// just the ones that loaded, so a config file created after this reload
|
| 1401 |
+
// is detected as a change on the next staleness check.
|
| 1402 |
+
s.captureStalenessSnapshot(append(slices.Clone(configPaths), loadedPaths...))
|
| 1403 |
+
|
| 1404 |
+
return nil
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
// autoReload conditionally reloads config from disk after writes.
|
| 1408 |
+
// It returns nil (no error) for expected skip cases: when auto-reload is
|
| 1409 |
+
// disabled during load/reload flows, or when working directory is not set
|
| 1410 |
+
// (e.g., during testing). Only actual reload failures return an error.
|
| 1411 |
+
func (s *ConfigStore) autoReload(ctx context.Context) error {
|
| 1412 |
+
if s.workingDir == "" {
|
| 1413 |
+
return nil // Expected skip: working directory not set
|
| 1414 |
+
}
|
| 1415 |
+
// Skip if a reload is already in progress. This handles both
|
| 1416 |
+
// concurrent auto-reloads after parallel writes and re-entrant
|
| 1417 |
+
// calls from configureProviders during a reload.
|
| 1418 |
+
//
|
| 1419 |
+
// Note: if a write completes after the in-progress reload has
|
| 1420 |
+
// already read the config file, that write won't be reflected in
|
| 1421 |
+
// memory until the next reload. This is acceptable because writes
|
| 1422 |
+
// are rare and the next user action or file-watch tick will pick
|
| 1423 |
+
// up the change. Callers that need guaranteed fresh state after a
|
| 1424 |
+
// write should call ReloadFromDisk explicitly.
|
| 1425 |
+
if !s.writeMu.TryLock() {
|
| 1426 |
+
return nil
|
| 1427 |
+
}
|
| 1428 |
+
defer s.writeMu.Unlock()
|
| 1429 |
+
return s.reloadFromDiskLocked(ctx)
|
| 1430 |
+
}
|
internal/config/update_model_bench_test.go
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"testing"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
// BenchmarkUpdatePreferredModel measures the full write cost of a single
|
| 11 |
+
// model selection, which is what the user experiences when picking a model
|
| 12 |
+
// in the TUI. It guards against regressing back to a full config reload on
|
| 13 |
+
// every selection.
|
| 14 |
+
func BenchmarkUpdatePreferredModel(b *testing.B) {
|
| 15 |
+
dir := b.TempDir()
|
| 16 |
+
configPath := filepath.Join(dir, "crush.json")
|
| 17 |
+
|
| 18 |
+
b.Setenv("CRUSH_GLOBAL_CONFIG", dir)
|
| 19 |
+
b.Setenv("CRUSH_GLOBAL_DATA", dir)
|
| 20 |
+
resetProviderState()
|
| 21 |
+
b.Cleanup(resetProviderState)
|
| 22 |
+
|
| 23 |
+
cfg := `{
|
| 24 |
+
"models": {
|
| 25 |
+
"large": {"provider": "openai", "model": "gpt-4"},
|
| 26 |
+
"small": {"provider": "openai", "model": "gpt-4o-mini"}
|
| 27 |
+
},
|
| 28 |
+
"providers": {
|
| 29 |
+
"openai": {
|
| 30 |
+
"api_key": "test-key",
|
| 31 |
+
"models": [
|
| 32 |
+
{"id": "gpt-4", "name": "GPT-4"},
|
| 33 |
+
{"id": "gpt-4o", "name": "GPT-4o"},
|
| 34 |
+
{"id": "gpt-4o-mini", "name": "GPT-4o mini"}
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
"anthropic": {
|
| 38 |
+
"api_key": "test-key-2",
|
| 39 |
+
"models": [
|
| 40 |
+
{"id": "claude-3", "name": "Claude 3"},
|
| 41 |
+
{"id": "claude-3-5", "name": "Claude 3.5"}
|
| 42 |
+
]
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}`
|
| 46 |
+
if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil {
|
| 47 |
+
b.Fatal(err)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
store, err := Load(dir, dir, false)
|
| 51 |
+
if err != nil {
|
| 52 |
+
b.Fatal(err)
|
| 53 |
+
}
|
| 54 |
+
store.globalDataPath = configPath
|
| 55 |
+
|
| 56 |
+
models := []SelectedModel{
|
| 57 |
+
{Provider: "openai", Model: "gpt-4"},
|
| 58 |
+
{Provider: "anthropic", Model: "claude-3"},
|
| 59 |
+
{Provider: "openai", Model: "gpt-4o"},
|
| 60 |
+
{Provider: "anthropic", Model: "claude-3-5"},
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
b.ReportAllocs()
|
| 64 |
+
i := 0
|
| 65 |
+
for b.Loop() {
|
| 66 |
+
m := models[i%len(models)]
|
| 67 |
+
if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, m); err != nil {
|
| 68 |
+
b.Fatal(err)
|
| 69 |
+
}
|
| 70 |
+
i++
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// BenchmarkReloadFromDisk isolates the full reload cost for comparison with
|
| 75 |
+
// BenchmarkUpdatePreferredModel.
|
| 76 |
+
func BenchmarkReloadFromDisk(b *testing.B) {
|
| 77 |
+
dir := b.TempDir()
|
| 78 |
+
configPath := filepath.Join(dir, "crush.json")
|
| 79 |
+
|
| 80 |
+
b.Setenv("CRUSH_GLOBAL_CONFIG", dir)
|
| 81 |
+
b.Setenv("CRUSH_GLOBAL_DATA", dir)
|
| 82 |
+
resetProviderState()
|
| 83 |
+
b.Cleanup(resetProviderState)
|
| 84 |
+
|
| 85 |
+
cfg := `{
|
| 86 |
+
"models": {"large": {"provider": "openai", "model": "gpt-4"}},
|
| 87 |
+
"providers": {
|
| 88 |
+
"openai": {"api_key": "test-key", "models": [{"id": "gpt-4", "name": "GPT-4"}]}
|
| 89 |
+
}
|
| 90 |
+
}`
|
| 91 |
+
if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil {
|
| 92 |
+
b.Fatal(err)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
store, err := Load(dir, dir, false)
|
| 96 |
+
if err != nil {
|
| 97 |
+
b.Fatal(err)
|
| 98 |
+
}
|
| 99 |
+
store.globalDataPath = configPath
|
| 100 |
+
|
| 101 |
+
ctx := context.Background()
|
| 102 |
+
b.ReportAllocs()
|
| 103 |
+
for b.Loop() {
|
| 104 |
+
if err := store.ReloadFromDisk(ctx); err != nil {
|
| 105 |
+
b.Fatal(err)
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
}
|
internal/csync/doc.go
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package csync provides concurrent data structures for safe access in
|
| 2 |
+
// multi-threaded environments.
|
| 3 |
+
package csync
|
internal/csync/maps.go
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"iter"
|
| 6 |
+
"maps"
|
| 7 |
+
"sync"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
// Map is a concurrent map implementation that provides thread-safe access.
|
| 11 |
+
type Map[K comparable, V any] struct {
|
| 12 |
+
inner map[K]V
|
| 13 |
+
mu sync.RWMutex
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// NewMap creates a new thread-safe map with the specified key and value types.
|
| 17 |
+
func NewMap[K comparable, V any]() *Map[K, V] {
|
| 18 |
+
return &Map[K, V]{
|
| 19 |
+
inner: make(map[K]V),
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// NewMapFrom creates a new thread-safe map from an existing map.
|
| 24 |
+
func NewMapFrom[K comparable, V any](m map[K]V) *Map[K, V] {
|
| 25 |
+
return &Map[K, V]{
|
| 26 |
+
inner: m,
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// NewLazyMap creates a new lazy-loaded map. The provided load function is
|
| 31 |
+
// executed in a separate goroutine to populate the map.
|
| 32 |
+
func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] {
|
| 33 |
+
m := &Map[K, V]{}
|
| 34 |
+
m.mu.Lock()
|
| 35 |
+
go func() {
|
| 36 |
+
defer m.mu.Unlock()
|
| 37 |
+
m.inner = load()
|
| 38 |
+
}()
|
| 39 |
+
return m
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Reset replaces the inner map with the new one.
|
| 43 |
+
func (m *Map[K, V]) Reset(input map[K]V) {
|
| 44 |
+
m.mu.Lock()
|
| 45 |
+
defer m.mu.Unlock()
|
| 46 |
+
m.inner = input
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Set sets the value for the specified key in the map.
|
| 50 |
+
func (m *Map[K, V]) Set(key K, value V) {
|
| 51 |
+
m.mu.Lock()
|
| 52 |
+
defer m.mu.Unlock()
|
| 53 |
+
m.inner[key] = value
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Del deletes the specified key from the map.
|
| 57 |
+
func (m *Map[K, V]) Del(key K) {
|
| 58 |
+
m.mu.Lock()
|
| 59 |
+
defer m.mu.Unlock()
|
| 60 |
+
delete(m.inner, key)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// CompareAndDelete deletes the key only if the current value matches the
|
| 64 |
+
// expected pointer. Returns true if the deletion occurred. This is the
|
| 65 |
+
// ABA-safe cleanup primitive: it prevents a deferred cleanup from removing
|
| 66 |
+
// a value that was replaced by a newer writer in the window between the
|
| 67 |
+
// explicit Del and the deferred Del.
|
| 68 |
+
func (m *Map[K, V]) CompareAndDelete(key K, expected any) bool {
|
| 69 |
+
m.mu.Lock()
|
| 70 |
+
defer m.mu.Unlock()
|
| 71 |
+
current, ok := m.inner[key]
|
| 72 |
+
if !ok {
|
| 73 |
+
return false
|
| 74 |
+
}
|
| 75 |
+
if any(current) != expected {
|
| 76 |
+
return false
|
| 77 |
+
}
|
| 78 |
+
delete(m.inner, key)
|
| 79 |
+
return true
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Get gets the value for the specified key from the map.
|
| 83 |
+
func (m *Map[K, V]) Get(key K) (V, bool) {
|
| 84 |
+
m.mu.RLock()
|
| 85 |
+
defer m.mu.RUnlock()
|
| 86 |
+
v, ok := m.inner[key]
|
| 87 |
+
return v, ok
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Len returns the number of items in the map.
|
| 91 |
+
func (m *Map[K, V]) Len() int {
|
| 92 |
+
m.mu.RLock()
|
| 93 |
+
defer m.mu.RUnlock()
|
| 94 |
+
return len(m.inner)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// GetOrSet gets and returns the key if it exists, otherwise, it executes the
|
| 98 |
+
// given function, set its return value for the given key, and returns it.
|
| 99 |
+
func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
|
| 100 |
+
got, ok := m.Get(key)
|
| 101 |
+
if ok {
|
| 102 |
+
return got
|
| 103 |
+
}
|
| 104 |
+
value := fn()
|
| 105 |
+
m.Set(key, value)
|
| 106 |
+
return value
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Take gets an item and then deletes it.
|
| 110 |
+
func (m *Map[K, V]) Take(key K) (V, bool) {
|
| 111 |
+
m.mu.Lock()
|
| 112 |
+
defer m.mu.Unlock()
|
| 113 |
+
v, ok := m.inner[key]
|
| 114 |
+
delete(m.inner, key)
|
| 115 |
+
return v, ok
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Copy returns a copy of the inner map.
|
| 119 |
+
func (m *Map[K, V]) Copy() map[K]V {
|
| 120 |
+
m.mu.RLock()
|
| 121 |
+
defer m.mu.RUnlock()
|
| 122 |
+
return maps.Clone(m.inner)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
|
| 126 |
+
func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
|
| 127 |
+
dst := m.Copy()
|
| 128 |
+
return func(yield func(K, V) bool) {
|
| 129 |
+
for k, v := range dst {
|
| 130 |
+
if !yield(k, v) {
|
| 131 |
+
return
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Seq returns an iter.Seq that yields values from the map.
|
| 138 |
+
func (m *Map[K, V]) Seq() iter.Seq[V] {
|
| 139 |
+
return func(yield func(V) bool) {
|
| 140 |
+
for _, v := range m.Seq2() {
|
| 141 |
+
if !yield(v) {
|
| 142 |
+
return
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
var (
|
| 149 |
+
_ json.Unmarshaler = &Map[string, any]{}
|
| 150 |
+
_ json.Marshaler = &Map[string, any]{}
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
// JSONSchemaAlias returns the underlying map type for JSON schema generation.
|
| 154 |
+
// Value receiver is required because github.com/invopop/jsonschema checks
|
| 155 |
+
// interface satisfaction on the non-pointer type after stripping pointers.
|
| 156 |
+
func (Map[K, V]) JSONSchemaAlias() any { //nolint
|
| 157 |
+
m := map[K]V{}
|
| 158 |
+
return m
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// UnmarshalJSON implements json.Unmarshaler.
|
| 162 |
+
func (m *Map[K, V]) UnmarshalJSON(data []byte) error {
|
| 163 |
+
m.mu.Lock()
|
| 164 |
+
defer m.mu.Unlock()
|
| 165 |
+
m.inner = make(map[K]V)
|
| 166 |
+
return json.Unmarshal(data, &m.inner)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// MarshalJSON implements json.Marshaler.
|
| 170 |
+
func (m *Map[K, V]) MarshalJSON() ([]byte, error) {
|
| 171 |
+
m.mu.RLock()
|
| 172 |
+
defer m.mu.RUnlock()
|
| 173 |
+
return json.Marshal(m.inner)
|
| 174 |
+
}
|
internal/csync/maps_test.go
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"maps"
|
| 6 |
+
"sync"
|
| 7 |
+
"sync/atomic"
|
| 8 |
+
"testing"
|
| 9 |
+
"testing/synctest"
|
| 10 |
+
"time"
|
| 11 |
+
|
| 12 |
+
"github.com/stretchr/testify/require"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
func TestNewMap(t *testing.T) {
|
| 16 |
+
t.Parallel()
|
| 17 |
+
|
| 18 |
+
m := NewMap[string, int]()
|
| 19 |
+
require.NotNil(t, m)
|
| 20 |
+
require.NotNil(t, m.inner)
|
| 21 |
+
require.Equal(t, 0, m.Len())
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func TestNewMapFrom(t *testing.T) {
|
| 25 |
+
t.Parallel()
|
| 26 |
+
|
| 27 |
+
original := map[string]int{
|
| 28 |
+
"key1": 1,
|
| 29 |
+
"key2": 2,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
m := NewMapFrom(original)
|
| 33 |
+
require.NotNil(t, m)
|
| 34 |
+
require.Equal(t, original, m.inner)
|
| 35 |
+
require.Equal(t, 2, m.Len())
|
| 36 |
+
|
| 37 |
+
value, ok := m.Get("key1")
|
| 38 |
+
require.True(t, ok)
|
| 39 |
+
require.Equal(t, 1, value)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func TestNewLazyMap(t *testing.T) {
|
| 43 |
+
t.Parallel()
|
| 44 |
+
|
| 45 |
+
synctest.Test(t, func(t *testing.T) {
|
| 46 |
+
t.Helper()
|
| 47 |
+
|
| 48 |
+
waiter := sync.Mutex{}
|
| 49 |
+
waiter.Lock()
|
| 50 |
+
var loadCalled atomic.Bool
|
| 51 |
+
|
| 52 |
+
loadFunc := func() map[string]int {
|
| 53 |
+
waiter.Lock()
|
| 54 |
+
defer waiter.Unlock()
|
| 55 |
+
loadCalled.Store(true)
|
| 56 |
+
return map[string]int{
|
| 57 |
+
"key1": 1,
|
| 58 |
+
"key2": 2,
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
m := NewLazyMap(loadFunc)
|
| 63 |
+
require.NotNil(t, m)
|
| 64 |
+
|
| 65 |
+
waiter.Unlock() // Allow the load function to proceed
|
| 66 |
+
time.Sleep(100 * time.Millisecond)
|
| 67 |
+
require.True(t, loadCalled.Load())
|
| 68 |
+
require.Equal(t, 2, m.Len())
|
| 69 |
+
|
| 70 |
+
value, ok := m.Get("key1")
|
| 71 |
+
require.True(t, ok)
|
| 72 |
+
require.Equal(t, 1, value)
|
| 73 |
+
})
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
func TestMap_Reset(t *testing.T) {
|
| 77 |
+
t.Parallel()
|
| 78 |
+
|
| 79 |
+
m := NewMapFrom(map[string]int{
|
| 80 |
+
"a": 10,
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
m.Reset(map[string]int{
|
| 84 |
+
"b": 20,
|
| 85 |
+
})
|
| 86 |
+
value, ok := m.Get("b")
|
| 87 |
+
require.True(t, ok)
|
| 88 |
+
require.Equal(t, 20, value)
|
| 89 |
+
require.Equal(t, 1, m.Len())
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
func TestMap_Set(t *testing.T) {
|
| 93 |
+
t.Parallel()
|
| 94 |
+
|
| 95 |
+
m := NewMap[string, int]()
|
| 96 |
+
|
| 97 |
+
m.Set("key1", 42)
|
| 98 |
+
value, ok := m.Get("key1")
|
| 99 |
+
require.True(t, ok)
|
| 100 |
+
require.Equal(t, 42, value)
|
| 101 |
+
require.Equal(t, 1, m.Len())
|
| 102 |
+
|
| 103 |
+
m.Set("key1", 100)
|
| 104 |
+
value, ok = m.Get("key1")
|
| 105 |
+
require.True(t, ok)
|
| 106 |
+
require.Equal(t, 100, value)
|
| 107 |
+
require.Equal(t, 1, m.Len())
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
func TestMap_GetOrSet(t *testing.T) {
|
| 111 |
+
t.Parallel()
|
| 112 |
+
|
| 113 |
+
m := NewMap[string, int]()
|
| 114 |
+
|
| 115 |
+
require.Equal(t, 42, m.GetOrSet("key1", func() int { return 42 }))
|
| 116 |
+
require.Equal(t, 42, m.GetOrSet("key1", func() int { return 99999 }))
|
| 117 |
+
require.Equal(t, 1, m.Len())
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
func TestMap_Get(t *testing.T) {
|
| 121 |
+
t.Parallel()
|
| 122 |
+
|
| 123 |
+
m := NewMap[string, int]()
|
| 124 |
+
|
| 125 |
+
value, ok := m.Get("nonexistent")
|
| 126 |
+
require.False(t, ok)
|
| 127 |
+
require.Equal(t, 0, value)
|
| 128 |
+
|
| 129 |
+
m.Set("key1", 42)
|
| 130 |
+
value, ok = m.Get("key1")
|
| 131 |
+
require.True(t, ok)
|
| 132 |
+
require.Equal(t, 42, value)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
func TestMap_Del(t *testing.T) {
|
| 136 |
+
t.Parallel()
|
| 137 |
+
|
| 138 |
+
m := NewMap[string, int]()
|
| 139 |
+
m.Set("key1", 42)
|
| 140 |
+
m.Set("key2", 100)
|
| 141 |
+
|
| 142 |
+
require.Equal(t, 2, m.Len())
|
| 143 |
+
|
| 144 |
+
m.Del("key1")
|
| 145 |
+
_, ok := m.Get("key1")
|
| 146 |
+
require.False(t, ok)
|
| 147 |
+
require.Equal(t, 1, m.Len())
|
| 148 |
+
|
| 149 |
+
value, ok := m.Get("key2")
|
| 150 |
+
require.True(t, ok)
|
| 151 |
+
require.Equal(t, 100, value)
|
| 152 |
+
|
| 153 |
+
m.Del("nonexistent")
|
| 154 |
+
require.Equal(t, 1, m.Len())
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
func TestMap_Len(t *testing.T) {
|
| 158 |
+
t.Parallel()
|
| 159 |
+
|
| 160 |
+
m := NewMap[string, int]()
|
| 161 |
+
require.Equal(t, 0, m.Len())
|
| 162 |
+
|
| 163 |
+
m.Set("key1", 1)
|
| 164 |
+
require.Equal(t, 1, m.Len())
|
| 165 |
+
|
| 166 |
+
m.Set("key2", 2)
|
| 167 |
+
require.Equal(t, 2, m.Len())
|
| 168 |
+
|
| 169 |
+
m.Del("key1")
|
| 170 |
+
require.Equal(t, 1, m.Len())
|
| 171 |
+
|
| 172 |
+
m.Del("key2")
|
| 173 |
+
require.Equal(t, 0, m.Len())
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// TestMap_CompareAndDelete verifies that CompareAndDelete only removes the
|
| 177 |
+
// entry when the stored pointer matches the expected value. This is the
|
| 178 |
+
// ABA-safe cleanup primitive used by the dispatch completion path to prevent
|
| 179 |
+
// a deferred cleanup from wiping a newer run's entry.
|
| 180 |
+
func TestMap_CompareAndDelete(t *testing.T) {
|
| 181 |
+
t.Parallel()
|
| 182 |
+
|
| 183 |
+
type entry struct{ val int }
|
| 184 |
+
m := NewMap[string, *entry]()
|
| 185 |
+
|
| 186 |
+
original := &entry{val: 1}
|
| 187 |
+
m.Set("k", original)
|
| 188 |
+
|
| 189 |
+
// Matching pointer: deletion succeeds.
|
| 190 |
+
require.True(t, m.CompareAndDelete("k", any(original)))
|
| 191 |
+
_, ok := m.Get("k")
|
| 192 |
+
require.False(t, ok)
|
| 193 |
+
|
| 194 |
+
// Re-set with a new value, try to delete with the stale pointer.
|
| 195 |
+
second := &entry{val: 2}
|
| 196 |
+
m.Set("k", second)
|
| 197 |
+
require.False(t, m.CompareAndDelete("k", any(original)), "stale pointer must not delete")
|
| 198 |
+
got, ok := m.Get("k")
|
| 199 |
+
require.True(t, ok)
|
| 200 |
+
require.Equal(t, 2, got.val)
|
| 201 |
+
|
| 202 |
+
// Missing key: returns false.
|
| 203 |
+
require.False(t, m.CompareAndDelete("absent", any(original)))
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
func TestMap_Take(t *testing.T) {
|
| 207 |
+
t.Parallel()
|
| 208 |
+
|
| 209 |
+
m := NewMap[string, int]()
|
| 210 |
+
m.Set("key1", 42)
|
| 211 |
+
m.Set("key2", 100)
|
| 212 |
+
|
| 213 |
+
require.Equal(t, 2, m.Len())
|
| 214 |
+
|
| 215 |
+
value, ok := m.Take("key1")
|
| 216 |
+
require.True(t, ok)
|
| 217 |
+
require.Equal(t, 42, value)
|
| 218 |
+
require.Equal(t, 1, m.Len())
|
| 219 |
+
|
| 220 |
+
_, exists := m.Get("key1")
|
| 221 |
+
require.False(t, exists)
|
| 222 |
+
|
| 223 |
+
value, ok = m.Get("key2")
|
| 224 |
+
require.True(t, ok)
|
| 225 |
+
require.Equal(t, 100, value)
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
func TestMap_Take_NonexistentKey(t *testing.T) {
|
| 229 |
+
t.Parallel()
|
| 230 |
+
|
| 231 |
+
m := NewMap[string, int]()
|
| 232 |
+
m.Set("key1", 42)
|
| 233 |
+
|
| 234 |
+
value, ok := m.Take("nonexistent")
|
| 235 |
+
require.False(t, ok)
|
| 236 |
+
require.Equal(t, 0, value)
|
| 237 |
+
require.Equal(t, 1, m.Len())
|
| 238 |
+
|
| 239 |
+
value, ok = m.Get("key1")
|
| 240 |
+
require.True(t, ok)
|
| 241 |
+
require.Equal(t, 42, value)
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
func TestMap_Take_EmptyMap(t *testing.T) {
|
| 245 |
+
t.Parallel()
|
| 246 |
+
|
| 247 |
+
m := NewMap[string, int]()
|
| 248 |
+
|
| 249 |
+
value, ok := m.Take("key1")
|
| 250 |
+
require.False(t, ok)
|
| 251 |
+
require.Equal(t, 0, value)
|
| 252 |
+
require.Equal(t, 0, m.Len())
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
func TestMap_Take_SameKeyTwice(t *testing.T) {
|
| 256 |
+
t.Parallel()
|
| 257 |
+
|
| 258 |
+
m := NewMap[string, int]()
|
| 259 |
+
m.Set("key1", 42)
|
| 260 |
+
|
| 261 |
+
value, ok := m.Take("key1")
|
| 262 |
+
require.True(t, ok)
|
| 263 |
+
require.Equal(t, 42, value)
|
| 264 |
+
require.Equal(t, 0, m.Len())
|
| 265 |
+
|
| 266 |
+
value, ok = m.Take("key1")
|
| 267 |
+
require.False(t, ok)
|
| 268 |
+
require.Equal(t, 0, value)
|
| 269 |
+
require.Equal(t, 0, m.Len())
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
func TestMap_Seq2(t *testing.T) {
|
| 273 |
+
t.Parallel()
|
| 274 |
+
|
| 275 |
+
m := NewMap[string, int]()
|
| 276 |
+
m.Set("key1", 1)
|
| 277 |
+
m.Set("key2", 2)
|
| 278 |
+
m.Set("key3", 3)
|
| 279 |
+
|
| 280 |
+
collected := maps.Collect(m.Seq2())
|
| 281 |
+
|
| 282 |
+
require.Equal(t, 3, len(collected))
|
| 283 |
+
require.Equal(t, 1, collected["key1"])
|
| 284 |
+
require.Equal(t, 2, collected["key2"])
|
| 285 |
+
require.Equal(t, 3, collected["key3"])
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
func TestMap_Seq2_EarlyReturn(t *testing.T) {
|
| 289 |
+
t.Parallel()
|
| 290 |
+
|
| 291 |
+
m := NewMap[string, int]()
|
| 292 |
+
m.Set("key1", 1)
|
| 293 |
+
m.Set("key2", 2)
|
| 294 |
+
m.Set("key3", 3)
|
| 295 |
+
|
| 296 |
+
count := 0
|
| 297 |
+
for range m.Seq2() {
|
| 298 |
+
count++
|
| 299 |
+
if count == 2 {
|
| 300 |
+
break
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
require.Equal(t, 2, count)
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
func TestMap_Seq2_EmptyMap(t *testing.T) {
|
| 308 |
+
t.Parallel()
|
| 309 |
+
|
| 310 |
+
m := NewMap[string, int]()
|
| 311 |
+
|
| 312 |
+
count := 0
|
| 313 |
+
for range m.Seq2() {
|
| 314 |
+
count++
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
require.Equal(t, 0, count)
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
func TestMap_Seq(t *testing.T) {
|
| 321 |
+
t.Parallel()
|
| 322 |
+
|
| 323 |
+
m := NewMap[string, int]()
|
| 324 |
+
m.Set("key1", 1)
|
| 325 |
+
m.Set("key2", 2)
|
| 326 |
+
m.Set("key3", 3)
|
| 327 |
+
|
| 328 |
+
collected := make([]int, 0)
|
| 329 |
+
for v := range m.Seq() {
|
| 330 |
+
collected = append(collected, v)
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
require.Equal(t, 3, len(collected))
|
| 334 |
+
require.Contains(t, collected, 1)
|
| 335 |
+
require.Contains(t, collected, 2)
|
| 336 |
+
require.Contains(t, collected, 3)
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
func TestMap_Seq_EarlyReturn(t *testing.T) {
|
| 340 |
+
t.Parallel()
|
| 341 |
+
|
| 342 |
+
m := NewMap[string, int]()
|
| 343 |
+
m.Set("key1", 1)
|
| 344 |
+
m.Set("key2", 2)
|
| 345 |
+
m.Set("key3", 3)
|
| 346 |
+
|
| 347 |
+
count := 0
|
| 348 |
+
for range m.Seq() {
|
| 349 |
+
count++
|
| 350 |
+
if count == 2 {
|
| 351 |
+
break
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
require.Equal(t, 2, count)
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
func TestMap_Seq_EmptyMap(t *testing.T) {
|
| 359 |
+
t.Parallel()
|
| 360 |
+
|
| 361 |
+
m := NewMap[string, int]()
|
| 362 |
+
|
| 363 |
+
count := 0
|
| 364 |
+
for range m.Seq() {
|
| 365 |
+
count++
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
require.Equal(t, 0, count)
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
func TestMap_MarshalJSON(t *testing.T) {
|
| 372 |
+
t.Parallel()
|
| 373 |
+
|
| 374 |
+
m := NewMap[string, int]()
|
| 375 |
+
m.Set("key1", 1)
|
| 376 |
+
m.Set("key2", 2)
|
| 377 |
+
|
| 378 |
+
data, err := json.Marshal(m)
|
| 379 |
+
require.NoError(t, err)
|
| 380 |
+
|
| 381 |
+
result := &Map[string, int]{}
|
| 382 |
+
err = json.Unmarshal(data, result)
|
| 383 |
+
require.NoError(t, err)
|
| 384 |
+
require.Equal(t, 2, result.Len())
|
| 385 |
+
v1, _ := result.Get("key1")
|
| 386 |
+
v2, _ := result.Get("key2")
|
| 387 |
+
require.Equal(t, 1, v1)
|
| 388 |
+
require.Equal(t, 2, v2)
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
func TestMap_MarshalJSON_EmptyMap(t *testing.T) {
|
| 392 |
+
t.Parallel()
|
| 393 |
+
|
| 394 |
+
m := NewMap[string, int]()
|
| 395 |
+
|
| 396 |
+
data, err := json.Marshal(m)
|
| 397 |
+
require.NoError(t, err)
|
| 398 |
+
require.Equal(t, "{}", string(data))
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
func TestMap_UnmarshalJSON(t *testing.T) {
|
| 402 |
+
t.Parallel()
|
| 403 |
+
|
| 404 |
+
jsonData := `{"key1": 1, "key2": 2}`
|
| 405 |
+
|
| 406 |
+
m := NewMap[string, int]()
|
| 407 |
+
err := json.Unmarshal([]byte(jsonData), m)
|
| 408 |
+
require.NoError(t, err)
|
| 409 |
+
|
| 410 |
+
require.Equal(t, 2, m.Len())
|
| 411 |
+
value, ok := m.Get("key1")
|
| 412 |
+
require.True(t, ok)
|
| 413 |
+
require.Equal(t, 1, value)
|
| 414 |
+
|
| 415 |
+
value, ok = m.Get("key2")
|
| 416 |
+
require.True(t, ok)
|
| 417 |
+
require.Equal(t, 2, value)
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
func TestMap_UnmarshalJSON_EmptyJSON(t *testing.T) {
|
| 421 |
+
t.Parallel()
|
| 422 |
+
|
| 423 |
+
jsonData := `{}`
|
| 424 |
+
|
| 425 |
+
m := NewMap[string, int]()
|
| 426 |
+
err := json.Unmarshal([]byte(jsonData), m)
|
| 427 |
+
require.NoError(t, err)
|
| 428 |
+
require.Equal(t, 0, m.Len())
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
func TestMap_UnmarshalJSON_InvalidJSON(t *testing.T) {
|
| 432 |
+
t.Parallel()
|
| 433 |
+
|
| 434 |
+
jsonData := `{"key1": 1, "key2":}`
|
| 435 |
+
|
| 436 |
+
m := NewMap[string, int]()
|
| 437 |
+
err := json.Unmarshal([]byte(jsonData), m)
|
| 438 |
+
require.Error(t, err)
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
func TestMap_UnmarshalJSON_OverwritesExistingData(t *testing.T) {
|
| 442 |
+
t.Parallel()
|
| 443 |
+
|
| 444 |
+
m := NewMap[string, int]()
|
| 445 |
+
m.Set("existing", 999)
|
| 446 |
+
|
| 447 |
+
jsonData := `{"key1": 1, "key2": 2}`
|
| 448 |
+
err := json.Unmarshal([]byte(jsonData), m)
|
| 449 |
+
require.NoError(t, err)
|
| 450 |
+
|
| 451 |
+
require.Equal(t, 2, m.Len())
|
| 452 |
+
_, ok := m.Get("existing")
|
| 453 |
+
require.False(t, ok)
|
| 454 |
+
|
| 455 |
+
value, ok := m.Get("key1")
|
| 456 |
+
require.True(t, ok)
|
| 457 |
+
require.Equal(t, 1, value)
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
func TestMap_JSONRoundTrip(t *testing.T) {
|
| 461 |
+
t.Parallel()
|
| 462 |
+
|
| 463 |
+
original := NewMap[string, int]()
|
| 464 |
+
original.Set("key1", 1)
|
| 465 |
+
original.Set("key2", 2)
|
| 466 |
+
original.Set("key3", 3)
|
| 467 |
+
|
| 468 |
+
data, err := json.Marshal(original)
|
| 469 |
+
require.NoError(t, err)
|
| 470 |
+
|
| 471 |
+
restored := NewMap[string, int]()
|
| 472 |
+
err = json.Unmarshal(data, restored)
|
| 473 |
+
require.NoError(t, err)
|
| 474 |
+
|
| 475 |
+
require.Equal(t, original.Len(), restored.Len())
|
| 476 |
+
|
| 477 |
+
for k, v := range original.Seq2() {
|
| 478 |
+
restoredValue, ok := restored.Get(k)
|
| 479 |
+
require.True(t, ok)
|
| 480 |
+
require.Equal(t, v, restoredValue)
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
func TestMap_ConcurrentAccess(t *testing.T) {
|
| 485 |
+
t.Parallel()
|
| 486 |
+
|
| 487 |
+
m := NewMap[int, int]()
|
| 488 |
+
const numGoroutines = 100
|
| 489 |
+
const numOperations = 100
|
| 490 |
+
|
| 491 |
+
var wg sync.WaitGroup
|
| 492 |
+
wg.Add(numGoroutines)
|
| 493 |
+
|
| 494 |
+
for i := range numGoroutines {
|
| 495 |
+
go func(id int) {
|
| 496 |
+
defer wg.Done()
|
| 497 |
+
for j := range numOperations {
|
| 498 |
+
key := id*numOperations + j
|
| 499 |
+
m.Set(key, key*2)
|
| 500 |
+
value, ok := m.Get(key)
|
| 501 |
+
require.True(t, ok)
|
| 502 |
+
require.Equal(t, key*2, value)
|
| 503 |
+
}
|
| 504 |
+
}(i)
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
wg.Wait()
|
| 508 |
+
|
| 509 |
+
require.Equal(t, numGoroutines*numOperations, m.Len())
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
func TestMap_ConcurrentReadWrite(t *testing.T) {
|
| 513 |
+
t.Parallel()
|
| 514 |
+
|
| 515 |
+
m := NewMap[int, int]()
|
| 516 |
+
const numReaders = 50
|
| 517 |
+
const numWriters = 50
|
| 518 |
+
const numOperations = 100
|
| 519 |
+
|
| 520 |
+
for i := range 1000 {
|
| 521 |
+
m.Set(i, i)
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
var wg sync.WaitGroup
|
| 525 |
+
wg.Add(numReaders + numWriters)
|
| 526 |
+
|
| 527 |
+
for range numReaders {
|
| 528 |
+
go func() {
|
| 529 |
+
defer wg.Done()
|
| 530 |
+
for j := range numOperations {
|
| 531 |
+
key := j % 1000
|
| 532 |
+
value, ok := m.Get(key)
|
| 533 |
+
if ok {
|
| 534 |
+
require.Equal(t, key, value)
|
| 535 |
+
}
|
| 536 |
+
_ = m.Len()
|
| 537 |
+
}
|
| 538 |
+
}()
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
for i := range numWriters {
|
| 542 |
+
go func(id int) {
|
| 543 |
+
defer wg.Done()
|
| 544 |
+
for j := range numOperations {
|
| 545 |
+
key := 1000 + id*numOperations + j
|
| 546 |
+
m.Set(key, key)
|
| 547 |
+
if j%10 == 0 {
|
| 548 |
+
m.Del(key)
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
}(i)
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
wg.Wait()
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
func TestMap_ConcurrentSeq2(t *testing.T) {
|
| 558 |
+
t.Parallel()
|
| 559 |
+
|
| 560 |
+
m := NewMap[int, int]()
|
| 561 |
+
for i := range 100 {
|
| 562 |
+
m.Set(i, i*2)
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
var wg sync.WaitGroup
|
| 566 |
+
const numIterators = 10
|
| 567 |
+
|
| 568 |
+
wg.Add(numIterators)
|
| 569 |
+
for range numIterators {
|
| 570 |
+
go func() {
|
| 571 |
+
defer wg.Done()
|
| 572 |
+
count := 0
|
| 573 |
+
for k, v := range m.Seq2() {
|
| 574 |
+
require.Equal(t, k*2, v)
|
| 575 |
+
count++
|
| 576 |
+
}
|
| 577 |
+
require.Equal(t, 100, count)
|
| 578 |
+
}()
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
wg.Wait()
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
func TestMap_ConcurrentSeq(t *testing.T) {
|
| 585 |
+
t.Parallel()
|
| 586 |
+
|
| 587 |
+
m := NewMap[int, int]()
|
| 588 |
+
for i := range 100 {
|
| 589 |
+
m.Set(i, i*2)
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
var wg sync.WaitGroup
|
| 593 |
+
const numIterators = 10
|
| 594 |
+
|
| 595 |
+
wg.Add(numIterators)
|
| 596 |
+
for range numIterators {
|
| 597 |
+
go func() {
|
| 598 |
+
defer wg.Done()
|
| 599 |
+
count := 0
|
| 600 |
+
values := make(map[int]bool)
|
| 601 |
+
for v := range m.Seq() {
|
| 602 |
+
values[v] = true
|
| 603 |
+
count++
|
| 604 |
+
}
|
| 605 |
+
require.Equal(t, 100, count)
|
| 606 |
+
for i := range 100 {
|
| 607 |
+
require.True(t, values[i*2])
|
| 608 |
+
}
|
| 609 |
+
}()
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
wg.Wait()
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
func TestMap_ConcurrentTake(t *testing.T) {
|
| 616 |
+
t.Parallel()
|
| 617 |
+
|
| 618 |
+
m := NewMap[int, int]()
|
| 619 |
+
const numItems = 1000
|
| 620 |
+
|
| 621 |
+
for i := range numItems {
|
| 622 |
+
m.Set(i, i*2)
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
var wg sync.WaitGroup
|
| 626 |
+
const numWorkers = 10
|
| 627 |
+
taken := make([][]int, numWorkers)
|
| 628 |
+
|
| 629 |
+
wg.Add(numWorkers)
|
| 630 |
+
for i := range numWorkers {
|
| 631 |
+
go func(workerID int) {
|
| 632 |
+
defer wg.Done()
|
| 633 |
+
taken[workerID] = make([]int, 0)
|
| 634 |
+
for j := workerID; j < numItems; j += numWorkers {
|
| 635 |
+
if value, ok := m.Take(j); ok {
|
| 636 |
+
taken[workerID] = append(taken[workerID], value)
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
}(i)
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
wg.Wait()
|
| 643 |
+
|
| 644 |
+
require.Equal(t, 0, m.Len())
|
| 645 |
+
|
| 646 |
+
allTaken := make(map[int]bool)
|
| 647 |
+
for _, workerTaken := range taken {
|
| 648 |
+
for _, value := range workerTaken {
|
| 649 |
+
require.False(t, allTaken[value], "Value %d was taken multiple times", value)
|
| 650 |
+
allTaken[value] = true
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
require.Equal(t, numItems, len(allTaken))
|
| 655 |
+
for i := range numItems {
|
| 656 |
+
require.True(t, allTaken[i*2], "Expected value %d to be taken", i*2)
|
| 657 |
+
}
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
func TestMap_TypeSafety(t *testing.T) {
|
| 661 |
+
t.Parallel()
|
| 662 |
+
|
| 663 |
+
stringIntMap := NewMap[string, int]()
|
| 664 |
+
stringIntMap.Set("key", 42)
|
| 665 |
+
value, ok := stringIntMap.Get("key")
|
| 666 |
+
require.True(t, ok)
|
| 667 |
+
require.Equal(t, 42, value)
|
| 668 |
+
|
| 669 |
+
intStringMap := NewMap[int, string]()
|
| 670 |
+
intStringMap.Set(42, "value")
|
| 671 |
+
strValue, ok := intStringMap.Get(42)
|
| 672 |
+
require.True(t, ok)
|
| 673 |
+
require.Equal(t, "value", strValue)
|
| 674 |
+
|
| 675 |
+
structMap := NewMap[string, struct{ Name string }]()
|
| 676 |
+
structMap.Set("key", struct{ Name string }{Name: "test"})
|
| 677 |
+
structValue, ok := structMap.Get("key")
|
| 678 |
+
require.True(t, ok)
|
| 679 |
+
require.Equal(t, "test", structValue.Name)
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
func TestMap_InterfaceCompliance(t *testing.T) {
|
| 683 |
+
t.Parallel()
|
| 684 |
+
|
| 685 |
+
var _ json.Marshaler = &Map[string, any]{}
|
| 686 |
+
var _ json.Unmarshaler = &Map[string, any]{}
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
func BenchmarkMap_Set(b *testing.B) {
|
| 690 |
+
m := NewMap[int, int]()
|
| 691 |
+
|
| 692 |
+
for i := 0; b.Loop(); i++ {
|
| 693 |
+
m.Set(i, i*2)
|
| 694 |
+
}
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
func BenchmarkMap_Get(b *testing.B) {
|
| 698 |
+
m := NewMap[int, int]()
|
| 699 |
+
for i := range 1000 {
|
| 700 |
+
m.Set(i, i*2)
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
for i := 0; b.Loop(); i++ {
|
| 704 |
+
m.Get(i % 1000)
|
| 705 |
+
}
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
func BenchmarkMap_Seq2(b *testing.B) {
|
| 709 |
+
m := NewMap[int, int]()
|
| 710 |
+
for i := range 1000 {
|
| 711 |
+
m.Set(i, i*2)
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
for b.Loop() {
|
| 715 |
+
for range m.Seq2() {
|
| 716 |
+
}
|
| 717 |
+
}
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
func BenchmarkMap_Seq(b *testing.B) {
|
| 721 |
+
m := NewMap[int, int]()
|
| 722 |
+
for i := range 1000 {
|
| 723 |
+
m.Set(i, i*2)
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
for b.Loop() {
|
| 727 |
+
for range m.Seq() {
|
| 728 |
+
}
|
| 729 |
+
}
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
func BenchmarkMap_Take(b *testing.B) {
|
| 733 |
+
m := NewMap[int, int]()
|
| 734 |
+
for i := range 1000 {
|
| 735 |
+
m.Set(i, i*2)
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
b.ResetTimer()
|
| 739 |
+
for i := 0; b.Loop(); i++ {
|
| 740 |
+
key := i % 1000
|
| 741 |
+
m.Take(key)
|
| 742 |
+
if i%1000 == 999 {
|
| 743 |
+
b.StopTimer()
|
| 744 |
+
for j := range 1000 {
|
| 745 |
+
m.Set(j, j*2)
|
| 746 |
+
}
|
| 747 |
+
b.StartTimer()
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
func BenchmarkMap_ConcurrentReadWrite(b *testing.B) {
|
| 753 |
+
m := NewMap[int, int]()
|
| 754 |
+
for i := range 1000 {
|
| 755 |
+
m.Set(i, i*2)
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
b.ResetTimer()
|
| 759 |
+
b.RunParallel(func(pb *testing.PB) {
|
| 760 |
+
i := 0
|
| 761 |
+
for pb.Next() {
|
| 762 |
+
if i%2 == 0 {
|
| 763 |
+
m.Get(i % 1000)
|
| 764 |
+
} else {
|
| 765 |
+
m.Set(i+1000, i*2)
|
| 766 |
+
}
|
| 767 |
+
i++
|
| 768 |
+
}
|
| 769 |
+
})
|
| 770 |
+
}
|
internal/csync/slices.go
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"iter"
|
| 5 |
+
"sync"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
// LazySlice is a thread-safe lazy-loaded slice.
|
| 9 |
+
type LazySlice[K any] struct {
|
| 10 |
+
inner []K
|
| 11 |
+
wg sync.WaitGroup
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// NewLazySlice creates a new slice and runs the [load] function in a goroutine
|
| 15 |
+
// to populate it.
|
| 16 |
+
func NewLazySlice[K any](load func() []K) *LazySlice[K] {
|
| 17 |
+
s := &LazySlice[K]{}
|
| 18 |
+
s.wg.Go(func() {
|
| 19 |
+
s.inner = load()
|
| 20 |
+
})
|
| 21 |
+
return s
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Seq returns an iterator that yields elements from the slice.
|
| 25 |
+
func (s *LazySlice[K]) Seq() iter.Seq[K] {
|
| 26 |
+
s.wg.Wait()
|
| 27 |
+
return func(yield func(K) bool) {
|
| 28 |
+
for _, v := range s.inner {
|
| 29 |
+
if !yield(v) {
|
| 30 |
+
return
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Slice is a thread-safe slice implementation that provides concurrent access.
|
| 37 |
+
type Slice[T any] struct {
|
| 38 |
+
inner []T
|
| 39 |
+
mu sync.RWMutex
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// NewSlice creates a new thread-safe slice.
|
| 43 |
+
func NewSlice[T any]() *Slice[T] {
|
| 44 |
+
return &Slice[T]{
|
| 45 |
+
inner: make([]T, 0),
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// NewSliceFrom creates a new thread-safe slice from an existing slice.
|
| 50 |
+
func NewSliceFrom[T any](s []T) *Slice[T] {
|
| 51 |
+
inner := make([]T, len(s))
|
| 52 |
+
copy(inner, s)
|
| 53 |
+
return &Slice[T]{
|
| 54 |
+
inner: inner,
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Append adds an element to the end of the slice.
|
| 59 |
+
func (s *Slice[T]) Append(items ...T) {
|
| 60 |
+
s.mu.Lock()
|
| 61 |
+
defer s.mu.Unlock()
|
| 62 |
+
s.inner = append(s.inner, items...)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Get returns the element at the specified index.
|
| 66 |
+
func (s *Slice[T]) Get(index int) (T, bool) {
|
| 67 |
+
s.mu.RLock()
|
| 68 |
+
defer s.mu.RUnlock()
|
| 69 |
+
var zero T
|
| 70 |
+
if index < 0 || index >= len(s.inner) {
|
| 71 |
+
return zero, false
|
| 72 |
+
}
|
| 73 |
+
return s.inner[index], true
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Len returns the number of elements in the slice.
|
| 77 |
+
func (s *Slice[T]) Len() int {
|
| 78 |
+
s.mu.RLock()
|
| 79 |
+
defer s.mu.RUnlock()
|
| 80 |
+
return len(s.inner)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// SetSlice replaces the entire slice with a new one.
|
| 84 |
+
func (s *Slice[T]) SetSlice(items []T) {
|
| 85 |
+
s.mu.Lock()
|
| 86 |
+
defer s.mu.Unlock()
|
| 87 |
+
s.inner = make([]T, len(items))
|
| 88 |
+
copy(s.inner, items)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Seq returns an iterator that yields elements from the slice.
|
| 92 |
+
func (s *Slice[T]) Seq() iter.Seq[T] {
|
| 93 |
+
return func(yield func(T) bool) {
|
| 94 |
+
for _, v := range s.Seq2() {
|
| 95 |
+
if !yield(v) {
|
| 96 |
+
return
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Seq2 returns an iterator that yields index-value pairs from the slice.
|
| 103 |
+
func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
|
| 104 |
+
items := s.Copy()
|
| 105 |
+
return func(yield func(int, T) bool) {
|
| 106 |
+
for i, v := range items {
|
| 107 |
+
if !yield(i, v) {
|
| 108 |
+
return
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Copy returns a copy of the inner slice.
|
| 115 |
+
func (s *Slice[T]) Copy() []T {
|
| 116 |
+
s.mu.RLock()
|
| 117 |
+
defer s.mu.RUnlock()
|
| 118 |
+
items := make([]T, len(s.inner))
|
| 119 |
+
copy(items, s.inner)
|
| 120 |
+
return items
|
| 121 |
+
}
|
internal/csync/slices_test.go
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"slices"
|
| 5 |
+
"sync"
|
| 6 |
+
"sync/atomic"
|
| 7 |
+
"testing"
|
| 8 |
+
"testing/synctest"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
func TestLazySlice_Seq(t *testing.T) {
|
| 15 |
+
t.Parallel()
|
| 16 |
+
|
| 17 |
+
synctest.Test(t, func(t *testing.T) {
|
| 18 |
+
t.Helper()
|
| 19 |
+
data := []string{"a", "b", "c"}
|
| 20 |
+
s := NewLazySlice(func() []string {
|
| 21 |
+
time.Sleep(10 * time.Millisecond) // Small delay to ensure loading happens
|
| 22 |
+
return data
|
| 23 |
+
})
|
| 24 |
+
require.Equal(t, data, slices.Collect(s.Seq()))
|
| 25 |
+
})
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func TestLazySlice_SeqWaitsForLoading(t *testing.T) {
|
| 29 |
+
t.Parallel()
|
| 30 |
+
synctest.Test(t, func(t *testing.T) {
|
| 31 |
+
t.Helper()
|
| 32 |
+
|
| 33 |
+
var loaded atomic.Bool
|
| 34 |
+
data := []string{"x", "y", "z"}
|
| 35 |
+
|
| 36 |
+
s := NewLazySlice(func() []string {
|
| 37 |
+
time.Sleep(100 * time.Millisecond)
|
| 38 |
+
loaded.Store(true)
|
| 39 |
+
return data
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
require.False(t, loaded.Load(), "should not be loaded immediately")
|
| 43 |
+
require.Equal(t, data, slices.Collect(s.Seq()))
|
| 44 |
+
require.True(t, loaded.Load(), "should be loaded after Seq")
|
| 45 |
+
})
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func TestLazySlice_EmptySlice(t *testing.T) {
|
| 49 |
+
t.Parallel()
|
| 50 |
+
s := NewLazySlice(func() []string {
|
| 51 |
+
return []string{}
|
| 52 |
+
})
|
| 53 |
+
require.Empty(t, slices.Collect(s.Seq()))
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestLazySlice_EarlyBreak(t *testing.T) {
|
| 57 |
+
t.Parallel()
|
| 58 |
+
|
| 59 |
+
synctest.Test(t, func(t *testing.T) {
|
| 60 |
+
t.Helper()
|
| 61 |
+
data := []string{"a", "b", "c", "d", "e"}
|
| 62 |
+
s := NewLazySlice(func() []string {
|
| 63 |
+
time.Sleep(10 * time.Millisecond) // Small delay to ensure loading happens
|
| 64 |
+
return data
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
var result []string
|
| 68 |
+
for v := range s.Seq() {
|
| 69 |
+
result = append(result, v)
|
| 70 |
+
if len(result) == 2 {
|
| 71 |
+
break
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
require.Equal(t, []string{"a", "b"}, result)
|
| 76 |
+
})
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
func TestSlice(t *testing.T) {
|
| 80 |
+
t.Run("NewSlice", func(t *testing.T) {
|
| 81 |
+
s := NewSlice[int]()
|
| 82 |
+
require.Equal(t, 0, s.Len())
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
t.Run("NewSliceFrom", func(t *testing.T) {
|
| 86 |
+
original := []int{1, 2, 3}
|
| 87 |
+
s := NewSliceFrom(original)
|
| 88 |
+
require.Equal(t, 3, s.Len())
|
| 89 |
+
|
| 90 |
+
// Verify it's a copy, not a reference
|
| 91 |
+
original[0] = 999
|
| 92 |
+
val, ok := s.Get(0)
|
| 93 |
+
require.True(t, ok)
|
| 94 |
+
require.Equal(t, 1, val)
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
t.Run("Append", func(t *testing.T) {
|
| 98 |
+
s := NewSlice[string]()
|
| 99 |
+
s.Append("hello")
|
| 100 |
+
s.Append("world")
|
| 101 |
+
|
| 102 |
+
require.Equal(t, 2, s.Len())
|
| 103 |
+
val, ok := s.Get(0)
|
| 104 |
+
require.True(t, ok)
|
| 105 |
+
require.Equal(t, "hello", val)
|
| 106 |
+
|
| 107 |
+
val, ok = s.Get(1)
|
| 108 |
+
require.True(t, ok)
|
| 109 |
+
require.Equal(t, "world", val)
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
t.Run("Get", func(t *testing.T) {
|
| 113 |
+
s := NewSliceFrom([]string{"a", "b", "c"})
|
| 114 |
+
|
| 115 |
+
val, ok := s.Get(1)
|
| 116 |
+
require.True(t, ok)
|
| 117 |
+
require.Equal(t, "b", val)
|
| 118 |
+
|
| 119 |
+
// Out of bounds
|
| 120 |
+
_, ok = s.Get(10)
|
| 121 |
+
require.False(t, ok)
|
| 122 |
+
|
| 123 |
+
// Negative index
|
| 124 |
+
_, ok = s.Get(-1)
|
| 125 |
+
require.False(t, ok)
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
t.Run("SetSlice", func(t *testing.T) {
|
| 129 |
+
s := NewSlice[int]()
|
| 130 |
+
s.Append(1)
|
| 131 |
+
s.Append(2)
|
| 132 |
+
|
| 133 |
+
newItems := []int{10, 20, 30}
|
| 134 |
+
s.SetSlice(newItems)
|
| 135 |
+
|
| 136 |
+
require.Equal(t, 3, s.Len())
|
| 137 |
+
require.Equal(t, newItems, slices.Collect(s.Seq()))
|
| 138 |
+
|
| 139 |
+
// Verify it's a copy
|
| 140 |
+
newItems[0] = 999
|
| 141 |
+
val, ok := s.Get(0)
|
| 142 |
+
require.True(t, ok)
|
| 143 |
+
require.Equal(t, 10, val)
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
t.Run("Slice", func(t *testing.T) {
|
| 147 |
+
original := []int{1, 2, 3}
|
| 148 |
+
s := NewSliceFrom(original)
|
| 149 |
+
|
| 150 |
+
copied := slices.Collect(s.Seq())
|
| 151 |
+
require.Equal(t, original, copied)
|
| 152 |
+
|
| 153 |
+
// Verify it's a copy
|
| 154 |
+
copied[0] = 999
|
| 155 |
+
val, ok := s.Get(0)
|
| 156 |
+
require.True(t, ok)
|
| 157 |
+
require.Equal(t, 1, val)
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
t.Run("Seq", func(t *testing.T) {
|
| 161 |
+
s := NewSliceFrom([]int{1, 2, 3})
|
| 162 |
+
|
| 163 |
+
var result []int
|
| 164 |
+
for v := range s.Seq() {
|
| 165 |
+
result = append(result, v)
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
require.Equal(t, []int{1, 2, 3}, result)
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
t.Run("SeqWithIndex", func(t *testing.T) {
|
| 172 |
+
s := NewSliceFrom([]string{"a", "b", "c"})
|
| 173 |
+
|
| 174 |
+
var indices []int
|
| 175 |
+
var values []string
|
| 176 |
+
for i, v := range s.Seq2() {
|
| 177 |
+
indices = append(indices, i)
|
| 178 |
+
values = append(values, v)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
require.Equal(t, []int{0, 1, 2}, indices)
|
| 182 |
+
require.Equal(t, []string{"a", "b", "c"}, values)
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
t.Run("ConcurrentAccess", func(t *testing.T) {
|
| 186 |
+
s := NewSlice[int]()
|
| 187 |
+
const numGoroutines = 100
|
| 188 |
+
const itemsPerGoroutine = 10
|
| 189 |
+
|
| 190 |
+
var wg sync.WaitGroup
|
| 191 |
+
|
| 192 |
+
// Concurrent appends
|
| 193 |
+
for i := range numGoroutines {
|
| 194 |
+
wg.Add(2)
|
| 195 |
+
go func(start int) {
|
| 196 |
+
defer wg.Done()
|
| 197 |
+
for j := range itemsPerGoroutine {
|
| 198 |
+
s.Append(start*itemsPerGoroutine + j)
|
| 199 |
+
}
|
| 200 |
+
}(i)
|
| 201 |
+
go func() {
|
| 202 |
+
defer wg.Done()
|
| 203 |
+
for range itemsPerGoroutine {
|
| 204 |
+
s.Len() // Just read the length
|
| 205 |
+
}
|
| 206 |
+
}()
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
wg.Wait()
|
| 210 |
+
|
| 211 |
+
// Should have all items
|
| 212 |
+
require.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
|
| 213 |
+
})
|
| 214 |
+
}
|
internal/csync/value.go
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"reflect"
|
| 5 |
+
"sync"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
// Value is a generic thread-safe wrapper for any value type.
|
| 9 |
+
//
|
| 10 |
+
// For slices, use [Slice]. For maps, use [Map]. Pointers are not supported.
|
| 11 |
+
type Value[T any] struct {
|
| 12 |
+
v T
|
| 13 |
+
mu sync.RWMutex
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// NewValue creates a new Value with the given initial value.
|
| 17 |
+
//
|
| 18 |
+
// Panics if t is a pointer, slice, or map. Use the dedicated types for those.
|
| 19 |
+
func NewValue[T any](t T) *Value[T] {
|
| 20 |
+
v := reflect.ValueOf(t)
|
| 21 |
+
switch v.Kind() {
|
| 22 |
+
case reflect.Pointer:
|
| 23 |
+
panic("csync.Value does not support pointer types")
|
| 24 |
+
case reflect.Slice:
|
| 25 |
+
panic("csync.Value does not support slice types; use csync.Slice")
|
| 26 |
+
case reflect.Map:
|
| 27 |
+
panic("csync.Value does not support map types; use csync.Map")
|
| 28 |
+
}
|
| 29 |
+
return &Value[T]{v: t}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Get returns the current value.
|
| 33 |
+
func (v *Value[T]) Get() T {
|
| 34 |
+
v.mu.RLock()
|
| 35 |
+
defer v.mu.RUnlock()
|
| 36 |
+
return v.v
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Set updates the value.
|
| 40 |
+
func (v *Value[T]) Set(t T) {
|
| 41 |
+
v.mu.Lock()
|
| 42 |
+
defer v.mu.Unlock()
|
| 43 |
+
v.v = t
|
| 44 |
+
}
|
internal/csync/value_test.go
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"sync"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"github.com/stretchr/testify/require"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func TestValue_GetSet(t *testing.T) {
|
| 11 |
+
t.Parallel()
|
| 12 |
+
|
| 13 |
+
v := NewValue(42)
|
| 14 |
+
require.Equal(t, 42, v.Get())
|
| 15 |
+
|
| 16 |
+
v.Set(100)
|
| 17 |
+
require.Equal(t, 100, v.Get())
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func TestValue_ZeroValue(t *testing.T) {
|
| 21 |
+
t.Parallel()
|
| 22 |
+
|
| 23 |
+
v := NewValue("")
|
| 24 |
+
require.Equal(t, "", v.Get())
|
| 25 |
+
|
| 26 |
+
v.Set("hello")
|
| 27 |
+
require.Equal(t, "hello", v.Get())
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func TestValue_Struct(t *testing.T) {
|
| 31 |
+
t.Parallel()
|
| 32 |
+
|
| 33 |
+
type config struct {
|
| 34 |
+
Name string
|
| 35 |
+
Count int
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
v := NewValue(config{Name: "test", Count: 1})
|
| 39 |
+
require.Equal(t, config{Name: "test", Count: 1}, v.Get())
|
| 40 |
+
|
| 41 |
+
v.Set(config{Name: "updated", Count: 2})
|
| 42 |
+
require.Equal(t, config{Name: "updated", Count: 2}, v.Get())
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func TestValue_PointerPanics(t *testing.T) {
|
| 46 |
+
t.Parallel()
|
| 47 |
+
|
| 48 |
+
require.Panics(t, func() {
|
| 49 |
+
NewValue(&struct{}{})
|
| 50 |
+
})
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
func TestValue_SlicePanics(t *testing.T) {
|
| 54 |
+
t.Parallel()
|
| 55 |
+
|
| 56 |
+
require.Panics(t, func() {
|
| 57 |
+
NewValue([]string{"a", "b"})
|
| 58 |
+
})
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func TestValue_MapPanics(t *testing.T) {
|
| 62 |
+
t.Parallel()
|
| 63 |
+
|
| 64 |
+
require.Panics(t, func() {
|
| 65 |
+
NewValue(map[string]int{"a": 1})
|
| 66 |
+
})
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
func TestValue_ConcurrentAccess(t *testing.T) {
|
| 70 |
+
t.Parallel()
|
| 71 |
+
|
| 72 |
+
v := NewValue(0)
|
| 73 |
+
var wg sync.WaitGroup
|
| 74 |
+
|
| 75 |
+
// Concurrent writers.
|
| 76 |
+
for i := range 100 {
|
| 77 |
+
wg.Add(1)
|
| 78 |
+
go func(val int) {
|
| 79 |
+
defer wg.Done()
|
| 80 |
+
v.Set(val)
|
| 81 |
+
}(i)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Concurrent readers.
|
| 85 |
+
for range 100 {
|
| 86 |
+
wg.Go(func() {
|
| 87 |
+
_ = v.Get()
|
| 88 |
+
})
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
wg.Wait()
|
| 92 |
+
|
| 93 |
+
// Value should be one of the set values (0-99).
|
| 94 |
+
got := v.Get()
|
| 95 |
+
require.GreaterOrEqual(t, got, 0)
|
| 96 |
+
require.Less(t, got, 100)
|
| 97 |
+
}
|
internal/csync/versionedmap.go
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"iter"
|
| 5 |
+
"sync/atomic"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
// NewVersionedMap creates a new versioned, thread-safe map.
|
| 9 |
+
func NewVersionedMap[K comparable, V any]() *VersionedMap[K, V] {
|
| 10 |
+
return &VersionedMap[K, V]{
|
| 11 |
+
m: NewMap[K, V](),
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// VersionedMap is a thread-safe map that keeps track of its version.
|
| 16 |
+
type VersionedMap[K comparable, V any] struct {
|
| 17 |
+
m *Map[K, V]
|
| 18 |
+
v atomic.Uint64
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Get gets the value for the specified key from the map.
|
| 22 |
+
func (m *VersionedMap[K, V]) Get(key K) (V, bool) {
|
| 23 |
+
return m.m.Get(key)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Set sets the value for the specified key in the map and increments the version.
|
| 27 |
+
func (m *VersionedMap[K, V]) Set(key K, value V) {
|
| 28 |
+
m.m.Set(key, value)
|
| 29 |
+
m.v.Add(1)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Del deletes the specified key from the map and increments the version.
|
| 33 |
+
func (m *VersionedMap[K, V]) Del(key K) {
|
| 34 |
+
m.m.Del(key)
|
| 35 |
+
m.v.Add(1)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
|
| 39 |
+
func (m *VersionedMap[K, V]) Seq2() iter.Seq2[K, V] {
|
| 40 |
+
return m.m.Seq2()
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// Copy returns a copy of the inner map.
|
| 44 |
+
func (m *VersionedMap[K, V]) Copy() map[K]V {
|
| 45 |
+
return m.m.Copy()
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Len returns the number of items in the map.
|
| 49 |
+
func (m *VersionedMap[K, V]) Len() int {
|
| 50 |
+
return m.m.Len()
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Version returns the current version of the map.
|
| 54 |
+
func (m *VersionedMap[K, V]) Version() uint64 {
|
| 55 |
+
return m.v.Load()
|
| 56 |
+
}
|
internal/csync/versionedmap_test.go
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package csync
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"github.com/stretchr/testify/require"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func TestVersionedMap_Set(t *testing.T) {
|
| 10 |
+
t.Parallel()
|
| 11 |
+
|
| 12 |
+
vm := NewVersionedMap[string, int]()
|
| 13 |
+
require.Equal(t, uint64(0), vm.Version())
|
| 14 |
+
|
| 15 |
+
vm.Set("key1", 42)
|
| 16 |
+
require.Equal(t, uint64(1), vm.Version())
|
| 17 |
+
|
| 18 |
+
value, ok := vm.Get("key1")
|
| 19 |
+
require.True(t, ok)
|
| 20 |
+
require.Equal(t, 42, value)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func TestVersionedMap_Del(t *testing.T) {
|
| 24 |
+
t.Parallel()
|
| 25 |
+
|
| 26 |
+
vm := NewVersionedMap[string, int]()
|
| 27 |
+
vm.Set("key1", 42)
|
| 28 |
+
initialVersion := vm.Version()
|
| 29 |
+
|
| 30 |
+
vm.Del("key1")
|
| 31 |
+
require.Equal(t, initialVersion+1, vm.Version())
|
| 32 |
+
|
| 33 |
+
_, ok := vm.Get("key1")
|
| 34 |
+
require.False(t, ok)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func TestVersionedMap_VersionIncrement(t *testing.T) {
|
| 38 |
+
t.Parallel()
|
| 39 |
+
|
| 40 |
+
vm := NewVersionedMap[string, int]()
|
| 41 |
+
initialVersion := vm.Version()
|
| 42 |
+
|
| 43 |
+
// Setting a value should increment the version
|
| 44 |
+
vm.Set("key1", 42)
|
| 45 |
+
require.Equal(t, initialVersion+1, vm.Version())
|
| 46 |
+
|
| 47 |
+
// Deleting a value should increment the version
|
| 48 |
+
vm.Del("key1")
|
| 49 |
+
require.Equal(t, initialVersion+2, vm.Version())
|
| 50 |
+
|
| 51 |
+
// Deleting a non-existent key should still increment the version
|
| 52 |
+
vm.Del("nonexistent")
|
| 53 |
+
require.Equal(t, initialVersion+3, vm.Version())
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestVersionedMap_ConcurrentAccess(t *testing.T) {
|
| 57 |
+
t.Parallel()
|
| 58 |
+
|
| 59 |
+
vm := NewVersionedMap[int, int]()
|
| 60 |
+
const numGoroutines = 100
|
| 61 |
+
const numOperations = 100
|
| 62 |
+
|
| 63 |
+
// Initial version
|
| 64 |
+
initialVersion := vm.Version()
|
| 65 |
+
|
| 66 |
+
// Perform concurrent Set and Del operations
|
| 67 |
+
for i := range numGoroutines {
|
| 68 |
+
go func(id int) {
|
| 69 |
+
for j := range numOperations {
|
| 70 |
+
key := id*numOperations + j
|
| 71 |
+
vm.Set(key, key*2)
|
| 72 |
+
vm.Del(key)
|
| 73 |
+
}
|
| 74 |
+
}(i)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Wait for operations to complete by checking the version
|
| 78 |
+
// This is a simplified check - in a real test you might want to use sync.WaitGroup
|
| 79 |
+
expectedMinVersion := initialVersion + uint64(numGoroutines*numOperations*2)
|
| 80 |
+
|
| 81 |
+
// Allow some time for operations to complete
|
| 82 |
+
for vm.Version() < expectedMinVersion {
|
| 83 |
+
// Busy wait - in a real test you'd use proper synchronization
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Final version should be at least the expected minimum
|
| 87 |
+
require.GreaterOrEqual(t, vm.Version(), expectedMinVersion)
|
| 88 |
+
require.Equal(t, 0, vm.Len())
|
| 89 |
+
}
|
internal/diff/diff.go
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package diff
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"strings"
|
| 5 |
+
|
| 6 |
+
"github.com/aymanbagabas/go-udiff"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
// GenerateDiff creates a unified diff from two file contents
|
| 10 |
+
func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
|
| 11 |
+
fileName = strings.TrimPrefix(fileName, "/")
|
| 12 |
+
|
| 13 |
+
var (
|
| 14 |
+
unified = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
|
| 15 |
+
additions = 0
|
| 16 |
+
removals = 0
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
lines := strings.SplitSeq(unified, "\n")
|
| 20 |
+
for line := range lines {
|
| 21 |
+
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
|
| 22 |
+
additions++
|
| 23 |
+
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
|
| 24 |
+
removals++
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return unified, additions, removals
|
| 29 |
+
}
|
internal/diffdetect/detect.go
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package diffdetect
|
| 2 |
+
|
| 3 |
+
import "strings"
|
| 4 |
+
|
| 5 |
+
// Signal describes which unified-diff markers were found while scanning text.
|
| 6 |
+
type Signal struct {
|
| 7 |
+
HasHunk bool
|
| 8 |
+
HasFileHeader bool
|
| 9 |
+
HasGitHeader bool
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
// Inspect scans content for unified-diff markers.
|
| 13 |
+
func Inspect(content string) Signal {
|
| 14 |
+
var signal Signal
|
| 15 |
+
for line := range strings.SplitSeq(content, "\n") {
|
| 16 |
+
if strings.HasPrefix(line, "@@") {
|
| 17 |
+
signal.HasHunk = true
|
| 18 |
+
}
|
| 19 |
+
if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
|
| 20 |
+
signal.HasFileHeader = true
|
| 21 |
+
}
|
| 22 |
+
if strings.HasPrefix(line, "diff --git ") {
|
| 23 |
+
signal.HasGitHeader = true
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
return signal
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// IsUnifiedDiff reports whether content appears to be a unified diff.
|
| 30 |
+
func IsUnifiedDiff(content string) bool {
|
| 31 |
+
signal := Inspect(content)
|
| 32 |
+
if signal.HasGitHeader && signal.HasFileHeader {
|
| 33 |
+
return true
|
| 34 |
+
}
|
| 35 |
+
return signal.HasHunk && signal.HasFileHeader
|
| 36 |
+
}
|
internal/diffdetect/detect_test.go
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package diffdetect
|
| 2 |
+
|
| 3 |
+
import "testing"
|
| 4 |
+
|
| 5 |
+
func TestInspect(t *testing.T) {
|
| 6 |
+
t.Parallel()
|
| 7 |
+
|
| 8 |
+
tests := []struct {
|
| 9 |
+
name string
|
| 10 |
+
content string
|
| 11 |
+
want Signal
|
| 12 |
+
}{
|
| 13 |
+
{
|
| 14 |
+
name: "git unified diff",
|
| 15 |
+
content: `diff --git a/main.go b/main.go
|
| 16 |
+
--- a/main.go
|
| 17 |
+
+++ b/main.go
|
| 18 |
+
@@ -1,2 +1,3 @@
|
| 19 |
+
package main
|
| 20 |
+
+import "fmt"
|
| 21 |
+
`,
|
| 22 |
+
want: Signal{HasHunk: true, HasFileHeader: true, HasGitHeader: true},
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
name: "non-git unified diff",
|
| 26 |
+
content: `--- old.c
|
| 27 |
+
+++ old.c
|
| 28 |
+
@@ -1 +1 @@
|
| 29 |
+
-old
|
| 30 |
+
+new
|
| 31 |
+
`,
|
| 32 |
+
want: Signal{HasHunk: true, HasFileHeader: true, HasGitHeader: false},
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
name: "plain text",
|
| 36 |
+
content: "hello world",
|
| 37 |
+
want: Signal{},
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
name: "hunk only",
|
| 41 |
+
content: "@@ -1 +1 @@\n-old\n+new\n",
|
| 42 |
+
want: Signal{HasHunk: true},
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
name: "headers only",
|
| 46 |
+
content: "--- a/file\n+++ b/file\n",
|
| 47 |
+
want: Signal{HasFileHeader: true},
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
for _, tt := range tests {
|
| 52 |
+
t.Run(tt.name, func(t *testing.T) {
|
| 53 |
+
t.Parallel()
|
| 54 |
+
got := Inspect(tt.content)
|
| 55 |
+
if got != tt.want {
|
| 56 |
+
t.Errorf("Inspect() = %+v, want %+v", got, tt.want)
|
| 57 |
+
}
|
| 58 |
+
})
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
func TestIsUnifiedDiff(t *testing.T) {
|
| 63 |
+
t.Parallel()
|
| 64 |
+
|
| 65 |
+
tests := []struct {
|
| 66 |
+
name string
|
| 67 |
+
content string
|
| 68 |
+
want bool
|
| 69 |
+
}{
|
| 70 |
+
{
|
| 71 |
+
name: "github-style multi-file diff",
|
| 72 |
+
content: `diff --git a/one.txt b/one.txt
|
| 73 |
+
--- a/one.txt
|
| 74 |
+
+++ b/one.txt
|
| 75 |
+
@@ -1 +1 @@
|
| 76 |
+
-a
|
| 77 |
+
+b
|
| 78 |
+
diff --git a/two.txt b/two.txt
|
| 79 |
+
--- a/two.txt
|
| 80 |
+
+++ b/two.txt
|
| 81 |
+
@@ -1 +1 @@
|
| 82 |
+
-c
|
| 83 |
+
+d
|
| 84 |
+
`,
|
| 85 |
+
want: true,
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
name: "non-git unified patch",
|
| 89 |
+
content: `--- a/old.c
|
| 90 |
+
+++ b/old.c
|
| 91 |
+
@@ -1,3 +1,4 @@
|
| 92 |
+
#include <stdio.h>
|
| 93 |
+
-int main() {
|
| 94 |
+
+int main(int argc, char **argv) {
|
| 95 |
+
return 0;
|
| 96 |
+
}
|
| 97 |
+
`,
|
| 98 |
+
want: true,
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
name: "new file from dev null",
|
| 102 |
+
content: `--- /dev/null
|
| 103 |
+
+++ newfile.txt
|
| 104 |
+
@@ -0,0 +1,2 @@
|
| 105 |
+
+hello
|
| 106 |
+
+world
|
| 107 |
+
`,
|
| 108 |
+
want: true,
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
name: "markdown false positive candidate",
|
| 112 |
+
content: `- Item one
|
| 113 |
+
- Item two
|
| 114 |
+
+ Bonus item
|
| 115 |
+
- Item three
|
| 116 |
+
`,
|
| 117 |
+
want: false,
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
name: "headers without hunk",
|
| 121 |
+
content: `--- a/somefile.txt
|
| 122 |
+
+++ b/somefile.txt
|
| 123 |
+
Just some content here
|
| 124 |
+
No hunk markers at all
|
| 125 |
+
`,
|
| 126 |
+
want: false,
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
name: "hunk without headers",
|
| 130 |
+
content: `@@ -1,3 +1,4 @@
|
| 131 |
+
some line
|
| 132 |
+
-another line
|
| 133 |
+
+changed line
|
| 134 |
+
`,
|
| 135 |
+
want: false,
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
name: "empty",
|
| 139 |
+
content: "",
|
| 140 |
+
want: false,
|
| 141 |
+
},
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
for _, tt := range tests {
|
| 145 |
+
t.Run(tt.name, func(t *testing.T) {
|
| 146 |
+
t.Parallel()
|
| 147 |
+
if got := IsUnifiedDiff(tt.content); got != tt.want {
|
| 148 |
+
t.Errorf("IsUnifiedDiff() = %v, want %v", got, tt.want)
|
| 149 |
+
}
|
| 150 |
+
})
|
| 151 |
+
}
|
| 152 |
+
}
|
internal/filetracker/service.go
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package filetracker provides functionality to track file reads in sessions.
|
| 2 |
+
package filetracker
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"context"
|
| 6 |
+
"fmt"
|
| 7 |
+
"log/slog"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"time"
|
| 11 |
+
|
| 12 |
+
"github.com/charmbracelet/crush/internal/db"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// Service defines the interface for tracking file reads in sessions.
|
| 16 |
+
type Service interface {
|
| 17 |
+
// RecordRead records when a file was read.
|
| 18 |
+
RecordRead(ctx context.Context, sessionID, path string)
|
| 19 |
+
|
| 20 |
+
// LastReadTime returns when a file was last read.
|
| 21 |
+
// Returns zero time if never read.
|
| 22 |
+
LastReadTime(ctx context.Context, sessionID, path string) time.Time
|
| 23 |
+
|
| 24 |
+
// ListReadFiles returns the paths of all files read in a session.
|
| 25 |
+
ListReadFiles(ctx context.Context, sessionID string) ([]string, error)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
type service struct {
|
| 29 |
+
q *db.Queries
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// NewService creates a new file tracker service.
|
| 33 |
+
func NewService(q *db.Queries) Service {
|
| 34 |
+
return &service{q: q}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// RecordRead records when a file was read.
|
| 38 |
+
func (s *service) RecordRead(ctx context.Context, sessionID, path string) {
|
| 39 |
+
if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{
|
| 40 |
+
SessionID: sessionID,
|
| 41 |
+
Path: relpath(path),
|
| 42 |
+
}); err != nil {
|
| 43 |
+
slog.Error("Error recording file read", "error", err, "file", path)
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// LastReadTime returns when a file was last read.
|
| 48 |
+
// Returns zero time if never read.
|
| 49 |
+
func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
|
| 50 |
+
readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{
|
| 51 |
+
SessionID: sessionID,
|
| 52 |
+
Path: relpath(path),
|
| 53 |
+
})
|
| 54 |
+
if err != nil {
|
| 55 |
+
return time.Time{}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return time.Unix(readFile.ReadAt, 0)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func relpath(path string) string {
|
| 62 |
+
path = filepath.Clean(path)
|
| 63 |
+
basepath, err := os.Getwd()
|
| 64 |
+
if err != nil {
|
| 65 |
+
slog.Warn("Error getting basepath", "error", err)
|
| 66 |
+
return path
|
| 67 |
+
}
|
| 68 |
+
relpath, err := filepath.Rel(basepath, path)
|
| 69 |
+
if err != nil {
|
| 70 |
+
slog.Warn("Error getting relpath", "error", err)
|
| 71 |
+
return path
|
| 72 |
+
}
|
| 73 |
+
return relpath
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// ListReadFiles returns the paths of all files read in a session.
|
| 77 |
+
func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
|
| 78 |
+
readFiles, err := s.q.ListSessionReadFiles(ctx, sessionID)
|
| 79 |
+
if err != nil {
|
| 80 |
+
return nil, fmt.Errorf("listing read files: %w", err)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
basepath, err := os.Getwd()
|
| 84 |
+
if err != nil {
|
| 85 |
+
return nil, fmt.Errorf("getting working directory: %w", err)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
paths := make([]string, 0, len(readFiles))
|
| 89 |
+
for _, rf := range readFiles {
|
| 90 |
+
paths = append(paths, filepath.Join(basepath, rf.Path))
|
| 91 |
+
}
|
| 92 |
+
return paths, nil
|
| 93 |
+
}
|
internal/filetracker/service_test.go
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package filetracker
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"testing"
|
| 6 |
+
"testing/synctest"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"github.com/charmbracelet/crush/internal/db"
|
| 10 |
+
"github.com/stretchr/testify/require"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
type testEnv struct {
|
| 14 |
+
ctx context.Context
|
| 15 |
+
q *db.Queries
|
| 16 |
+
svc Service
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
func setupTest(t *testing.T) *testEnv {
|
| 20 |
+
t.Helper()
|
| 21 |
+
|
| 22 |
+
conn, err := db.Connect(t.Context(), t.TempDir())
|
| 23 |
+
require.NoError(t, err)
|
| 24 |
+
t.Cleanup(func() { conn.Close() })
|
| 25 |
+
|
| 26 |
+
q := db.New(conn)
|
| 27 |
+
return &testEnv{
|
| 28 |
+
ctx: t.Context(),
|
| 29 |
+
q: q,
|
| 30 |
+
svc: NewService(q),
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func (e *testEnv) createSession(t *testing.T, sessionID string) {
|
| 35 |
+
t.Helper()
|
| 36 |
+
_, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{
|
| 37 |
+
ID: sessionID,
|
| 38 |
+
Title: "Test Session",
|
| 39 |
+
})
|
| 40 |
+
require.NoError(t, err)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func TestService_RecordRead(t *testing.T) {
|
| 44 |
+
env := setupTest(t)
|
| 45 |
+
|
| 46 |
+
sessionID := "test-session-1"
|
| 47 |
+
path := "/path/to/file.go"
|
| 48 |
+
env.createSession(t, sessionID)
|
| 49 |
+
|
| 50 |
+
env.svc.RecordRead(env.ctx, sessionID, path)
|
| 51 |
+
|
| 52 |
+
lastRead := env.svc.LastReadTime(env.ctx, sessionID, path)
|
| 53 |
+
require.False(t, lastRead.IsZero(), "expected non-zero time after recording read")
|
| 54 |
+
require.WithinDuration(t, time.Now(), lastRead, 2*time.Second)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func TestService_LastReadTime_NotFound(t *testing.T) {
|
| 58 |
+
env := setupTest(t)
|
| 59 |
+
|
| 60 |
+
lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path")
|
| 61 |
+
require.True(t, lastRead.IsZero(), "expected zero time for unread file")
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func TestService_RecordRead_UpdatesTimestamp(t *testing.T) {
|
| 65 |
+
env := setupTest(t)
|
| 66 |
+
|
| 67 |
+
sessionID := "test-session-2"
|
| 68 |
+
path := "/path/to/file.go"
|
| 69 |
+
env.createSession(t, sessionID)
|
| 70 |
+
|
| 71 |
+
env.svc.RecordRead(env.ctx, sessionID, path)
|
| 72 |
+
firstRead := env.svc.LastReadTime(env.ctx, sessionID, path)
|
| 73 |
+
require.False(t, firstRead.IsZero())
|
| 74 |
+
|
| 75 |
+
synctest.Test(t, func(t *testing.T) {
|
| 76 |
+
time.Sleep(100 * time.Millisecond)
|
| 77 |
+
synctest.Wait()
|
| 78 |
+
env.svc.RecordRead(env.ctx, sessionID, path)
|
| 79 |
+
secondRead := env.svc.LastReadTime(env.ctx, sessionID, path)
|
| 80 |
+
|
| 81 |
+
require.False(t, secondRead.Before(firstRead), "second read time should not be before first")
|
| 82 |
+
})
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
func TestService_RecordRead_DifferentSessions(t *testing.T) {
|
| 86 |
+
env := setupTest(t)
|
| 87 |
+
|
| 88 |
+
path := "/shared/file.go"
|
| 89 |
+
session1, session2 := "session-1", "session-2"
|
| 90 |
+
env.createSession(t, session1)
|
| 91 |
+
env.createSession(t, session2)
|
| 92 |
+
|
| 93 |
+
env.svc.RecordRead(env.ctx, session1, path)
|
| 94 |
+
|
| 95 |
+
lastRead1 := env.svc.LastReadTime(env.ctx, session1, path)
|
| 96 |
+
require.False(t, lastRead1.IsZero())
|
| 97 |
+
|
| 98 |
+
lastRead2 := env.svc.LastReadTime(env.ctx, session2, path)
|
| 99 |
+
require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read")
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
func TestService_RecordRead_DifferentPaths(t *testing.T) {
|
| 103 |
+
env := setupTest(t)
|
| 104 |
+
|
| 105 |
+
sessionID := "test-session-3"
|
| 106 |
+
path1, path2 := "/path/to/file1.go", "/path/to/file2.go"
|
| 107 |
+
env.createSession(t, sessionID)
|
| 108 |
+
|
| 109 |
+
env.svc.RecordRead(env.ctx, sessionID, path1)
|
| 110 |
+
|
| 111 |
+
lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1)
|
| 112 |
+
require.False(t, lastRead1.IsZero())
|
| 113 |
+
|
| 114 |
+
lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2)
|
| 115 |
+
require.True(t, lastRead2.IsZero(), "path2 should not be recorded")
|
| 116 |
+
}
|
internal/lock/lock.go
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package lock provides cross-process advisory file locking.
|
| 2 |
+
//
|
| 3 |
+
// File acquires an exclusive lock on the file at path, blocking until
|
| 4 |
+
// the context is cancelled (or its deadline elapses). TryFile does the
|
| 5 |
+
// same but returns ErrContended immediately if the lock is already
|
| 6 |
+
// held. In both cases the returned release function drops the lock and
|
| 7 |
+
// closes the underlying file descriptor.
|
| 8 |
+
//
|
| 9 |
+
// The lock is released automatically by the kernel on process
|
| 10 |
+
// termination (including crash), so no stale-lock recovery is needed.
|
| 11 |
+
//
|
| 12 |
+
// The lock file at path is created if it does not exist. It is never
|
| 13 |
+
// unlinked — flock is keyed by inode, not path, and unlinking could
|
| 14 |
+
// create a window where two processes lock different inodes at the
|
| 15 |
+
// same path.
|
| 16 |
+
//
|
| 17 |
+
// This is the canonical file-locking helper for Crush. Callers should
|
| 18 |
+
// prefer it over rolling their own platform-specific code.
|
| 19 |
+
package lock
|
| 20 |
+
|
| 21 |
+
import (
|
| 22 |
+
"context"
|
| 23 |
+
"errors"
|
| 24 |
+
"fmt"
|
| 25 |
+
"os"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
// ErrContended is returned by TryFile when the lock is already held by
|
| 29 |
+
// another process.
|
| 30 |
+
var ErrContended = errors.New("file lock is held by another process")
|
| 31 |
+
|
| 32 |
+
// File acquires an exclusive advisory lock on the file at path, blocking
|
| 33 |
+
// until the lock is acquired or ctx is cancelled. It returns a release
|
| 34 |
+
// function that drops the lock and closes the underlying file descriptor.
|
| 35 |
+
//
|
| 36 |
+
// Pass a context with a deadline (e.g. context.WithTimeout) to bound the
|
| 37 |
+
// wait. Pass context.Background() to block indefinitely.
|
| 38 |
+
func File(ctx context.Context, path string) (func(), error) {
|
| 39 |
+
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
| 40 |
+
if err != nil {
|
| 41 |
+
return nil, fmt.Errorf("open lock file %q: %w", path, err)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
release, err := lockFile(ctx, f)
|
| 45 |
+
if err != nil {
|
| 46 |
+
f.Close()
|
| 47 |
+
return nil, err
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return func() {
|
| 51 |
+
release()
|
| 52 |
+
f.Close()
|
| 53 |
+
}, nil
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// TryFile is like File but returns ErrContended immediately if the lock
|
| 57 |
+
// is already held by another process. Use this when you want to fail
|
| 58 |
+
// fast rather than wait.
|
| 59 |
+
func TryFile(path string) (func(), error) {
|
| 60 |
+
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
| 61 |
+
if err != nil {
|
| 62 |
+
return nil, fmt.Errorf("open lock file %q: %w", path, err)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
release, err := tryLockFile(f)
|
| 66 |
+
if err != nil {
|
| 67 |
+
f.Close()
|
| 68 |
+
return nil, err
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return func() {
|
| 72 |
+
release()
|
| 73 |
+
f.Close()
|
| 74 |
+
}, nil
|
| 75 |
+
}
|
internal/lock/lock_test.go
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package lock
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"sync"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
func TestTryFile_AcquiresWhenFree(t *testing.T) {
|
| 15 |
+
t.Parallel()
|
| 16 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 17 |
+
|
| 18 |
+
release, err := TryFile(path)
|
| 19 |
+
require.NoError(t, err)
|
| 20 |
+
require.NotNil(t, release)
|
| 21 |
+
release()
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func TestTryFile_ReturnsErrContendedWhenHeld(t *testing.T) {
|
| 25 |
+
t.Parallel()
|
| 26 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 27 |
+
|
| 28 |
+
release, err := TryFile(path)
|
| 29 |
+
require.NoError(t, err)
|
| 30 |
+
t.Cleanup(release)
|
| 31 |
+
|
| 32 |
+
_, err = TryFile(path)
|
| 33 |
+
require.ErrorIs(t, err, ErrContended)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func TestTryFile_ReacquireAfterRelease(t *testing.T) {
|
| 37 |
+
t.Parallel()
|
| 38 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 39 |
+
|
| 40 |
+
release, err := TryFile(path)
|
| 41 |
+
require.NoError(t, err)
|
| 42 |
+
release()
|
| 43 |
+
|
| 44 |
+
release2, err := TryFile(path)
|
| 45 |
+
require.NoError(t, err)
|
| 46 |
+
t.Cleanup(release2)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func TestFile_AcquiresWhenFree(t *testing.T) {
|
| 50 |
+
t.Parallel()
|
| 51 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 52 |
+
|
| 53 |
+
release, err := File(context.Background(), path)
|
| 54 |
+
require.NoError(t, err)
|
| 55 |
+
t.Cleanup(release)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
func TestFile_BlocksThenSucceeds(t *testing.T) {
|
| 59 |
+
t.Parallel()
|
| 60 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 61 |
+
|
| 62 |
+
release, err := TryFile(path)
|
| 63 |
+
require.NoError(t, err)
|
| 64 |
+
|
| 65 |
+
// Release the lock after a short delay so the blocking acquirer
|
| 66 |
+
// can complete within the test timeout.
|
| 67 |
+
go func() {
|
| 68 |
+
time.Sleep(150 * time.Millisecond)
|
| 69 |
+
release()
|
| 70 |
+
}()
|
| 71 |
+
|
| 72 |
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
| 73 |
+
defer cancel()
|
| 74 |
+
release2, err := File(ctx, path)
|
| 75 |
+
require.NoError(t, err, "should acquire after first releases")
|
| 76 |
+
release2()
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
func TestFile_RespectsContextDeadline(t *testing.T) {
|
| 80 |
+
t.Parallel()
|
| 81 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 82 |
+
|
| 83 |
+
release, err := TryFile(path)
|
| 84 |
+
require.NoError(t, err)
|
| 85 |
+
t.Cleanup(release)
|
| 86 |
+
|
| 87 |
+
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
| 88 |
+
defer cancel()
|
| 89 |
+
start := time.Now()
|
| 90 |
+
_, err = File(ctx, path)
|
| 91 |
+
elapsed := time.Since(start)
|
| 92 |
+
require.Error(t, err)
|
| 93 |
+
require.True(t, errors.Is(err, context.DeadlineExceeded), "expected deadline exceeded, got %v", err)
|
| 94 |
+
require.Less(t, elapsed, 1*time.Second, "should return promptly after deadline")
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
func TestFile_RespectsContextCancellation(t *testing.T) {
|
| 98 |
+
t.Parallel()
|
| 99 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 100 |
+
|
| 101 |
+
release, err := TryFile(path)
|
| 102 |
+
require.NoError(t, err)
|
| 103 |
+
t.Cleanup(release)
|
| 104 |
+
|
| 105 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 106 |
+
done := make(chan error, 1)
|
| 107 |
+
go func() {
|
| 108 |
+
_, err := File(ctx, path)
|
| 109 |
+
done <- err
|
| 110 |
+
}()
|
| 111 |
+
|
| 112 |
+
time.Sleep(50 * time.Millisecond)
|
| 113 |
+
cancel()
|
| 114 |
+
|
| 115 |
+
select {
|
| 116 |
+
case err := <-done:
|
| 117 |
+
require.ErrorIs(t, err, context.Canceled)
|
| 118 |
+
case <-time.After(2 * time.Second):
|
| 119 |
+
t.Fatal("File did not return after context cancellation")
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// TestFile_ConcurrentAcquirers verifies that multiple blocking acquirers
|
| 124 |
+
// queue up correctly: each gets the lock in turn, exactly one at a time.
|
| 125 |
+
func TestFile_ConcurrentAcquirers(t *testing.T) {
|
| 126 |
+
t.Parallel()
|
| 127 |
+
path := filepath.Join(t.TempDir(), "test.lock")
|
| 128 |
+
|
| 129 |
+
const n = 5
|
| 130 |
+
var (
|
| 131 |
+
mu sync.Mutex
|
| 132 |
+
inside int
|
| 133 |
+
maxSeen int
|
| 134 |
+
finished int
|
| 135 |
+
)
|
| 136 |
+
var wg sync.WaitGroup
|
| 137 |
+
wg.Add(n)
|
| 138 |
+
for range n {
|
| 139 |
+
go func() {
|
| 140 |
+
defer wg.Done()
|
| 141 |
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
| 142 |
+
defer cancel()
|
| 143 |
+
release, err := File(ctx, path)
|
| 144 |
+
if err != nil {
|
| 145 |
+
t.Errorf("acquire failed: %v", err)
|
| 146 |
+
return
|
| 147 |
+
}
|
| 148 |
+
mu.Lock()
|
| 149 |
+
inside++
|
| 150 |
+
if inside > maxSeen {
|
| 151 |
+
maxSeen = inside
|
| 152 |
+
}
|
| 153 |
+
mu.Unlock()
|
| 154 |
+
|
| 155 |
+
time.Sleep(20 * time.Millisecond)
|
| 156 |
+
|
| 157 |
+
mu.Lock()
|
| 158 |
+
inside--
|
| 159 |
+
finished++
|
| 160 |
+
mu.Unlock()
|
| 161 |
+
release()
|
| 162 |
+
}()
|
| 163 |
+
}
|
| 164 |
+
wg.Wait()
|
| 165 |
+
|
| 166 |
+
require.Equal(t, n, finished)
|
| 167 |
+
require.Equal(t, 1, maxSeen, "lock must be mutually exclusive")
|
| 168 |
+
}
|
internal/lock/lock_unix.go
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//go:build !windows
|
| 2 |
+
|
| 3 |
+
package lock
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"context"
|
| 7 |
+
"errors"
|
| 8 |
+
"fmt"
|
| 9 |
+
"os"
|
| 10 |
+
"time"
|
| 11 |
+
|
| 12 |
+
"golang.org/x/sys/unix"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// retrySleep is the interval between non-blocking flock retries in the
|
| 16 |
+
// blocking File path. Small enough that contention resolution feels
|
| 17 |
+
// snappy; large enough that we don't burn a CPU spinning.
|
| 18 |
+
const retrySleep = 100 * time.Millisecond
|
| 19 |
+
|
| 20 |
+
func lockFile(ctx context.Context, f *os.File) (func(), error) {
|
| 21 |
+
for {
|
| 22 |
+
err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
|
| 23 |
+
if err == nil {
|
| 24 |
+
return func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }, nil
|
| 25 |
+
}
|
| 26 |
+
if !errors.Is(err, unix.EWOULDBLOCK) {
|
| 27 |
+
return nil, fmt.Errorf("flock: %w", err)
|
| 28 |
+
}
|
| 29 |
+
select {
|
| 30 |
+
case <-ctx.Done():
|
| 31 |
+
return nil, fmt.Errorf("acquire lock: %w", ctx.Err())
|
| 32 |
+
case <-time.After(retrySleep):
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func tryLockFile(f *os.File) (func(), error) {
|
| 38 |
+
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
| 39 |
+
if errors.Is(err, unix.EWOULDBLOCK) {
|
| 40 |
+
return nil, ErrContended
|
| 41 |
+
}
|
| 42 |
+
return nil, fmt.Errorf("flock: %w", err)
|
| 43 |
+
}
|
| 44 |
+
return func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }, nil
|
| 45 |
+
}
|
internal/lock/lock_windows.go
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//go:build windows
|
| 2 |
+
|
| 3 |
+
package lock
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"context"
|
| 7 |
+
"errors"
|
| 8 |
+
"fmt"
|
| 9 |
+
"math"
|
| 10 |
+
"os"
|
| 11 |
+
"time"
|
| 12 |
+
|
| 13 |
+
"golang.org/x/sys/windows"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// retrySleep is the interval between non-blocking lock retries in the
|
| 17 |
+
// blocking File path.
|
| 18 |
+
const retrySleep = 100 * time.Millisecond
|
| 19 |
+
|
| 20 |
+
func lockFile(ctx context.Context, f *os.File) (func(), error) {
|
| 21 |
+
h := windows.Handle(f.Fd())
|
| 22 |
+
for {
|
| 23 |
+
ol := new(windows.Overlapped)
|
| 24 |
+
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
| 25 |
+
err := windows.LockFileEx(h, flags, 0, math.MaxUint32, math.MaxUint32, ol)
|
| 26 |
+
if err == nil {
|
| 27 |
+
return func() {
|
| 28 |
+
ol := new(windows.Overlapped)
|
| 29 |
+
_ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, math.MaxUint32, math.MaxUint32, ol)
|
| 30 |
+
}, nil
|
| 31 |
+
}
|
| 32 |
+
if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) && !errors.Is(err, windows.ERROR_IO_PENDING) {
|
| 33 |
+
return nil, fmt.Errorf("LockFileEx: %w", err)
|
| 34 |
+
}
|
| 35 |
+
select {
|
| 36 |
+
case <-ctx.Done():
|
| 37 |
+
return nil, fmt.Errorf("acquire lock: %w", ctx.Err())
|
| 38 |
+
case <-time.After(retrySleep):
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func tryLockFile(f *os.File) (func(), error) {
|
| 44 |
+
h := windows.Handle(f.Fd())
|
| 45 |
+
ol := new(windows.Overlapped)
|
| 46 |
+
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
| 47 |
+
if err := windows.LockFileEx(h, flags, 0, math.MaxUint32, math.MaxUint32, ol); err != nil {
|
| 48 |
+
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_IO_PENDING) {
|
| 49 |
+
return nil, ErrContended
|
| 50 |
+
}
|
| 51 |
+
return nil, fmt.Errorf("LockFileEx: %w", err)
|
| 52 |
+
}
|
| 53 |
+
return func() {
|
| 54 |
+
ol := new(windows.Overlapped)
|
| 55 |
+
_ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, math.MaxUint32, math.MaxUint32, ol)
|
| 56 |
+
}, nil
|
| 57 |
+
}
|
internal/logout/choose.go
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package logout
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"strings"
|
| 6 |
+
|
| 7 |
+
"charm.land/bubbles/v2/help"
|
| 8 |
+
"charm.land/bubbles/v2/key"
|
| 9 |
+
tea "charm.land/bubbletea/v2"
|
| 10 |
+
"charm.land/lipgloss/v2"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
type chooseKeyMap struct {
|
| 14 |
+
Up key.Binding
|
| 15 |
+
Down key.Binding
|
| 16 |
+
Confirm key.Binding
|
| 17 |
+
Cancel key.Binding
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func defaultChooseKeyMap() chooseKeyMap {
|
| 21 |
+
return chooseKeyMap{
|
| 22 |
+
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "select")),
|
| 23 |
+
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("", "")),
|
| 24 |
+
Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
|
| 25 |
+
Cancel: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("esc", "cancel")),
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
func (k chooseKeyMap) ShortHelp() []key.Binding {
|
| 30 |
+
return []key.Binding{k.Up, k.Confirm, k.Cancel}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func (k chooseKeyMap) FullHelp() [][]key.Binding {
|
| 34 |
+
return [][]key.Binding{{k.Up, k.Down, k.Confirm, k.Cancel}}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// chooseModel is the Bubble Tea model for a multiple choice question with
|
| 38 |
+
// a ">" cursor.
|
| 39 |
+
type chooseModel struct {
|
| 40 |
+
question string
|
| 41 |
+
options []string
|
| 42 |
+
cursor int
|
| 43 |
+
chosen int
|
| 44 |
+
|
| 45 |
+
help help.Model
|
| 46 |
+
keymap chooseKeyMap
|
| 47 |
+
|
| 48 |
+
quitting bool
|
| 49 |
+
blankLines int
|
| 50 |
+
width int
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
func newChooseModel(question string, options []string) chooseModel {
|
| 54 |
+
return chooseModel{
|
| 55 |
+
question: question,
|
| 56 |
+
options: options,
|
| 57 |
+
chosen: -1,
|
| 58 |
+
help: help.New(),
|
| 59 |
+
keymap: defaultChooseKeyMap(),
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// runChoose runs the multiple choice TUI and returns the selected option
|
| 64 |
+
// index, or -1 when canceled.
|
| 65 |
+
func runChoose(question string, options []string) (int, error) {
|
| 66 |
+
p := tea.NewProgram(newChooseModel(question, options))
|
| 67 |
+
final, err := p.Run()
|
| 68 |
+
if err != nil {
|
| 69 |
+
return -1, err
|
| 70 |
+
}
|
| 71 |
+
m, ok := final.(chooseModel)
|
| 72 |
+
if !ok {
|
| 73 |
+
return -1, errors.New("unexpected choose program model")
|
| 74 |
+
}
|
| 75 |
+
return m.chosen, nil
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Init initializes the choose TUI.
|
| 79 |
+
func (m chooseModel) Init() tea.Cmd {
|
| 80 |
+
return nil
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Update handles messages for the choose TUI.
|
| 84 |
+
func (m chooseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
| 85 |
+
switch msg := msg.(type) {
|
| 86 |
+
case tea.WindowSizeMsg:
|
| 87 |
+
m.width = msg.Width
|
| 88 |
+
m.help.SetWidth(msg.Width)
|
| 89 |
+
return m, nil
|
| 90 |
+
|
| 91 |
+
case tea.KeyPressMsg:
|
| 92 |
+
switch {
|
| 93 |
+
case key.Matches(msg, m.keymap.Cancel):
|
| 94 |
+
m.quitting = true
|
| 95 |
+
m.blankLines = m.contentHeight()
|
| 96 |
+
return m, tea.Quit
|
| 97 |
+
case key.Matches(msg, m.keymap.Confirm):
|
| 98 |
+
m.chosen = m.cursor
|
| 99 |
+
m.quitting = true
|
| 100 |
+
m.blankLines = m.contentHeight()
|
| 101 |
+
return m, tea.Quit
|
| 102 |
+
case key.Matches(msg, m.keymap.Up):
|
| 103 |
+
m.cursor--
|
| 104 |
+
if m.cursor < 0 {
|
| 105 |
+
m.cursor = len(m.options) - 1
|
| 106 |
+
}
|
| 107 |
+
return m, nil
|
| 108 |
+
case key.Matches(msg, m.keymap.Down):
|
| 109 |
+
m.cursor++
|
| 110 |
+
if m.cursor >= len(m.options) {
|
| 111 |
+
m.cursor = 0
|
| 112 |
+
}
|
| 113 |
+
return m, nil
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
return m, nil
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// content renders the full choose view. The trailing zero-width space
|
| 120 |
+
// keeps the final blank line alive (the renderer erases trailing empty
|
| 121 |
+
// lines).
|
| 122 |
+
func (m chooseModel) content() string {
|
| 123 |
+
var b strings.Builder
|
| 124 |
+
|
| 125 |
+
b.WriteString("\n ")
|
| 126 |
+
b.WriteString(questionStyle.Render(m.question))
|
| 127 |
+
b.WriteString("\n\n")
|
| 128 |
+
for i, option := range m.options {
|
| 129 |
+
if i == m.cursor {
|
| 130 |
+
b.WriteString(" " + choiceSelectedStyle.Render("> "+option))
|
| 131 |
+
} else {
|
| 132 |
+
b.WriteString(" " + option)
|
| 133 |
+
}
|
| 134 |
+
if i < len(m.options)-1 {
|
| 135 |
+
b.WriteString("\n")
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
b.WriteString("\n\n ")
|
| 139 |
+
b.WriteString(m.help.View(m.keymap))
|
| 140 |
+
b.WriteString("\n")
|
| 141 |
+
return b.String()
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// contentHeight returns the height of the current view in lines.
|
| 145 |
+
func (m chooseModel) contentHeight() int {
|
| 146 |
+
return lipgloss.Height(m.content())
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// View renders the choose TUI.
|
| 150 |
+
func (m chooseModel) View() tea.View {
|
| 151 |
+
if m.quitting {
|
| 152 |
+
// Render the same number of blank lines as the last frame so the
|
| 153 |
+
// renderer overwrites it and the TUI disappears on exit. A truly
|
| 154 |
+
// empty view doesn't work under Fang's wrapped output: the renderer
|
| 155 |
+
// drops the touched-lines buffer when the height shrinks to zero
|
| 156 |
+
// and leaves the frame on screen.
|
| 157 |
+
return tea.NewView("")
|
| 158 |
+
}
|
| 159 |
+
return tea.NewView(m.content())
|
| 160 |
+
}
|