diff --git a/AGENTS.md b/AGENTS.md index 654f1cd0a7fe1cbb50a3026f86f31b68e04f8043..b6d5eb67b042454862ce8f20ab6035fc7aba9692 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,5 +73,8 @@ func TestYourFunction(t *testing.T) { - Try to keep commits to one line, not including your attribution. Only use multi-line commits when additional context is truly necessary. +## Working on Configuration +Anytime you need to work on the config before starting work read the internal/config/AGENTS.md file. + ## Working on the TUI (UI) -Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file +Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file. diff --git a/go.mod b/go.mod index 2358911b7f6c3633b82b14e589c5db14c02d15d6..8bc84ec58564854b42e388f7d8878f16bc1b7711 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,6 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/posthog/posthog-go v1.9.1 github.com/pressly/goose/v3 v3.26.0 - github.com/qjebbs/go-jsons v1.0.0-alpha.4 github.com/rivo/uniseg v0.4.7 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 diff --git a/go.sum b/go.sum index 91d0707fd0a5d50c4d64a8c68b606747b743f4c0..7176be350b60199c3590590b688faaf94d8a9e1c 100644 --- a/go.sum +++ b/go.sum @@ -304,8 +304,6 @@ github.com/posthog/posthog-go v1.9.1 h1:9bkcRnYSvcgMxL2s9QlCnd1DVnm2qWXxWu5o0HSF github.com/posthog/posthog-go v1.9.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= -github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs= -github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 09313f363d5d692971801354e0f5d609a20015ca..77210a09961e5319d125d25cbcf96d4ee51eecb3 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -1,13 +1,10 @@ package agent import ( - "bytes" "cmp" "context" - "encoding/json" "errors" "fmt" - "io" "log/slog" "maps" "net/http" @@ -41,7 +38,6 @@ import ( "charm.land/fantasy/providers/openrouter" "charm.land/fantasy/providers/vercel" openaisdk "github.com/openai/openai-go/v2/option" - "github.com/qjebbs/go-jsons" ) type Coordinator interface { @@ -199,49 +195,16 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions { options := fantasy.ProviderOptions{} - cfgOpts := []byte("{}") - providerCfgOpts := []byte("{}") - catwalkOpts := []byte("{}") - - if model.ModelCfg.ProviderOptions != nil { - data, err := json.Marshal(model.ModelCfg.ProviderOptions) - if err == nil { - cfgOpts = data - } - } - - if providerCfg.ProviderOptions != nil { - data, err := json.Marshal(providerCfg.ProviderOptions) - if err == nil { - providerCfgOpts = data - } - } - + // Merge provider options: catwalk (base) -> provider config -> model config (highest priority) + mergedOptions := make(map[string]any) if model.CatwalkCfg.Options.ProviderOptions != nil { - data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions) - if err == nil { - catwalkOpts = data - } - } - - readers := []io.Reader{ - bytes.NewReader(catwalkOpts), - bytes.NewReader(providerCfgOpts), - bytes.NewReader(cfgOpts), + maps.Copy(mergedOptions, model.CatwalkCfg.Options.ProviderOptions) } - - got, err := jsons.Merge(readers) - if err != nil { - slog.Error("Could not merge call config", "err", err) - return options + if providerCfg.ProviderOptions != nil { + maps.Copy(mergedOptions, providerCfg.ProviderOptions) } - - mergedOptions := make(map[string]any) - - err = json.Unmarshal([]byte(got), &mergedOptions) - if err != nil { - slog.Error("Could not create config for call", "err", err) - return options + if model.ModelCfg.ProviderOptions != nil { + maps.Copy(mergedOptions, model.ModelCfg.ProviderOptions) } providerType := providerCfg.Type diff --git a/internal/config/AGENTS.md b/internal/config/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..e94481b02d23db453c437f0868e9c647bcdc7c8d --- /dev/null +++ b/internal/config/AGENTS.md @@ -0,0 +1,27 @@ +# Configuration Development Guide + +## Merge Rules + +When adding new fields to config structs (`Config`, `Options`, `MCPConfig`, `LSPConfig`, `TUIOptions`, `Tools`, `ProviderConfig`), you **must** update the corresponding `merge()` method in `config.go` and add test cases to `merge_test.go`. + +### Merge Behavior Patterns + +Each field type has a specific merge strategy: + +| Type | Strategy | Example | +|------|----------|---------| +| **Booleans** | `true` if any config has `true` | `Disabled`, `Debug`, `Progress` | +| **Strings** | Later value replaces earlier | `Model`, `InitializeAs`, `TrailerStyle` | +| **Slices (paths/tools)** | Appended, sorted, deduped | `SkillsPaths`, `DisabledTools` | +| **Slices (args)** | Later replaces earlier entirely | `Args` in LSPConfig | +| **Maps** | Merged, later values overwrite keys | `Env`, `Headers`, `Options` | +| **Timeouts** | Max value wins | `Timeout` in MCPConfig/LSPConfig | +| **Pointers** | Later non-nil replaces earlier | `MaxTokens`, `Temperature` | +| **Structs** | Call sub-struct's `merge()` method | `TUI`, `Tools` | + +### Adding a New Config Field + +1. Add the field to the appropriate struct in `config.go` +2. Add merge logic to the struct's `merge()` method following the patterns above +3. Add a test case in `merge_test.go` verifying the merge behavior +4. Run `go test ./internal/config/... -v -run TestConfigMerging` to verify diff --git a/internal/config/config.go b/internal/config/config.go index d5f3b8fb65b0d8d7f694fa3368d0263f4c3336a9..d20719eb0a5a8929dfea04feebd0f3364fa74089 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -163,6 +163,26 @@ func (pc *ProviderConfig) SetupGitHubCopilot() { maps.Copy(pc.ExtraHeaders, copilot.Headers()) } +func (pc ProviderConfig) merge(t ProviderConfig) ProviderConfig { + pc.ID = cmp.Or(t.ID, pc.ID) + pc.Name = cmp.Or(t.Name, pc.Name) + pc.BaseURL = cmp.Or(t.BaseURL, pc.BaseURL) + pc.Type = cmp.Or(t.Type, pc.Type) + pc.APIKey = cmp.Or(t.APIKey, pc.APIKey) + pc.APIKeyTemplate = cmp.Or(t.APIKeyTemplate, pc.APIKeyTemplate) + pc.OAuthToken = cmp.Or(t.OAuthToken, pc.OAuthToken) + pc.Disable = pc.Disable || t.Disable + pc.SystemPromptPrefix = cmp.Or(t.SystemPromptPrefix, pc.SystemPromptPrefix) + pc.ExtraHeaders = mergeMaps(pc.ExtraHeaders, t.ExtraHeaders) + pc.ExtraBody = mergeMaps(pc.ExtraBody, t.ExtraBody) + pc.ProviderOptions = mergeMaps(pc.ProviderOptions, t.ProviderOptions) + pc.ExtraParams = mergeMaps(pc.ExtraParams, t.ExtraParams) + if len(t.Models) > 0 { + pc.Models = t.Models + } + return pc +} + type MCPType string const ( @@ -185,6 +205,21 @@ type MCPConfig struct { Headers map[string]string `json:"headers,omitempty" jsonschema:"description=HTTP headers for HTTP/SSE MCP servers"` } +func (m MCPConfig) merge(o MCPConfig) MCPConfig { + m.Env = mergeMaps(m.Env, o.Env) + m.Headers = mergeMaps(m.Headers, o.Headers) + m.Disabled = m.Disabled || o.Disabled + m.DisabledTools = append(m.DisabledTools, o.DisabledTools...) + m.Timeout = max(m.Timeout, o.Timeout) + m.Command = cmp.Or(o.Command, m.Command) + if len(o.Args) > 0 { + m.Args = o.Args + } + m.Type = cmp.Or(o.Type, m.Type) + m.URL = cmp.Or(o.URL, m.URL) + return m +} + type LSPConfig struct { Disabled bool `json:"disabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"` Command string `json:"command,omitempty" jsonschema:"description=Command to execute for the LSP server,example=gopls"` @@ -197,6 +232,21 @@ type LSPConfig struct { Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for LSP server initialization,default=30,example=60,example=120"` } +func (l LSPConfig) merge(o LSPConfig) LSPConfig { + l.Env = mergeMaps(l.Env, o.Env) + l.InitOptions = mergeMaps(l.InitOptions, o.InitOptions) + l.Options = mergeMaps(l.Options, o.Options) + l.RootMarkers = sortedCompact(append(l.RootMarkers, o.RootMarkers...)) + l.FileTypes = sortedCompact(append(l.FileTypes, o.FileTypes...)) + l.Disabled = l.Disabled || o.Disabled + l.Timeout = max(l.Timeout, o.Timeout) + if len(o.Args) > 0 { + l.Args = o.Args + } + l.Command = cmp.Or(o.Command, l.Command) + return l +} + type TUIOptions struct { CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"` DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"` @@ -207,6 +257,15 @@ type TUIOptions struct { Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"` } +func (o TUIOptions) merge(t TUIOptions) TUIOptions { + o.CompactMode = o.CompactMode || t.CompactMode + o.DiffMode = cmp.Or(t.DiffMode, o.DiffMode) + o.Completions.MaxDepth = cmp.Or(t.Completions.MaxDepth, o.Completions.MaxDepth) + o.Completions.MaxItems = cmp.Or(t.Completions.MaxItems, o.Completions.MaxItems) + o.Transparent = cmp.Or(t.Transparent, o.Transparent) + return o +} + // Completions defines options for the completions UI. type Completions struct { MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"` @@ -263,6 +322,32 @@ type Options struct { Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"` } +func (o Options) merge(t Options) Options { + o.ContextPaths = append(o.ContextPaths, t.ContextPaths...) + o.SkillsPaths = append(o.SkillsPaths, t.SkillsPaths...) + o.Debug = o.Debug || t.Debug + o.DebugLSP = o.DebugLSP || t.DebugLSP + o.DisableAutoSummarize = o.DisableAutoSummarize || t.DisableAutoSummarize + o.DisableProviderAutoUpdate = o.DisableProviderAutoUpdate || t.DisableProviderAutoUpdate + o.DisableDefaultProviders = o.DisableDefaultProviders || t.DisableDefaultProviders + o.DisableMetrics = o.DisableMetrics || t.DisableMetrics + o.DataDirectory = cmp.Or(t.DataDirectory, o.DataDirectory) + o.InitializeAs = cmp.Or(t.InitializeAs, o.InitializeAs) + o.DisabledTools = append(o.DisabledTools, t.DisabledTools...) + o.AutoLSP = cmp.Or(t.AutoLSP, o.AutoLSP) + o.Progress = cmp.Or(t.Progress, o.Progress) + *o.TUI = o.TUI.merge(*t.TUI) + if t.Attribution != nil { + if o.Attribution == nil { + o.Attribution = &Attribution{} + } + o.Attribution.TrailerStyle = cmp.Or(t.Attribution.TrailerStyle, o.Attribution.TrailerStyle) + o.Attribution.CoAuthoredBy = cmp.Or(t.Attribution.CoAuthoredBy, o.Attribution.CoAuthoredBy) + o.Attribution.GeneratedWith = o.Attribution.GeneratedWith || t.Attribution.GeneratedWith + } + return o +} + type MCPs map[string]MCPConfig type MCP struct { @@ -353,6 +438,12 @@ type Tools struct { Ls ToolLs `json:"ls,omitempty"` } +func (o Tools) merge(t Tools) Tools { + o.Ls.MaxDepth = cmp.Or(t.Ls.MaxDepth, o.Ls.MaxDepth) + o.Ls.MaxItems = cmp.Or(t.Ls.MaxItems, o.Ls.MaxItems) + return o +} + type ToolLs struct { MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"` MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"` @@ -395,6 +486,47 @@ type Config struct { knownProviders []catwalk.Provider `json:"-"` } +func (c Config) merge(t Config) Config { + for name, mcp := range t.MCP { + existing, ok := c.MCP[name] + if !ok { + c.MCP[name] = mcp + continue + } + c.MCP[name] = existing.merge(mcp) + } + for name, lsp := range t.LSP { + existing, ok := c.LSP[name] + if !ok { + c.LSP[name] = lsp + continue + } + c.LSP[name] = existing.merge(lsp) + } + // simple override + maps.Copy(c.Models, t.Models) + c.Schema = cmp.Or(c.Schema, t.Schema) + if t.Options != nil { + *c.Options = c.Options.merge(*t.Options) + } + if t.Permissions != nil { + c.Permissions.AllowedTools = append(c.Permissions.AllowedTools, t.Permissions.AllowedTools...) + } + if c.Providers != nil { + for key, value := range t.Providers.Seq2() { + existing, ok := c.Providers.Get(key) + if !ok { + c.Providers.Set(key, value) + continue + } + c.Providers.Set(key, existing.merge(value)) + } + } + c.Tools = c.Tools.merge(t.Tools) + + return c +} + func (c *Config) WorkingDir() string { return c.workingDir } @@ -860,3 +992,16 @@ func ptrValOr[T any](t *T, el T) T { } return *t } + +func mergeMaps[K comparable, V any](base, overlay map[K]V) map[K]V { + if base == nil { + base = make(map[K]V) + } + maps.Copy(base, overlay) + return base +} + +func sortedCompact[S ~[]E, E cmp.Ordered](s S) S { + slices.Sort(s) + return slices.Compact(s) +} diff --git a/internal/config/load.go b/internal/config/load.go index a651f4846307ed9729ba8a10835e98aece486dbd..54b3e06f027adfb53c2a11c71b96f465a72dccca 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -24,7 +24,6 @@ import ( "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/log" powernapConfig "github.com/charmbracelet/x/powernap/pkg/config" - "github.com/qjebbs/go-jsons" ) const defaultCatwalkURL = "https://catwalk.charm.sh" @@ -675,15 +674,15 @@ func loadFromBytes(configs [][]byte) (*Config, error) { return &Config{}, nil } - data, err := jsons.Merge(configs) - if err != nil { - return nil, err - } - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return nil, err + result := newConfig() + for _, bts := range configs { + config := newConfig() + if err := json.Unmarshal(bts, &config); err != nil { + return nil, err + } + *result = result.merge(*config) } - return &config, nil + return result, nil } func hasVertexCredentials(env env.Env) bool { @@ -799,3 +798,18 @@ func GlobalSkillsDirs() []string { } func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" } + +func newConfig() *Config { + return &Config{ + Agents: map[string]Agent{}, + MCP: map[string]MCPConfig{}, + LSP: map[string]LSPConfig{}, + Models: map[SelectedModelType]SelectedModel{}, + RecentModels: map[SelectedModelType][]SelectedModel{}, + Options: &Options{ + TUI: &TUIOptions{}, + }, + Permissions: &Permissions{}, + Providers: csync.NewMap[string, ProviderConfig](), + } +} diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6a76488a0f9ec7ebe5ff7d74ef24a65adae3f36f --- /dev/null +++ b/internal/config/merge_test.go @@ -0,0 +1,812 @@ +package config + +import ( + "encoding/json" + "maps" + "slices" + "testing" + + "github.com/charmbracelet/crush/internal/csync" + "github.com/stretchr/testify/require" +) + +// TestConfigMerging defines the rules on how configuration merging works. +// Generally, things are either appended to or replaced by the later configuration. +// Whether one or the other happen depends on effects its effects. +func TestConfigMerging(t *testing.T) { + t.Run("empty", func(t *testing.T) { + c := exerciseMerge(t, Config{}, Config{}) + require.NotNil(t, c) + }) + + t.Run("mcps", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "foo": { + Command: "foo-mcp", + Args: []string{"serve"}, + Type: MCPSSE, + Timeout: 10, + }, + "zaz": { + Disabled: true, + Env: map[string]string{"FOO": "bar"}, + Headers: map[string]string{"api-key": "exposed"}, + URL: "nope", + }, + }, + }, Config{ + MCP: MCPs{ + "foo": { + Args: []string{"serve", "--stdio"}, + Type: MCPStdio, + Timeout: 7, + }, + "bar": { + Command: "bar", + }, + "zaz": { + Env: map[string]string{"FOO": "foo", "BAR": "bar"}, + Headers: map[string]string{"api-key": "$API"}, + URL: "http://bar", + }, + }, + }) + require.NotNil(t, c) + require.Len(t, slices.Collect(maps.Keys(c.MCP)), 3) + + // foo: merged from both configs + foo := c.MCP["foo"] + require.Equal(t, "foo-mcp", foo.Command) + require.Equal(t, []string{"serve", "--stdio"}, foo.Args) + require.Equal(t, MCPStdio, foo.Type) + require.Equal(t, 10, foo.Timeout) // max of 10 and 7 + + // bar: only in second config + require.Equal(t, "bar", c.MCP["bar"].Command) + + // zaz: merged, env/headers merged, disabled stays true + zaz := c.MCP["zaz"] + require.True(t, zaz.Disabled) + require.Equal(t, "http://bar", zaz.URL) + require.Equal(t, "foo", zaz.Env["FOO"]) // overwritten + require.Equal(t, "bar", zaz.Env["BAR"]) // added + require.Equal(t, "$API", zaz.Headers["api-key"]) + }) + + t.Run("lsps", func(t *testing.T) { + result := exerciseMerge(t, Config{ + LSP: LSPs{ + "gopls": LSPConfig{ + Env: map[string]string{"FOO": "bar"}, + RootMarkers: []string{"go.sum"}, + FileTypes: []string{"go"}, + }, + }, + }, Config{ + LSP: LSPs{ + "gopls": LSPConfig{ + Command: "gopls", + InitOptions: map[string]any{"a": 10}, + RootMarkers: []string{"go.sum"}, + }, + }, + }, Config{ + LSP: LSPs{ + "gopls": LSPConfig{ + Args: []string{"serve", "--stdio"}, + InitOptions: map[string]any{"a": 12, "b": 18}, + RootMarkers: []string{"go.sum", "go.mod"}, + FileTypes: []string{"go"}, + Disabled: true, + }, + }, + }, + Config{ + LSP: LSPs{ + "gopls": LSPConfig{ + Options: map[string]any{"opt1": "10"}, + RootMarkers: []string{"go.work"}, + }, + }, + }, + ) + require.NotNil(t, result) + require.Equal(t, LSPConfig{ + Disabled: true, + Command: "gopls", + Args: []string{"serve", "--stdio"}, + Env: map[string]string{"FOO": "bar"}, + FileTypes: []string{"go"}, + RootMarkers: []string{"go.mod", "go.sum", "go.work"}, + InitOptions: map[string]any{"a": 12.0, "b": 18.0}, + Options: map[string]any{"opt1": "10"}, + }, result.LSP["gopls"]) + }) + + t.Run("tui_options", func(t *testing.T) { + maxDepth := 5 + maxItems := 100 + newMaxDepth := 10 + newMaxItems := 200 + + c := exerciseMerge(t, Config{ + Options: &Options{ + TUI: &TUIOptions{ + CompactMode: false, + DiffMode: "unified", + Completions: Completions{ + MaxDepth: &maxDepth, + MaxItems: &maxItems, + }, + }, + }, + }, Config{ + Options: &Options{ + TUI: &TUIOptions{ + CompactMode: true, + DiffMode: "split", + Completions: Completions{ + MaxDepth: &newMaxDepth, + MaxItems: &newMaxItems, + }, + }, + }, + }) + + require.NotNil(t, c) + require.True(t, c.Options.TUI.CompactMode) + require.Equal(t, "split", c.Options.TUI.DiffMode) + require.Equal(t, newMaxDepth, *c.Options.TUI.Completions.MaxDepth) + }) + + t.Run("options", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + ContextPaths: []string{"CRUSH.md"}, + Debug: false, + DebugLSP: false, + DisableProviderAutoUpdate: false, + DisableMetrics: false, + DataDirectory: ".crush", + DisabledTools: []string{"bash"}, + Attribution: &Attribution{ + TrailerStyle: TrailerStyleNone, + GeneratedWith: false, + }, + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + ContextPaths: []string{".cursorrules"}, + Debug: true, + DebugLSP: true, + DisableProviderAutoUpdate: true, + DisableMetrics: true, + DataDirectory: ".custom", + DisabledTools: []string{"edit"}, + Attribution: &Attribution{ + TrailerStyle: TrailerStyleCoAuthoredBy, + GeneratedWith: true, + }, + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"CRUSH.md", ".cursorrules"}, c.Options.ContextPaths) + require.True(t, c.Options.Debug) + require.True(t, c.Options.DebugLSP) + require.True(t, c.Options.DisableProviderAutoUpdate) + require.True(t, c.Options.DisableMetrics) + require.Equal(t, ".custom", c.Options.DataDirectory) + require.Equal(t, []string{"bash", "edit"}, c.Options.DisabledTools) + require.Equal(t, TrailerStyleCoAuthoredBy, c.Options.Attribution.TrailerStyle) + require.True(t, c.Options.Attribution.GeneratedWith) + }) + + t.Run("tools", func(t *testing.T) { + maxDepth := 5 + maxItems := 100 + newMaxDepth := 10 + newMaxItems := 200 + + c := exerciseMerge(t, Config{ + Tools: Tools{ + Ls: ToolLs{ + MaxDepth: &maxDepth, + MaxItems: &maxItems, + }, + }, + }, Config{ + Tools: Tools{ + Ls: ToolLs{ + MaxDepth: &newMaxDepth, + MaxItems: &newMaxItems, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, newMaxDepth, *c.Tools.Ls.MaxDepth) + }) + + t.Run("models", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Models: map[SelectedModelType]SelectedModel{ + "large": { + Model: "gpt-4", + Provider: "openai", + }, + }, + }, Config{ + Models: map[SelectedModelType]SelectedModel{ + "large": { + Model: "gpt-4o", + Provider: "openai", + }, + "small": { + Model: "gpt-3.5-turbo", + Provider: "openai", + }, + }, + }) + + require.NotNil(t, c) + require.Len(t, c.Models, 2) + require.Equal(t, "gpt-4o", c.Models["large"].Model) + require.Equal(t, "gpt-3.5-turbo", c.Models["small"].Model) + }) + + t.Run("schema", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Schema: "https://example.com/schema.json", + }, Config{ + Schema: "https://example.com/new-schema.json", + }) + + require.NotNil(t, c) + require.Equal(t, "https://example.com/schema.json", c.Schema) + }) + + t.Run("schema_empty_first", func(t *testing.T) { + c := exerciseMerge(t, Config{}, Config{ + Schema: "https://example.com/schema.json", + }) + + require.NotNil(t, c) + require.Equal(t, "https://example.com/schema.json", c.Schema) + }) + + t.Run("permissions", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Permissions: &Permissions{ + AllowedTools: []string{"bash", "view"}, + }, + }, Config{ + Permissions: &Permissions{ + AllowedTools: []string{"edit", "write"}, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"bash", "view", "edit", "write"}, c.Permissions.AllowedTools) + }) + + t.Run("mcp_timeout_max", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "test": { + Timeout: 10, + }, + }, + }, Config{ + MCP: MCPs{ + "test": { + Timeout: 5, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, 10, c.MCP["test"].Timeout) + }) + + t.Run("mcp_disabled_true_if_any", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "test": { + Disabled: false, + }, + }, + }, Config{ + MCP: MCPs{ + "test": { + Disabled: true, + }, + }, + }) + + require.NotNil(t, c) + require.True(t, c.MCP["test"].Disabled) + }) + + t.Run("lsp_disabled_true_if_any", func(t *testing.T) { + c := exerciseMerge(t, Config{ + LSP: LSPs{ + "test": { + Disabled: false, + }, + }, + }, Config{ + LSP: LSPs{ + "test": { + Disabled: true, + }, + }, + }) + + require.NotNil(t, c) + require.True(t, c.LSP["test"].Disabled) + }) + + t.Run("lsp_args_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + LSP: LSPs{ + "test": { + Args: []string{"old", "args"}, + }, + }, + }, Config{ + LSP: LSPs{ + "test": { + Args: []string{"new", "args"}, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"new", "args"}, c.LSP["test"].Args) + }) + + t.Run("lsp_filetypes_merged_and_deduplicated", func(t *testing.T) { + c := exerciseMerge(t, Config{ + LSP: LSPs{ + "test": { + FileTypes: []string{"go", "mod"}, + }, + }, + }, Config{ + LSP: LSPs{ + "test": { + FileTypes: []string{"go", "sum"}, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"go", "mod", "sum"}, c.LSP["test"].FileTypes) + }) + + t.Run("lsp_rootmarkers_merged_and_deduplicated", func(t *testing.T) { + c := exerciseMerge(t, Config{ + LSP: LSPs{ + "test": { + RootMarkers: []string{"go.mod", "go.sum"}, + }, + }, + }, Config{ + LSP: LSPs{ + "test": { + RootMarkers: []string{"go.sum", "go.work"}, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"go.mod", "go.sum", "go.work"}, c.LSP["test"].RootMarkers) + }) + + t.Run("options_attribution_nil", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + Attribution: &Attribution{ + TrailerStyle: TrailerStyleCoAuthoredBy, + GeneratedWith: true, + }, + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.Equal(t, TrailerStyleCoAuthoredBy, c.Options.Attribution.TrailerStyle) + require.True(t, c.Options.Attribution.GeneratedWith) + }) + + t.Run("tui_compact_mode_true_if_any", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + TUI: &TUIOptions{ + CompactMode: false, + }, + }, + }, Config{ + Options: &Options{ + TUI: &TUIOptions{ + CompactMode: true, + }, + }, + }) + + require.NotNil(t, c) + require.True(t, c.Options.TUI.CompactMode) + }) + + t.Run("tui_diff_mode_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + TUI: &TUIOptions{ + DiffMode: "unified", + }, + }, + }, Config{ + Options: &Options{ + TUI: &TUIOptions{ + DiffMode: "split", + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, "split", c.Options.TUI.DiffMode) + }) + + t.Run("options_data_directory_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + DataDirectory: ".crush", + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + DataDirectory: ".custom", + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.Equal(t, ".custom", c.Options.DataDirectory) + }) + + t.Run("mcp_args_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "test": { + Args: []string{"old"}, + }, + }, + }, Config{ + MCP: MCPs{ + "test": { + Args: []string{"new"}, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"new"}, c.MCP["test"].Args) + }) + + t.Run("mcp_command_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "test": { + Command: "old-command", + }, + }, + }, Config{ + MCP: MCPs{ + "test": { + Command: "new-command", + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, "new-command", c.MCP["test"].Command) + }) + + t.Run("mcp_type_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "test": { + Type: MCPSSE, + }, + }, + }, Config{ + MCP: MCPs{ + "test": { + Type: MCPStdio, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, MCPStdio, c.MCP["test"].Type) + }) + + t.Run("mcp_url_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "test": { + URL: "http://old", + }, + }, + }, Config{ + MCP: MCPs{ + "test": { + URL: "http://new", + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, "http://new", c.MCP["test"].URL) + }) + + t.Run("lsp_command_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + LSP: LSPs{ + "test": { + Command: "old-command", + }, + }, + }, Config{ + LSP: LSPs{ + "test": { + Command: "new-command", + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, "new-command", c.LSP["test"].Command) + }) + + t.Run("lsp_timeout_max", func(t *testing.T) { + c := exerciseMerge(t, Config{ + LSP: LSPs{ + "test": { + Timeout: 60, + }, + }, + }, Config{ + LSP: LSPs{ + "test": { + Timeout: 30, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, 60, c.LSP["test"].Timeout) + }) + + t.Run("mcp_disabled_tools_appended", func(t *testing.T) { + c := exerciseMerge(t, Config{ + MCP: MCPs{ + "test": { + DisabledTools: []string{"tool1"}, + }, + }, + }, Config{ + MCP: MCPs{ + "test": { + DisabledTools: []string{"tool2"}, + }, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"tool1", "tool2"}, c.MCP["test"].DisabledTools) + }) + + t.Run("options_skills_paths_appended", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + SkillsPaths: []string{"/path/1"}, + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + SkillsPaths: []string{"/path/2"}, + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.Equal(t, []string{"/path/1", "/path/2"}, c.Options.SkillsPaths) + }) + + t.Run("tui_transparent_replaced", func(t *testing.T) { + trueVal := true + falseVal := false + c := exerciseMerge(t, Config{ + Options: &Options{ + TUI: &TUIOptions{ + Transparent: &falseVal, + }, + }, + }, Config{ + Options: &Options{ + TUI: &TUIOptions{ + Transparent: &trueVal, + }, + }, + }) + + require.NotNil(t, c) + require.True(t, *c.Options.TUI.Transparent) + }) + + t.Run("options_initialize_as_replaced", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + InitializeAs: "CRUSH.md", + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + InitializeAs: "AGENTS.md", + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.Equal(t, "AGENTS.md", c.Options.InitializeAs) + }) + + t.Run("options_auto_lsp_replaced", func(t *testing.T) { + trueVal := true + c := exerciseMerge(t, Config{ + Options: &Options{ + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + AutoLSP: &trueVal, + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.True(t, *c.Options.AutoLSP) + }) + + t.Run("options_disable_auto_summarize_true_if_any", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + DisableAutoSummarize: false, + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + DisableAutoSummarize: true, + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.True(t, c.Options.DisableAutoSummarize) + }) + + t.Run("options_disable_default_providers_true_if_any", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Options: &Options{ + DisableDefaultProviders: false, + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + DisableDefaultProviders: true, + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.True(t, c.Options.DisableDefaultProviders) + }) + + t.Run("provider_config_merge_preserves_fields", func(t *testing.T) { + // Tests that merging a later provider config with empty fields + // does not overwrite earlier non-empty fields. + c := exerciseMerge(t, Config{ + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + APIKey: "key1", + BaseURL: "https://api.openai.com/v1", + }, + }), + }, Config{ + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + APIKey: "key2", + BaseURL: "https://api.openai.com/v2", + }, + }), + }, Config{ + // Later config with empty provider - should not clear fields. + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": {}, + }), + }) + + require.NotNil(t, c) + pc, ok := c.Providers.Get("openai") + require.True(t, ok) + require.Equal(t, "key2", pc.APIKey) + require.Equal(t, "https://api.openai.com/v2", pc.BaseURL) + }) + + t.Run("provider_config_disable_true_if_any", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + APIKey: "key1", + Disable: false, + }, + }), + }, Config{ + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + Disable: true, + }, + }), + }) + + require.NotNil(t, c) + pc, ok := c.Providers.Get("openai") + require.True(t, ok) + require.True(t, pc.Disable) + require.Equal(t, "key1", pc.APIKey) + }) + + t.Run("provider_config_extra_headers_merged", func(t *testing.T) { + c := exerciseMerge(t, Config{ + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + ExtraHeaders: map[string]string{"X-First": "value1"}, + }, + }), + }, Config{ + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + ExtraHeaders: map[string]string{"X-Second": "value2"}, + }, + }), + }) + + require.NotNil(t, c) + pc, ok := c.Providers.Get("openai") + require.True(t, ok) + require.Equal(t, "value1", pc.ExtraHeaders["X-First"]) + require.Equal(t, "value2", pc.ExtraHeaders["X-Second"]) + }) +} + +func exerciseMerge(tb testing.TB, confs ...Config) *Config { + tb.Helper() + data := make([][]byte, 0, len(confs)) + for _, c := range confs { + bts, err := json.Marshal(c) + require.NoError(tb, err) + data = append(data, bts) + } + result, err := loadFromBytes(data) + require.NoError(tb, err) + return result +}