diff --git a/cmd/ghostty-switch/main_test.go b/cmd/ghostty-switch/main_test.go index 817dc22..a41ccc0 100644 --- a/cmd/ghostty-switch/main_test.go +++ b/cmd/ghostty-switch/main_test.go @@ -179,6 +179,32 @@ func TestCLIProfileApply(t *testing.T) { } } +func TestCLIProfileApplyClearsListValuedKeys(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + overridePath := filepath.Join(dir, "overrides") + configPath := writeTestConfig(t, dir, overridePath) + + cmd := exec.Command(bin, "--config", configPath, "profile", "dark") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("profile dark: %v\n%s", err, out) + } + + data, err := os.ReadFile(overridePath) + if err != nil { + t.Fatal(err) + } + + // The dark profile sets font-family, which Ghostty treats as list-valued. + // The override file must emit a clearing line before the value line so the + // override replaces — rather than appends to — the main config's font-family. + wantPair := "font-family =\nfont-family = JetBrains Mono\n" + if !strings.Contains(string(data), wantPair) { + t.Errorf("override file missing clearing+value pair %q, got:\n%s", wantPair, data) + } +} + func TestCLIConfigFlagWithoutValue(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "--config") diff --git a/internal/overrides/overrides.go b/internal/overrides/overrides.go index 9411caa..cc69eb3 100644 --- a/internal/overrides/overrides.go +++ b/internal/overrides/overrides.go @@ -12,6 +12,19 @@ import ( const header = "# Managed by ghostty-switch — do not edit manually" +// listValuedKeys are Ghostty config directives that accumulate across +// occurrences (each line appends to a list) rather than scalar last-write-wins. +// Each is preceded in the override file by a clearing line ("key =") so the +// override replaces — rather than appends to — values from the main config +// that loaded earlier. See issue #20. +var listValuedKeys = map[string]struct{}{ + "font-family": {}, + "font-family-bold": {}, + "font-family-italic": {}, + "font-family-bold-italic": {}, + "keybind": {}, +} + // Read parses the override file into a key-value map. Returns an empty map // if the file does not exist. func Read(path string) (map[string]string, error) { @@ -59,6 +72,9 @@ func Write(path string, settings map[string]string) error { keys := sortedKeys(settings) for _, k := range keys { + if _, isList := listValuedKeys[k]; isList { + fmt.Fprintf(&b, "%s =\n", k) + } fmt.Fprintf(&b, "%s = %s\n", k, settings[k]) } diff --git a/internal/overrides/overrides_test.go b/internal/overrides/overrides_test.go index fed294c..616c59e 100644 --- a/internal/overrides/overrides_test.go +++ b/internal/overrides/overrides_test.go @@ -119,6 +119,96 @@ func TestReadSkipsCommentsAndBlanks(t *testing.T) { } } +func TestWriteEmitsClearingLineForListValuedKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overrides") + + settings := map[string]string{ + "font-family": "MartianMono Nerd Font", + "font-size": "14", + "theme": "Nord", + } + + if err := Write(path, settings); err != nil { + t.Fatalf("Write: %v", err) + } + + data, _ := os.ReadFile(path) + content := string(data) + + // Clearing line must precede the value line, consecutive, for list-valued keys. + wantPair := "font-family =\nfont-family = MartianMono Nerd Font\n" + if !strings.Contains(content, wantPair) { + t.Errorf("expected output to contain clearing+value pair %q, got:\n%s", wantPair, content) + } + + // Scalar keys must not get a clearing line. + for _, scalar := range []string{"font-size", "theme"} { + bare := scalar + " =\n" + if strings.Contains(content, bare) { + t.Errorf("scalar key %q must not have a clearing line; got:\n%s", scalar, content) + } + } +} + +func TestWriteEmitsClearingLineForEachListValuedKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overrides") + + settings := map[string]string{ + "font-family": "Mono", + "font-family-bold": "Mono Bold", + "font-family-italic": "Mono Italic", + "font-family-bold-italic": "Mono Bold Italic", + "keybind": "ctrl+a=copy_to_clipboard", + } + + if err := Write(path, settings); err != nil { + t.Fatalf("Write: %v", err) + } + + content := func() string { + data, _ := os.ReadFile(path) + return string(data) + }() + + for k, v := range settings { + want := k + " =\n" + k + " = " + v + "\n" + if !strings.Contains(content, want) { + t.Errorf("expected clearing+value pair for %q (%q), got:\n%s", k, want, content) + } + } +} + +func TestReadWriteRoundTripPreservesListValuedKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overrides") + + original := map[string]string{ + "font-family": "MartianMono Nerd Font", + "keybind": "ctrl+a=copy_to_clipboard", + "theme": "Nord", + } + + if err := Write(path, original); err != nil { + t.Fatalf("Write: %v", err) + } + + got, err := Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + + if len(got) != len(original) { + t.Errorf("expected %d entries, got %d: %v", len(original), len(got), got) + } + for k, v := range original { + if got[k] != v { + t.Errorf("key %q = %q, want %q", k, got[k], v) + } + } +} + func TestMerge(t *testing.T) { existing := map[string]string{ "theme": "Nord",