diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb3480..8944182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- +- `encoding.TextMarshaler` and `encoding.TextUnmarshaler` are honoured by + default, with no option to switch them off. A type that implements them is + encoded as a TOML string and decoded from one: `net.IP` becomes + `"192.0.2.1"`, and a user type with `MarshalText` or `UnmarshalText` follows. + `MarshalTOML` and `UnmarshalTOML` still win over the text methods, and the + four date-time types keep their bare timestamp form instead of becoming a + quoted string. A struct type that implements the interface now encodes as a + string where it was a table before, which is the breaking part of the change. +- `time.Duration` is encoded in its canonical Go form as a TOML string, + `1h30m0s`, because TOML has no duration type; the decoder reads that string + back and still accepts a bare integer as the nanosecond count. ### Changed diff --git a/README.md b/README.md index 282e4d4..c471173 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ the entire official [toml-test](https://github.com/toml-lang/toml-test) suite: - **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that match no destination field, at every struct depth. - **Custom types**: `Marshaler` and `Unmarshaler` let a type control its own - TOML representation in both directions. + TOML representation in both directions, and `encoding.TextMarshaler` and + `TextUnmarshaler` are honoured by default, so `net.IP`, `time.Duration` and + user types with text methods need no configuration. - **Cancellation**: every entry point has a `*Context` sibling that honours a `context.Context`. - **Configurable emission**: `Encoder` options for declaration-order output, diff --git a/decode.go b/decode.go index e9cd0cd..140eb98 100644 --- a/decode.go +++ b/decode.go @@ -4,6 +4,7 @@ package interpres import ( + "encoding" "fmt" "reflect" "slices" @@ -63,6 +64,19 @@ func (d *decoder) assign(data any, dst reflect.Value) error { } } + // A TOML string fills a destination that implements + // encoding.TextUnmarshaler, the rule encoding/json follows. Every other + // value kind keeps its own rule, so an integer still reaches a numeric + // destination. + if s, isString := data.(string); isString { + if tu, ok := textUnmarshalerOf(dst); ok { + if err := tu.UnmarshalText([]byte(s)); err != nil { + return fmt.Errorf("unmarshal text: %w", err) + } + return nil + } + } + switch v := data.(type) { case map[string]any: return d.assignTable(v, dst) @@ -71,6 +85,9 @@ func (d *decoder) assign(data any, dst reflect.Value) error { case []any: return d.assignSlice(v, dst) case string: + if dst.Type() == durationType { + return setDuration(dst, v) + } return setBasic(dst, reflect.ValueOf(v), "string") case bool: return setBasic(dst, reflect.ValueOf(v), "bool") @@ -94,6 +111,26 @@ func (d *decoder) assign(data any, dst reflect.Value) error { } } +// textUnmarshalerOf finds the encoding.TextUnmarshaler for dst: on the value +// itself, or on its address, so a pointer-receiver UnmarshalText is invoked on +// an addressable struct field. The TOML date-time types are excluded, because +// they carry time.Time's UnmarshalText through an embedded field while their +// only accepted form is a bare timestamp. +func textUnmarshalerOf(dst reflect.Value) (encoding.TextUnmarshaler, bool) { + if !dst.CanInterface() || isDateTimeType(dst.Type()) { + return nil, false + } + if u, ok := dst.Interface().(encoding.TextUnmarshaler); ok { + return u, true + } + if dst.CanAddr() { + if u, ok := dst.Addr().Interface().(encoding.TextUnmarshaler); ok { + return u, true + } + } + return nil, false +} + func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error { switch dst.Kind() { case reflect.Struct: @@ -209,6 +246,19 @@ func setBasic(dst, val reflect.Value, kind string) error { return nil } +// setDuration reads a duration literal into a time.Duration destination. TOML +// has no duration type, so the encoder writes the canonical Go form and the +// decoder reads that back; a bare integer stays the nanosecond count it has +// always been, and reaches the destination through setInt. +func setDuration(dst reflect.Value, s string) error { + d, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("interpres: invalid duration %q", s) + } + dst.SetInt(int64(d)) + return nil +} + func setInt(dst reflect.Value, v int64) error { switch dst.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: diff --git a/decode_test.go b/decode_test.go index 4e29b83..24769f8 100644 --- a/decode_test.go +++ b/decode_test.go @@ -8,9 +8,11 @@ import ( "errors" "fmt" "math" + "net" "slices" "strings" "testing" + "time" ) func TestSyntaxErrorMessage(t *testing.T) { @@ -747,3 +749,205 @@ func TestUnmarshalIntoDefinedScalarTypes(t *testing.T) { t.Error("F = false, want true") } } + +// --- encoding.TextUnmarshaler and time.Duration ---------------------------- + +// textReceiver implements encoding.TextUnmarshaler on the pointer receiver. +type textReceiver struct{ Text string } + +func (t *textReceiver) UnmarshalText(text []byte) error { + t.Text = "got:" + string(text) + return nil +} + +// upperText is a defined string type whose UnmarshalText transforms the +// content, so a plain string assignment would leave the wrong value behind. +type upperText string + +func (u *upperText) UnmarshalText(text []byte) error { + *u = upperText(strings.ToUpper(string(text))) + return nil +} + +// failingTextUnmarshaler fails the decode from UnmarshalText. +type failingTextUnmarshaler struct{} + +func (f *failingTextUnmarshaler) UnmarshalText(_ []byte) error { return errors.New("text boom") } + +// textAndTOMLReceiver implements both decode interfaces; the TOML method wins. +type textAndTOMLReceiver struct{ From string } + +func (t *textAndTOMLReceiver) UnmarshalTOML(any) error { t.From = "toml"; return nil } + +func (t *textAndTOMLReceiver) UnmarshalText([]byte) error { t.From = "text"; return nil } + +func TestTextUnmarshalerByPointer(t *testing.T) { + type Cfg struct { + R textReceiver `toml:"r"` + } + var cfg Cfg + if err := Unmarshal([]byte(`r = "hello"`), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.R.Text != "got:hello" { + t.Errorf("Text = %q, want \"got:hello\"", cfg.R.Text) + } +} + +func TestTextUnmarshalerWinsOverKindAssignment(t *testing.T) { + type Cfg struct { + U upperText `toml:"u"` + } + var cfg Cfg + if err := Unmarshal([]byte(`u = "abc"`), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.U != "ABC" { + t.Errorf("U = %q, want \"ABC\"", cfg.U) + } +} + +func TestTextUnmarshalerForNetIP(t *testing.T) { + type Cfg struct { + V4 net.IP `toml:"v4"` + V6 net.IP `toml:"v6"` + IPs []net.IP `toml:"ips"` + } + in := "v4 = \"192.0.2.1\"\nv6 = \"2001:db8::68\"\nips = [\"198.51.100.7\", \"203.0.113.9\"]\n" + var cfg Cfg + if err := Unmarshal([]byte(in), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := cfg.V4.String(); got != "192.0.2.1" { + t.Errorf("V4 = %q, want \"192.0.2.1\"", got) + } + if got := cfg.V6.String(); got != "2001:db8::68" { + t.Errorf("V6 = %q, want \"2001:db8::68\"", got) + } + if len(cfg.IPs) != 2 || cfg.IPs[0].String() != "198.51.100.7" || cfg.IPs[1].String() != "203.0.113.9" { + t.Errorf("IPs = %v, want two addresses", cfg.IPs) + } +} + +func TestTextUnmarshalerSeesStringsOnly(t *testing.T) { + // An integer keeps its own rule: the text method is not consulted, and the + // value does not reach the receiver. + type Cfg struct { + R textReceiver `toml:"r"` + } + var cfg Cfg + err := Unmarshal([]byte("r = 1\n"), &cfg) + if err == nil { + t.Fatal("expected an integer to be rejected for a text receiver") + } + if cfg.R.Text != "" { + t.Errorf("Text = %q, want it untouched", cfg.R.Text) + } +} + +func TestUnmarshalTOMLWinsOverTextUnmarshaler(t *testing.T) { + type Cfg struct { + B textAndTOMLReceiver `toml:"b"` + } + var cfg Cfg + if err := Unmarshal([]byte(`b = "x"`), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.B.From != "toml" { + t.Errorf("From = %q, want \"toml\"", cfg.B.From) + } +} + +func TestTextUnmarshalerErrorCarriesPath(t *testing.T) { + type Inner struct { + F failingTextUnmarshaler `toml:"f"` + } + type Cfg struct { + Inner Inner `toml:"inner"` + } + var cfg Cfg + err := Unmarshal([]byte("[inner]\nf = \"x\"\n"), &cfg) + if err == nil { + t.Fatal("expected an error from UnmarshalText") + } + if !strings.Contains(err.Error(), "unmarshal text: text boom") { + t.Errorf("err = %v, want the text error wrapped", err) + } + de, ok := errors.AsType[*DecodeError](err) + if !ok { + t.Fatalf("expected a *DecodeError, got %T: %v", err, err) + } + if !slices.Equal(de.Path, []string{"inner", "f"}) { + t.Fatalf("Path = %v, want [inner f]", de.Path) + } +} + +func TestTextUnmarshalerReportsBadText(t *testing.T) { + var cfg struct { + IP net.IP `toml:"ip"` + } + err := Unmarshal([]byte(`ip = "not-an-ip"`), &cfg) + if err == nil { + t.Fatal("expected an error for a malformed address") + } + if !strings.Contains(err.Error(), "unmarshal text:") { + t.Errorf("err = %v, want it wrapped as a text error", err) + } +} + +func TestUnmarshalDurations(t *testing.T) { + type Cfg struct { + FromText time.Duration `toml:"from_text"` + FromInt time.Duration `toml:"from_int"` + Fraction time.Duration `toml:"fraction"` + } + in := "from_text = \"1h30m\"\nfrom_int = 5400000000000\nfraction = \"1.5s\"\n" + var cfg Cfg + if err := Unmarshal([]byte(in), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.FromText != 90*time.Minute { + t.Errorf("FromText = %v, want %v", cfg.FromText, 90*time.Minute) + } + if cfg.FromInt != 90*time.Minute { + t.Errorf("FromInt = %v, want %v", cfg.FromInt, 90*time.Minute) + } + if cfg.Fraction != 1500*time.Millisecond { + t.Errorf("Fraction = %v, want %v", cfg.Fraction, 1500*time.Millisecond) + } +} + +func TestUnmarshalDurationRejectsMalformedText(t *testing.T) { + var cfg struct { + D time.Duration `toml:"d"` + } + err := Unmarshal([]byte("d = \"90\"\n"), &cfg) + if err == nil { + t.Fatal("expected an error for a duration without a unit") + } + if !strings.Contains(err.Error(), "invalid duration") { + t.Errorf("err = %v, want an invalid-duration message", err) + } +} + +func TestQuotedStringNeverBecomesDateTime(t *testing.T) { + // The date-time types take a bare timestamp only, so the text path is + // excluded for them and a quoted string stays a string. + var stamp struct { + S time.Time `toml:"s"` + } + err := Unmarshal([]byte("s = \"2026-06-26T10:00:00Z\"\n"), &stamp) + if err == nil { + t.Fatal("expected a quoted string to be rejected for time.Time") + } + if !strings.Contains(err.Error(), "cannot assign string") { + t.Errorf("err = %v, want a cannot-assign message", err) + } + + var day struct { + D LocalDate `toml:"d"` + } + if err := Unmarshal([]byte("d = \"1979-05-27\"\n"), &day); err == nil { + t.Fatal("expected a quoted string to be rejected for LocalDate") + } +} diff --git a/docs/API.md b/docs/API.md index a4ade37..0c83ac8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -147,6 +147,9 @@ zero date for a local time). Every kind may omit the seconds as of TOML 1.1 (`07:32`, `1979-05-27T07:32`); such a value carries a zero second, and the canonical rendering writes full seconds. There is no implicit conversion between the offset and local kinds; assigning one to the other is an error. +The four types take a bare timestamp and never a quoted string, so a document +that writes a date-time with quotes does not decode into them, and neither +`encoding.TextUnmarshaler` nor the embedded `time.Time` changes that. ### Arrays of tables @@ -177,6 +180,38 @@ automatically, and a nil pointer destination is allocated first. An error returned from `UnmarshalTOML` halts the decode and propagates wrapped with the key path, for example `addr: unmarshal: not a string`. +### Custom decoding: `encoding.TextUnmarshaler` + +A destination type that implements `encoding.TextUnmarshaler` receives a TOML +string as its text content, the rule `encoding/json` follows: + +```go +func (ip *IP) UnmarshalText(text []byte) error +``` + +The decoder looks for the method on the destination and on its address, so a +pointer-receiver `UnmarshalText` is invoked on an addressable struct field, and +the elements of a slice destination are reached the same way. The text path +applies to TOML strings only: every other value kind keeps its own rule, so +`r = 1` does not reach a receiver that expects text. An error from +`UnmarshalText` halts the decode and propagates with the key path and the +prefix `unmarshal text:`, for example `addr: unmarshal text: not an address`. + +[`UnmarshalTOML`](#custom-decoding-unmarshaler) wins over `UnmarshalText` when +a type implements both, and the four [date-time +types](#date-time-values) are excluded: a quoted string stays a string and +never becomes a `time.Time` or one of the local wrappers. + +### Durations + +TOML has no duration type, so `time.Duration` has a rule of its own. The +encoder writes the canonical Go form in a TOML string, `1h30m0s`, and the +decoder reads that string back with `time.ParseDuration`. A bare integer is +still the nanosecond count it has always been, so `from_int = 5400000000000` +and `from_text = "1h30m"` decode to the same duration. Text that +`time.ParseDuration` rejects, `d = "90"` among it, fails with +`interpres: invalid duration "90"`. + ### Strict decoding By default unknown keys are dropped silently. A `Decoder` built with @@ -329,6 +364,27 @@ func (p Port) MarshalTOML() (any, error) { } ``` +### Custom encoding: `encoding.TextMarshaler` + +A type that implements `encoding.TextMarshaler` is encoded as a TOML string +holding the text the method returns, which is the rule `encoding/json` follows: + +```go +func (ip IP) MarshalText() ([]byte, error) +``` + +The encoder looks for the method on the value and on its address, so a +pointer-receiver `MarshalText` is found on a struct field of an addressable +value (pass a pointer to `Marshal`) and always on a slice element. `net.IP`, +`netip.Addr` and user types follow this rule, and a struct that implements the +interface becomes a string rather than a table. `MarshalTOML` wins when a type +implements both, the four [date-time types](#date-time-values) keep their bare +timestamp form, and text that is not valid UTF-8 is an error rather than a +replacement character. + +A duration carries no text method of its own; see [Durations](#durations) for +its rule. + ### Arrays An array whose every element is a table (`[]struct`, `[]map[string]V`, after diff --git a/encode.go b/encode.go index dcd4a17..2d0651c 100644 --- a/encode.go +++ b/encode.go @@ -6,6 +6,7 @@ package interpres import ( "bytes" "context" + "encoding" "errors" "fmt" "maps" @@ -23,6 +24,8 @@ var ( localDateType = reflect.TypeFor[LocalDate]() localTimeType = reflect.TypeFor[LocalTime]() timeGoType = reflect.TypeFor[time.Time]() + durationType = reflect.TypeFor[time.Duration]() + textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() ) // encoder produces a TOML document from a Go value via a small intermediate @@ -319,6 +322,15 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error { v = reflect.ValueOf(mv) } } + // A type that renders itself as text becomes a TOML string, whether it is + // a scalar kind or a struct. + s, isText, err := textValue(v) + if err != nil { + return &EncodeError{Path: joinKey(ctx, name), Err: err} + } + if isText { + return doc.appendScalar(name, s, ctx) + } v = followPtr(v) if !v.IsValid() { return nil @@ -500,6 +512,20 @@ func normaliseValue(v reflect.Value) (any, error) { if t := v.Type(); t == timeGoType || isLocalDateType(t) { return v.Interface(), nil } + // TOML has no duration type, so a duration goes out in its canonical Go + // form, the shape it comes back in. + if v.Type() == durationType { + return time.Duration(v.Int()).String(), nil + } + // A type that renders itself as text becomes a TOML string, scalar kinds + // and structs alike. + s, isText, err := textValue(v) + if err != nil { + return nil, err + } + if isText { + return s, nil + } switch v.Kind() { case reflect.String: return v.String(), nil @@ -573,10 +599,75 @@ func isLocalDateType(t reflect.Type) bool { return t == localDateTimeType || t == localDateType || t == localTimeType } +// isDateTimeType reports whether t is one of the four TOML date-time types, +// which the encoder emits as bare atoms. Pointers are looked through. The types +// carry time.Time's text methods through an embedded field, and the atom form +// takes precedence over them. +func isDateTimeType(t reflect.Type) bool { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t == timeGoType || isLocalDateType(t) +} + +// isTextMarshalerType reports whether t or *t implements +// encoding.TextMarshaler. An array of such values stays a value array, because +// each element's TOML form is a string. +func isTextMarshalerType(t reflect.Type) bool { + if isDateTimeType(t) { + return false + } + return t.Implements(textMarshalerType) || reflect.PointerTo(t).Implements(textMarshalerType) +} + +// textValue returns the string a value renders itself as through +// encoding.TextMarshaler. The date-time types are excluded, because their +// embedded time.Time would answer with an RFC 3339 string where the TOML form +// is a bare timestamp. A nil pointer offers no text and is left to the ordinary +// nil handling, which omits the field. +func textValue(v reflect.Value) (string, bool, error) { + for v.Kind() == reflect.Interface && !v.IsNil() { + v = v.Elem() + } + if !v.IsValid() || isDateTimeType(v.Type()) { + return "", false, nil + } + if v.Kind() == reflect.Pointer && v.IsNil() { + return "", false, nil + } + m, ok := textMarshalerOf(v) + if !ok { + return "", false, nil + } + b, err := m.MarshalText() + if err != nil { + return "", true, err + } + return string(b), true, nil +} + +// textMarshalerOf finds the encoding.TextMarshaler for v: on the value itself, +// or on its address, so a pointer-receiver MarshalText is found on an +// addressable struct field. +func textMarshalerOf(v reflect.Value) (encoding.TextMarshaler, bool) { + if !v.CanInterface() { + return nil, false + } + if m, ok := v.Interface().(encoding.TextMarshaler); ok { + return m, true + } + if v.CanAddr() { + if m, ok := v.Addr().Interface().(encoding.TextMarshaler); ok { + return m, true + } + } + return nil, false +} + func isTableElementType(t reflect.Type) bool { switch t.Kind() { case reflect.Struct: - return !isScalarStruct(t) + return !isScalarStruct(t) && !isTextMarshalerType(t) case reflect.Map: return t.Key().Kind() == reflect.String } diff --git a/encode_test.go b/encode_test.go index ed9932c..0ace3eb 100644 --- a/encode_test.go +++ b/encode_test.go @@ -8,6 +8,7 @@ import ( "context" "errors" "math" + "net" "reflect" "strings" "testing" @@ -1372,3 +1373,202 @@ func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) { t.Fatalf("Path = %q, want %q", ee.Path, "items[0]") } } + +// --- encoding.TextMarshaler and time.Duration ------------------------------ + +// textTag is a value-receiver encoding.TextMarshaler, so the encoder finds the +// method on the value itself. +type textTag string + +func (t textTag) MarshalText() ([]byte, error) { return []byte("tag:" + string(t)), nil } + +// textPointer carries MarshalText on the pointer receiver only, so the encoder +// has to look at the address of an addressable field. +type textPointer struct{ V string } + +func (t *textPointer) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(t.V)), nil } + +// textAndTOML implements both encoding interfaces; the TOML method wins. +type textAndTOML struct{} + +func (textAndTOML) MarshalTOML() (any, error) { return "toml", nil } +func (textAndTOML) MarshalText() ([]byte, error) { return []byte("text"), nil } + +// brokenText fails the marshal from MarshalText. +type brokenText struct{} + +func (brokenText) MarshalText() ([]byte, error) { return nil, errors.New("text boom") } + +// notUTF8 renders bytes that no TOML string can carry. +type notUTF8 struct{} + +func (notUTF8) MarshalText() ([]byte, error) { return []byte{0xff, 0xfe}, nil } + +// textTagBoth renders itself with a prefix and strips it again on decode, so +// the round trip through a TOML string is lossless. +type textTagBoth string + +func (t textTagBoth) MarshalText() ([]byte, error) { return []byte("tag:" + string(t)), nil } + +func (t *textTagBoth) UnmarshalText(text []byte) error { + trimmed, ok := strings.CutPrefix(string(text), "tag:") + if !ok { + return errors.New("textTagBoth: missing the tag prefix") + } + *t = textTagBoth(trimmed) + return nil +} + +func TestMarshalTextValues(t *testing.T) { + // The pointer receiver is reachable only through an addressable field, so + // the whole value is marshalled through a pointer here. + type Cfg struct { + IP net.IP `toml:"ip"` + Duration time.Duration `toml:"duration"` + Tag textTag `toml:"tag"` + Pointer textPointer `toml:"pointer"` + Both textAndTOML `toml:"both"` + } + out, err := Marshal(&Cfg{ + IP: net.IPv4(192, 0, 2, 1), + Duration: 90 * time.Minute, + Tag: "x", + Pointer: textPointer{V: "abc"}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "ip = \"192.0.2.1\"\nduration = \"1h30m0s\"\ntag = \"tag:x\"\npointer = \"ABC\"\nboth = \"toml\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalTextValuesInContainers(t *testing.T) { + // Slice elements are addressable, so a pointer-receiver MarshalText is used + // there too, and an array of such values stays a value array: each element's + // TOML form is a string, so the [[header]] form cannot carry it. + type Cfg struct { + Map map[string]net.IP `toml:"map"` + Durs []time.Duration `toml:"durs"` + Ptrs []textPointer `toml:"ptrs"` + Empty []textPointer `toml:"empty"` + } + out, err := Marshal(Cfg{ + Map: map[string]net.IP{"a": net.IPv4(10, 0, 0, 1)}, + Durs: []time.Duration{0, 250 * time.Millisecond}, + Ptrs: []textPointer{{V: "a"}, {V: "b"}}, + Empty: []textPointer{}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "durs = [\"0s\", \"250ms\"]\nptrs = [\"A\", \"B\"]\nempty = []\n\n[map]\na = \"10.0.0.1\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalTextLeavesDateTimesAlone(t *testing.T) { + // The four date-time types carry time.Time's text methods through an + // embedded field; their TOML form is a bare atom, never a quoted string. + stamp := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC) + type Cfg struct { + Stamp time.Time `toml:"stamp"` + Ptr *time.Time `toml:"ptr"` + Day LocalDate `toml:"day"` + At LocalDateTime `toml:"at"` + Clock LocalTime `toml:"clock"` + } + out, err := Marshal(&Cfg{ + Stamp: stamp, + Ptr: &stamp, + Day: LocalDate{time.Date(1979, 5, 27, 0, 0, 0, 0, time.UTC)}, + At: LocalDateTime{time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)}, + Clock: LocalTime{time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "stamp = 2026-06-26T10:00:00Z\nptr = 2026-06-26T10:00:00Z\nday = 1979-05-27\nat = 1979-05-27T07:32:00\nclock = 07:32:00\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalTextNilPointerOmitted(t *testing.T) { + type Cfg struct { + P *textPointer `toml:"p"` + K string `toml:"k"` + } + out, err := Marshal(&Cfg{K: "x"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if want := "k = \"x\"\n"; string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalTextErrorCarriesPath(t *testing.T) { + type Inner struct { + F brokenText `toml:"f"` + } + type Cfg struct { + Inner Inner `toml:"inner"` + } + _, err := Marshal(Cfg{}) + if err == nil { + t.Fatal("expected an error from MarshalText") + } + if !strings.Contains(err.Error(), "text boom") { + t.Errorf("err = %v, want substring \"text boom\"", err) + } + ee, ok := errors.AsType[*EncodeError](err) + if !ok { + t.Fatalf("expected an *EncodeError, got %T: %v", err, err) + } + if ee.Path != "inner.f" { + t.Fatalf("Path = %q, want %q", ee.Path, "inner.f") + } +} + +func TestMarshalTextRejectsInvalidUTF8(t *testing.T) { + // A TOML string holds UTF-8 only, so text that is not gets an error rather + // than replacement characters. + _, err := Marshal(struct { + V notUTF8 `toml:"v"` + }{}) + if err == nil { + t.Fatal("expected an error for text that is not valid UTF-8") + } + if !strings.Contains(err.Error(), "UTF-8") { + t.Errorf("err = %v, want a UTF-8 message", err) + } +} + +func TestMarshalTextValuesRoundTrip(t *testing.T) { + type Cfg struct { + Duration time.Duration `toml:"duration"` + IP net.IP `toml:"ip"` + Tag textTagBoth `toml:"tag"` + } + in := Cfg{Duration: 90 * time.Minute, IP: net.IPv4(198, 51, 100, 7), Tag: "y"} + out, err := Marshal(&in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back Cfg + if err := Unmarshal(out, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if back.Duration != in.Duration { + t.Errorf("Duration = %v, want %v", back.Duration, in.Duration) + } + if !back.IP.Equal(in.IP) { + t.Errorf("IP = %v, want %v", back.IP, in.IP) + } + if back.Tag != in.Tag { + t.Errorf("Tag = %q, want %q", back.Tag, in.Tag) + } +} diff --git a/interpres.go b/interpres.go index 3f7f268..b313693 100644 --- a/interpres.go +++ b/interpres.go @@ -123,6 +123,11 @@ func ParseContext(ctx context.Context, data []byte) (map[string]any, error) { // case-insensitive match on the field name when no tag is present. A tag of // "-" skips the field. // +// A destination implementing Unmarshaler receives the parsed value as it is, +// a TOML string fills a destination implementing encoding.TextUnmarshaler, and +// a time.Duration destination takes a duration literal such as `1h30m` or a +// bare integer as its nanosecond count. +// // Unmarshal is equivalent to UnmarshalContext with context.Background. func Unmarshal(data []byte, v any) error { return UnmarshalContext(context.Background(), data, v) @@ -177,6 +182,10 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error { // then encodes as if the returned value had been passed in its place, which // is useful for emitting a Go type as a different TOML shape (for example, a // struct as an inline table or a primitive alias as a richer value). +// +// MarshalTOML wins over encoding.TextMarshaler when a type implements both. +// A type that implements only encoding.TextMarshaler is encoded as a TOML +// string holding its text, and needs no method here. type Marshaler interface { MarshalTOML() (any, error) } @@ -194,6 +203,10 @@ type Marshaler interface { // UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the // destination type implements the interface. The decoder does not need to // consult the concrete return value; whatever the receiver stores is kept. +// +// UnmarshalTOML wins over encoding.TextUnmarshaler when a type implements +// both. A type that implements only encoding.TextUnmarshaler is filled from a +// TOML string holding its text, and needs no method here. type Unmarshaler interface { UnmarshalTOML(data any) error } @@ -222,6 +235,9 @@ type Unmarshaler interface { // variants). // - Values implementing Marshaler are encoded by calling MarshalTOML and // using its result. +// - Values implementing encoding.TextMarshaler, and not one of the +// date-time types, encode as a TOML string holding the text the method +// returns. time.Duration is written in its canonical Go form, `1h30m0s`. // - nil pointer fields are omitted. // // Marshal cannot encode cyclic data structures; passing one will loop until