From 1cf99ab7a47df3a5f979541e447b9b01a4d4ec25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 19 Aug 2026 11:23:00 +0200 Subject: [PATCH] feat: toml-test adapter command Assisted-by: GLM 5.3 Flash --- cmd/interpres-decode/main.go | 129 ++++++++++++++++++ cmd/interpres-decode/main_test.go | 212 ++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 cmd/interpres-decode/main.go create mode 100644 cmd/interpres-decode/main_test.go diff --git a/cmd/interpres-decode/main.go b/cmd/interpres-decode/main.go new file mode 100644 index 0000000..60bace0 --- /dev/null +++ b/cmd/interpres-decode/main.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +// Command interpres-decode reads a TOML document from standard input and writes +// the toml-test "tagged JSON" representation to standard output. +// +// It exits non-zero on a parse error, which is how the toml-test harness checks +// that invalid documents are rejected. Run the official suite against it with: +// +// toml-test ./interpres-decode +package main + +import ( + "encoding/json" + "fmt" + "io" + "math" + "os" + "strconv" + "time" + + "sourcedock.dev/petrbalvin/interpres" +) + +func main() { + os.Exit(Run(os.Stdin, os.Stdout, os.Stderr)) +} + +// Run reads a TOML document from stdin, emits the toml-test tagged-JSON form +// on stdout, and returns the process exit code (0 success, 1 parse error, +// 2 I/O, encoding, or unsupported-value error). +func Run(stdin io.Reader, stdout, stderr io.Writer) int { + data, err := io.ReadAll(stdin) + if err != nil { + fmt.Fprintln(stderr, "read stdin:", err) + return 2 + } + tree, err := interpres.Parse(data) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + tagged, err := tag(tree) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + enc := json.NewEncoder(stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(tagged); err != nil { + fmt.Fprintln(stderr, "encode:", err) + return 2 + } + return 0 +} + +// tag converts an interpres value into its toml-test tagged-JSON form. Tables +// become JSON objects and arrays become JSON arrays; scalars are wrapped in a +// {"type", "value"} object. An error is returned for value types the encoder +// has no tagged representation for. +func tag(v any) (any, error) { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, val := range x { + tagged, err := tag(val) + if err != nil { + return nil, err + } + out[k] = tagged + } + return out, nil + case []any: + out := make([]any, len(x)) + for i, e := range x { + tagged, err := tag(e) + if err != nil { + return nil, err + } + out[i] = tagged + } + return out, nil + case []map[string]any: + out := make([]any, len(x)) + for i, e := range x { + tagged, err := tag(e) + if err != nil { + return nil, err + } + out[i] = tagged + } + return out, nil + case string: + return tagged("string", x), nil + case bool: + return tagged("bool", strconv.FormatBool(x)), nil + case int64: + return tagged("integer", strconv.FormatInt(x, 10)), nil + case float64: + return tagged("float", formatFloat(x)), nil + case time.Time: + return tagged("datetime", x.Format(time.RFC3339Nano)), nil + case interpres.LocalDateTime: + return tagged("datetime-local", x.Format("2006-01-02T15:04:05.999999999")), nil + case interpres.LocalDate: + return tagged("date-local", x.Format("2006-01-02")), nil + case interpres.LocalTime: + return tagged("time-local", x.Format("15:04:05.999999999")), nil + default: + return nil, fmt.Errorf("unsupported value type %T", v) + } +} + +func tagged(typ, value string) map[string]string { + return map[string]string{"type": typ, "value": value} +} + +func formatFloat(f float64) string { + switch { + case math.IsInf(f, 1): + return "inf" + case math.IsInf(f, -1): + return "-inf" + case math.IsNaN(f): + return "nan" + default: + return strconv.FormatFloat(f, 'g', -1, 64) + } +} diff --git a/cmd/interpres-decode/main_test.go b/cmd/interpres-decode/main_test.go new file mode 100644 index 0000000..4d8d1da --- /dev/null +++ b/cmd/interpres-decode/main_test.go @@ -0,0 +1,212 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "sourcedock.dev/petrbalvin/interpres" +) + +func TestRunParsesValidTOML(t *testing.T) { + var stdout, stderr bytes.Buffer + in := bytes.NewReader([]byte(`name = "x" +port = 8080 +enabled = true +`)) + if code := Run(in, &stdout, &stderr); code != 0 { + t.Fatalf("Run returned %d, stderr = %q", code, stderr.String()) + } + var got map[string]any + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String()) + } + if name := got["name"].(map[string]any); name["type"] != "string" || name["value"] != "x" { + t.Errorf("name = %#v", got["name"]) + } + if port := got["port"].(map[string]any); port["type"] != "integer" || port["value"] != "8080" { + t.Errorf("port = %#v", got["port"]) + } + if en := got["enabled"].(map[string]any); en["type"] != "bool" || en["value"] != "true" { + t.Errorf("enabled = %#v", got["enabled"]) + } +} + +func TestRunRejectsInvalidInput(t *testing.T) { + var stdout, stderr bytes.Buffer + in := bytes.NewReader([]byte("v = \n")) + code := Run(in, &stdout, &stderr) + if code != 1 { + t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("stdout should be empty on parse error, got %q", stdout.String()) + } +} + +func TestRunReadErrorReturnsTwo(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run(errorReader{}, &stdout, &stderr) + if code != 2 { + t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "read stdin") { + t.Errorf("stderr = %q, want it to mention 'read stdin'", stderr.String()) + } +} + +// errorReader is an io.Reader that always reports an error. +type errorReader struct{} + +func (errorReader) Read(_ []byte) (int, error) { return 0, errors.New("boom") } + +func TestRunEncodeErrorReturnsTwo(t *testing.T) { + var stderr bytes.Buffer + w := errorWriter{} + in := bytes.NewReader([]byte(`k = "v"` + "\n")) + code := Run(in, w, &stderr) + if code != 2 { + t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "encode") { + t.Errorf("stderr = %q, want it to mention 'encode'", stderr.String()) + } +} + +// errorWriter is an io.Writer that always reports an error. +type errorWriter struct{} + +func (errorWriter) Write(_ []byte) (int, error) { return 0, errors.New("boom") } + +func TestTagHandlesAllScalarTypes(t *testing.T) { + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + ldt := interpres.LocalDateTime{Time: now} + ld := interpres.LocalDate{Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)} + lt := interpres.LocalTime{Time: time.Date(0, 1, 1, 3, 4, 5, 0, time.UTC)} + + cases := []struct { + name string + in any + want map[string]string + }{ + {"string", "x", map[string]string{"type": "string", "value": "x"}}, + {"bool", true, map[string]string{"type": "bool", "value": "true"}}, + {"bool-false", false, map[string]string{"type": "bool", "value": "false"}}, + {"int64", int64(42), map[string]string{"type": "integer", "value": "42"}}, + {"float", 1.5, map[string]string{"type": "float", "value": "1.5"}}, + {"time", now, map[string]string{"type": "datetime", "value": "2026-01-02T03:04:05Z"}}, + {"ldt", ldt, map[string]string{"type": "datetime-local", "value": "2026-01-02T03:04:05"}}, + {"ld", ld, map[string]string{"type": "date-local", "value": "2026-01-02"}}, + {"lt", lt, map[string]string{"type": "time-local", "value": "03:04:05"}}, + } + for _, c := range cases { + got, err := tag(c.in) + if err != nil { + t.Errorf("%s: tag returned error %v", c.name, err) + continue + } + m, ok := got.(map[string]string) + if !ok { + t.Errorf("%s: tag returned %T, want map[string]string", c.name, got) + continue + } + if m["type"] != c.want["type"] || m["value"] != c.want["value"] { + t.Errorf("%s: tag = %#v, want %#v", c.name, m, c.want) + } + } +} + +func TestTagRejectsUnsupportedType(t *testing.T) { + // Channels are not produced by the parser; tag must reject them explicitly. + var ch chan int + got, err := tag(ch) + if err == nil { + t.Fatalf("expected error for chan int, got value %#v", got) + } + if !strings.Contains(err.Error(), "unsupported") { + t.Errorf("err = %v, want substring 'unsupported'", err) + } + if got != nil { + t.Errorf("got = %#v, want nil", got) + } + + // The same applies when an unsupported type is nested inside a table. + got, err = tag(map[string]any{"x": ch}) + if err == nil { + t.Fatalf("expected error for nested chan int, got %#v", got) + } +} + +// Exercise the table, array-of-tables, and array shapes so all of tag's +// switch arms are visited. +func TestTagHandlesContainers(t *testing.T) { + tbl := map[string]any{ + "name": "x", + "items": []any{ + int64(1), int64(2), + }, + } + got, err := tag(tbl) + if err != nil { + t.Fatalf("tag(tbl) returned error: %v", err) + } + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("tag returned %T, want map", got) + } + name, ok := m["name"].(map[string]string) + if !ok || name["value"] != "x" { + t.Errorf("name = %#v", m["name"]) + } + items, ok := m["items"].([]any) + if !ok { + t.Fatalf("items = %T, want []any", m["items"]) + } + if len(items) != 2 { + t.Fatalf("len(items) = %d, want 2", len(items)) + } + for _, raw := range items { + if _, ok := raw.(map[string]string); !ok { + t.Errorf("item = %T, want map[string]string", raw) + } + } + + // Direct tests of the array branches. + if got, err := tag([]any{int64(1)}); err != nil { + t.Errorf("tag([]any) returned error: %v", err) + } else if len(got.([]any)) != 1 { + t.Errorf("[]any tag = %#v", got) + } + if got, err := tag([]map[string]any{{"k": "v"}}); err != nil { + t.Errorf("tag([]map) returned error: %v", err) + } else if len(got.([]any)) != 1 { + t.Errorf("[]map[string]any tag = %#v", got) + } +} + +func TestFormatFloatAllBranches(t *testing.T) { + cases := map[float64]string{ + 1.5: "1.5", + 0: "0", + 1e10: "1e+10", + -2.5e-3: "-0.0025", + } + for in, want := range cases { + if got := formatFloat(in); got != want { + t.Errorf("formatFloat(%v) = %q, want %q", in, got, want) + } + } +} + +func TestTaggedHelper(t *testing.T) { + got := tagged("string", "x") + if got["type"] != "string" || got["value"] != "x" { + t.Errorf("tagged = %#v", got) + } +}