diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d86f1..591630a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 schema walk recursed through the embedded type forever, so such a `Unmarshal` call hung the process; the walk now tracks the struct types on the current path and stops when one repeats. +- `Marshal` emits exactly one key when two struct fields resolve to the same + TOML name, picking the field the decoder would fill (the shallower one, the + later declaration at equal depth). Such a struct previously marshalled into + a duplicate key, and the output never re-parsed, breaking the round-trip + guarantee. - An array-of-tables header whose path runs through an inline table (`a = {b = {}}` followed by `[[a.b.c]]`) is rejected. The frozen-inline-table check covered `[table]` headers and dotted keys but not the intermediate diff --git a/decode.go b/decode.go index 5dadf2e..9124a32 100644 --- a/decode.go +++ b/decode.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "reflect" + "slices" "strings" "sync" "time" @@ -323,6 +324,15 @@ func newStructSchema(t reflect.Type) structSchema { return s } +// ownsKey reports whether the field at path is the one that resolves key. +// The encoder consults it to emit exactly the field the decoder would fill, +// so a struct with two fields mapping to one key does not marshal into a +// duplicate TOML key. +func (s structSchema) ownsKey(key string, path []int) bool { + loc, ok := s.byName[key] + return ok && slices.Equal(loc.index, path) +} + // fieldByIndex walks an index path from a struct value, allocating nil // pointers along the way so a key can reach through an embedded pointer // struct. Every field on the path is exported, so each step is settable. diff --git a/encode.go b/encode.go index f2318eb..1efc647 100644 --- a/encode.go +++ b/encode.go @@ -144,6 +144,16 @@ func (d *tomlDoc) partitionedEntries() (scalars []entry, tables []entry, arrays // --- reflection walk: struct --------------------------------------------- func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error { + return walkStructDoc(v, doc, ctx, nil, cachedStructSchema(v.Type())) +} + +// walkStructDoc emits the fields of v into doc. prefix is v's index path from +// the struct whose schema resolves key conflicts; an embedded struct is walked +// with the outer schema and a longer prefix, so every leaf competes under the +// decoder's rule: the shallower field wins, the later declaration at equal +// depth. A field another field shadows is skipped, because emitting both +// would duplicate the key and the output would not re-parse. +func walkStructDoc(v reflect.Value, doc *tomlDoc, ctx string, prefix []int, schema structSchema) error { t := v.Type() for i := range t.NumField() { if i%ctxCheckInterval == 0 { @@ -155,6 +165,7 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error { if f.PkgPath != "" { continue } + path := append(append([]int{}, prefix...), i) if f.Anonymous { tag, _ := f.Tag.Lookup("toml") if tag == "-" { @@ -169,12 +180,15 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error { case reflect.Struct: if isScalarStruct(fv.Type()) { name := strings.ToLower(f.Name) + if !schema.ownsKey(name, path) { + continue + } if err := doc.appendScalar(name, fv.Interface(), ctx); err != nil { return err } continue } - if err := buildStructDoc(fv, doc, ctx); err != nil { + if err := walkStructDoc(fv, doc, ctx, path, schema); err != nil { return err } continue @@ -190,6 +204,9 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error { if name == "-" { continue } + if !schema.ownsKey(strings.ToLower(name), path) { + continue + } if fieldOmitted(f, v.Field(i)) { continue } diff --git a/encode_test.go b/encode_test.go index 90652f3..e23e47d 100644 --- a/encode_test.go +++ b/encode_test.go @@ -383,6 +383,42 @@ func TestMarshalerErrorPropagates(t *testing.T) { } } +// Two fields that resolve to one TOML key must marshal as one key, resolved +// the way the decoder resolves it, or the output would carry a duplicate key +// and never re-parse. +func TestMarshalDuplicateKeyResolvesToOneField(t *testing.T) { + type SameLevel struct { + First int `toml:"v"` + Second string `toml:"v"` + } + out, err := Marshal(SameLevel{First: 1, Second: "s"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if want := "v = \"s\"\n"; string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + + type Base struct { + Name string `toml:"name"` + } + type Embedded struct { + Base + Name string `toml:"name"` + } + out, err = Marshal(Embedded{Base: Base{Name: "inner"}, Name: "outer"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // The shallower field wins, matching the decoder. + if want := "name = \"outer\"\n"; string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + if _, err := Parse(out); err != nil { + t.Errorf("re-parse: %v\ndoc:\n%s", err, out) + } +} + func TestMarshalEmbeddedScalarStruct(t *testing.T) { // A field declared directly as a scalar-struct type (here LocalDateTime) // must be encoded as a TOML scalar at the parent level, not rendered as