diff --git a/cmd/cli/main.go b/cmd/cli/main.go index cb618be..f289669 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -40,6 +40,9 @@ func main() { func printValidationErrors(parserName string, errors ...types.FieldParseError) { for _, fieldErr := range errors { - utils.PrintErr(text.FgRed, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err) + if fieldErr.Value == "" { + utils.PrintErr(text.FgYellow, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err) + } + utils.PrintErr(text.FgYellow, "[%s] Field '%s' (%s): %v", parserName, fieldErr.Field, fieldErr.Value, fieldErr.Err) } } diff --git a/pkg/config/cli.go b/pkg/config/cli.go index 7be6418..b18d0a6 100644 --- a/pkg/config/cli.go +++ b/pkg/config/cli.go @@ -53,8 +53,7 @@ Flags: -skip-verify bool Skip SSL/TLS certificate verification (default %v)` type ConfigCLIParser struct { - args []string - configFile *types.ConfigFile + args []string } func NewConfigCLIParser(args []string) *ConfigCLIParser { @@ -64,10 +63,6 @@ func NewConfigCLIParser(args []string) *ConfigCLIParser { return &ConfigCLIParser{args: args} } -func (parser ConfigCLIParser) GetConfigFile() *types.ConfigFile { - return parser.configFile -} - type stringSliceArg []string func (arg *stringSliceArg) String() string { @@ -91,7 +86,7 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) { var ( config = &Config{} - configFile string + configFiles = stringSliceArg{} yes bool skipVerify bool method string @@ -108,8 +103,8 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) { ) { - flagSet.StringVar(&configFile, "config-file", "", "Config file") - flagSet.StringVar(&configFile, "f", "", "Config file") + flagSet.Var(&configFiles, "config-file", "Config file") + flagSet.Var(&configFiles, "f", "Config file") flagSet.BoolVar(&yes, "yes", false, "Answer yes to all questions") flagSet.BoolVar(&yes, "y", false, "Answer yes to all questions") @@ -171,28 +166,50 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) { flagSet.Visit(func(flagVar *flag.Flag) { switch flagVar.Name { case "config-file", "f": - var err error - parser.configFile, err = types.ParseConfigFile(configFile) - _ = utils.HandleErrorOrDie(err, - utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error { - fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("config-file", errors.New("file extension not found"))) - return nil - }), - utils.OnCustomError(func(err types.RemoteConfigFileParseError) error { - fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("config-file", fmt.Errorf("parse error: %w", err))) - return nil - }), - utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error { - fieldParseErrors = append( - fieldParseErrors, - *types.NewFieldParseError( - "config-file", - fmt.Errorf("file type '%s' not supported (supported types: %s)", err.Type, types.ConfigFileTypeYAML), - ), - ) - return nil - }), - ) + for i, configFile := range configFiles { + configFileParsed, err := types.ParseConfigFile(configFile) + + _ = utils.HandleErrorOrDie(err, + utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error { + fieldParseErrors = append( + fieldParseErrors, + *types.NewFieldParseError( + fmt.Sprintf("config-file[%d]", i), + configFile, + errors.New("file extension not found"), + ), + ) + return nil + }), + utils.OnCustomError(func(err types.RemoteConfigFileParseError) error { + fieldParseErrors = append( + fieldParseErrors, + *types.NewFieldParseError( + fmt.Sprintf("config-file[%d]", i), + configFile, + fmt.Errorf("parse error: %w", err), + ), + ) + return nil + }), + utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error { + fieldParseErrors = append( + fieldParseErrors, + *types.NewFieldParseError( + fmt.Sprintf("config-file[%d]", i), + configFile, + fmt.Errorf("file type '%s' not supported (supported types: %s)", err.Type, types.ConfigFileTypeYAML), + ), + ) + return nil + }), + ) + + if err == nil { + config.Files = append(config.Files, *configFileParsed) + } + } + case "yes", "y": config.Yes = utils.ToPtr(yes) case "skip-verify": @@ -202,7 +219,7 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) { case "url", "u": urlParsed, err := url.Parse(urlInput) if err != nil { - fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", err)) + fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", urlInput, err)) } else { config.URL = urlParsed } @@ -228,7 +245,7 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) { if err != nil { fieldParseErrors = append( fieldParseErrors, - *types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), err), + *types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err), ) } } diff --git a/pkg/config/cli_test.go b/pkg/config/cli_test.go index 5c708cb..4c6b149 100644 --- a/pkg/config/cli_test.go +++ b/pkg/config/cli_test.go @@ -20,7 +20,6 @@ func TestNewConfigCLIParser(t *testing.T) { require.NotNil(t, parser) assert.Equal(t, args, parser.args) - assert.Nil(t, parser.configFile) }) t.Run("NewConfigCLIParser with nil args", func(t *testing.T) { @@ -28,7 +27,6 @@ func TestNewConfigCLIParser(t *testing.T) { require.NotNil(t, parser) assert.Equal(t, []string{}, parser.args) - assert.Nil(t, parser.configFile) }) t.Run("NewConfigCLIParser with empty args", func(t *testing.T) { @@ -37,21 +35,6 @@ func TestNewConfigCLIParser(t *testing.T) { require.NotNil(t, parser) assert.Equal(t, args, parser.args) - assert.Nil(t, parser.configFile) - }) -} - -func TestConfigCLIParser_GetConfigFile(t *testing.T) { - t.Run("GetConfigFile returns nil when no config file is set", func(t *testing.T) { - parser := NewConfigCLIParser([]string{"dodo"}) - assert.Nil(t, parser.GetConfigFile()) - }) - - t.Run("GetConfigFile returns config file when set", func(t *testing.T) { - parser := NewConfigCLIParser([]string{"dodo"}) - expectedConfigFile := &types.ConfigFile{} - parser.configFile = expectedConfigFile - assert.Equal(t, expectedConfigFile, parser.GetConfigFile()) }) } @@ -130,6 +113,7 @@ func TestConfigCLIParser_Parse(t *testing.T) { require.ErrorAs(t, err, &fieldErr) assert.Len(t, fieldErr.Errors, 1) assert.Equal(t, "url", fieldErr.Errors[0].Field) + assert.Equal(t, "://invalid-url", fieldErr.Errors[0].Value) }) t.Run("Parse with method flag", func(t *testing.T) { @@ -272,6 +256,7 @@ func TestConfigCLIParser_Parse(t *testing.T) { require.ErrorAs(t, err, &fieldErr) assert.Len(t, fieldErr.Errors, 1) assert.Equal(t, "proxy[0]", fieldErr.Errors[0].Field) + assert.Equal(t, "://invalid-proxy", fieldErr.Errors[0].Value) }) t.Run("Parse with mixed valid and invalid proxies", func(t *testing.T) { @@ -283,6 +268,7 @@ func TestConfigCLIParser_Parse(t *testing.T) { require.ErrorAs(t, err, &fieldErr) assert.Len(t, fieldErr.Errors, 1) assert.Equal(t, "proxy[1]", fieldErr.Errors[0].Field) + assert.Equal(t, "://invalid", fieldErr.Errors[0].Value) }) t.Run("Parse with long flag names", func(t *testing.T) { @@ -318,7 +304,7 @@ func TestConfigCLIParser_Parse(t *testing.T) { require.NoError(t, err) require.NotNil(t, config) - assert.NotNil(t, parser.GetConfigFile()) + assert.Len(t, config.Files, 1) }) t.Run("Parse with config-file flag using long form", func(t *testing.T) { @@ -327,7 +313,7 @@ func TestConfigCLIParser_Parse(t *testing.T) { require.NoError(t, err) require.NotNil(t, config) - assert.NotNil(t, parser.GetConfigFile()) + assert.Len(t, config.Files, 1) }) t.Run("Parse with config-file flag invalid extension", func(t *testing.T) { @@ -338,7 +324,8 @@ func TestConfigCLIParser_Parse(t *testing.T) { var fieldErr types.FieldParseErrors require.ErrorAs(t, err, &fieldErr) assert.Len(t, fieldErr.Errors, 1) - assert.Equal(t, "config-file", fieldErr.Errors[0].Field) + assert.Equal(t, "config-file[0]", fieldErr.Errors[0].Field) + assert.Equal(t, "/path/to/config", fieldErr.Errors[0].Value) assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file extension not found") }) @@ -350,7 +337,8 @@ func TestConfigCLIParser_Parse(t *testing.T) { var fieldErr types.FieldParseErrors require.ErrorAs(t, err, &fieldErr) assert.Len(t, fieldErr.Errors, 1) - assert.Equal(t, "config-file", fieldErr.Errors[0].Field) + assert.Equal(t, "config-file[0]", fieldErr.Errors[0].Field) + assert.Equal(t, "/path/to/config.json", fieldErr.Errors[0].Value) assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file type") assert.Contains(t, fieldErr.Errors[0].Err.Error(), "not supported") }) @@ -361,7 +349,16 @@ func TestConfigCLIParser_Parse(t *testing.T) { require.NoError(t, err) require.NotNil(t, config) - assert.NotNil(t, parser.GetConfigFile()) + assert.Len(t, config.Files, 1) + }) + + t.Run("Parse with multiple config files", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/config1.yaml", "-f", "/path/config2.yml"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + assert.Len(t, config.Files, 2) }) t.Run("Parse with all flags combined", func(t *testing.T) { @@ -412,7 +409,7 @@ func TestConfigCLIParser_Parse(t *testing.T) { assert.Len(t, config.Proxies, 1) assert.Equal(t, "http://proxy.example.com:3128", config.Proxies[0].String()) - assert.NotNil(t, parser.GetConfigFile()) + assert.Len(t, config.Files, 1) }) t.Run("Parse with multiple field parse errors", func(t *testing.T) { @@ -437,6 +434,15 @@ func TestConfigCLIParser_Parse(t *testing.T) { assert.True(t, fields["url"]) assert.True(t, fields["proxy[0]"]) assert.True(t, fields["proxy[1]"]) + + // Check error values + values := make(map[string]string) + for _, parseErr := range fieldErr.Errors { + values[parseErr.Field] = parseErr.Value + } + assert.Equal(t, "://invalid-url", values["url"]) + assert.Equal(t, "://invalid-proxy1", values["proxy[0]"]) + assert.Equal(t, "://invalid-proxy2", values["proxy[1]"]) }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 86f2b18..0d30dc6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,6 +29,7 @@ var Defaults = struct { var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"} type Config struct { + Files []types.ConfigFile Method *string URL *url.URL Timeout *time.Duration @@ -49,6 +50,7 @@ func NewConfig() *Config { } func (config *Config) MergeConfig(newConfig *Config) { + config.Files = append(config.Files, newConfig.Files...) if newConfig.Method != nil { config.Method = newConfig.Method } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index e7c4f20..ff8a2d2 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -23,6 +23,7 @@ func TestMergeConfig(t *testing.T) { newDuration := 2 * time.Minute config := &Config{ + Files: []types.ConfigFile{}, Method: utils.ToPtr("GET"), URL: originalURL, Timeout: &originalTimeout, @@ -39,6 +40,7 @@ func TestMergeConfig(t *testing.T) { } newConfig := &Config{ + Files: []types.ConfigFile{}, Method: utils.ToPtr("POST"), URL: newURL, Timeout: &newTimeout, @@ -76,6 +78,7 @@ func TestMergeConfig(t *testing.T) { originalTimeout := 5 * time.Second config := &Config{ + Files: []types.ConfigFile{}, Method: utils.ToPtr("GET"), URL: originalURL, Timeout: &originalTimeout, @@ -85,6 +88,7 @@ func TestMergeConfig(t *testing.T) { newURL, _ := url.Parse("https://new.example.com") newConfig := &Config{ + Files: []types.ConfigFile{}, URL: newURL, DodosCount: utils.ToPtr(uint(10)), } @@ -103,6 +107,7 @@ func TestMergeConfig(t *testing.T) { originalTimeout := 5 * time.Second config := &Config{ + Files: []types.ConfigFile{}, Method: utils.ToPtr("GET"), URL: originalURL, Timeout: &originalTimeout, @@ -112,6 +117,7 @@ func TestMergeConfig(t *testing.T) { } newConfig := &Config{ + Files: []types.ConfigFile{}, Method: nil, URL: nil, Timeout: nil, @@ -132,7 +138,9 @@ func TestMergeConfig(t *testing.T) { }) t.Run("MergeConfig with empty slices", func(t *testing.T) { + configFile, _ := types.ParseConfigFile("original.yml") config := &Config{ + Files: []types.ConfigFile{*configFile}, Params: types.Params{{Key: "original", Value: []string{"value"}}}, Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, Cookies: types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, @@ -141,6 +149,7 @@ func TestMergeConfig(t *testing.T) { } newConfig := &Config{ + Files: []types.ConfigFile{}, Params: types.Params{}, Headers: types.Headers{}, Cookies: types.Cookies{}, @@ -150,6 +159,7 @@ func TestMergeConfig(t *testing.T) { config.MergeConfig(newConfig) + assert.Equal(t, []types.ConfigFile{*configFile}, config.Files, "Empty Files should not override") assert.Equal(t, types.Params{{Key: "original", Value: []string{"value"}}}, config.Params, "Empty Params should not override") assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Empty Headers should not override") assert.Equal(t, types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, config.Cookies, "Empty Cookies should not override") @@ -157,6 +167,28 @@ func TestMergeConfig(t *testing.T) { assert.Equal(t, types.Proxies{}, config.Proxies, "Empty Proxies should not override") }) + t.Run("MergeConfig with Files field", func(t *testing.T) { + configFile1, _ := types.ParseConfigFile("config1.yml") + configFile2, _ := types.ParseConfigFile("config2.yaml") + + config := &Config{ + Files: []types.ConfigFile{*configFile1}, + Method: utils.ToPtr("GET"), + Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, + } + + newConfig := &Config{ + Files: []types.ConfigFile{*configFile2}, + Method: utils.ToPtr("POST"), + } + + config.MergeConfig(newConfig) + + assert.Equal(t, "POST", *config.Method, "Method should be updated") + assert.Equal(t, []types.ConfigFile{*configFile1, *configFile2}, config.Files, "Files should be appended") + assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged") + }) + t.Run("MergeConfig on empty original config", func(t *testing.T) { config := &Config{} @@ -165,6 +197,7 @@ func TestMergeConfig(t *testing.T) { newDuration := 2 * time.Minute newConfig := &Config{ + Files: []types.ConfigFile{}, Method: utils.ToPtr("POST"), URL: newURL, Timeout: &newTimeout, @@ -246,6 +279,7 @@ func TestSetDefaults(t *testing.T) { t.Run("SetDefaults adds User-Agent when missing", func(t *testing.T) { config := &Config{ + Files: []types.ConfigFile{}, Headers: types.Headers{{Key: "Content-Type", Value: []string{"application/json"}}}, } @@ -268,6 +302,7 @@ func TestSetDefaults(t *testing.T) { t.Run("SetDefaults with partial config", func(t *testing.T) { config := &Config{ + Files: []types.ConfigFile{}, Method: utils.ToPtr("PUT"), Yes: utils.ToPtr(true), } @@ -306,6 +341,7 @@ func TestSetDefaults(t *testing.T) { t.Run("SetDefaults with empty Headers initializes correctly", func(t *testing.T) { config := &Config{ + Files: []types.ConfigFile{}, Headers: types.Headers{}, } diff --git a/pkg/types/errors.go b/pkg/types/errors.go index 90c5d3a..233659e 100644 --- a/pkg/types/errors.go +++ b/pkg/types/errors.go @@ -22,14 +22,15 @@ var ( type FieldParseError struct { Field string + Value string Err error } -func NewFieldParseError(field string, err error) *FieldParseError { +func NewFieldParseError(field string, value string, err error) *FieldParseError { if err == nil { err = ErrNoError } - return &FieldParseError{field, err} + return &FieldParseError{field, value, err} } func (e FieldParseError) Error() string { diff --git a/pkg/types/errors_test.go b/pkg/types/errors_test.go index 3788eaa..264ead7 100644 --- a/pkg/types/errors_test.go +++ b/pkg/types/errors_test.go @@ -10,7 +10,7 @@ import ( func TestFieldParseError_Error(t *testing.T) { t.Run("Error returns formatted message", func(t *testing.T) { originalErr := errors.New("invalid value") - fieldErr := NewFieldParseError("username", originalErr) + fieldErr := NewFieldParseError("username", "testuser", originalErr) expected := "Field 'username' parse failed: invalid value" assert.Equal(t, expected, fieldErr.Error()) @@ -18,14 +18,14 @@ func TestFieldParseError_Error(t *testing.T) { t.Run("Error with empty field name", func(t *testing.T) { originalErr := errors.New("test error") - fieldErr := NewFieldParseError("", originalErr) + fieldErr := NewFieldParseError("", "somevalue", originalErr) expected := "Field '' parse failed: test error" assert.Equal(t, expected, fieldErr.Error()) }) t.Run("Error with nil underlying error", func(t *testing.T) { - fieldErr := NewFieldParseError("field", nil) + fieldErr := NewFieldParseError("field", "value123", nil) expected := "Field 'field' parse failed: no error (internal)" assert.Equal(t, expected, fieldErr.Error()) @@ -35,13 +35,13 @@ func TestFieldParseError_Error(t *testing.T) { func TestFieldParseError_Unwrap(t *testing.T) { t.Run("Unwrap returns original error", func(t *testing.T) { originalErr := errors.New("original error") - fieldErr := NewFieldParseError("field", originalErr) + fieldErr := NewFieldParseError("field", "value", originalErr) assert.Equal(t, originalErr, fieldErr.Unwrap()) }) t.Run("Unwrap with nil error", func(t *testing.T) { - fieldErr := NewFieldParseError("field", nil) + fieldErr := NewFieldParseError("field", "value", nil) assert.Equal(t, ErrNoError, fieldErr.Unwrap()) }) @@ -50,16 +50,18 @@ func TestFieldParseError_Unwrap(t *testing.T) { func TestNewFieldParseError(t *testing.T) { t.Run("Creates FieldParseError with correct values", func(t *testing.T) { originalErr := errors.New("test error") - fieldErr := NewFieldParseError("testField", originalErr) + fieldErr := NewFieldParseError("testField", "testValue", originalErr) assert.Equal(t, "testField", fieldErr.Field) + assert.Equal(t, "testValue", fieldErr.Value) assert.Equal(t, originalErr, fieldErr.Err) }) t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) { - fieldErr := NewFieldParseError("testField", nil) + fieldErr := NewFieldParseError("testField", "testValue", nil) assert.Equal(t, "testField", fieldErr.Field) + assert.Equal(t, "testValue", fieldErr.Value) assert.Equal(t, ErrNoError, fieldErr.Err) }) } @@ -72,7 +74,7 @@ func TestFieldParseErrors_Error(t *testing.T) { }) t.Run("Error with single error returns single error message", func(t *testing.T) { - fieldErr := *NewFieldParseError("field1", errors.New("error1")) + fieldErr := *NewFieldParseError("field1", "value1", errors.New("error1")) fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr}) expected := "Field 'field1' parse failed: error1" @@ -80,9 +82,9 @@ func TestFieldParseErrors_Error(t *testing.T) { }) t.Run("Error with multiple errors returns concatenated messages", func(t *testing.T) { - fieldErr1 := *NewFieldParseError("field1", errors.New("error1")) - fieldErr2 := *NewFieldParseError("field2", errors.New("error2")) - fieldErr3 := *NewFieldParseError("field3", errors.New("error3")) + fieldErr1 := *NewFieldParseError("field1", "value1", errors.New("error1")) + fieldErr2 := *NewFieldParseError("field2", "value2", errors.New("error2")) + fieldErr3 := *NewFieldParseError("field3", "value3", errors.New("error3")) fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2, fieldErr3}) expected := "Field 'field1' parse failed: error1\nField 'field2' parse failed: error2\nField 'field3' parse failed: error3" @@ -90,8 +92,8 @@ func TestFieldParseErrors_Error(t *testing.T) { }) t.Run("Error with two errors", func(t *testing.T) { - fieldErr1 := *NewFieldParseError("username", errors.New("too short")) - fieldErr2 := *NewFieldParseError("email", errors.New("invalid format")) + fieldErr1 := *NewFieldParseError("username", "john", errors.New("too short")) + fieldErr2 := *NewFieldParseError("email", "invalid", errors.New("invalid format")) fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2}) expected := "Field 'username' parse failed: too short\nField 'email' parse failed: invalid format" @@ -101,8 +103,8 @@ func TestFieldParseErrors_Error(t *testing.T) { func TestNewFieldParseErrors(t *testing.T) { t.Run("Creates FieldParseErrors with correct values", func(t *testing.T) { - fieldErr1 := *NewFieldParseError("field1", errors.New("error1")) - fieldErr2 := *NewFieldParseError("field2", errors.New("error2")) + fieldErr1 := *NewFieldParseError("field1", "value1", errors.New("error1")) + fieldErr2 := *NewFieldParseError("field2", "value2", errors.New("error2")) fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2}) assert.Len(t, fieldErrors.Errors, 2) @@ -258,7 +260,7 @@ func TestErrorConstants(t *testing.T) { func TestErrorImplementsErrorInterface(t *testing.T) { t.Run("FieldParseError implements error interface", func(t *testing.T) { - var err error = NewFieldParseError("field", errors.New("test")) + var err error = NewFieldParseError("field", "value", errors.New("test")) assert.Error(t, err) })