Files
magpie/internal/edit/toml_write_test.go
T
yetone d37b821908 toml: Codex's config.toml with a multi-line array (notify = [ … ]) is edited right, and an edit that fails leaves every file as it was (#253 kerwinliu00001: "edited TOML is invalid: line 2, column 1: incomplete number", config.toml left with model_provider gone and [model_providers.magpie] added but no model) — the top-level key edits (GetTOMLTop, SetTOMLTop, DelTOMLTop) went line by line with regexes: a new key went after the last line that looked like key = …, which for notify = [ was inside the array; key = value lines inside a multi-line string were read as keys and a line starting with [ as a table; replacing or deleting a multi-line value touched only its first line. They now locate the top-level keys with the TOML parser, as the table edits already did, each with its value's whole range however many lines it takes (parseTOMLFile, the unnamed root table beside the tables); SetTOMLTop finds all its keys in the file as read and writes once. A file the parser refuses is still read line by line, so a broken config keeps showing its model, and writes to it are refused untouched. And a pick is several writes (Codex: drop model_provider, add magpie's provider table, set openai_base_url and model), each checked, so one failing left the earlier ones on disk: edit.Atomically(fn, paths…) puts each file back to its bytes and mode, or removes one it created, when fn fails, and every field set and Sync of Codex (config.toml and magpie-models.json, so Codex in WSL too), Kimi and Grok goes through it, as do Codex's MCP server writes (putCodex, delCodex). Tested in temp dirs and a temp HOME: the reporter's file read, set and deleted directly and through Codex's model field (signed in, with model_provider and model above the array, with magpie as the provider), the array's text kept and the TOML valid, and back to a Codex model; nested arrays, trailing commas, comments in arrays, [x] and a = b in strings, an inline table in an array; """ and ''' strings holding model = … and [table] lines not read as keys, new keys after their closing quotes, one deleted whole; a forced failure of Codex's last write leaving config.toml byte-identical; TestAtomically. They fail on main (the Codex ones with the reporter's error). go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real ~/.codex, in WSL or in the GUI; multi-line inline tables (TOML 1.1) are refused by the parser and so left untouched with an error.
2026-09-30 09:31:59 +08:00

139 lines
5.1 KiB
Go

