From 633f548e7b55fcad5cb1fe358cd52bba61af926f Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 30 Sep 2026 01:07:28 +0400 Subject: [PATCH] fix(config): report YAML config errors more clearly --- internal/config/bytesize.go | 11 +-------- internal/config/config.go | 15 ++++++++---- internal/config/file.go | 46 ++++++++++++++++++++++++++++++++++++- internal/types/errors.go | 23 +++++++++++++++++++ 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/internal/config/bytesize.go b/internal/config/bytesize.go index 50abbc4..f770d4a 100644 --- a/internal/config/bytesize.go +++ b/internal/config/bytesize.go @@ -30,20 +30,11 @@ func (size *byteSize) Set(value string) error { return nil } -// UnmarshalYAML accepts both a YAML integer and a string with a unit. +// UnmarshalYAML reads the raw scalar so a size parses the same way here as on the command line. func (size *byteSize) UnmarshalYAML(node *yaml.Node) error { if node.Kind != yaml.ScalarNode { return fmt.Errorf("expected a size, but got %s", node.Tag) } - if node.Tag == "!!int" { - var parsed uint64 - if err := node.Decode(&parsed); err != nil { - return types.ErrByteSizeInvalid - } - *size = byteSize(parsed) - return nil - } - return size.Set(node.Value) } diff --git a/internal/config/config.go b/internal/config/config.go index 397dede..ff81115 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -652,12 +652,19 @@ func ReadAllConfigs() *Config { for _, configFile := range append(envConfig.Files, cliConf.Files...) { fileConfig, err := parseConfigFile(configFile, 10) + + // A nested configFile fails under its own name, not the one given on the command line. + path := configFile.Path() + if configFileErr, ok := errors.AsType[types.ConfigFileError](err); ok { + path = configFileErr.Path + } + _ = utilsErr.MustHandle(err, utilsErr.OnType(func(err types.ConfigFileReadError) error { cliParser.PrintHelp() fmt.Fprint(os.Stderr, lipgloss.Sprintln( StyleYellow.Render( - fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(), + fmt.Sprintf("\nFailed to read config file (%s): ", path)+err.Error(), ), )) os.Exit(1) @@ -666,14 +673,14 @@ func ReadAllConfigs() *Config { utilsErr.OnType(func(err types.UnmarshalError) error { fmt.Fprint(os.Stderr, lipgloss.Sprintln( StyleYellow.Render( - fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(), + fmt.Sprintf("\nFailed to parse config file (%s): ", path)+err.Error(), ), )) os.Exit(1) return nil }), utilsErr.OnType(func(err types.FieldParseErrors) error { - printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...) + printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", path), err.Errors...) os.Exit(1) return nil }), @@ -697,7 +704,7 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error) configFileParser := NewConfigFileParser(configFile) fileConfig, err := configFileParser.Parse() if err != nil { - return nil, err + return nil, types.NewConfigFileError(configFile.Path(), err) } if maxDepth <= 0 { diff --git a/internal/config/file.go b/internal/config/file.go index 3199cf8..8c181d0 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -3,12 +3,14 @@ package config import ( "bytes" "context" + "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" + "reflect" "strings" "time" @@ -240,7 +242,15 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { decoder.KnownFields(true) // The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one. if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint - return nil, types.NewUnmarshalError(err) + return nil, types.NewUnmarshalError(yamlError(err)) + } + + // Decode reads one document, so anything after the first "---" would be dropped silently. + switch err := decoder.Decode(&yaml.Node{}); { + case err == nil: + return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments) + case err != io.EOF: //nolint:errorlint + return nil, types.NewUnmarshalError(yamlError(err)) } var fieldParseErrors []types.FieldParseError @@ -315,3 +325,37 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { return config, nil } + +// configYAMLTypeName is the Go type name the yaml package puts in its messages. +var configYAMLTypeName = reflect.TypeFor[configYAML]().String() + +// yamlError rewrites yaml decode errors so they name config keys instead of Go types. +func yamlError(err error) error { + var loadErrors *yaml.LoadErrors + if !errors.As(err, &loadErrors) || len(loadErrors.Errors) == 0 { + return err + } + + var builder strings.Builder + for i, loadError := range loadErrors.Errors { + if i > 0 { + builder.WriteString("\n") + } + if loadError.Mark.Line > 0 { + fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line) + } + builder.WriteString(yamlErrorMessage(loadError.Message)) + } + + return errors.New(builder.String()) +} + +// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"". +func yamlErrorMessage(message string) string { + if field, ok := strings.CutPrefix(message, "field "); ok { + if name, _, ok := strings.Cut(field, " not found in type "); ok { + return fmt.Sprintf("unknown key %q", name) + } + } + return strings.ReplaceAll(message, configYAMLTypeName, "the config file") +} diff --git a/internal/types/errors.go b/internal/types/errors.go index a6c4e4f..beaa15d 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -19,6 +19,8 @@ var ( ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit)) ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit") + + ErrYAMLMultipleDocuments = errors.New("expected a single YAML document, but found more than one") ) type FieldParseError struct { @@ -306,6 +308,27 @@ func (e CLIUnexpectedArgsError) Error() string { // ======================================== Config File ======================================== +// ConfigFileError names the file a parse error came from, which nested configFile chains lose. +type ConfigFileError struct { + Path string + error error +} + +func NewConfigFileError(path string, err error) ConfigFileError { + if err == nil { + err = errNoError + } + return ConfigFileError{path, err} +} + +func (e ConfigFileError) Error() string { + return e.error.Error() +} + +func (e ConfigFileError) Unwrap() error { + return e.error +} + type ConfigFileReadError struct { error error }