fix(config): show precise malformed config diagnostics
This commit is contained in:
parent
6ce0306c66
commit
cf63c9711f
6 changed files with 518 additions and 7 deletions
|
|
@ -996,7 +996,9 @@ func LoadConfig(path string) (*Config, error) {
|
|||
Version int `json:"version"`
|
||||
}
|
||||
if e := json.Unmarshal(data, &versionInfo); e != nil {
|
||||
return nil, fmt.Errorf("failed to detect config version: %w", e)
|
||||
e = wrapJSONError(data, e, "config.json")
|
||||
logger.ErrorCF("config", formatDiagnosticLogMessage("Malformed config file", e), map[string]any{"path": path})
|
||||
return nil, e
|
||||
}
|
||||
if len(data) <= 10 {
|
||||
logger.Warn(fmt.Sprintf("content is [%s]", string(data)))
|
||||
|
|
@ -1014,6 +1016,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||
// Legacy config (no version field)
|
||||
v, e := loadConfigV0(data)
|
||||
if e != nil {
|
||||
logger.ErrorCF("config", formatDiagnosticLogMessage("Failed to load legacy config", e), map[string]any{"path": path})
|
||||
return nil, e
|
||||
}
|
||||
cfg, e = v.Migrate()
|
||||
|
|
@ -1052,6 +1055,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||
)
|
||||
cfg, err = loadConfig(data)
|
||||
if err != nil {
|
||||
logger.ErrorCF("config", formatDiagnosticLogMessage("Failed to load config", err), map[string]any{"path": path})
|
||||
return nil, err
|
||||
}
|
||||
secPath := securityPath(path)
|
||||
|
|
@ -1086,6 +1090,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||
// Current version
|
||||
cfg, err = loadConfig(data)
|
||||
if err != nil {
|
||||
logger.ErrorCF("config", formatDiagnosticLogMessage("Failed to load config", err), map[string]any{"path": path})
|
||||
return nil, err
|
||||
}
|
||||
// Load security configuration
|
||||
|
|
|
|||
|
|
@ -588,6 +588,72 @@ func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_SyntaxErrorReportsLineAndColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"enabled\": true,,\n \"format\": \"markdown\"\n }\n }\n}\n"
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(configPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected syntax error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "syntax error at line 5, column 23") {
|
||||
t.Fatalf("expected line/column diagnostic, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "\"enabled\": true,,") {
|
||||
t.Fatalf("expected source snippet in diagnostic, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "^") {
|
||||
t.Fatalf("expected caret marker in diagnostic, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_TypeErrorReportsFieldPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"fetch_limit_bytes\": \"oops\"\n }\n }\n}\n"
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(configPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected type error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "type error at line 5, column 33") {
|
||||
t.Fatalf("expected line/column diagnostic, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fetch_limit_bytes") {
|
||||
t.Fatalf("expected field name in diagnostic, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "\"fetch_limit_bytes\": \"oops\"") {
|
||||
t.Fatalf("expected source snippet in diagnostic, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "^") {
|
||||
t.Fatalf("expected caret marker in diagnostic, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_UnknownFieldsReportsExactPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
raw := "{\n \"version\": 2,\n \"tools\": {\n \"weeb\": {\n \"enabled\": true\n },\n \"web\": {\n \"fatch_limit_bytes\": 123\n }\n }\n}\n"
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(configPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown field error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "tools.weeb") || !strings.Contains(err.Error(), "tools.web.fatch_limit_bytes") {
|
||||
t.Fatalf("expected exact unknown field paths, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.Exec.AllowRemote {
|
||||
|
|
|
|||
441
pkg/config/diagnostics.go
Normal file
441
pkg/config/diagnostics.go
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func decodeJSONWithDiagnostics(data []byte, target any, label string) error {
|
||||
var raw any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return wrapJSONError(data, err, label)
|
||||
}
|
||||
|
||||
unknownFields := collectUnknownJSONFields(raw, reflect.TypeOf(target), "")
|
||||
if len(unknownFields) > 0 {
|
||||
sort.Strings(unknownFields)
|
||||
return fmt.Errorf(
|
||||
"%s contains unknown field(s): %s",
|
||||
label,
|
||||
strings.Join(unknownFields, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return wrapJSONError(data, err, label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DiagnosticSummary(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
summary, _ := splitDiagnosticError(err.Error())
|
||||
return stripANSISequences(summary)
|
||||
}
|
||||
|
||||
func formatDiagnosticLogMessage(prefix string, err error) string {
|
||||
if err == nil {
|
||||
return prefix
|
||||
}
|
||||
|
||||
summary, preview := splitDiagnosticError(err.Error())
|
||||
summary = stripANSISequences(summary)
|
||||
if preview == "" {
|
||||
if summary == "" {
|
||||
return prefix
|
||||
}
|
||||
return prefix + ": " + summary
|
||||
}
|
||||
if summary == "" {
|
||||
return prefix + "\n" + preview
|
||||
}
|
||||
return prefix + ": " + summary + "\n" + preview
|
||||
}
|
||||
|
||||
func wrapJSONError(data []byte, err error, label string) error {
|
||||
switch e := err.(type) {
|
||||
case *json.SyntaxError:
|
||||
line, column := lineAndColumnForOffset(data, e.Offset)
|
||||
preview := diagnosticPreviewForOffset(data, e.Offset)
|
||||
if preview != "" {
|
||||
return fmt.Errorf(
|
||||
"%s syntax error at line %d, column %d: %w\n%s",
|
||||
label,
|
||||
line,
|
||||
column,
|
||||
err,
|
||||
preview,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("%s syntax error at line %d, column %d: %w", label, line, column, err)
|
||||
case *json.UnmarshalTypeError:
|
||||
line, column := lineAndColumnForOffset(data, e.Offset)
|
||||
preview := diagnosticPreviewForOffset(data, e.Offset)
|
||||
field := strings.TrimSpace(e.Field)
|
||||
if field != "" {
|
||||
if preview != "" {
|
||||
return fmt.Errorf(
|
||||
"%s type error at line %d, column %d for field %q: expected %s but got %s\n%s",
|
||||
label,
|
||||
line,
|
||||
column,
|
||||
field,
|
||||
e.Type.String(),
|
||||
e.Value,
|
||||
preview,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%s type error at line %d, column %d for field %q: expected %s but got %s",
|
||||
label,
|
||||
line,
|
||||
column,
|
||||
field,
|
||||
e.Type.String(),
|
||||
e.Value,
|
||||
)
|
||||
}
|
||||
if preview != "" {
|
||||
return fmt.Errorf(
|
||||
"%s type error at line %d, column %d: expected %s but got %s\n%s",
|
||||
label,
|
||||
line,
|
||||
column,
|
||||
e.Type.String(),
|
||||
e.Value,
|
||||
preview,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%s type error at line %d, column %d: expected %s but got %s",
|
||||
label,
|
||||
line,
|
||||
column,
|
||||
e.Type.String(),
|
||||
e.Value,
|
||||
)
|
||||
default:
|
||||
return fmt.Errorf("failed to parse %s: %w", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
func splitDiagnosticError(message string) (string, string) {
|
||||
if idx := strings.IndexByte(message, '\n'); idx >= 0 {
|
||||
return message[:idx], message[idx+1:]
|
||||
}
|
||||
return message, ""
|
||||
}
|
||||
|
||||
func stripANSISequences(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != 0x1b {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(s) || s[i+1] != '[' {
|
||||
continue
|
||||
}
|
||||
i += 2
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
if c >= '@' && c <= '~' {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func diagnosticPreviewForOffset(data []byte, offset int64) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
start, end := lineBoundsForOffset(data, offset)
|
||||
if start >= end {
|
||||
return ""
|
||||
}
|
||||
|
||||
lineNumber, column := lineAndColumnForOffset(data, offset)
|
||||
line := strings.TrimRight(string(data[start:end]), "\r\n")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
trimmedLine, trimOffset := trimDiagnosticLine(line, column)
|
||||
if trimmedLine == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
prefix := fmt.Sprintf("%4d | ", lineNumber)
|
||||
caretColumn := column - trimOffset
|
||||
if caretColumn < 1 {
|
||||
caretColumn = 1
|
||||
}
|
||||
|
||||
if diagnosticsUseColor() {
|
||||
linePrefix := "\x1b[2m" + prefix + "\x1b[0m"
|
||||
caretPrefix := "\x1b[2m" + strings.Repeat(" ", len(fmt.Sprintf("%4d", lineNumber))) + " | " + "\x1b[0m"
|
||||
highlighted := highlightDiagnosticColumn(trimmedLine, caretColumn)
|
||||
caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1))
|
||||
return fmt.Sprintf(
|
||||
" %s%s\n %s%s\x1b[1;31m^\x1b[0m",
|
||||
linePrefix,
|
||||
highlighted,
|
||||
caretPrefix,
|
||||
caretPad,
|
||||
)
|
||||
}
|
||||
|
||||
caretPrefix := strings.Repeat(" ", len(prefix))
|
||||
caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1))
|
||||
return fmt.Sprintf(
|
||||
" %s%s\n %s%s^",
|
||||
prefix,
|
||||
trimmedLine,
|
||||
caretPrefix,
|
||||
caretPad,
|
||||
)
|
||||
}
|
||||
|
||||
func lineAndColumnForOffset(data []byte, offset int64) (int, int) {
|
||||
if offset <= 0 {
|
||||
return 1, 1
|
||||
}
|
||||
if offset > int64(len(data)) {
|
||||
offset = int64(len(data))
|
||||
}
|
||||
|
||||
line := 1
|
||||
column := 1
|
||||
for i := int64(0); i < offset-1; i++ {
|
||||
if data[i] == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
continue
|
||||
}
|
||||
column++
|
||||
}
|
||||
return line, column
|
||||
}
|
||||
|
||||
func lineBoundsForOffset(data []byte, offset int64) (int, int) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
if offset <= 0 {
|
||||
offset = 1
|
||||
}
|
||||
if offset > int64(len(data)) {
|
||||
offset = int64(len(data))
|
||||
}
|
||||
|
||||
index := int(offset - 1)
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(data) {
|
||||
index = len(data) - 1
|
||||
}
|
||||
|
||||
start := index
|
||||
for start > 0 && data[start-1] != '\n' {
|
||||
start--
|
||||
}
|
||||
|
||||
end := index
|
||||
for end < len(data) && data[end] != '\n' {
|
||||
end++
|
||||
}
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
func trimDiagnosticLine(line string, column int) (string, int) {
|
||||
runes := []rune(line)
|
||||
if len(runes) == 0 {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
if len(runes) <= 160 {
|
||||
return line, 0
|
||||
}
|
||||
|
||||
const contextBefore = 60
|
||||
const maxWidth = 160
|
||||
|
||||
start := column - 1 - contextBefore
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start > len(runes)-maxWidth {
|
||||
start = len(runes) - maxWidth
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
end := start + maxWidth
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
|
||||
trimmed := string(runes[start:end])
|
||||
trimOffset := start
|
||||
|
||||
if start > 0 {
|
||||
trimmed = "..." + trimmed
|
||||
trimOffset -= 3
|
||||
}
|
||||
if end < len(runes) {
|
||||
trimmed += "..."
|
||||
}
|
||||
|
||||
return trimmed, trimOffset
|
||||
}
|
||||
|
||||
func diagnosticsUseColor() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
func highlightDiagnosticColumn(line string, column int) string {
|
||||
runes := []rune(line)
|
||||
if column < 1 || column > len(runes) {
|
||||
return line
|
||||
}
|
||||
|
||||
index := column - 1
|
||||
return string(runes[:index]) + "\x1b[31m" + string(runes[index]) + "\x1b[0m" + string(runes[index+1:])
|
||||
}
|
||||
|
||||
func maxRuneCount(s string, count int) int {
|
||||
if count <= 0 {
|
||||
return 0
|
||||
}
|
||||
runes := []rune(s)
|
||||
if count > len(runes) {
|
||||
count = len(runes)
|
||||
}
|
||||
return utf8.RuneCountInString(string(runes[:count]))
|
||||
}
|
||||
|
||||
func collectUnknownJSONFields(raw any, targetType reflect.Type, path string) []string {
|
||||
targetType = derefType(targetType)
|
||||
if targetType == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch targetType.Kind() {
|
||||
case reflect.Struct:
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
fieldMap := jsonFieldTypeMap(targetType)
|
||||
var issues []string
|
||||
for key, value := range obj {
|
||||
fieldType, exists := fieldMap[key]
|
||||
fieldPath := appendJSONPath(path, key)
|
||||
if !exists {
|
||||
issues = append(issues, fieldPath)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, collectUnknownJSONFields(value, fieldType, fieldPath)...)
|
||||
}
|
||||
return issues
|
||||
case reflect.Slice, reflect.Array:
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var issues []string
|
||||
elemType := targetType.Elem()
|
||||
for i, item := range items {
|
||||
itemPath := fmt.Sprintf("%s[%d]", path, i)
|
||||
issues = append(issues, collectUnknownJSONFields(item, elemType, itemPath)...)
|
||||
}
|
||||
return issues
|
||||
case reflect.Map:
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var issues []string
|
||||
elemType := targetType.Elem()
|
||||
for key, value := range obj {
|
||||
fieldPath := appendJSONPath(path, key)
|
||||
issues = append(issues, collectUnknownJSONFields(value, elemType, fieldPath)...)
|
||||
}
|
||||
return issues
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func jsonFieldTypeMap(t reflect.Type) map[string]reflect.Type {
|
||||
result := make(map[string]reflect.Type)
|
||||
populateJSONFieldTypeMap(result, derefType(t))
|
||||
return result
|
||||
}
|
||||
|
||||
func populateJSONFieldTypeMap(result map[string]reflect.Type, t reflect.Type) {
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := field.Tag.Get("json")
|
||||
name := strings.Split(tag, ",")[0]
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Anonymous && name == "" {
|
||||
populateJSONFieldTypeMap(result, derefType(field.Type))
|
||||
continue
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = field.Name
|
||||
}
|
||||
result[name] = field.Type
|
||||
}
|
||||
}
|
||||
|
||||
func derefType(t reflect.Type) reflect.Type {
|
||||
for t != nil && t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func appendJSONPath(path, segment string) string {
|
||||
if path == "" {
|
||||
return segment
|
||||
}
|
||||
return path + "." + segment
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
|
@ -477,7 +476,7 @@ func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 {
|
|||
// loadConfigV0 loads a legacy config (no version field)
|
||||
func loadConfigV0(data []byte) (migratable, error) {
|
||||
var v0 configV0
|
||||
if err := json.Unmarshal(data, &v0); err != nil {
|
||||
if err := decodeJSONWithDiagnostics(data, &v0, "config.json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -522,14 +521,14 @@ func loadConfig(data []byte) (*Config, error) {
|
|||
// index position. We only reset cfg.ModelList when the user actually provides
|
||||
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
|
||||
var tmp Config
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tmp.ModelList) > 0 {
|
||||
cfg.ModelList = nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ func loadSecurityConfig(cfg *Config, securityPath string) error {
|
|||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("failed to parse security config: %w", err)
|
||||
return fmt.Errorf("failed to parse security config %s: %w", securityPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
|||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
logger.Fatalf("error loading config: %v", err)
|
||||
logger.FatalCF("gateway", "error loading config", map[string]any{"error": config.DiagnosticSummary(err)})
|
||||
}
|
||||
|
||||
if err = preCheckConfig(cfg); err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue