package config import ( "fmt" "os" "strconv" "strings" "github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/parser" "github.com/goccy/go-yaml/token" ) // SetConfigValue updates a config value in the YAML config file on disk. // It does not update the in-memory config or viper state. Callers that need // the updated value must re-initialize the config after calling this function. func SetConfigValue(key, value string) error { if globalConfig.IsManaged() { return NewManagedConfigError() } configPath, err := userConfigFilePath() if err != nil { return fmt.Errorf("failed to get config file path: %w", err) } if err := ensureConfigFileExists(configPath); err != nil { return err } fi, err := os.Stat(configPath) if err != nil { return fmt.Errorf("failed to stat config file: %w", err) } data, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("failed to read config file: %w", err) } result, err := setValueInYAML(data, key, value) if err != nil { return fmt.Errorf("failed to set config value: %w", err) } if err := os.WriteFile(configPath, result, fi.Mode()); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil } func GetConfigValue(key string) (any, error) { if key == "" { return nil, fmt.Errorf("key cannot be empty") } v := Get().viper if v == nil { return nil, fmt.Errorf("config not initialized") } if !v.IsSet(key) { return nil, fmt.Errorf("unknown config key: %s", key) } return v.Get(key), nil } func setValueInYAML(data []byte, key, value string) ([]byte, error) { if key == "" { return nil, fmt.Errorf("key cannot be empty") } file, err := parser.ParseBytes(data, parser.ParseComments) if err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } if len(file.Docs) == 0 { return nil, fmt.Errorf("no documents found in YAML") } root, ok := file.Docs[0].Body.(*ast.MappingNode) if !ok { return nil, fmt.Errorf("root is not a mapping node") } segments := strings.Split(key, ".") if err := setValueAtPath(root, segments, value); err != nil { return nil, err } return []byte(file.String()), nil } func setValueAtPath(node *ast.MappingNode, segments []string, value string) error { if len(segments) == 0 { return fmt.Errorf("key cannot be empty") } target := segments[0] for _, mv := range node.Values { if mv.Key.String() != target { continue } if len(segments) == 1 { return replaceScalarValue(mv, value) } childMapping, ok := mv.Value.(*ast.MappingNode) if !ok { return fmt.Errorf("key not found: intermediate key %q is not a mapping", target) } return setValueAtPath(childMapping, segments[1:], value) } return fmt.Errorf("key not found: %q", target) } func replaceScalarValue(mv *ast.MappingValueNode, value string) error { if mv.Value == nil { return fmt.Errorf("cannot set value: %q has no existing value", mv.Key.String()) } pos := mv.Value.GetToken().Position switch mv.Value.(type) { case *ast.MappingNode: return fmt.Errorf("cannot set value on non-scalar node: %q is a mapping", mv.Key.String()) case *ast.SequenceNode: return fmt.Errorf("cannot set value on non-scalar node: %q is a sequence", mv.Key.String()) case *ast.StringNode: newNode, err := createStringNode(value, pos) if err != nil { return err } return mv.Replace(newNode) case *ast.BoolNode: if value != "true" && value != "false" { return fmt.Errorf("invalid value %q for %q: expected true or false", value, mv.Key.String()) } case *ast.IntegerNode: if _, err := strconv.ParseInt(value, 10, 64); err != nil { return fmt.Errorf("invalid value %q for %q: expected an integer", value, mv.Key.String()) } default: return fmt.Errorf("unsupported node type for key %q: %T", mv.Key.String(), mv.Value) } newNode, err := createScalarNode(value, pos) if err != nil { return err } return mv.Replace(newNode) } func createStringNode(value string, pos *token.Position) (ast.Node, error) { newPos := &token.Position{ Line: pos.Line, Column: pos.Column, Offset: pos.Offset, } if needsQuoting(value) { tk := token.New(value, value, newPos) tk.Type = token.DoubleQuoteType return ast.String(tk), nil } tk := token.String(value, value, newPos) return ast.String(tk), nil } func needsQuoting(value string) bool { if value == "" { return true } if value == "true" || value == "false" || value == "null" || value == "True" || value == "False" || value == "yes" || value == "no" { return true } if _, err := strconv.ParseInt(value, 10, 64); err == nil { return true } if _, err := strconv.ParseFloat(value, 64); err == nil { return true } return false } func createScalarNode(value string, pos *token.Position) (ast.Node, error) { newPos := &token.Position{ Line: pos.Line, Column: pos.Column, Offset: pos.Offset, } if value == "true" || value == "false" { tk := token.New(value, value, newPos) return ast.Bool(tk), nil } if _, err := strconv.ParseInt(value, 10, 64); err == nil { tk := token.New(value, value, newPos) return ast.Integer(tk), nil } tk := token.String(value, value, newPos) return ast.String(tk), nil } func ensureConfigFileExists(path string) error { _, err := os.Stat(path) if err == nil { return nil } if !os.IsNotExist(err) { return fmt.Errorf("failed to stat config file %q: %w", path, err) } return WriteTemplateConfig() }