diff --git a/action/config/generate.go b/action/config/generate.go index a61c662f..9bfdfeb0 100644 --- a/action/config/generate.go +++ b/action/config/generate.go @@ -75,13 +75,22 @@ func genBytes(c *Config) ([]byte, error) { URL: c.GitHub.URL, }, }, - Org: c.Org, - Repo: c.Repo, - Output: c.Output, - Color: &c.Color.Enabled, - ColorFormat: c.Color.Format, - ColorTheme: c.Color.Theme, - NoGit: c.NoGit, + Org: c.Org, + Repo: c.Repo, + Output: c.Output, + Color: &c.Color.Enabled, + NoGit: c.NoGit, + } + + // only save if theme was user specified; this prevents saving default "monokai" + // during login, which would bypass auto-detection for light terminal backgrounds. + if c.Color.UserSpecified { + config.ColorTheme = c.Color.Theme + } + + // only save if not default + if c.Color.Format != "" && c.Color.Format != "terminal256" { + config.ColorFormat = c.Color.Format } out, err := yaml.Marshal(config) diff --git a/action/config/generate_test.go b/action/config/generate_test.go index 0ae59406..1b26d52a 100644 --- a/action/config/generate_test.go +++ b/action/config/generate_test.go @@ -6,6 +6,20 @@ import ( "testing" "github.com/spf13/afero" + yaml "go.yaml.in/yaml/v3" + + "github.com/go-vela/cli/internal/output" +) + +// Test constants for color configuration. +const ( + testThemeMonokai = "monokai" + testThemeMonokaiLight = "monokailight" + testThemeDracula = "dracula" + testThemeSolarizedDark = "solarized-dark" + testFormatTerminal256 = "terminal256" + testFormatTerminal16 = "terminal16" + testFormatTerminal16m = "terminal16m" ) func TestConfig_Config_Generate(t *testing.T) { @@ -44,3 +58,306 @@ func TestConfig_Config_Generate(t *testing.T) { } } } + +func TestConfig_Config_Generate_ColorTheme(t *testing.T) { + // setup tests + tests := []struct { + name string + config *Config + expectTheme bool + expectThemeValue string + expectFormat bool + expectFormatValue string + }{ + { + name: "default theme not user specified - should not save theme", + config: &Config{ + Action: "generate", + File: ".vela.yml", + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: false, + }, + }, + expectTheme: false, + expectFormat: false, + }, + { + name: "custom theme user specified - should save theme", + config: &Config{ + Action: "generate", + File: ".vela.yml", + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeDracula, + ThemeLight: testThemeMonokaiLight, + UserSpecified: true, + }, + }, + expectTheme: true, + expectThemeValue: testThemeDracula, + expectFormat: false, + }, + { + name: "default theme user specified - should save theme", + config: &Config{ + Action: "generate", + File: ".vela.yml", + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: true, + }, + }, + expectTheme: true, + expectThemeValue: testThemeMonokai, + expectFormat: false, + }, + { + name: "custom format non-default - should save format", + config: &Config{ + Action: "generate", + File: ".vela.yml", + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Format: testFormatTerminal16, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: false, + }, + }, + expectTheme: false, + expectFormat: true, + expectFormatValue: testFormatTerminal16, + }, + { + name: "custom format and theme both specified - should save both", + config: &Config{ + Action: "generate", + File: ".vela.yml", + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Format: testFormatTerminal16m, + Theme: testThemeSolarizedDark, + ThemeLight: testThemeMonokaiLight, + UserSpecified: true, + }, + }, + expectTheme: true, + expectThemeValue: testThemeSolarizedDark, + expectFormat: true, + expectFormatValue: testFormatTerminal16m, + }, + { + name: "empty format - should not save format", + config: &Config{ + Action: "generate", + File: ".vela.yml", + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Format: "", + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: false, + }, + }, + expectTheme: false, + expectFormat: false, + }, + { + name: "default format terminal256 - should not save format", + config: &Config{ + Action: "generate", + File: ".vela.yml", + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: false, + }, + }, + expectTheme: false, + expectFormat: false, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // setup filesystem + appFS = afero.NewMemMapFs() + + err := test.config.Generate() + if err != nil { + t.Errorf("Generate returned err: %v", err) + return + } + + // read the generated file + a := &afero.Afero{Fs: appFS} + + content, err := a.ReadFile(test.config.File) + if err != nil { + t.Errorf("Failed to read generated file: %v", err) + return + } + + // parse the YAML + var configFile ConfigFile + + err = yaml.Unmarshal(content, &configFile) + if err != nil { + t.Errorf("Failed to unmarshal YAML: %v", err) + return + } + + // check theme + if test.expectTheme { + if configFile.ColorTheme == "" { + t.Errorf("Expected color_theme to be saved, but it was not") + } + + if configFile.ColorTheme != test.expectThemeValue { + t.Errorf("ColorTheme = %v, want %v", configFile.ColorTheme, test.expectThemeValue) + } + } else { + if configFile.ColorTheme != "" { + t.Errorf("Expected color_theme to not be saved, but got: %v", configFile.ColorTheme) + } + } + + // check format + if test.expectFormat { + if configFile.ColorFormat == "" { + t.Errorf("Expected color_format to be saved, but it was not") + } + + if configFile.ColorFormat != test.expectFormatValue { + t.Errorf("ColorFormat = %v, want %v", configFile.ColorFormat, test.expectFormatValue) + } + } else { + if configFile.ColorFormat != "" { + t.Errorf("Expected color_format to not be saved, but got: %v", configFile.ColorFormat) + } + } + }) + } +} + +func TestConfig_genBytes_ColorTheme(t *testing.T) { + // setup tests + tests := []struct { + name string + config *Config + wantTheme string + wantFormat string + wantNoTheme bool + wantNoFormat bool + }{ + { + name: "user specified theme", + config: &Config{ + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Theme: testThemeDracula, + Format: testFormatTerminal256, + UserSpecified: true, + }, + }, + wantTheme: testThemeDracula, + wantNoFormat: true, // default format shouldn't be saved + }, + { + name: "default theme not user specified", + config: &Config{ + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Theme: testThemeMonokai, + Format: testFormatTerminal256, + UserSpecified: false, + }, + }, + wantNoTheme: true, + wantNoFormat: true, + }, + { + name: "custom format", + config: &Config{ + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Theme: testThemeMonokai, + Format: testFormatTerminal16, + UserSpecified: false, + }, + }, + wantFormat: testFormatTerminal16, + wantNoTheme: true, + }, + { + name: "both theme and format custom", + config: &Config{ + GitHub: &GitHub{}, + Color: output.ColorOptions{ + Enabled: true, + Theme: testThemeSolarizedDark, + Format: testFormatTerminal16m, + UserSpecified: true, + }, + }, + wantTheme: testThemeSolarizedDark, + wantFormat: testFormatTerminal16m, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + bytes, err := genBytes(test.config) + if err != nil { + t.Errorf("genBytes returned err: %v", err) + return + } + + var configFile ConfigFile + + err = yaml.Unmarshal(bytes, &configFile) + if err != nil { + t.Errorf("Failed to unmarshal YAML: %v", err) + return + } + + if test.wantNoTheme { + if configFile.ColorTheme != "" { + t.Errorf("Expected no theme, but got: %v", configFile.ColorTheme) + } + } else if configFile.ColorTheme != test.wantTheme { + t.Errorf("ColorTheme = %v, want %v", configFile.ColorTheme, test.wantTheme) + } + + if test.wantNoFormat { + if configFile.ColorFormat != "" { + t.Errorf("Expected no format, but got: %v", configFile.ColorFormat) + } + } else if configFile.ColorFormat != test.wantFormat { + t.Errorf("ColorFormat = %v, want %v", configFile.ColorFormat, test.wantFormat) + } + }) + } +} diff --git a/cmd/vela-cli/main.go b/cmd/vela-cli/main.go index c8fe2964..4fcdaa97 100644 --- a/cmd/vela-cli/main.go +++ b/cmd/vela-cli/main.go @@ -137,7 +137,7 @@ func main() { &cli.StringFlag{ Sources: cli.EnvVars("VELA_COLOR_THEME"), Name: internal.FlagColorTheme, - Usage: "configures the output color theme (default: monokai)", + Usage: "configures the output color theme (default: monokai or monokailight) - use 'vela view themes' to see available themes", }, } diff --git a/cmd/vela-cli/view.go b/cmd/vela-cli/view.go index a4fb46bd..ad5ad5b6 100644 --- a/cmd/vela-cli/view.go +++ b/cmd/vela-cli/view.go @@ -18,6 +18,7 @@ import ( "github.com/go-vela/cli/command/service" "github.com/go-vela/cli/command/settings" "github.com/go-vela/cli/command/step" + "github.com/go-vela/cli/command/themes" "github.com/go-vela/cli/command/user" "github.com/go-vela/cli/command/worker" ) @@ -96,6 +97,11 @@ var viewCmds = &cli.Command{ // https://pkg.go.dev/github.com/go-vela/cli/command/step?tab=doc#CommandView step.CommandView, + // add the sub command for viewing available themes + // + // https://pkg.go.dev/github.com/go-vela/cli/command/themes?tab=doc#CommandView + themes.CommandView, + // add the sub command for viewing a user // // https://pkg.go.dev/github.com/go-vela/cli/command/user?tab=doc#CommandView diff --git a/command/themes/doc.go b/command/themes/doc.go new file mode 100644 index 00000000..9db21447 --- /dev/null +++ b/command/themes/doc.go @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package themes provides the defined CLI theme commands for Vela. +// +// Usage: +// +// import "github.com/go-vela/cli/command/themes" +package themes diff --git a/command/themes/view.go b/command/themes/view.go new file mode 100644 index 00000000..13b3c3a2 --- /dev/null +++ b/command/themes/view.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +package themes + +import ( + "context" + "fmt" + "slices" + + "github.com/alecthomas/chroma/v2/styles" + "github.com/urfave/cli/v3" + + "github.com/go-vela/cli/action" + "github.com/go-vela/cli/internal" + "github.com/go-vela/cli/internal/output" +) + +// CommandView defines the command for viewing available color themes. +var CommandView = &cli.Command{ + Name: "themes", + Description: "Use this command to view available color themes.", + Usage: "View available color themes for syntax highlighting", + Action: view, + Flags: []cli.Flag{ + &cli.StringFlag{ + Sources: cli.EnvVars("VELA_OUTPUT", "THEME_OUTPUT"), + Name: internal.FlagOutput, + Aliases: []string{"op"}, + Usage: "format the output in json, spew or yaml", + }, + }, + CustomHelpTemplate: fmt.Sprintf(`%s +EXAMPLES: + 1. View available color themes. + $ {{.FullName}} + 2. View available color themes with JSON output. + $ {{.FullName}} --output json + 3. View available color themes with YAML output. + $ {{.FullName}} --output yaml + +DOCUMENTATION: + + https://go-vela.github.io/docs/reference/cli/themes/view/ +`, cli.CommandHelpTemplate), +} + +// helper function to view available color themes. +func view(_ context.Context, c *cli.Command) error { + // load variables from the config file + err := action.Load(c) + if err != nil { + return err + } + + // get all available theme names from chroma + themeNames := styles.Names() + + // sort them alphabetically for consistent output + slices.Sort(themeNames) + + // handle the output based off the provided configuration + switch c.String(internal.FlagOutput) { + case output.DriverDump: + // output the themes in dump format + return output.Dump(themeNames) + case output.DriverJSON: + // output the themes in JSON format + return output.JSON(themeNames, output.ColorOptionsFromCLIContext(c)) + case output.DriverSpew: + // output the themes in spew format + return output.Spew(themeNames) + case output.DriverYAML: + // output the themes in YAML format + return output.YAML(themeNames, output.ColorOptionsFromCLIContext(c)) + default: + // output the themes in a simple list format + fmt.Println("Available color themes:") + fmt.Println() + + for _, theme := range themeNames { + fmt.Printf(" - %s\n", theme) + } + + fmt.Println() + fmt.Println("Use --color.theme to set a theme") + fmt.Println("Or set in config file: vela config update --color.theme ") + + return nil + } +} diff --git a/command/themes/view_test.go b/command/themes/view_test.go new file mode 100644 index 00000000..7c0289c8 --- /dev/null +++ b/command/themes/view_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package themes + +import ( + "net/http/httptest" + "testing" + + "github.com/urfave/cli/v3" + + "github.com/go-vela/cli/test" + "github.com/go-vela/server/mock/server" +) + +func TestThemes_View(t *testing.T) { + // setup test server + s := httptest.NewServer(server.FakeHandler()) + + // setup tests + tests := []struct { + name string + failure bool + cmd *cli.Command + args []string + }{ + { + name: "default output", + failure: false, + cmd: test.Command(s.URL, view, CommandView.Flags), + args: []string{}, + }, + { + name: "json output", + failure: false, + cmd: test.Command(s.URL, view, CommandView.Flags), + args: []string{"--output", "json"}, + }, + { + name: "yaml output", + failure: false, + cmd: test.Command(s.URL, view, CommandView.Flags), + args: []string{"--output", "yaml"}, + }, + { + name: "dump output", + failure: false, + cmd: test.Command(s.URL, view, CommandView.Flags), + args: []string{"--output", "dump"}, + }, + { + name: "spew output", + failure: false, + cmd: test.Command(s.URL, view, CommandView.Flags), + args: []string{"--output", "spew"}, + }, + { + name: "short flag alias", + failure: false, + cmd: test.Command(s.URL, view, CommandView.Flags), + args: []string{"--op", "json"}, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.cmd.Run(t.Context(), append([]string{"test"}, test.args...)) + + if test.failure { + if err == nil { + t.Errorf("view should have returned err") + } + + return + } + + if err != nil { + t.Errorf("view returned err: %v", err) + } + }) + } +} diff --git a/go.mod b/go.mod index 6c8597f6..fd17a091 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/gosuri/uitable v0.0.4 github.com/joho/godotenv v1.5.1 github.com/manifoldco/promptui v0.9.0 + github.com/muesli/termenv v0.16.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.14.0 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 @@ -35,6 +36,7 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/PuerkitoBio/purell v1.2.1 // indirect github.com/adhocore/gronx v1.19.6 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect @@ -115,6 +117,7 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -133,7 +136,7 @@ require ( github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect diff --git a/go.sum b/go.sum index 907669f2..c6ebcccf 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -263,6 +265,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= @@ -299,6 +303,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= @@ -317,8 +323,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= diff --git a/internal/output/color.go b/internal/output/color.go index afd4b0bf..146ce909 100644 --- a/internal/output/color.go +++ b/internal/output/color.go @@ -7,6 +7,7 @@ import ( "os" chroma "github.com/alecthomas/chroma/v2/quick" + "github.com/muesli/termenv" "github.com/sirupsen/logrus" "github.com/urfave/cli/v3" "golang.org/x/term" @@ -16,32 +17,98 @@ import ( // ColorOptions defines the output color options used for syntax highlighting. type ColorOptions struct { - Enabled bool - Theme string - Format string + Enabled bool + Format string + Theme string + ThemeLight string + UserSpecified bool } -// ColorOptionsFromCLIContext creates a ColorOptions from a CLI context. -func ColorOptionsFromCLIContext(c *cli.Command) ColorOptions { - opts := ColorOptions{ - Enabled: true, - Format: "terminal256", - Theme: "monokai", +// GetTheme returns the appropriate theme based on terminal background +// and user preferences. +func (opts ColorOptions) GetTheme() string { + if opts.UserSpecified { + logrus.Debug("using user specified color theme") + + return opts.Theme + } + + if termenv.HasDarkBackground() { + logrus.Debug("detected dark terminal background, using default theme") + + return opts.Theme + } + + logrus.Debug("detected light terminal background, using default light theme") + + return opts.ThemeLight +} + +// shouldEnableColor determines if color should be enabled based on +// environment variables and CLI flags following standard conventions. +func shouldEnableColor(c *cli.Command) bool { + // 1. NO_COLOR - if set to any value, disable colors (highest priority) + // See: https://no-color.org/ + if _, exists := os.LookupEnv("NO_COLOR"); exists { + logrus.Debug("NO_COLOR set, colors will be suppressed") + + return false + } + + // 2. User-specified --color flag takes precedence over env vars + if c.IsSet(internal.FlagColor) { + logrus.Debug("--color is set, using supplied value") + + return internal.StringToBool(c.String(internal.FlagColor)) } - opts.Enabled = internal.StringToBool(c.String(internal.FlagColor)) + // 3. CLICOLOR_FORCE - if non-zero, force colors even if not a TTY + // See: https://bixense.com/clicolors/ + if cliColorForce := os.Getenv("CLICOLOR_FORCE"); cliColorForce != "" && cliColorForce != "0" { + logrus.Debug("CLICOLOR_FORCE is set, forcing colors") - // if it's not a terminal, don't use color + return true + } + + // 4. CLICOLOR=0 explicitly disables colors + if cliColor := os.Getenv("CLICOLOR"); cliColor == "0" { + logrus.Debug("CLICOLOR set to '0', colors will be suppressed") + + return false + } + + // 5. If not a terminal, don't use color by default if !term.IsTerminal(int(os.Stdout.Fd())) { - opts.Enabled = false + logrus.Debug("no TTY, colors will be suppressed") + + return false + } + + // Default: enable colors + return true +} + +// ColorOptionsFromCLIContext creates a ColorOptions from a CLI context. +func ColorOptionsFromCLIContext(c *cli.Command) ColorOptions { + opts := ColorOptions{ + Enabled: shouldEnableColor(c), + Format: "terminal256", + Theme: "monokai", + ThemeLight: "monokailight", + UserSpecified: false, } if c.IsSet(internal.FlagColorFormat) { + logrus.Debug("using supplied color format value") + opts.Format = c.String(internal.FlagColorFormat) } if c.IsSet(internal.FlagColorTheme) { + logrus.Debug("using user supplied color theme") + opts.Theme = c.String(internal.FlagColorTheme) + opts.UserSpecified = true } return opts @@ -52,7 +119,9 @@ func Highlight(str string, lexer string, opts ColorOptions) string { if opts.Enabled { buf := new(bytes.Buffer) - err := chroma.Highlight(buf, str, lexer, opts.Format, opts.Theme) + theme := opts.GetTheme() + + err := chroma.Highlight(buf, str, lexer, opts.Format, theme) if err == nil { str = buf.String() } else { diff --git a/internal/output/color_test.go b/internal/output/color_test.go new file mode 100644 index 00000000..9ede36e2 --- /dev/null +++ b/internal/output/color_test.go @@ -0,0 +1,594 @@ +// SPDX-License-Identifier: Apache-2.0 + +package output + +import ( + "os" + "testing" + + "github.com/urfave/cli/v3" + + "github.com/go-vela/cli/internal" +) + +// Test constants for color themes and formats. +const ( + testThemeMonokai = "monokai" + testThemeMonokaiLight = "monokailight" + testThemeDracula = "dracula" + testThemeSolarizedDark = "solarized-dark" + testFormatTerminal256 = "terminal256" + testFormatTerminal16 = "terminal16" + testFormatTerminal16m = "terminal16m" +) + +func TestOutput_ColorOptions_GetTheme(t *testing.T) { + // setup tests + tests := []struct { + name string + opts ColorOptions + want string + }{ + { + name: "user specified theme - should use custom theme", + opts: ColorOptions{ + Theme: "customtheme", + ThemeLight: testThemeMonokaiLight, + UserSpecified: true, + }, + want: "customtheme", + }, + { + name: "user specified theme - ignores light theme", + opts: ColorOptions{ + Theme: testThemeDracula, + ThemeLight: testThemeMonokaiLight, + UserSpecified: true, + }, + want: testThemeDracula, + }, + { + name: "default with dark background - should use dark theme", + opts: ColorOptions{ + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: false, + }, + want: testThemeMonokai, // assumes dark background in test environment + }, + { + name: "default themes configured", + opts: ColorOptions{ + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: false, + }, + want: testThemeMonokai, // result depends on terminal background + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.opts.GetTheme() + + // For user-specified themes, we can assert exact matches + if test.opts.UserSpecified { + if got != test.want { + t.Errorf("GetTheme() = %v, want %v", got, test.want) + } + } else { + // For auto-detected themes, just verify it returns one of the valid themes + if got != test.opts.Theme && got != test.opts.ThemeLight { + t.Errorf("GetTheme() = %v, want either %v or %v", got, test.opts.Theme, test.opts.ThemeLight) + } + } + }) + } +} + +func TestOutput_ColorOptionsFromCLIContext(t *testing.T) { + // setup tests + tests := []struct { + name string + flags []cli.Flag + args []string + wantEnabled bool + wantFormat string + wantTheme string + wantUserSpec bool + }{ + { + name: "default values - no flags set", + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + Value: "true", + }, + &cli.StringFlag{ + Name: internal.FlagColorFormat, + }, + &cli.StringFlag{ + Name: internal.FlagColorTheme, + }, + }, + args: []string{"test"}, + wantEnabled: true, + wantFormat: testFormatTerminal256, + wantTheme: testThemeMonokai, + wantUserSpec: false, + }, + { + name: "color disabled", + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + Value: "false", + }, + &cli.StringFlag{ + Name: internal.FlagColorFormat, + }, + &cli.StringFlag{ + Name: internal.FlagColorTheme, + }, + }, + args: []string{"test"}, + wantEnabled: false, + wantFormat: testFormatTerminal256, + wantTheme: testThemeMonokai, + wantUserSpec: false, + }, + { + name: "custom format set", + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + Value: "true", + }, + &cli.StringFlag{ + Name: internal.FlagColorFormat, + Value: testFormatTerminal16, + }, + &cli.StringFlag{ + Name: internal.FlagColorTheme, + }, + }, + args: []string{"test", "--color.format", testFormatTerminal16}, + wantEnabled: true, + wantFormat: testFormatTerminal16, + wantTheme: testThemeMonokai, + wantUserSpec: false, + }, + { + name: "custom theme set - user specified", + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + Value: "true", + }, + &cli.StringFlag{ + Name: internal.FlagColorFormat, + }, + &cli.StringFlag{ + Name: internal.FlagColorTheme, + }, + }, + args: []string{"test", "--color.theme", testThemeDracula}, + wantEnabled: true, + wantFormat: testFormatTerminal256, + wantTheme: testThemeDracula, + wantUserSpec: true, + }, + { + name: "all custom values set", + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + Value: "true", + }, + &cli.StringFlag{ + Name: internal.FlagColorFormat, + }, + &cli.StringFlag{ + Name: internal.FlagColorTheme, + }, + }, + args: []string{"test", "--color.format", testFormatTerminal16m, "--color.theme", testThemeSolarizedDark}, + wantEnabled: true, + wantFormat: testFormatTerminal16m, + wantTheme: testThemeSolarizedDark, + wantUserSpec: true, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := &cli.Command{ + Name: "test", + Flags: test.flags, + } + + err := cmd.Run(t.Context(), test.args) + if err != nil { + t.Errorf("unable to run command: %v", err) + } + + got := ColorOptionsFromCLIContext(cmd) + + // Note: Enabled might be false if not running in a terminal + // We only check if it matches expected when terminal detection is not involved + if os.Getenv("CI") == "" && got.Enabled != test.wantEnabled { + // Skip enabled check in CI environments where terminal detection may vary + t.Logf("Enabled = %v, want %v (may vary based on terminal)", got.Enabled, test.wantEnabled) + } + + if got.Format != test.wantFormat { + t.Errorf("Format = %v, want %v", got.Format, test.wantFormat) + } + + if got.Theme != test.wantTheme { + t.Errorf("Theme = %v, want %v", got.Theme, test.wantTheme) + } + + if got.UserSpecified != test.wantUserSpec { + t.Errorf("UserSpecified = %v, want %v", got.UserSpecified, test.wantUserSpec) + } + + if got.ThemeLight != testThemeMonokaiLight { + t.Errorf("ThemeLight = %v, want %v", got.ThemeLight, testThemeMonokaiLight) + } + }) + } +} + +func TestOutput_Highlight(t *testing.T) { + // setup tests + tests := []struct { + name string + input string + lexer string + opts ColorOptions + wantEmpty bool + }{ + { + name: "enabled with yaml", + input: "key: value\nfoo: bar\n", + lexer: "yaml", + opts: ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + }, + wantEmpty: false, + }, + { + name: "disabled - returns original", + input: "key: value\nfoo: bar\n", + lexer: "yaml", + opts: ColorOptions{ + Enabled: false, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + }, + wantEmpty: false, + }, + { + name: "json lexer", + input: `{"key": "value", "foo": "bar"}`, + lexer: "json", + opts: ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + }, + wantEmpty: false, + }, + { + name: "empty string", + input: "", + lexer: "yaml", + opts: ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + }, + wantEmpty: true, + }, + { + name: "custom theme", + input: "key: value\n", + lexer: "yaml", + opts: ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeDracula, + ThemeLight: testThemeMonokaiLight, + UserSpecified: true, + }, + wantEmpty: false, + }, + { + name: "light theme auto-selected", + input: "key: value\n", + lexer: "yaml", + opts: ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + UserSpecified: false, + }, + wantEmpty: false, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := Highlight(test.input, test.lexer, test.opts) + + if test.wantEmpty && got != "" { + t.Errorf("Highlight() = %v, want empty string", got) + } + + if !test.wantEmpty && got == "" && test.input != "" { + t.Errorf("Highlight() returned empty string, want non-empty") + } + + // When disabled, output should equal input + if !test.opts.Enabled && got != test.input { + t.Errorf("Highlight() with disabled colors = %v, want %v", got, test.input) + } + + // When enabled, output should contain the input (possibly with color codes) + if test.opts.Enabled && test.input != "" && len(got) < len(test.input) { + t.Errorf("Highlight() returned shorter string than input") + } + }) + } +} + +func TestOutput_Highlight_InvalidLexer(t *testing.T) { + // Test with invalid lexer - should log warning but not fail + opts := ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: testThemeMonokai, + ThemeLight: testThemeMonokaiLight, + } + + input := "some text" + got := Highlight(input, "invalidlexer123", opts) + + // With invalid lexer, chroma may still try to highlight or return original + // Just verify it doesn't panic and returns something + if got == "" && input != "" { + t.Errorf("Highlight() with invalid lexer returned empty string") + } +} + +func TestOutput_Highlight_InvalidTheme(t *testing.T) { + // Test with invalid theme - should log warning but not fail + opts := ColorOptions{ + Enabled: true, + Format: testFormatTerminal256, + Theme: "invalidtheme123", + ThemeLight: testThemeMonokaiLight, + } + + input := "key: value\n" + got := Highlight(input, "yaml", opts) + + // With invalid theme, chroma may fall back to default or return original + // Just verify it doesn't panic and returns something + if got == "" && input != "" { + t.Errorf("Highlight() with invalid theme returned empty string") + } +} + +func TestOutput_ColorOptions_EnvironmentVariables(t *testing.T) { + // setup tests + tests := []struct { + name string + envVars map[string]string + flags []cli.Flag + args []string + wantEnabled bool + }{ + { + name: "NO_COLOR set - disables colors", + envVars: map[string]string{"NO_COLOR": "1"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, + }, + { + name: "NO_COLOR empty - still disables colors", + envVars: map[string]string{"NO_COLOR": ""}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, + }, + { + name: "CLICOLOR_FORCE=1 - forces colors", + envVars: map[string]string{"CLICOLOR_FORCE": "1"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: true, + }, + { + name: "CLICOLOR_FORCE=true - forces colors", + envVars: map[string]string{"CLICOLOR_FORCE": "true"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: true, + }, + { + name: "CLICOLOR_FORCE=0 - does not force colors", + envVars: map[string]string{"CLICOLOR_FORCE": "0"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, // will be disabled because not a TTY + }, + { + name: "CLICOLOR=0 - disables colors", + envVars: map[string]string{"CLICOLOR": "0"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, + }, + { + name: "CLICOLOR=1 - enables colors if TTY", + envVars: map[string]string{"CLICOLOR": "1"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, // will be disabled in test (not a TTY) + }, + { + name: "NO_COLOR overrides CLICOLOR_FORCE", + envVars: map[string]string{"NO_COLOR": "1", "CLICOLOR_FORCE": "1"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, + }, + { + name: "NO_COLOR overrides CLICOLOR", + envVars: map[string]string{"NO_COLOR": "1", "CLICOLOR": "1"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, + }, + { + name: "flag --color=true overrides CLICOLOR=0", + envVars: map[string]string{"CLICOLOR": "0"}, + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + }, + }, + args: []string{"test", "--color", "true"}, + wantEnabled: true, + }, + { + name: "flag --color=false overrides CLICOLOR_FORCE", + envVars: map[string]string{"CLICOLOR_FORCE": "1"}, + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + }, + }, + args: []string{"test", "--color", "false"}, + wantEnabled: false, + }, + { + name: "NO_COLOR overrides flag --color=true", + envVars: map[string]string{"NO_COLOR": "1"}, + flags: []cli.Flag{ + &cli.StringFlag{ + Name: internal.FlagColor, + }, + }, + args: []string{"test", "--color", "true"}, + wantEnabled: false, + }, + { + name: "CLICOLOR_FORCE takes precedence over CLICOLOR=0", + envVars: map[string]string{"CLICOLOR_FORCE": "1", "CLICOLOR": "0"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: true, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Set test environment variables using t.Setenv for automatic cleanup + for key, value := range test.envVars { + t.Setenv(key, value) + } + + // Create command + cmd := &cli.Command{ + Name: "test", + Flags: test.flags, + } + + err := cmd.Run(t.Context(), test.args) + if err != nil { + t.Errorf("unable to run command: %v", err) + } + + got := ColorOptionsFromCLIContext(cmd) + + if got.Enabled != test.wantEnabled { + t.Errorf("Enabled = %v, want %v", got.Enabled, test.wantEnabled) + } + }) + } +} + +func TestOutput_shouldEnableColor(t *testing.T) { + // setup tests + tests := []struct { + name string + envVars map[string]string + flags []cli.Flag + args []string + wantEnabled bool + }{ + { + name: "default - no env vars, no flags", + envVars: map[string]string{}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, // false in test environment (not a TTY) + }, + { + name: "NO_COLOR takes absolute precedence", + envVars: map[string]string{"NO_COLOR": "anything"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: false, + }, + { + name: "CLICOLOR_FORCE forces color even without TTY", + envVars: map[string]string{"CLICOLOR_FORCE": "1"}, + flags: []cli.Flag{}, + args: []string{"test"}, + wantEnabled: true, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Set test environment variables using t.Setenv for automatic cleanup + for key, value := range test.envVars { + t.Setenv(key, value) + } + + // Create command + cmd := &cli.Command{ + Name: "test", + Flags: test.flags, + } + + err := cmd.Run(t.Context(), test.args) + if err != nil { + t.Errorf("unable to run command: %v", err) + } + + got := shouldEnableColor(cmd) + + if got != test.wantEnabled { + t.Errorf("shouldEnableColor() = %v, want %v", got, test.wantEnabled) + } + }) + } +}