Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 118 additions & 68 deletions theme/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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++ {
Expand All @@ -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)
}
Expand All @@ -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")
Expand Down
86 changes: 85 additions & 1 deletion theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bufio"
"cmp"
"embed"
"fmt"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
}
Loading