diff --git a/internal/config/config.go b/internal/config/config.go index 02c0b468d..b913ecaec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,11 @@ package config import ( + "cmp" "context" "fmt" "log/slog" + "maps" "net/http" "net/url" "os" @@ -131,6 +133,28 @@ 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 { + // headers and env gets merged, new replacing existing values + maps.Copy(m.Env, o.Env) + maps.Copy(m.Headers, o.Headers) + + // bools are true if any is true + m.Disabled = o.Disabled || m.Disabled + + // max timeout + m.Timeout = max(o.Timeout, m.Timeout) + + // everything else is replaced if non-zero + 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:"required,description=Command to execute for the LSP server,example=gopls"` @@ -142,6 +166,43 @@ type LSPConfig struct { Options map[string]any `json:"options,omitempty" jsonschema:"description=LSP server-specific settings passed during initialization"` } +func (l LSPConfig) merge(o LSPConfig) LSPConfig { + // all maps gets merged, new replacing existing values + if l.Env == nil { + l.Env = make(map[string]string) + } + maps.Copy(l.Env, o.Env) + if l.InitOptions == nil { + l.InitOptions = make(map[string]any) + } + maps.Copy(l.InitOptions, o.InitOptions) + if l.Options == nil { + l.Options = make(map[string]any) + } + maps.Copy(l.Options, o.Options) + + // filetypes and rootmarkers get merged + l.RootMarkers = append(l.RootMarkers, o.RootMarkers...) + l.FileTypes = append(l.FileTypes, o.FileTypes...) + slices.Sort(l.RootMarkers) + slices.Sort(l.FileTypes) + l.RootMarkers = slices.Compact(l.RootMarkers) + l.FileTypes = slices.Compact(l.FileTypes) + + // disabled if any disabled + l.Disabled = l.Disabled || o.Disabled + + // args get replaced if non-empty + if len(o.Args) > 0 { + l.Args = o.Args + } + + // command takes precedence: + 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"` @@ -151,6 +212,14 @@ type TUIOptions struct { Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"` } +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.MaxDepth, o.Completions.MaxDepth) + 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"` @@ -184,6 +253,23 @@ type Options struct { DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` } +func (o Options) merge(t Options) Options { + o.ContextPaths = append(o.ContextPaths, t.ContextPaths...) + o.Debug = o.Debug || t.Debug + o.DebugLSP = o.DebugLSP || t.DebugLSP + o.DisableProviderAutoUpdate = o.DisableProviderAutoUpdate || t.DisableProviderAutoUpdate + o.DisableMetrics = o.DisableMetrics || t.DisableMetrics + o.DataDirectory = cmp.Or(t.DataDirectory, o.DataDirectory) + o.DisabledTools = append(o.DisabledTools, t.DisabledTools...) + *o.TUI = o.TUI.merge(*t.TUI) + if t.Attribution != nil { + o.Attribution = &Attribution{} + o.Attribution.CoAuthoredBy = o.Attribution.CoAuthoredBy || t.Attribution.CoAuthoredBy + o.Attribution.GeneratedWith = o.Attribution.GeneratedWith || t.Attribution.GeneratedWith + } + return o +} + type MCPs map[string]MCPConfig type MCP struct { @@ -274,6 +360,12 @@ type Tools struct { Ls ToolLs `json:"ls,omitzero"` } +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.MaxDepth, o.Ls.MaxDepth) + 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"` @@ -313,6 +405,42 @@ 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() { + c.Providers.Set(key, value) + } + } + c.Tools = c.Tools.merge(t.Tools) + + return c +} + func (c *Config) WorkingDir() string { return c.workingDir } diff --git a/internal/config/load.go b/internal/config/load.go index cc7e54393..87f8f7113 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -26,21 +26,6 @@ import ( const defaultCatwalkURL = "https://catwalk.charm.sh" -// LoadReader config via io.Reader. -func LoadReader(fd io.Reader) (*Config, error) { - data, err := io.ReadAll(fd) - if err != nil { - return nil, err - } - - var config Config - err = json.Unmarshal(data, &config) - if err != nil { - return nil, err - } - return &config, err -} - // Load loads the configuration from the default paths. func Load(workingDir, dataDir string, debug bool) (*Config, error) { configPaths := lookupConfigs(workingDir) @@ -612,12 +597,19 @@ func loadFromReaders(readers []io.Reader) (*Config, error) { return &Config{}, nil } - merged, err := Merge(readers) - if err != nil { - return nil, fmt.Errorf("failed to merge configuration readers: %w", err) + result := newConfig() + for _, r := range readers { + bts, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("could not read: %w", err) + } + config := newConfig() + if err := json.Unmarshal(bts, &config); err != nil { + return nil, err + } + *result = result.merge(*config) } - - return LoadReader(merged) + return result, nil } func hasVertexCredentials(env env.Env) bool { @@ -701,3 +693,17 @@ func isInsideWorktree() bool { ).CombinedOutput() return err == nil && strings.TrimSpace(string(bts)) == "true" } + +func newConfig() *Config { + return &Config{ + Agents: map[string]Agent{}, + MCP: map[string]MCPConfig{}, + LSP: map[string]LSPConfig{}, + Models: map[SelectedModelType]SelectedModel{}, + Options: &Options{ + TUI: &TUIOptions{}, + }, + Permissions: &Permissions{}, + Providers: csync.NewMap[string, ProviderConfig](), + } +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go index d6c783bb6..321fedede 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -5,7 +5,6 @@ import ( "log/slog" "os" "path/filepath" - "strings" "testing" "github.com/charmbracelet/catwalk/pkg/catwalk" @@ -22,21 +21,6 @@ func TestMain(m *testing.M) { os.Exit(exitVal) } -func TestConfig_LoadFromReaders(t *testing.T) { - data1 := strings.NewReader(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`) - data2 := strings.NewReader(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`) - data3 := strings.NewReader(`{"providers": {"openai": {}}}`) - - loadedConfig, err := loadFromReaders([]io.Reader{data1, data2, data3}) - - require.NoError(t, err) - require.NotNil(t, loadedConfig) - require.Equal(t, 1, loadedConfig.Providers.Len()) - pc, _ := loadedConfig.Providers.Get("openai") - require.Equal(t, "key2", pc.APIKey) - require.Equal(t, "https://api.openai.com/v2", pc.BaseURL) -} - func TestConfig_setDefaults(t *testing.T) { cfg := &Config{} diff --git a/internal/config/merge.go b/internal/config/merge.go deleted file mode 100644 index 3c9b7d628..000000000 --- a/internal/config/merge.go +++ /dev/null @@ -1,16 +0,0 @@ -package config - -import ( - "bytes" - "io" - - "github.com/qjebbs/go-jsons" -) - -func Merge(data []io.Reader) (io.Reader, error) { - got, err := jsons.Merge(data) - if err != nil { - return nil, err - } - return bytes.NewReader(got), nil -} diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go index 1b721bf2e..4c5d69aa5 100644 --- a/internal/config/merge_test.go +++ b/internal/config/merge_test.go @@ -1,27 +1,590 @@ package config import ( + "bytes" + "encoding/json" "io" - "strings" + "maps" + "slices" "testing" + + "github.com/stretchr/testify/require" ) -func TestMerge(t *testing.T) { - data1 := strings.NewReader(`{"foo": "bar"}`) - data2 := strings.NewReader(`{"baz": "qux"}`) +// 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) + }) - merged, err := Merge([]io.Reader{data1, data2}) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } + 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) + require.Equal(t, MCPConfig{ + Command: "foo-mcp", + Args: []string{"serve", "--stdio"}, + Type: MCPStdio, + Timeout: 10, + }, c.MCP["foo"]) + require.Equal(t, MCPConfig{ + Command: "bar", + }, c.MCP["bar"]) + require.Equal(t, MCPConfig{ + Disabled: true, + URL: "http://bar", + Env: map[string]string{"FOO": "foo", "BAR": "bar"}, + Headers: map[string]string{"api-key": "$API"}, + }, c.MCP["zaz"]) + }) - expected := `{"foo":"bar","baz":"qux"}` - got, err := io.ReadAll(merged) - if err != nil { - t.Fatalf("expected no error reading merged data, got %v", err) - } + 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{ + CoAuthoredBy: false, + 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{ + CoAuthoredBy: true, + 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.True(t, c.Options.Attribution.CoAuthoredBy) + 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{ + CoAuthoredBy: true, + GeneratedWith: true, + }, + TUI: &TUIOptions{}, + }, + }, Config{ + Options: &Options{ + TUI: &TUIOptions{}, + }, + }) + + require.NotNil(t, c) + require.True(t, c.Options.Attribution.CoAuthoredBy) + 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) + }) +} - if string(got) != expected { - t.Errorf("expected %s, got %s", expected, string(got)) +func exerciseMerge(tb testing.TB, confs ...Config) *Config { + tb.Helper() + readers := make([]io.Reader, 0, len(confs)) + for _, c := range confs { + bts, err := json.Marshal(c) + require.NoError(tb, err) + readers = append(readers, bytes.NewReader(bts)) } + result, err := loadFromReaders(readers) + require.NoError(tb, err) + return result }