feat: unify generated configuration loading and extensible decoders
This commit is contained in:
94
config/diagnostic.go
Normal file
94
config/diagnostic.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Error is a value-free diagnostic. Cause may contain private parser details;
|
||||
// errors.Is/As remain available, but Error never formats Cause automatically.
|
||||
type Error struct {
|
||||
File, Field, Env, Reason string
|
||||
Line, Column int
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
where := "config"
|
||||
if e.File != "" {
|
||||
where = fmt.Sprintf("config %q", e.File)
|
||||
}
|
||||
if e.Line > 0 {
|
||||
where += fmt.Sprintf(":%d:%d", e.Line, e.Column)
|
||||
}
|
||||
if e.Env != "" {
|
||||
where += fmt.Sprintf(" env %q", e.Env)
|
||||
}
|
||||
if e.Field != "" {
|
||||
where += fmt.Sprintf(" field %q", e.Field)
|
||||
}
|
||||
return where + ": " + e.Reason
|
||||
}
|
||||
func (e *Error) Unwrap() error { return e.cause }
|
||||
|
||||
// DecodeError is retained for callers of errors.As from earlier releases.
|
||||
type DecodeError = Error
|
||||
|
||||
// Invalid reports a public, value-free validation reason for a configuration field.
|
||||
func Invalid(field, reason string) error { return &ConfigError{Field: field, Message: reason} }
|
||||
|
||||
// AtField prefixes a nested validation error without losing its original cause.
|
||||
// Unstructured validation errors are deliberately not echoed; use Invalid when
|
||||
// a custom validator needs a public reason rather than a private error message.
|
||||
func AtField(field string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
d := validationError(err)
|
||||
d.Field = joinField(field, d.Field)
|
||||
return d
|
||||
}
|
||||
func validationError(err error) *Error {
|
||||
var d *Error
|
||||
if errors.As(err, &d) {
|
||||
out := *d
|
||||
out.cause = err
|
||||
return &out
|
||||
}
|
||||
var c *ConfigError
|
||||
if errors.As(err, &c) {
|
||||
return &Error{Field: c.Field, Reason: c.Message, cause: err}
|
||||
}
|
||||
return &Error{Reason: "validation failed", cause: err}
|
||||
}
|
||||
func joinField(parent, child string) string {
|
||||
if parent == "" {
|
||||
return child
|
||||
}
|
||||
if child == "" {
|
||||
return parent
|
||||
}
|
||||
if strings.HasPrefix(child, "[") {
|
||||
return parent + child
|
||||
}
|
||||
return parent + "." + child
|
||||
}
|
||||
|
||||
// Position uses one-based source line/column numbers. Zero means unavailable.
|
||||
type Position struct{ Line, Column int }
|
||||
|
||||
// Document is the common decoder result. Values use JSON-compatible data:
|
||||
// string, bool, json.Number, nil, []any, map[string]any. Text is available for
|
||||
// formats such as XML whose scalar tokens have no inherent JSON type.
|
||||
type Document struct {
|
||||
Value any
|
||||
Positions map[string]Position
|
||||
}
|
||||
|
||||
type Text string
|
||||
|
||||
func (d Document) diagnostic(field, reason string, cause error) *Error {
|
||||
p := d.Positions[field]
|
||||
return &Error{Field: field, Reason: reason, Line: p.Line, Column: p.Column, cause: cause}
|
||||
}
|
||||
Reference in New Issue
Block a user