Skip to content

Commit 9ba49b3

Browse files
authored
feat(config): add configuration validation (#15)
1 parent 753f75a commit 9ba49b3

8 files changed

Lines changed: 289 additions & 13 deletions

File tree

internal/cmd/lint.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func doLint(workflows []*workflow.Workflow, configFile string) int {
6767

6868
issues, err := l.Lint()
6969
if err != nil {
70-
fmt.Fprintf(os.Stderr, "failed to lint workflows: %v\n", err)
70+
printError("failed to lint workflows: %v", err)
7171
return 1
7272
}
7373

@@ -97,14 +97,14 @@ func doLint(workflows []*workflow.Workflow, configFile string) int {
9797
func doLintWithFix(l *linter.WorkflowLinter, issues []*linter.Issue, issuesExitCode int) int {
9898
// Apply fixes
9999
if err := l.Fix(); err != nil {
100-
fmt.Fprintf(os.Stderr, "failed to fix workflows: %v\n", err)
100+
printError("failed to fix workflows: %v", err)
101101
return 1
102102
}
103103

104104
// Re-lint to see what issues remain after fixing
105105
remainingIssues, err := l.Lint()
106106
if err != nil {
107-
fmt.Fprintf(os.Stderr, "failed to re-lint workflows: %v\n", err)
107+
printError("failed to re-lint workflows: %v", err)
108108
return 1
109109
}
110110

internal/cmd/root.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ var rootCmd = &cobra.Command{
1616

1717
func Execute() {
1818
if err := rootCmd.Execute(); err != nil {
19-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
19+
printError("%v", err)
2020
os.Exit(1)
2121
}
2222
}
2323

24+
// printError prints a formatted error message to stderr.
25+
func printError(format string, args ...any) {
26+
fmt.Fprintf(os.Stderr, "✗ Error: "+format+"\n", args...)
27+
}
28+
2429
func init() {
2530
rootCmd.AddCommand(initCmd)
2631
rootCmd.AddCommand(lintCmd)

internal/config/config.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,42 @@ type Config struct {
2525
Upgrade *UpgradeConfig `yaml:"upgrade,omitempty"`
2626
}
2727

28+
// Validate checks all configuration values for validity.
29+
func (c *Config) Validate() error {
30+
if err := c.Run.Validate(); err != nil {
31+
return err
32+
}
33+
if err := c.Linters.Validate(); err != nil {
34+
return err
35+
}
36+
if err := c.Upgrade.Validate(); err != nil {
37+
return err
38+
}
39+
return nil
40+
}
41+
2842
// RunConfig specifies general runtime settings.
2943
type RunConfig struct {
3044
Timeout string `yaml:"timeout"` // Duration string (e.g., "2m", "30s")
3145
IssuesExitCode int `yaml:"issues-exit-code"` // Exit code when issues are found (default: 1)
3246
}
3347

48+
// Validate checks RunConfig for invalid values.
49+
func (r *RunConfig) Validate() error {
50+
if r == nil {
51+
return nil
52+
}
53+
if r.Timeout != "" {
54+
if _, err := time.ParseDuration(r.Timeout); err != nil {
55+
return fmt.Errorf("invalid timeout %q: %w", r.Timeout, err)
56+
}
57+
}
58+
if r.IssuesExitCode != 0 && (r.IssuesExitCode < 1 || r.IssuesExitCode > 255) {
59+
return fmt.Errorf("issues-exit-code must be between 1 and 255, got %d", r.IssuesExitCode)
60+
}
61+
return nil
62+
}
63+
3464
const (
3565
// DefaultTimeout is the default timeout for operations.
3666
DefaultTimeout = 5 * time.Minute
@@ -41,7 +71,7 @@ const (
4171
// GetTimeout returns the configured timeout duration.
4272
// Returns DefaultTimeout if not configured or invalid.
4373
func (c *Config) GetTimeout() time.Duration {
44-
if c.Run == nil || c.Run.Timeout == "" {
74+
if c == nil || c.Run == nil || c.Run.Timeout == "" {
4575
return DefaultTimeout
4676
}
4777
d, err := time.ParseDuration(c.Run.Timeout)
@@ -55,7 +85,7 @@ func (c *Config) GetTimeout() time.Duration {
5585
// Returns DefaultIssuesExitCode (1) if not configured or invalid.
5686
// Exit codes must be in range 1-255; values outside this range return the default.
5787
func (c *Config) GetIssuesExitCode() int {
58-
if c.Run == nil || c.Run.IssuesExitCode <= 0 || c.Run.IssuesExitCode > 255 {
88+
if c == nil || c.Run == nil || c.Run.IssuesExitCode <= 0 || c.Run.IssuesExitCode > 255 {
5989
return DefaultIssuesExitCode
6090
}
6191
return c.Run.IssuesExitCode
@@ -82,6 +112,10 @@ func LoadConfig(filename string) (*Config, error) {
82112
return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
83113
}
84114

115+
if err := cfg.Validate(); err != nil {
116+
return nil, fmt.Errorf("invalid config: %w", err)
117+
}
118+
85119
cfg.ensureDefaults()
86120
return &cfg, nil
87121
}

internal/config/config_test.go

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ func TestLoadConfig_NonExistent(t *testing.T) {
1717
if cfg.Linters == nil {
1818
t.Error("cfg.Linters is nil, want non-nil")
1919
}
20-
if cfg.Linters.Default != "all" {
21-
t.Errorf("cfg.Linters.Default = %q, want %q", cfg.Linters.Default, "all")
20+
if cfg.Linters.Default != defaultLinterDefault {
21+
t.Errorf("cfg.Linters.Default = %q, want %q", cfg.Linters.Default, defaultLinterDefault)
2222
}
2323
if cfg.Upgrade == nil {
2424
t.Error("cfg.Upgrade is nil, want non-nil")
@@ -39,7 +39,7 @@ linters:
3939
enable:
4040
- permissions
4141
disable:
42-
- security
42+
- secrets
4343
settings:
4444
format:
4545
indent-width: 4
@@ -65,8 +65,8 @@ upgrade:
6565
if len(cfg.Linters.Enable) != 1 || cfg.Linters.Enable[0] != "permissions" {
6666
t.Errorf("cfg.Linters.Enable = %v, want [permissions]", cfg.Linters.Enable)
6767
}
68-
if len(cfg.Linters.Disable) != 1 || cfg.Linters.Disable[0] != "security" {
69-
t.Errorf("cfg.Linters.Disable = %v, want [security]", cfg.Linters.Disable)
68+
if len(cfg.Linters.Disable) != 1 || cfg.Linters.Disable[0] != "secrets" {
69+
t.Errorf("cfg.Linters.Disable = %v, want [secrets]", cfg.Linters.Disable)
7070
}
7171

7272
// Check upgrade config
@@ -630,8 +630,8 @@ func TestShouldUpdate(t *testing.T) {
630630
func TestFullDefaultLinterConfig(t *testing.T) {
631631
cfg := FullDefaultLinterConfig()
632632

633-
if cfg.Default != "all" {
634-
t.Errorf("Default = %q, want %q", cfg.Default, "all")
633+
if cfg.Default != defaultLinterDefault {
634+
t.Errorf("Default = %q, want %q", cfg.Default, defaultLinterDefault)
635635
}
636636

637637
// Should have all linters enabled
@@ -693,3 +693,129 @@ func TestUpgradeConfig_EnsureDefaults(t *testing.T) {
693693
})
694694
}
695695
}
696+
697+
func TestConfig_Validate(t *testing.T) {
698+
tests := []struct {
699+
name string
700+
config *Config
701+
wantErr bool
702+
}{
703+
{
704+
name: "valid empty config",
705+
config: &Config{},
706+
wantErr: false,
707+
},
708+
{
709+
name: "valid full config",
710+
config: &Config{
711+
Run: &RunConfig{Timeout: "5m", IssuesExitCode: 2},
712+
Linters: &LinterConfig{Default: "all", Enable: []string{"versions"}},
713+
Upgrade: &UpgradeConfig{Version: "tag"},
714+
},
715+
wantErr: false,
716+
},
717+
{
718+
name: "invalid timeout",
719+
config: &Config{Run: &RunConfig{Timeout: "invalid"}},
720+
wantErr: true,
721+
},
722+
{
723+
name: "invalid exit code too low",
724+
config: &Config{Run: &RunConfig{IssuesExitCode: -1}},
725+
wantErr: true,
726+
},
727+
{
728+
name: "invalid exit code too high",
729+
config: &Config{Run: &RunConfig{IssuesExitCode: 300}},
730+
wantErr: true,
731+
},
732+
{
733+
name: "invalid linter default",
734+
config: &Config{Linters: &LinterConfig{Default: "invalid"}},
735+
wantErr: true,
736+
},
737+
{
738+
name: "unknown linter in enable",
739+
config: &Config{Linters: &LinterConfig{Enable: []string{"unknown"}}},
740+
wantErr: true,
741+
},
742+
{
743+
name: "unknown linter in disable",
744+
config: &Config{Linters: &LinterConfig{Disable: []string{"unknown"}}},
745+
wantErr: true,
746+
},
747+
{
748+
name: "invalid upgrade version format",
749+
config: &Config{Upgrade: &UpgradeConfig{Version: "invalid"}},
750+
wantErr: true,
751+
},
752+
{
753+
name: "invalid format indent-width",
754+
config: &Config{Linters: &LinterConfig{
755+
Settings: &LinterSettings{Format: &FormatSettings{IndentWidth: -1}},
756+
}},
757+
wantErr: true,
758+
},
759+
{
760+
name: "invalid format max-line-length",
761+
config: &Config{Linters: &LinterConfig{
762+
Settings: &LinterSettings{Format: &FormatSettings{MaxLineLength: -1}},
763+
}},
764+
wantErr: true,
765+
},
766+
{
767+
name: "invalid style min-name-length negative",
768+
config: &Config{Linters: &LinterConfig{
769+
Settings: &LinterSettings{Style: &StyleSettings{MinNameLength: -1}},
770+
}},
771+
wantErr: true,
772+
},
773+
{
774+
name: "invalid style max-name-length negative",
775+
config: &Config{Linters: &LinterConfig{
776+
Settings: &LinterSettings{Style: &StyleSettings{MaxNameLength: -1}},
777+
}},
778+
wantErr: true,
779+
},
780+
{
781+
name: "invalid style max-run-lines negative",
782+
config: &Config{Linters: &LinterConfig{
783+
Settings: &LinterSettings{Style: &StyleSettings{MaxRunLines: -1}},
784+
}},
785+
wantErr: true,
786+
},
787+
{
788+
name: "invalid style min > max name length",
789+
config: &Config{Linters: &LinterConfig{
790+
Settings: &LinterSettings{Style: &StyleSettings{MinNameLength: 10, MaxNameLength: 5}},
791+
}},
792+
wantErr: true,
793+
},
794+
{
795+
name: "invalid naming convention",
796+
config: &Config{Linters: &LinterConfig{
797+
Settings: &LinterSettings{Style: &StyleSettings{NamingConvention: "invalid"}},
798+
}},
799+
wantErr: true,
800+
},
801+
{
802+
name: "valid settings",
803+
config: &Config{Linters: &LinterConfig{
804+
Settings: &LinterSettings{
805+
Format: &FormatSettings{IndentWidth: 4, MaxLineLength: 100},
806+
Style: &StyleSettings{MinNameLength: 3, MaxNameLength: 50, NamingConvention: "title"},
807+
},
808+
}},
809+
wantErr: false,
810+
},
811+
}
812+
813+
for _, tt := range tests {
814+
t.Run(tt.name, func(t *testing.T) {
815+
err := tt.config.Validate()
816+
if (err != nil) != tt.wantErr {
817+
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
818+
}
819+
})
820+
}
821+
}

internal/config/format_settings.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package config
22

3+
import "fmt"
4+
35
const (
46
defaultIndentWidth = 2
57
defaultMaxLineLength = 120
@@ -13,6 +15,20 @@ type FormatSettings struct {
1315
MaxLineLength int `yaml:"max-line-length"`
1416
}
1517

18+
// Validate checks FormatSettings for invalid values.
19+
func (f *FormatSettings) Validate() error {
20+
if f == nil {
21+
return nil
22+
}
23+
if f.IndentWidth < 0 {
24+
return fmt.Errorf("format.indent-width must be non-negative, got %d", f.IndentWidth)
25+
}
26+
if f.MaxLineLength < 0 {
27+
return fmt.Errorf("format.max-line-length must be non-negative, got %d", f.MaxLineLength)
28+
}
29+
return nil
30+
}
31+
1632
// DefaultFormatSettings returns the default format linter settings.
1733
func DefaultFormatSettings() *FormatSettings {
1834
return &FormatSettings{

internal/config/linter_config.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package config
22

3+
import (
4+
"fmt"
5+
"slices"
6+
)
7+
38
const defaultLinterDefault = "all"
49

510
// LinterConfig specifies which linters to enable and their behavior.
@@ -11,12 +16,50 @@ type LinterConfig struct {
1116
Settings *LinterSettings `yaml:"settings,omitempty"` // Per-linter settings
1217
}
1318

19+
// Validate checks LinterConfig for invalid values.
20+
func (l *LinterConfig) Validate() error {
21+
if l == nil {
22+
return nil
23+
}
24+
if l.Default != "" && l.Default != "all" && l.Default != "none" {
25+
return fmt.Errorf("linters.default must be \"all\" or \"none\", got %q", l.Default)
26+
}
27+
for _, name := range l.Enable {
28+
if !slices.Contains(allLinters, name) {
29+
return fmt.Errorf("unknown linter %q in linters.enable", name)
30+
}
31+
}
32+
for _, name := range l.Disable {
33+
if !slices.Contains(allLinters, name) {
34+
return fmt.Errorf("unknown linter %q in linters.disable", name)
35+
}
36+
}
37+
if err := l.Settings.Validate(); err != nil {
38+
return err
39+
}
40+
return nil
41+
}
42+
1443
// LinterSettings contains per-linter configuration.
1544
type LinterSettings struct {
1645
Format *FormatSettings `yaml:"format,omitempty"`
1746
Style *StyleSettings `yaml:"style,omitempty"`
1847
}
1948

49+
// Validate checks LinterSettings for invalid values.
50+
func (s *LinterSettings) Validate() error {
51+
if s == nil {
52+
return nil
53+
}
54+
if err := s.Format.Validate(); err != nil {
55+
return err
56+
}
57+
if err := s.Style.Validate(); err != nil {
58+
return err
59+
}
60+
return nil
61+
}
62+
2063
// DefaultLinterConfig returns a minimal LinterConfig with default values.
2164
func DefaultLinterConfig() *LinterConfig {
2265
return &LinterConfig{

0 commit comments

Comments
 (0)