diff --git a/cmd/gh-oss-stats/badgeConfig.go b/cmd/gh-oss-stats/badgeConfig.go index a5bf0b9..d6fda04 100644 --- a/cmd/gh-oss-stats/badgeConfig.go +++ b/cmd/gh-oss-stats/badgeConfig.go @@ -13,6 +13,16 @@ type BadgeConfig struct { output string sort string limit int + // Custom color overrides (empty = use theme default) + colorBackground string + colorBackgroundAlt string + colorText string + colorTextSecondary string + colorBorder string + colorAccent string + colorPositive string + colorNegative string + colorStar string } // newBadgeConfig creates a new BadgeConfig with default values @@ -34,4 +44,14 @@ func (bf *BadgeConfig) registerBadgeFlags(fs *flag.FlagSet) { fs.StringVar(&bf.output, "badge-output", "", "Badge output file (default: badge.svg)") fs.StringVar(&bf.sort, "badge-sort", string(badge.DefaultSortBy), "Sort contributions by: prs, stars, commits") fs.IntVar(&bf.limit, "badge-limit", badge.DefaultPRsLimit, "Number of contributions to show") + + fs.StringVar(&bf.colorBackground, "badge-color-background", "", "Custom background color (hex, e.g. #1a1b26)") + fs.StringVar(&bf.colorBackgroundAlt, "badge-color-background-alt", "", "Custom alt background color (hex)") + fs.StringVar(&bf.colorText, "badge-color-text", "", "Custom primary text color (hex)") + fs.StringVar(&bf.colorTextSecondary, "badge-color-text-secondary", "", "Custom secondary text color (hex)") + fs.StringVar(&bf.colorBorder, "badge-color-border", "", "Custom border color (hex)") + fs.StringVar(&bf.colorAccent, "badge-color-accent", "", "Custom accent/brand color (hex)") + fs.StringVar(&bf.colorPositive, "badge-color-positive", "", "Custom positive/additions color (hex)") + fs.StringVar(&bf.colorNegative, "badge-color-negative", "", "Custom negative/deletions color (hex)") + fs.StringVar(&bf.colorStar, "badge-color-star", "", "Custom star count color (hex)") } diff --git a/cmd/gh-oss-stats/badgeConfig_test.go b/cmd/gh-oss-stats/badgeConfig_test.go index 887cb2c..ebe077e 100644 --- a/cmd/gh-oss-stats/badgeConfig_test.go +++ b/cmd/gh-oss-stats/badgeConfig_test.go @@ -188,8 +188,115 @@ func TestBadgeConfigPointerReturn(t *testing.T) { // Modifying bc1 should not affect bc2 bc1.style = "modified" - + if bc2.style == "modified" { t.Error("Modifying bc1 affected bc2; they should be independent instances") } } + +func TestBadgeColorFlagsRegistered(t *testing.T) { + bc := newBadgeConfig() + fs := flag.NewFlagSet("test", flag.ContinueOnError) + bc.registerBadgeFlags(fs) + + colorFlags := []string{ + "badge-color-background", + "badge-color-background-alt", + "badge-color-text", + "badge-color-text-secondary", + "badge-color-border", + "badge-color-accent", + "badge-color-positive", + "badge-color-negative", + "badge-color-star", + } + + for _, name := range colorFlags { + t.Run(name, func(t *testing.T) { + f := fs.Lookup(name) + if f == nil { + t.Errorf("flag --%s not registered", name) + return + } + if f.DefValue != "" { + t.Errorf("flag --%s default = %q, want %q", name, f.DefValue, "") + } + }) + } +} + +func TestBadgeColorFlagDefaults(t *testing.T) { + bc := newBadgeConfig() + fs := flag.NewFlagSet("test", flag.ContinueOnError) + bc.registerBadgeFlags(fs) + + // Parse with no color flags — all color fields should remain empty + if err := fs.Parse([]string{}); err != nil { + t.Fatalf("Parse failed: %v", err) + } + + fields := []struct { + name string + got string + }{ + {"colorBackground", bc.colorBackground}, + {"colorBackgroundAlt", bc.colorBackgroundAlt}, + {"colorText", bc.colorText}, + {"colorTextSecondary", bc.colorTextSecondary}, + {"colorBorder", bc.colorBorder}, + {"colorAccent", bc.colorAccent}, + {"colorPositive", bc.colorPositive}, + {"colorNegative", bc.colorNegative}, + {"colorStar", bc.colorStar}, + } + + for _, f := range fields { + if f.got != "" { + t.Errorf("%s default = %q, want empty string", f.name, f.got) + } + } +} + +func TestBadgeColorFlagsParsed(t *testing.T) { + bc := newBadgeConfig() + fs := flag.NewFlagSet("test", flag.ContinueOnError) + bc.registerBadgeFlags(fs) + + args := []string{ + "--badge-color-background", "#0d1117", + "--badge-color-background-alt", "#161b22", + "--badge-color-text", "#c9d1d9", + "--badge-color-text-secondary", "#8b949e", + "--badge-color-border", "#30363d", + "--badge-color-accent", "#58a6ff", + "--badge-color-positive", "#3fb950", + "--badge-color-negative", "#f85149", + "--badge-color-star", "#e3b341", + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse failed: %v", err) + } + + tests := []struct { + name string + got string + want string + }{ + {"colorBackground", bc.colorBackground, "#0d1117"}, + {"colorBackgroundAlt", bc.colorBackgroundAlt, "#161b22"}, + {"colorText", bc.colorText, "#c9d1d9"}, + {"colorTextSecondary", bc.colorTextSecondary, "#8b949e"}, + {"colorBorder", bc.colorBorder, "#30363d"}, + {"colorAccent", bc.colorAccent, "#58a6ff"}, + {"colorPositive", bc.colorPositive, "#3fb950"}, + {"colorNegative", bc.colorNegative, "#f85149"}, + {"colorStar", bc.colorStar, "#e3b341"}, + } + + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want) + } + } +} diff --git a/cmd/gh-oss-stats/badgeGeneration.go b/cmd/gh-oss-stats/badgeGeneration.go index aebee13..1e953a7 100644 --- a/cmd/gh-oss-stats/badgeGeneration.go +++ b/cmd/gh-oss-stats/badgeGeneration.go @@ -3,11 +3,21 @@ package main import ( "fmt" "os" + "regexp" "github.com/mabd-dev/gh-oss-stats/pkg/ossstats" "github.com/mabd-dev/gh-oss-stats/pkg/ossstats/badge" ) +var hexColorRE = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`) + +func validateHexColor(flag, value string) error { + if !hexColorRE.MatchString(value) { + return fmt.Errorf("invalid color for --%s: %q (expected hex format, e.g. #rgb, #rrggbb, #rrggbbaa)", flag, value) + } + return nil +} + func createBadgeOptions(conf BadgeConfig) (badge.BadgeOptions, error) { badgeStyle, err := badge.BadgeStyleFromName(conf.style) if err != nil { @@ -33,12 +43,53 @@ func createBadgeOptions(conf BadgeConfig) (badge.BadgeOptions, error) { os.Exit(1) } + colorFlags := []struct { + flag string + value string + }{ + {"badge-color-background", conf.colorBackground}, + {"badge-color-background-alt", conf.colorBackgroundAlt}, + {"badge-color-text", conf.colorText}, + {"badge-color-text-secondary", conf.colorTextSecondary}, + {"badge-color-border", conf.colorBorder}, + {"badge-color-accent", conf.colorAccent}, + {"badge-color-positive", conf.colorPositive}, + {"badge-color-negative", conf.colorNegative}, + {"badge-color-star", conf.colorStar}, + } + for _, cf := range colorFlags { + if cf.value != "" { + if err := validateHexColor(cf.flag, cf.value); err != nil { + return badge.BadgeOptions{}, err + } + } + } + + var customColors *badge.ThemeColors + if conf.colorBackground != "" || conf.colorBackgroundAlt != "" || + conf.colorText != "" || conf.colorTextSecondary != "" || + conf.colorBorder != "" || conf.colorAccent != "" || + conf.colorPositive != "" || conf.colorNegative != "" || conf.colorStar != "" { + customColors = &badge.ThemeColors{ + Background: conf.colorBackground, + BackgroundAlt: conf.colorBackgroundAlt, + Text: conf.colorText, + TextSecondary: conf.colorTextSecondary, + Border: conf.colorBorder, + Accent: conf.colorAccent, + Positive: conf.colorPositive, + Negative: conf.colorNegative, + Star: conf.colorStar, + } + } + return badge.BadgeOptions{ - Style: badgeStyle, - Variant: badgeVariant, - Theme: badgeTheme, - SortBy: badgeSortBy, - Limit: conf.limit, + Style: badgeStyle, + Variant: badgeVariant, + Theme: badgeTheme, + SortBy: badgeSortBy, + Limit: conf.limit, + CustomColors: customColors, }, nil } diff --git a/cmd/gh-oss-stats/badgeGeneration_test.go b/cmd/gh-oss-stats/badgeGeneration_test.go new file mode 100644 index 0000000..454997d --- /dev/null +++ b/cmd/gh-oss-stats/badgeGeneration_test.go @@ -0,0 +1,334 @@ +package main + +import ( + "testing" + + "github.com/mabd-dev/gh-oss-stats/pkg/ossstats/badge" +) + +func TestCreateBadgeOptionsNoCustomColors(t *testing.T) { + conf := BadgeConfig{ + style: string(badge.DefaultBadgeStyle), + variant: string(badge.DefaultBadgeVariant), + theme: string(badge.DefaultBadgeTheme), + sort: string(badge.DefaultSortBy), + limit: badge.DefaultPRsLimit, + // all color fields left as zero value ("") + } + + opts, err := createBadgeOptions(conf) + if err != nil { + t.Fatalf("createBadgeOptions failed: %v", err) + } + + if opts.CustomColors != nil { + t.Errorf("CustomColors = %+v, want nil when no color flags are set", opts.CustomColors) + } +} + +func TestCreateBadgeOptionsAllCustomColors(t *testing.T) { + conf := BadgeConfig{ + style: string(badge.DefaultBadgeStyle), + variant: string(badge.DefaultBadgeVariant), + theme: string(badge.DefaultBadgeTheme), + sort: string(badge.DefaultSortBy), + limit: badge.DefaultPRsLimit, + colorBackground: "#0d1117", + colorBackgroundAlt: "#161b22", + colorText: "#c9d1d9", + colorTextSecondary: "#8b949e", + colorBorder: "#30363d", + colorAccent: "#58a6ff", + colorPositive: "#3fb950", + colorNegative: "#f85149", + colorStar: "#e3b341", + } + + opts, err := createBadgeOptions(conf) + if err != nil { + t.Fatalf("createBadgeOptions failed: %v", err) + } + + if opts.CustomColors == nil { + t.Fatal("CustomColors is nil, want non-nil when color flags are set") + } + + c := opts.CustomColors + tests := []struct { + name string + got string + want string + }{ + {"Background", c.Background, "#0d1117"}, + {"BackgroundAlt", c.BackgroundAlt, "#161b22"}, + {"Text", c.Text, "#c9d1d9"}, + {"TextSecondary", c.TextSecondary, "#8b949e"}, + {"Border", c.Border, "#30363d"}, + {"Accent", c.Accent, "#58a6ff"}, + {"Positive", c.Positive, "#3fb950"}, + {"Negative", c.Negative, "#f85149"}, + {"Star", c.Star, "#e3b341"}, + } + + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("CustomColors.%s = %q, want %q", tt.name, tt.got, tt.want) + } + } +} + +func TestCreateBadgeOptionsPartialCustomColors(t *testing.T) { + // Only accent and star are set; all others should be empty strings in CustomColors. + conf := BadgeConfig{ + style: string(badge.DefaultBadgeStyle), + variant: string(badge.DefaultBadgeVariant), + theme: string(badge.DefaultBadgeTheme), + sort: string(badge.DefaultSortBy), + limit: badge.DefaultPRsLimit, + colorAccent: "#ff6b6b", + colorStar: "#ffd700", + } + + opts, err := createBadgeOptions(conf) + if err != nil { + t.Fatalf("createBadgeOptions failed: %v", err) + } + + if opts.CustomColors == nil { + t.Fatal("CustomColors is nil, want non-nil when at least one color flag is set") + } + + if opts.CustomColors.Accent != "#ff6b6b" { + t.Errorf("Accent = %q, want %q", opts.CustomColors.Accent, "#ff6b6b") + } + if opts.CustomColors.Star != "#ffd700" { + t.Errorf("Star = %q, want %q", opts.CustomColors.Star, "#ffd700") + } + // Unset fields should be empty strings (theme override logic in RenderSVG will skip them). + if opts.CustomColors.Background != "" { + t.Errorf("Background = %q, want empty string for unset flag", opts.CustomColors.Background) + } +} + +func TestCreateBadgeOptionsSingleColorFlag(t *testing.T) { + colorFields := []struct { + name string + makeConf func() BadgeConfig + check func(*badge.ThemeColors) (string, string) // returns got, want + }{ + { + "background", + func() BadgeConfig { + c := baseConf() + c.colorBackground = "#aabbcc" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.Background, "#aabbcc" }, + }, + { + "background-alt", + func() BadgeConfig { + c := baseConf() + c.colorBackgroundAlt = "#112233" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.BackgroundAlt, "#112233" }, + }, + { + "text", + func() BadgeConfig { + c := baseConf() + c.colorText = "#ffffff" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.Text, "#ffffff" }, + }, + { + "text-secondary", + func() BadgeConfig { + c := baseConf() + c.colorTextSecondary = "#aaaaaa" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.TextSecondary, "#aaaaaa" }, + }, + { + "border", + func() BadgeConfig { + c := baseConf() + c.colorBorder = "#333333" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.Border, "#333333" }, + }, + { + "accent", + func() BadgeConfig { + c := baseConf() + c.colorAccent = "#ff0000" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.Accent, "#ff0000" }, + }, + { + "positive", + func() BadgeConfig { + c := baseConf() + c.colorPositive = "#00ff00" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.Positive, "#00ff00" }, + }, + { + "negative", + func() BadgeConfig { + c := baseConf() + c.colorNegative = "#ff00ff" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.Negative, "#ff00ff" }, + }, + { + "star", + func() BadgeConfig { + c := baseConf() + c.colorStar = "#ffff00" + return c + }, + func(c *badge.ThemeColors) (string, string) { return c.Star, "#ffff00" }, + }, + } + + for _, tt := range colorFields { + t.Run(tt.name, func(t *testing.T) { + opts, err := createBadgeOptions(tt.makeConf()) + if err != nil { + t.Fatalf("createBadgeOptions failed: %v", err) + } + if opts.CustomColors == nil { + t.Fatal("CustomColors is nil, want non-nil") + } + got, want := tt.check(opts.CustomColors) + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + } +} + +// baseConf returns a minimal valid BadgeConfig with no color overrides. +func baseConf() BadgeConfig { + return BadgeConfig{ + style: string(badge.DefaultBadgeStyle), + variant: string(badge.DefaultBadgeVariant), + theme: string(badge.DefaultBadgeTheme), + sort: string(badge.DefaultSortBy), + limit: badge.DefaultPRsLimit, + } +} + +func TestValidateHexColor(t *testing.T) { + tests := []struct { + value string + wantErr bool + }{ + // Valid + {"#abc", false}, + {"#ABC", false}, + {"#1a2b3c", false}, + {"#1A2B3C", false}, + {"#00000000", false}, // 8-digit with alpha + // Invalid + {"", true}, // empty (callers skip empty, but function itself rejects) + {"abc", true}, // missing # + {"#gg0000", true}, // invalid hex digits + {"#12345", true}, // 5 digits + {"#1234567", true}, // 7 digits + {"#123456789", true}, // 9 digits + {"red", true}, // named color + {"rgb(0,0,0)", true}, // CSS function + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + err := validateHexColor("badge-color-accent", tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("validateHexColor(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + }) + } +} + +func TestValidateHexColorErrorMessage(t *testing.T) { + err := validateHexColor("badge-color-accent", "notacolor") + if err == nil { + t.Fatal("expected error, got nil") + } + msg := err.Error() + if !contains(msg, "--badge-color-accent") { + t.Errorf("error message should mention the flag name, got: %q", msg) + } + if !contains(msg, "notacolor") { + t.Errorf("error message should mention the invalid value, got: %q", msg) + } +} + +func TestCreateBadgeOptionsInvalidColor(t *testing.T) { + colorFlagFields := []struct { + flagName string + setConf func(*BadgeConfig) + }{ + {"badge-color-background", func(c *BadgeConfig) { c.colorBackground = "red" }}, + {"badge-color-background-alt", func(c *BadgeConfig) { c.colorBackgroundAlt = "blue" }}, + {"badge-color-text", func(c *BadgeConfig) { c.colorText = "white" }}, + {"badge-color-text-secondary", func(c *BadgeConfig) { c.colorTextSecondary = "gray" }}, + {"badge-color-border", func(c *BadgeConfig) { c.colorBorder = "black" }}, + {"badge-color-accent", func(c *BadgeConfig) { c.colorAccent = "invalid" }}, + {"badge-color-positive", func(c *BadgeConfig) { c.colorPositive = "green" }}, + {"badge-color-negative", func(c *BadgeConfig) { c.colorNegative = "rgb(255,0,0)" }}, + {"badge-color-star", func(c *BadgeConfig) { c.colorStar = "gold" }}, + } + + for _, tt := range colorFlagFields { + t.Run(tt.flagName, func(t *testing.T) { + conf := baseConf() + tt.setConf(&conf) + + _, err := createBadgeOptions(conf) + if err == nil { + t.Fatalf("expected error for invalid color on --%s, got nil", tt.flagName) + } + if !contains(err.Error(), "--"+tt.flagName) { + t.Errorf("error should mention the flag name --%s, got: %q", tt.flagName, err.Error()) + } + }) + } +} + +func TestCreateBadgeOptionsValidColors(t *testing.T) { + validColors := []string{"#fff", "#FFF", "#1a2b3c", "#1A2B3C", "#00000000"} + + for _, color := range validColors { + t.Run(color, func(t *testing.T) { + conf := baseConf() + conf.colorAccent = color + + _, err := createBadgeOptions(conf) + if err != nil { + t.Errorf("unexpected error for valid color %q: %v", color, err) + } + }) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()) +} diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 035397f..da3e7d3 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -189,8 +189,17 @@ These flags control badge generation and styling. Used by the main command (with | --badge-output | string | ./badge.svg | Output file path for generated badge | | --badge-sort | string | prs | Sort contributions by: `prs`, `stars`, `commits` | | --badge-limit | int | 5 | Number of contributions to display in detailed badge | - - +| --badge-color-background | string | "" | Custom main background color (hex, e.g. `#1a1b26`) | +| --badge-color-background-alt | string | "" | Custom alt background color (hex) | +| --badge-color-text | string | "" | Custom primary text color (hex) | +| --badge-color-text-secondary | string | "" | Custom secondary/label text color (hex) | +| --badge-color-border | string | "" | Custom border/divider color (hex) | +| --badge-color-accent | string | "" | Custom accent/brand color (hex) | +| --badge-color-positive | string | "" | Custom positive/additions color (hex) | +| --badge-color-negative | string | "" | Custom negative/deletions color (hex) | +| --badge-color-star | string | "" | Custom star count color (hex) | + +> Custom color flags override individual colors from the selected theme. Unset flags retain the theme's color. ### Badge Generation diff --git a/pkg/ossstats/badge/badge.go b/pkg/ossstats/badge/badge.go index fd2128e..ad3b64c 100644 --- a/pkg/ossstats/badge/badge.go +++ b/pkg/ossstats/badge/badge.go @@ -49,6 +49,36 @@ func RenderSVG(stats *ossstats.Stats, opts BadgeOptions) (string, error) { // Get theme colors colors := GetThemeColors(opts.Theme) + if opts.CustomColors != nil { + c := opts.CustomColors + if c.Background != "" { + colors.Background = c.Background + } + if c.BackgroundAlt != "" { + colors.BackgroundAlt = c.BackgroundAlt + } + if c.Text != "" { + colors.Text = c.Text + } + if c.TextSecondary != "" { + colors.TextSecondary = c.TextSecondary + } + if c.Border != "" { + colors.Border = c.Border + } + if c.Accent != "" { + colors.Accent = c.Accent + } + if c.Positive != "" { + colors.Positive = c.Positive + } + if c.Negative != "" { + colors.Negative = c.Negative + } + if c.Star != "" { + colors.Star = c.Star + } + } // Prepare base template data data := templateData{ diff --git a/pkg/ossstats/badge/types.go b/pkg/ossstats/badge/types.go index 70b7284..c351eb9 100644 --- a/pkg/ossstats/badge/types.go +++ b/pkg/ossstats/badge/types.go @@ -2,9 +2,10 @@ package badge // BadgeOptions contains all configuration for badge generation type BadgeOptions struct { - Style BadgeStyle - Variant BadgeVariant - Theme BadgeTheme - SortBy SortBy // For detailed badge - how to sort contributions (default: prs) - Limit int // For detailed badge - max contributions to show (default: 5) + Style BadgeStyle + Variant BadgeVariant + Theme BadgeTheme + SortBy SortBy // For detailed badge - how to sort contributions (default: prs) + Limit int // For detailed badge - max contributions to show (default: 5) + CustomColors *ThemeColors // Optional per-color overrides (applied on top of Theme) }