package edit
import (
"errors"
"os"
"strings"
"testing"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2/unstable"
)
func TestTOMLWritesRejectInvalidOutput(t *testing.T) {
for _, tc := range []struct {
name string
edit func(string) error
}{
{"table name", func(p string) error {
return SetTOMLTable(p, "mcp_servers.my server", KV{Path: "name", Value: "new"})
}},
{"table key", func(p string) error {
return SetTOMLTable(p, "a", KV{Path: "bad key", Value: "new"})
}},
{"table value", func(p string) error {
return SetTOMLTable(p, "a", KV{Path: "name", Value: Raw("[")})
}},
{"key table name", func(p string) error {
return SetTOMLKey(p, "mcp_servers.my server", "name", "new")
}},
{"key name", func(p string) error { return SetTOMLKey(p, "a", "bad key", "new") }},
{"key value", func(p string) error { return SetTOMLKey(p, "a", "name", Raw("[")) }},
{"empty value", func(p string) error { return SetTOMLKey(p, "a", "name", Raw("")) }},
{"invalid date", func(p string) error { return SetTOMLKey(p, "a", "name", Raw("2026-09-99")) }},
{"batch table name", func(p string) error {
return SetTOMLTables(p, []string{"a"}, []Table{{Name: "mcp_servers.my server"}})
}},
{"batch key", func(p string) error {
return SetTOMLTables(p, []string{"a"}, []Table{{Name: "a", KVs: []KV{{Path: "bad key", Value: "new"}}}})
}},
{"top key", func(p string) error { return SetTOMLTop(p, KV{Path: "bad key", Value: "new"}) }},
} {
t.Run(tc.name, func(t *testing.T) {
for _, state := range []string{"existing", "missing"} {
t.Run(state, func(t *testing.T) {
input := ""
if state == "existing" {
input = "# keep\n[a]\nname = \"old\" # keep\n\n[b]\nenabled = true\n"
}
p := tmpFile(t, "config.toml", input)
err := tc.edit(p)
if err == nil || !strings.HasPrefix(err.Error(), p+": edited TOML is invalid: line ") ||
!strings.Contains(err.Error(), ", column ") {
t.Fatalf("expected a located error for the edited TOML, got %v", err)
}
var parseErr *unstable.ParserError
var decodeErr *toml.DecodeError
if !errors.As(err, &parseErr) && !errors.As(err, &decodeErr) {
t.Fatalf("lost the underlying TOML error: %v", err)
}
if state == "existing" {
assertTOMLContent(t, p, input)
} else if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid edit created the missing file: %v", err)
}
})
}
})
}
}
func TestTOMLWritesRejectDefinitionConflicts(t *testing.T) {
for _, tc := range []struct {
name, input string
edit func(string) error
}{
{"scalar as table", "a = 1\n", func(p string) error { return SetTOMLTable(p, "a") }},
{"inline table as table", "a = { name = \"old\" }\n", func(p string) error {
return SetTOMLKey(p, "a", "name", "new")
}},
{"table as key", "[a]\n[b]\nkeep = true\n", func(p string) error {
return SetTOMLTop(p, KV{Path: "a", Value: "new"})
}},
{"child table as key", "[a]\nname = \"old\"\n[a.child]\nkeep = true\n", func(p string) error {
return SetTOMLKey(p, "a", "child", Raw("{}"))
}},
{"array as table", "[[a]]\nname = \"old\"\n", func(p string) error {
return SetTOMLTables(p, nil, []Table{{Name: "a"}})
}},
{"duplicate key", "[a]\nname = \"old\"\n", func(p string) error {
return SetTOMLTable(p, "a", KV{Path: "name", Value: "new"}, KV{Path: `"name"`, Value: "duplicate"})
}},
{"duplicate table", "[a]\nname = \"old\"\n", func(p string) error {
return SetTOMLTables(p, []string{"a"}, []Table{{Name: "a"}, {Name: `"a"`}})
}},
} {
t.Run(tc.name, func(t *testing.T) {
p := tmpFile(t, "config.toml", tc.input)
err := tc.edit(p)
if err == nil || !strings.HasPrefix(err.Error(), p+": edited TOML is invalid: ") {
t.Fatalf("expected a definition conflict naming the file, got %v", err)
}
assertTOMLContent(t, p, tc.input)
})
}
}
// A top-level value over several lines is replaced or removed as a whole.
func TestTOMLTopMultilineEdits(t *testing.T) {
const input = "value = [\n1,\n2\n]\n\n[a]\nkeep = true\n"
for _, tc := range []struct {
name, want string
edit func(string) error
}{
{"replace", "value = \"new\"\n\n[a]\nkeep = true\n", func(p string) error { return SetTOMLTop(p, KV{Path: "value", Value: "new"}) }},
{"delete", "\n[a]\nkeep = true\n", func(p string) error { return DelTOMLTop(p, "value") }},
{"add", "value = [\n1,\n2\n]\nother = \"x\"\n\n[a]\nkeep = true\n", func(p string) error { return SetTOMLTop(p, KV{Path: "other", Value: "x"}) }},
} {
t.Run(tc.name, func(t *testing.T) {
p := tmpFile(t, "config.toml", input)
if err := tc.edit(p); err != nil {
t.Fatal(err)
}
assertTOMLContent(t, p, tc.want)
})
}
}
func TestTOMLWritesAcceptQuotedNames(t *testing.T) {
const name = `mcp_servers."my server"`
p := tmpFile(t, "config.toml", "# keep\n")
if err := SetTOMLTable(p, name, KV{Path: `"display name"`, Value: "old"}); err != nil {
t.Fatal(err)
}
if err := SetTOMLKey(p, name, "display name", "new"); err != nil {
t.Fatal(err)
}
assertTOMLContent(t, p, "# keep\n\n["+name+"]\n\"display name\" = \"new\"\n")
}