merge_test.go

  1package config
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"io"
  7	"maps"
  8	"slices"
  9	"testing"
 10
 11	"github.com/stretchr/testify/require"
 12)
 13
 14// TestConfigMerging defines the rules on how configuration merging works.
 15// Generally, things are either appended to or replaced by the later configuration.
 16// Whether one or the other happen depends on effects its effects.
 17func TestConfigMerging(t *testing.T) {
 18	t.Run("empty", func(t *testing.T) {
 19		c := exerciseMerge(t, Config{}, Config{})
 20		require.NotNil(t, c)
 21	})
 22
 23	t.Run("mcps", func(t *testing.T) {
 24		c := exerciseMerge(t, Config{
 25			MCP: MCPs{
 26				"foo": {
 27					Command: "foo-mcp",
 28					Args:    []string{"serve"},
 29					Type:    MCPSSE,
 30					Timeout: 10,
 31				},
 32				"zaz": {
 33					Disabled: true,
 34					Env:      map[string]string{"FOO": "bar"},
 35					Headers:  map[string]string{"api-key": "exposed"},
 36					URL:      "nope",
 37				},
 38			},
 39		}, Config{
 40			MCP: MCPs{
 41				"foo": {
 42					Args:    []string{"serve", "--stdio"},
 43					Type:    MCPStdio,
 44					Timeout: 7,
 45				},
 46				"bar": {
 47					Command: "bar",
 48				},
 49				"zaz": {
 50					Env:     map[string]string{"FOO": "foo", "BAR": "bar"},
 51					Headers: map[string]string{"api-key": "$API"},
 52					URL:     "http://bar",
 53				},
 54			},
 55		})
 56		require.NotNil(t, c)
 57		require.Len(t, slices.Collect(maps.Keys(c.MCP)), 3)
 58		require.Equal(t, MCPConfig{
 59			Command: "foo-mcp",
 60			Args:    []string{"serve", "--stdio"},
 61			Type:    MCPStdio,
 62			Timeout: 10,
 63		}, c.MCP["foo"])
 64		require.Equal(t, MCPConfig{
 65			Command: "bar",
 66		}, c.MCP["bar"])
 67		require.Equal(t, MCPConfig{
 68			Disabled: true,
 69			URL:      "http://bar",
 70			Env:      map[string]string{"FOO": "foo", "BAR": "bar"},
 71			Headers:  map[string]string{"api-key": "$API"},
 72		}, c.MCP["zaz"])
 73	})
 74
 75	t.Run("lsps", func(t *testing.T) {
 76		result := exerciseMerge(t, Config{
 77			LSP: LSPs{
 78				"gopls": LSPConfig{
 79					Env:         map[string]string{"FOO": "bar"},
 80					RootMarkers: []string{"go.sum"},
 81					FileTypes:   []string{"go"},
 82				},
 83			},
 84		}, Config{
 85			LSP: LSPs{
 86				"gopls": LSPConfig{
 87					Command:     "gopls",
 88					InitOptions: map[string]any{"a": 10},
 89					RootMarkers: []string{"go.sum"},
 90				},
 91			},
 92		}, Config{
 93			LSP: LSPs{
 94				"gopls": LSPConfig{
 95					Args:        []string{"serve", "--stdio"},
 96					InitOptions: map[string]any{"a": 12, "b": 18},
 97					RootMarkers: []string{"go.sum", "go.mod"},
 98					FileTypes:   []string{"go"},
 99					Disabled:    true,
100				},
101			},
102		},
103			Config{
104				LSP: LSPs{
105					"gopls": LSPConfig{
106						Options:     map[string]any{"opt1": "10"},
107						RootMarkers: []string{"go.work"},
108					},
109				},
110			},
111		)
112		require.NotNil(t, result)
113		require.Equal(t, LSPConfig{
114			Disabled:    true,
115			Command:     "gopls",
116			Args:        []string{"serve", "--stdio"},
117			Env:         map[string]string{"FOO": "bar"},
118			FileTypes:   []string{"go"},
119			RootMarkers: []string{"go.mod", "go.sum", "go.work"},
120			InitOptions: map[string]any{"a": 12.0, "b": 18.0},
121			Options:     map[string]any{"opt1": "10"},
122		}, result.LSP["gopls"])
123	})
124
125	t.Run("tui_options", func(t *testing.T) {
126		maxDepth := 5
127		maxItems := 100
128		newMaxDepth := 10
129		newMaxItems := 200
130
131		c := exerciseMerge(t, Config{
132			Options: &Options{
133				TUI: &TUIOptions{
134					CompactMode: false,
135					DiffMode:    "unified",
136					Completions: Completions{
137						MaxDepth: &maxDepth,
138						MaxItems: &maxItems,
139					},
140				},
141			},
142		}, Config{
143			Options: &Options{
144				TUI: &TUIOptions{
145					CompactMode: true,
146					DiffMode:    "split",
147					Completions: Completions{
148						MaxDepth: &newMaxDepth,
149						MaxItems: &newMaxItems,
150					},
151				},
152			},
153		})
154
155		require.NotNil(t, c)
156		require.True(t, c.Options.TUI.CompactMode)
157		require.Equal(t, "split", c.Options.TUI.DiffMode)
158		require.Equal(t, newMaxDepth, *c.Options.TUI.Completions.MaxDepth)
159	})
160
161	t.Run("options", func(t *testing.T) {
162		c := exerciseMerge(t, Config{
163			Options: &Options{
164				ContextPaths:              []string{"CRUSH.md"},
165				Debug:                     false,
166				DebugLSP:                  false,
167				DisableProviderAutoUpdate: false,
168				DisableMetrics:            false,
169				DataDirectory:             ".crush",
170				DisabledTools:             []string{"bash"},
171				Attribution: &Attribution{
172					CoAuthoredBy:  false,
173					GeneratedWith: false,
174				},
175				TUI: &TUIOptions{},
176			},
177		}, Config{
178			Options: &Options{
179				ContextPaths:              []string{".cursorrules"},
180				Debug:                     true,
181				DebugLSP:                  true,
182				DisableProviderAutoUpdate: true,
183				DisableMetrics:            true,
184				DataDirectory:             ".custom",
185				DisabledTools:             []string{"edit"},
186				Attribution: &Attribution{
187					CoAuthoredBy:  true,
188					GeneratedWith: true,
189				},
190				TUI: &TUIOptions{},
191			},
192		})
193
194		require.NotNil(t, c)
195		require.Equal(t, []string{"CRUSH.md", ".cursorrules"}, c.Options.ContextPaths)
196		require.True(t, c.Options.Debug)
197		require.True(t, c.Options.DebugLSP)
198		require.True(t, c.Options.DisableProviderAutoUpdate)
199		require.True(t, c.Options.DisableMetrics)
200		require.Equal(t, ".custom", c.Options.DataDirectory)
201		require.Equal(t, []string{"bash", "edit"}, c.Options.DisabledTools)
202		require.True(t, c.Options.Attribution.CoAuthoredBy)
203		require.True(t, c.Options.Attribution.GeneratedWith)
204	})
205
206	t.Run("tools", func(t *testing.T) {
207		maxDepth := 5
208		maxItems := 100
209		newMaxDepth := 10
210		newMaxItems := 200
211
212		c := exerciseMerge(t, Config{
213			Tools: Tools{
214				Ls: ToolLs{
215					MaxDepth: &maxDepth,
216					MaxItems: &maxItems,
217				},
218			},
219		}, Config{
220			Tools: Tools{
221				Ls: ToolLs{
222					MaxDepth: &newMaxDepth,
223					MaxItems: &newMaxItems,
224				},
225			},
226		})
227
228		require.NotNil(t, c)
229		require.Equal(t, newMaxDepth, *c.Tools.Ls.MaxDepth)
230	})
231
232	t.Run("models", func(t *testing.T) {
233		c := exerciseMerge(t, Config{
234			Models: map[SelectedModelType]SelectedModel{
235				"large": {
236					Model:    "gpt-4",
237					Provider: "openai",
238				},
239			},
240		}, Config{
241			Models: map[SelectedModelType]SelectedModel{
242				"large": {
243					Model:    "gpt-4o",
244					Provider: "openai",
245				},
246				"small": {
247					Model:    "gpt-3.5-turbo",
248					Provider: "openai",
249				},
250			},
251		})
252
253		require.NotNil(t, c)
254		require.Len(t, c.Models, 2)
255		require.Equal(t, "gpt-4o", c.Models["large"].Model)
256		require.Equal(t, "gpt-3.5-turbo", c.Models["small"].Model)
257	})
258
259	t.Run("schema", func(t *testing.T) {
260		c := exerciseMerge(t, Config{
261			Schema: "https://example.com/schema.json",
262		}, Config{
263			Schema: "https://example.com/new-schema.json",
264		})
265
266		require.NotNil(t, c)
267		require.Equal(t, "https://example.com/schema.json", c.Schema)
268	})
269
270	t.Run("schema_empty_first", func(t *testing.T) {
271		c := exerciseMerge(t, Config{}, Config{
272			Schema: "https://example.com/schema.json",
273		})
274
275		require.NotNil(t, c)
276		require.Equal(t, "https://example.com/schema.json", c.Schema)
277	})
278
279	t.Run("permissions", func(t *testing.T) {
280		c := exerciseMerge(t, Config{
281			Permissions: &Permissions{
282				AllowedTools: []string{"bash", "view"},
283			},
284		}, Config{
285			Permissions: &Permissions{
286				AllowedTools: []string{"edit", "write"},
287			},
288		})
289
290		require.NotNil(t, c)
291		require.Equal(t, []string{"bash", "view", "edit", "write"}, c.Permissions.AllowedTools)
292	})
293
294	t.Run("mcp_timeout_max", func(t *testing.T) {
295		c := exerciseMerge(t, Config{
296			MCP: MCPs{
297				"test": {
298					Timeout: 10,
299				},
300			},
301		}, Config{
302			MCP: MCPs{
303				"test": {
304					Timeout: 5,
305				},
306			},
307		})
308
309		require.NotNil(t, c)
310		require.Equal(t, 10, c.MCP["test"].Timeout)
311	})
312
313	t.Run("mcp_disabled_true_if_any", func(t *testing.T) {
314		c := exerciseMerge(t, Config{
315			MCP: MCPs{
316				"test": {
317					Disabled: false,
318				},
319			},
320		}, Config{
321			MCP: MCPs{
322				"test": {
323					Disabled: true,
324				},
325			},
326		})
327
328		require.NotNil(t, c)
329		require.True(t, c.MCP["test"].Disabled)
330	})
331
332	t.Run("lsp_disabled_true_if_any", func(t *testing.T) {
333		c := exerciseMerge(t, Config{
334			LSP: LSPs{
335				"test": {
336					Disabled: false,
337				},
338			},
339		}, Config{
340			LSP: LSPs{
341				"test": {
342					Disabled: true,
343				},
344			},
345		})
346
347		require.NotNil(t, c)
348		require.True(t, c.LSP["test"].Disabled)
349	})
350
351	t.Run("lsp_args_replaced", func(t *testing.T) {
352		c := exerciseMerge(t, Config{
353			LSP: LSPs{
354				"test": {
355					Args: []string{"old", "args"},
356				},
357			},
358		}, Config{
359			LSP: LSPs{
360				"test": {
361					Args: []string{"new", "args"},
362				},
363			},
364		})
365
366		require.NotNil(t, c)
367		require.Equal(t, []string{"new", "args"}, c.LSP["test"].Args)
368	})
369
370	t.Run("lsp_filetypes_merged_and_deduplicated", func(t *testing.T) {
371		c := exerciseMerge(t, Config{
372			LSP: LSPs{
373				"test": {
374					FileTypes: []string{"go", "mod"},
375				},
376			},
377		}, Config{
378			LSP: LSPs{
379				"test": {
380					FileTypes: []string{"go", "sum"},
381				},
382			},
383		})
384
385		require.NotNil(t, c)
386		require.Equal(t, []string{"go", "mod", "sum"}, c.LSP["test"].FileTypes)
387	})
388
389	t.Run("lsp_rootmarkers_merged_and_deduplicated", func(t *testing.T) {
390		c := exerciseMerge(t, Config{
391			LSP: LSPs{
392				"test": {
393					RootMarkers: []string{"go.mod", "go.sum"},
394				},
395			},
396		}, Config{
397			LSP: LSPs{
398				"test": {
399					RootMarkers: []string{"go.sum", "go.work"},
400				},
401			},
402		})
403
404		require.NotNil(t, c)
405		require.Equal(t, []string{"go.mod", "go.sum", "go.work"}, c.LSP["test"].RootMarkers)
406	})
407
408	t.Run("options_attribution_nil", func(t *testing.T) {
409		c := exerciseMerge(t, Config{
410			Options: &Options{
411				Attribution: &Attribution{
412					CoAuthoredBy:  true,
413					GeneratedWith: true,
414				},
415				TUI: &TUIOptions{},
416			},
417		}, Config{
418			Options: &Options{
419				TUI: &TUIOptions{},
420			},
421		})
422
423		require.NotNil(t, c)
424		require.True(t, c.Options.Attribution.CoAuthoredBy)
425		require.True(t, c.Options.Attribution.GeneratedWith)
426	})
427
428	t.Run("tui_compact_mode_true_if_any", func(t *testing.T) {
429		c := exerciseMerge(t, Config{
430			Options: &Options{
431				TUI: &TUIOptions{
432					CompactMode: false,
433				},
434			},
435		}, Config{
436			Options: &Options{
437				TUI: &TUIOptions{
438					CompactMode: true,
439				},
440			},
441		})
442
443		require.NotNil(t, c)
444		require.True(t, c.Options.TUI.CompactMode)
445	})
446
447	t.Run("tui_diff_mode_replaced", func(t *testing.T) {
448		c := exerciseMerge(t, Config{
449			Options: &Options{
450				TUI: &TUIOptions{
451					DiffMode: "unified",
452				},
453			},
454		}, Config{
455			Options: &Options{
456				TUI: &TUIOptions{
457					DiffMode: "split",
458				},
459			},
460		})
461
462		require.NotNil(t, c)
463		require.Equal(t, "split", c.Options.TUI.DiffMode)
464	})
465
466	t.Run("options_data_directory_replaced", func(t *testing.T) {
467		c := exerciseMerge(t, Config{
468			Options: &Options{
469				DataDirectory: ".crush",
470				TUI:           &TUIOptions{},
471			},
472		}, Config{
473			Options: &Options{
474				DataDirectory: ".custom",
475				TUI:           &TUIOptions{},
476			},
477		})
478
479		require.NotNil(t, c)
480		require.Equal(t, ".custom", c.Options.DataDirectory)
481	})
482
483	t.Run("mcp_args_replaced", func(t *testing.T) {
484		c := exerciseMerge(t, Config{
485			MCP: MCPs{
486				"test": {
487					Args: []string{"old"},
488				},
489			},
490		}, Config{
491			MCP: MCPs{
492				"test": {
493					Args: []string{"new"},
494				},
495			},
496		})
497
498		require.NotNil(t, c)
499		require.Equal(t, []string{"new"}, c.MCP["test"].Args)
500	})
501
502	t.Run("mcp_command_replaced", func(t *testing.T) {
503		c := exerciseMerge(t, Config{
504			MCP: MCPs{
505				"test": {
506					Command: "old-command",
507				},
508			},
509		}, Config{
510			MCP: MCPs{
511				"test": {
512					Command: "new-command",
513				},
514			},
515		})
516
517		require.NotNil(t, c)
518		require.Equal(t, "new-command", c.MCP["test"].Command)
519	})
520
521	t.Run("mcp_type_replaced", func(t *testing.T) {
522		c := exerciseMerge(t, Config{
523			MCP: MCPs{
524				"test": {
525					Type: MCPSSE,
526				},
527			},
528		}, Config{
529			MCP: MCPs{
530				"test": {
531					Type: MCPStdio,
532				},
533			},
534		})
535
536		require.NotNil(t, c)
537		require.Equal(t, MCPStdio, c.MCP["test"].Type)
538	})
539
540	t.Run("mcp_url_replaced", func(t *testing.T) {
541		c := exerciseMerge(t, Config{
542			MCP: MCPs{
543				"test": {
544					URL: "http://old",
545				},
546			},
547		}, Config{
548			MCP: MCPs{
549				"test": {
550					URL: "http://new",
551				},
552			},
553		})
554
555		require.NotNil(t, c)
556		require.Equal(t, "http://new", c.MCP["test"].URL)
557	})
558
559	t.Run("lsp_command_replaced", func(t *testing.T) {
560		c := exerciseMerge(t, Config{
561			LSP: LSPs{
562				"test": {
563					Command: "old-command",
564				},
565			},
566		}, Config{
567			LSP: LSPs{
568				"test": {
569					Command: "new-command",
570				},
571			},
572		})
573
574		require.NotNil(t, c)
575		require.Equal(t, "new-command", c.LSP["test"].Command)
576	})
577}
578
579func exerciseMerge(tb testing.TB, confs ...Config) *Config {
580	tb.Helper()
581	readers := make([]io.Reader, 0, len(confs))
582	for _, c := range confs {
583		bts, err := json.Marshal(c)
584		require.NoError(tb, err)
585		readers = append(readers, bytes.NewReader(bts))
586	}
587	result, err := loadFromReaders(readers)
588	require.NoError(tb, err)
589	return result
590}