diff --git a/theme/load_test.go b/theme/load_test.go index c6fbe22f7..25ce030af 100644 --- a/theme/load_test.go +++ b/theme/load_test.go @@ -8,8 +8,7 @@ import ( ) func TestLoadAllIncludesBuiltinThemes(t *testing.T) { - // Point HOME somewhere empty so only embedded themes load. - t.Setenv("HOME", t.TempDir()) + t.Setenv("CLIAMP_CONFIG_DIR", filepath.Join(t.TempDir(), "empty")) themes := LoadAll() if len(themes) == 0 { @@ -33,7 +32,7 @@ func TestLoadAllIncludesBuiltinThemes(t *testing.T) { } func TestLoadAllSortedCaseInsensitive(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + t.Setenv("CLIAMP_CONFIG_DIR", filepath.Join(t.TempDir(), "empty")) themes := LoadAll() for i := 1; i < len(themes); i++ { @@ -45,74 +44,126 @@ func TestLoadAllSortedCaseInsensitive(t *testing.T) { } } -func TestLoadAllUserThemeOverridesBuiltin(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - // Put a user override file named "dracula.toml" with a distinctive accent color. - userDir := filepath.Join(home, ".config", "cliamp", "themes") - if err := os.MkdirAll(userDir, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - overridden := `accent = "#ff00ff" -fg = "#123456" -` - if err := os.WriteFile(filepath.Join(userDir, "dracula.toml"), []byte(overridden), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - themes := LoadAll() - var got Theme - for _, th := range themes { - if strings.EqualFold(th.Name, "dracula") { - got = th - break - } - } - if got.Name == "" { - t.Fatal("dracula theme not present after override") - } - if got.Accent != "#ff00ff" { - t.Errorf("Accent = %q, want #ff00ff (user override)", got.Accent) - } - if got.FG != "#123456" { - t.Errorf("FG = %q, want #123456 (user override)", got.FG) - } -} - -func TestLoadAllAddsUserOnlyTheme(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - userDir := filepath.Join(home, ".config", "cliamp", "themes") - if err := os.MkdirAll(userDir, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - custom := `accent = "#abcdef"` - if err := os.WriteFile(filepath.Join(userDir, "mytheme.toml"), []byte(custom), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - themes := LoadAll() - var found bool - for _, th := range themes { - if th.Name == "mytheme" { - found = true - if th.Accent != "#abcdef" { - t.Errorf("Accent = %q, want #abcdef", th.Accent) +func TestLoadAllUserThemeScenarios(t *testing.T) { + tests := []struct { + name string + fileName string + fileBody string + check func(t *testing.T, themes []Theme) + }{ + { + name: "partial merge onto builtin", + fileName: "dracula.toml", + fileBody: "accent = \"#ff00ff\"\nfg = \"#123456\"\n", + check: func(t *testing.T, themes []Theme) { + var got Theme + for _, th := range themes { + if strings.EqualFold(th.Name, "dracula") { + got = th + break + } + } + if got.Name == "" { + t.Fatal("dracula theme not present after merge") + } + if got.Accent != "#ff00ff" { + t.Errorf("Accent = %q, want #ff00ff", got.Accent) + } + if got.FG != "#123456" { + t.Errorf("FG = %q, want #123456", got.FG) + } + if got.BrightFG != "#f8f8f2" { + t.Errorf("built-in BrightFG should survive: got %q, want #f8f8f2", got.BrightFG) + } + }, + }, + { + name: "full standalone theme accepted", + fileName: "mytheme.toml", + fileBody: `accent = "#abcdef" +bright_fg = "#ffffff" +fg = "#cccccc" +green = "#00ff00" +yellow = "#ffff00" +red = "#ff0000"`, + check: func(t *testing.T, themes []Theme) { + var found bool + for _, th := range themes { + if th.Name == "mytheme" { + found = true + if th.Accent != "#abcdef" { + t.Errorf("Accent = %q, want #abcdef", th.Accent) + } + } + } + if !found { + t.Error("user theme mytheme not loaded") + } + }, + }, + { + name: "partial without builtin match skipped", + fileName: "broken.toml", + fileBody: "accent = \"#ff0000\"", + check: func(t *testing.T, themes []Theme) { + for _, th := range themes { + if th.Name == "broken" { + t.Fatal("partial theme without built-in match should not be loaded") + } + } + }, + }, + { + name: "invalid hex in merge ignored", + fileName: "dracula.toml", + fileBody: `accent = "#ff0000" +bright_fg = "#f8f8f2" +fg = "#6272a4" +green = "#50fa7b" +yellow = "#f1fa8c" +red = "not-a-color"`, + check: func(t *testing.T, themes []Theme) { + var got Theme + for _, th := range themes { + if strings.EqualFold(th.Name, "dracula") { + got = th + break + } + } + if got.Name == "" { + t.Fatal("dracula theme not found after merge") + } + if got.Accent != "#ff0000" { + t.Errorf("valid Accent should merge: got %q, want #ff0000", got.Accent) + } + if got.Red != "#ff5555" { + t.Errorf("invalid Red should be ignored (built-in): got %q, want #ff5555", got.Red) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configDir := filepath.Join(t.TempDir(), ".config", "cliamp") + t.Setenv("CLIAMP_CONFIG_DIR", configDir) + userDir := filepath.Join(configDir, "themes") + if err := os.MkdirAll(userDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) } - } - } - if !found { - t.Error("user theme mytheme not loaded") + if err := os.WriteFile(filepath.Join(userDir, tt.fileName), []byte(tt.fileBody), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + tt.check(t, LoadAll()) + }) } } func TestLoadAllIgnoresNonTomlFiles(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - userDir := filepath.Join(home, ".config", "cliamp", "themes") + configDir := filepath.Join(t.TempDir(), ".config", "cliamp") + t.Setenv("CLIAMP_CONFIG_DIR", configDir) + userDir := filepath.Join(configDir, "themes") if err := os.MkdirAll(userDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } @@ -133,8 +184,7 @@ func TestLoadAllIgnoresNonTomlFiles(t *testing.T) { } func TestLoadAllMissingUserDir(t *testing.T) { - // HOME points at a dir where ~/.config/cliamp/themes doesn't exist. - t.Setenv("HOME", t.TempDir()) + t.Setenv("CLIAMP_CONFIG_DIR", filepath.Join(t.TempDir(), "empty")) themes := LoadAll() if len(themes) == 0 { t.Error("LoadAll() with missing user dir should still return built-in themes") diff --git a/theme/theme.go b/theme/theme.go index 20fd9c19b..3c5dd3c3c 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -5,6 +5,7 @@ import ( "bufio" "cmp" "embed" + "fmt" "io" "os" "path/filepath" @@ -36,6 +37,71 @@ func (t Theme) IsDefault() bool { return t.Accent == "" && t.Green == "" && t.BrightFG == "" } +// validHex reports whether s is a valid hex color like "#fff" or "#aabbcc". +func validHex(s string) bool { + if len(s) < 4 || s[0] != '#' { + return false + } + for _, r := range s[1:] { + if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') && !(r >= 'A' && r <= 'F') { + return false + } + } + n := len(s) - 1 + return n == 3 || n == 6 || n == 8 +} + +// Validate checks that all six color fields are non-empty hex values. +func (t Theme) Validate() error { + missing := make([]string, 0, 6) + if !validHex(t.Accent) { + missing = append(missing, "accent") + } + if !validHex(t.BrightFG) { + missing = append(missing, "bright_fg") + } + if !validHex(t.FG) { + missing = append(missing, "fg") + } + if !validHex(t.Green) { + missing = append(missing, "green") + } + if !validHex(t.Yellow) { + missing = append(missing, "yellow") + } + if !validHex(t.Red) { + missing = append(missing, "red") + } + if len(missing) > 0 { + return fmt.Errorf("missing or invalid hex fields: %s", strings.Join(missing, ", ")) + } + return nil +} + +// merge applies every non-empty valid-hex field from src onto dst. +// Fields with invalid hex values are silently ignored so that a corrupt +// or partial user file only changes the colours it explicitly sets. +func merge(dst *Theme, src Theme) { + if validHex(src.Accent) { + dst.Accent = src.Accent + } + if validHex(src.BrightFG) { + dst.BrightFG = src.BrightFG + } + if validHex(src.FG) { + dst.FG = src.FG + } + if validHex(src.Green) { + dst.Green = src.Green + } + if validHex(src.Yellow) { + dst.Yellow = src.Yellow + } + if validHex(src.Red) { + dst.Red = src.Red + } +} + // Default returns a sentinel "Default" theme with empty hex values, // signaling that ANSI fallback colors should be used. func Default() Theme { @@ -129,6 +195,12 @@ func loadBuiltin(themes map[string]Theme) { } // loadUserDir loads themes from ~/.config/cliamp/themes/*.toml. +// +// If a user file matches a built-in theme name its fields are merged onto +// the built-in, so that a partial override (e.g. only "accent = ...") +// changes only that colour. Themes without a built-in match require all +// six hex fields to pass validation. Invalid hex values in either mode are +// silently ignored — the corresponding built-in (or zero) value survives. func loadUserDir(dir string, themes map[string]Theme) { entries, err := os.ReadDir(dir) if err != nil { @@ -149,6 +221,18 @@ func loadUserDir(dir string, themes map[string]Theme) { if err != nil { continue } - themes[strings.ToLower(name)] = t + key := strings.ToLower(name) + if _, exists := themes[key]; exists { + // Merge onto the existing (built-in) theme. + existing := themes[key] + merge(&existing, t) + themes[key] = existing + } else { + // New theme (no built-in match): all six fields required. + if err := t.Validate(); err != nil { + continue + } + themes[key] = t + } } } diff --git a/theme/theme_test.go b/theme/theme_test.go index 18927b725..64a1ed287 100644 --- a/theme/theme_test.go +++ b/theme/theme_test.go @@ -123,3 +123,136 @@ func TestParsedThemeNotDefault(t *testing.T) { t.Error("theme with accent should not be IsDefault()") } } + +func TestValidHex(t *testing.T) { + tests := []struct { + s string + want bool + }{ + {"#fff", true}, + {"#FFf", true}, + {"#aabbcc", true}, + {"#AABBCC", true}, + {"#aabbccdd", true}, + {"", false}, + {"aabbcc", false}, + {"#xyz", false}, + {"#12", false}, + {"#1234567", false}, + {"#gggggg", false}, + } + for _, tt := range tests { + got := validHex(tt.s) + if got != tt.want { + t.Errorf("validHex(%q) = %v, want %v", tt.s, got, tt.want) + } + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + theme Theme + wantErr bool + errMsg string + }{ + { + name: "all valid fields", + theme: Theme{"test", "#bd93f9", "#f8f8f2", "#6272a4", "#50fa7b", "#f1fa8c", "#ff5555"}, + wantErr: false, + }, + { + name: "partial fields", + theme: Theme{"partial", "#ff0000", "", "", "", "", ""}, + wantErr: true, + }, + { + name: "all empty", + theme: Theme{"empty", "", "", "", "", "", ""}, + wantErr: true, + }, + { + name: "invalid hex one field", + theme: Theme{"bad", "#ff0000", "#f8f8f2", "#6272a4", "#50fa7b", "#f1fa8c", "not-a-color"}, + wantErr: true, + }, + { + name: "error contains field names", + theme: Theme{"broken", "#ff0000", "", "", "", "", ""}, + wantErr: true, + errMsg: "bright_fg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.theme.Validate() + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.errMsg != "" && err != nil { + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("error %q should contain %q", err.Error(), tt.errMsg) + } + } + }) + } +} + +func TestMerge(t *testing.T) { + builtin := Theme{"dracula", "#bd93f9", "#f8f8f2", "#6272a4", "#50fa7b", "#f1fa8c", "#ff5555"} + + tests := []struct { + name string + src Theme + check func(t *testing.T, dst Theme) + }{ + { + name: "partial override", + src: Theme{"dracula", "#ff00ff", "", "#123456", "", "", ""}, + check: func(t *testing.T, dst Theme) { + if dst.Accent != "#ff00ff" { + t.Errorf("Accent = %q, want #ff00ff", dst.Accent) + } + if dst.FG != "#123456" { + t.Errorf("FG = %q, want #123456", dst.FG) + } + if dst.BrightFG != "#f8f8f2" { + t.Errorf("BrightFG should survive: got %q", dst.BrightFG) + } + if dst.Green != "#50fa7b" { + t.Errorf("Green should survive: got %q", dst.Green) + } + if dst.Yellow != "#f1fa8c" { + t.Errorf("Yellow should survive: got %q", dst.Yellow) + } + if dst.Red != "#ff5555" { + t.Errorf("Red should survive: got %q", dst.Red) + } + }, + }, + { + name: "ignores invalid hex", + src: Theme{"dracula", "#aa0000", "", "", "", "", "not-a-color"}, + check: func(t *testing.T, dst Theme) { + if dst.Accent != "#aa0000" { + t.Errorf("valid Accent should merge: got %q", dst.Accent) + } + if dst.Red != "#ff5555" { + t.Errorf("invalid Red should be ignored: got %q, want #ff5555", dst.Red) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dst := builtin + merge(&dst, tt.src) + tt.check(t, dst) + }) + } +}