Files
magpie/internal/edit/file.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

112 lines
2.8 KiB
Go

// Package edit provides format-preserving editors for the config files that
// coding agents keep: JSON/JSONC, TOML and YAML. Only the requested key
// changes; comments, ordering and indentation are left as they are.
package edit
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
// Read returns the file contents, or (nil, nil) when the file does not exist.
func Read(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return b, err
}
// WriteAtomic writes data to path via a temp file + rename so a crash can
// never leave a half-written config behind. File mode is preserved.
func WriteAtomic(path string, data []byte) error {
mode := fs.FileMode(0o644)
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }
if _, err := tmp.Write(data); err != nil {
tmp.Close()
cleanup()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return err
}
return nil
}
// Atomically runs fn, which may write the files at paths in several steps,
// and puts each of them back as it was — its bytes and mode, or its absence
// — when fn fails, so an edit that fails part way leaves no file half made.
// A file that can't be read beforehand is left to fn as it is.
func Atomically(fn func() error, paths ...string) error {
type saved struct {
path string
data []byte
mode fs.FileMode
exists bool
}
var before []saved
for _, p := range paths {
st, err := os.Stat(p)
if errors.Is(err, fs.ErrNotExist) {
before = append(before, saved{path: p})
continue
}
if err != nil || !st.Mode().IsRegular() {
continue
}
b, err := os.ReadFile(p)
if err != nil {
continue
}
before = append(before, saved{p, b, st.Mode().Perm(), true})
}
err := fn()
if err == nil {
return nil
}
for _, s := range before {
now, rerr := os.ReadFile(s.path)
switch {
case !s.exists:
if !errors.Is(rerr, fs.ErrNotExist) {
if e := os.Remove(s.path); e != nil && !errors.Is(e, fs.ErrNotExist) {
err = errors.Join(err, fmt.Errorf("put %s back: %w", s.path, e))
}
}
case rerr != nil || !bytes.Equal(now, s.data):
if e := WriteAtomic(s.path, s.data); e != nil {
err = errors.Join(err, fmt.Errorf("put %s back: %w", s.path, e))
} else if e := os.Chmod(s.path, s.mode); e != nil {
err = errors.Join(err, fmt.Errorf("put %s back: %w", s.path, e))
}
}
}
return err
}