diff --git a/internal/edit/text.go b/internal/edit/text.go index 61f5f324..235f23eb 100644 --- a/internal/edit/text.go +++ b/internal/edit/text.go @@ -5,6 +5,8 @@ import ( "regexp" "strconv" "strings" + + "gopkg.in/yaml.v3" ) // Top-level YAML keys are edited line by line. That keeps every comment and @@ -44,7 +46,9 @@ func SetYAMLTop(path string, kvs ...KV) error { lines := splitLines(string(raw)) for _, kv := range kvs { v := toString(kv.Value) - if !yamlPlain.MatchString(v) { + var decoded any + _, isString := kv.Value.(string) + if !yamlPlain.MatchString(v) || (isString && (yaml.Unmarshal([]byte(v), &decoded) != nil || decoded != v)) { v = strconv.Quote(v) } lines = setLine(lines, kv.Path, kv.Path+": "+v, nil, func(line string) (string, bool) { @@ -139,8 +143,9 @@ func tomlValue(v string) string { } func yamlValue(v string) string { - if s, ok := unquote(v); ok { - return s + var n yaml.Node + if err := yaml.Unmarshal([]byte(v), &n); err == nil && len(n.Content) > 0 && n.Content[0].Kind == yaml.ScalarNode { + return n.Content[0].Value } if i := strings.Index(v, " #"); i >= 0 { v = strings.TrimSpace(v[:i]) diff --git a/internal/edit/yaml_roundtrip_test.go b/internal/edit/yaml_roundtrip_test.go new file mode 100644 index 00000000..3a633fbe --- /dev/null +++ b/internal/edit/yaml_roundtrip_test.go @@ -0,0 +1,57 @@ +package edit + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestYAMLTopStringScalars(t *testing.T) { + for _, v := range []string{"null", "true", "123", "a\t\nb"} { + p := filepath.Join(t.TempDir(), "goose.yaml") + if e := SetYAMLTop(p, KV{"GOOSE_MODEL", v}); e != nil { + t.Fatal(e) + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if e := yaml.Unmarshal(b, &m); e != nil { + t.Fatal(e) + } + if got, ok := m["GOOSE_MODEL"].(string); !ok || got != v { + t.Errorf("value %q serialized %q reads %T %v", v, b, m["GOOSE_MODEL"], m["GOOSE_MODEL"]) + } + } +} +func TestYAMLTopSingleQuotedEscape(t *testing.T) { + p := tmpFile(t, "goose.yaml", "GOOSE_MODEL: 'owner''s/model'\n") + v, ok := GetYAMLTop(p, "GOOSE_MODEL") + if !ok { + t.Fatal("model not found") + } + if v != "owner's/model" { + t.Fatalf("got %q", v) + } +} + +func TestYAMLTopTypedValues(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.yaml") + if err := SetYAMLTop(p, KV{"enabled", true}, KV{"count", 123}); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := yaml.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if m["enabled"] != true || m["count"] != 123 { + t.Fatalf("types changed: %#v", m) + } +}