// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package main import ( "bytes" "encoding/json" "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" "sourcedock.dev/petrbalvin/interpres/v2" ) func TestRunParsesValidTOML(t *testing.T) { var stdout, stderr bytes.Buffer in := bytes.NewReader([]byte(`name = "x" port = 8080 enabled = true `)) if code := Run(nil, 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(nil, 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(nil, 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(nil, 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) } } func TestValidateStdinAcceptsValidDocument(t *testing.T) { var stdout, stderr bytes.Buffer in := bytes.NewReader([]byte("title = \"ok\"\n")) if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 0 { t.Fatalf("Run returned %d, stderr = %q", code, stderr.String()) } if stdout.Len() != 0 || stderr.Len() != 0 { t.Fatalf("validate should be quiet on success, stdout %q stderr %q", stdout.String(), stderr.String()) } } func TestValidateStdinRejectsInvalidDocument(t *testing.T) { var stdout, stderr bytes.Buffer in := bytes.NewReader([]byte("title = \"unterminated\n")) if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 1 { t.Fatalf("Run returned %d, want 1; stderr = %q", code, stderr.String()) } if !strings.Contains(stderr.String(), "") || !strings.Contains(stderr.String(), "line 1") { t.Fatalf("stderr = %q, want the name and the line", stderr.String()) } if stdout.Len() != 0 { t.Fatalf("stdout should stay empty, got %q", stdout.String()) } } func TestValidateFiles(t *testing.T) { dir := t.TempDir() good := dir + "/good.toml" bad := dir + "/bad.toml" if err := os.WriteFile(good, []byte("a = 1\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(bad, []byte("a =\n"), 0o644); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer if code := Run([]string{"-validate", good}, nil, &stdout, &stderr); code != 0 { t.Fatalf("one valid file: Run returned %d, stderr = %q", code, stderr.String()) } if code := Run([]string{"-validate", good, bad}, nil, &stdout, &stderr); code != 1 { t.Fatalf("valid plus invalid: Run returned %d, want 1; stderr = %q", code, stderr.String()) } if !strings.Contains(stderr.String(), bad) || !strings.Contains(stderr.String(), "line 1") { t.Fatalf("stderr = %q, want the file name and the line", stderr.String()) } } func TestValidateMissingFileReturnsTwo(t *testing.T) { var stdout, stderr bytes.Buffer if code := Run([]string{"-validate", "no-such-file.toml"}, nil, &stdout, &stderr); code != 2 { t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String()) } } func TestAdapterModeRejectsPositionalArgument(t *testing.T) { var stdout, stderr bytes.Buffer in := bytes.NewReader([]byte("a = 1\n")) if code := Run([]string{"file.toml"}, in, &stdout, &stderr); code != 2 { t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String()) } if !strings.Contains(stderr.String(), "-validate") { t.Fatalf("stderr = %q, want it to point at -validate", stderr.String()) } } func TestUnknownFlagReturnsTwo(t *testing.T) { var stdout, stderr bytes.Buffer if code := Run([]string{"-nope"}, nil, &stdout, &stderr); code != 2 { t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String()) } } // --- encoder mode ---------------------------------------------------------- func TestRunEncoderScalars(t *testing.T) { in := `{ "s": {"type": "string", "value": "quote \" and backslash \\"}, "nl": {"type": "string", "value": "line1\nline2"}, "i": {"type": "integer", "value": "-9223372036854775808"}, "g": {"type": "float", "value": "1.5"}, "f": {"type": "float", "value": "inf"}, "b": {"type": "bool", "value": "false"}, "dt": {"type": "datetime", "value": "1979-05-27T07:32:00-07:00"}, "ldt": {"type": "datetime-local", "value": "1979-05-27T07:32:00"}, "ld": {"type": "date-local", "value": "1979-05-27"}, "lt": {"type": "time-local", "value": "07:32:00.999"} } ` var stdout, stderr bytes.Buffer code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr) if code != 0 { t.Fatalf("Run returned %d, stderr = %q", code, stderr.String()) } want := "b = false\n" + "dt = 1979-05-27T07:32-07:00\n" + "f = inf\n" + "g = 1.5\n" + "i = -9223372036854775808\n" + "ld = 1979-05-27\n" + "ldt = 1979-05-27T07:32\n" + "lt = 07:32:00.999\n" + "nl = \"line1\\nline2\"\n" + "s = \"quote \\\" and backslash \\\\\"\n" if stdout.String() != want { t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want) } } func TestRunEncoderNested(t *testing.T) { in := `{ "tbl": {"x": {"type": "bool", "value": "true"}, "sub": {"y": {"type": "integer", "value": "1"}}}, "items": [{"n": {"type": "string", "value": "a"}}, {"n": {"type": "string", "value": "b"}}], "list": [{"type": "integer", "value": "1"}, {"type": "string", "value": "two"}], "emptyTbl": {}, "emptyArr": [] } ` var stdout, stderr bytes.Buffer code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr) if code != 0 { t.Fatalf("Run returned %d, stderr = %q", code, stderr.String()) } want := "emptyArr = []\n" + "list = [1, \"two\"]\n" + "\n[emptyTbl]\n" + "\n[tbl]\nx = true\n" + "\n[tbl.sub]\ny = 1\n" + "\n[[items]]\nn = \"a\"\n" + "\n[[items]]\nn = \"b\"\n" if stdout.String() != want { t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want) } } func TestRunEncoderFloatTagDecides(t *testing.T) { // A float with no fraction is described by a bare integer literal, so the // tag decides the type; the output must stay a float. var stdout, stderr bytes.Buffer in := `{"whole": {"type": "float", "value": "1"}, "exp": {"type": "float", "value": "5e+22"}}` code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr) if code != 0 { t.Fatalf("Run returned %d, stderr = %q", code, stderr.String()) } if want := "exp = 5e+22\nwhole = 1.0\n"; stdout.String() != want { t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want) } } func TestRunEncoderRejectsBadInput(t *testing.T) { cases := []struct { name string in string want string }{ {"not-json", "not json", "decode JSON"}, {"top-level-array", `[{"type": "integer", "value": "1"}]`, "must be a JSON object"}, {"untagged-scalar", `{"x": 1}`, "unsupported JSON value"}, {"literal-mismatch", `{"x": {"type": "integer", "value": "1.5"}}`, "parsed as float64"}, {"offset-for-local", `{"x": {"type": "datetime-local", "value": "1979-05-27T07:32:00Z"}}`, "parsed as interpres.OffsetDateTime"}, {"bad-literal", `{"x": {"type": "date-local", "value": "nope"}}`, "date-local"}, {"smuggled-statement", `{"x": {"type": "integer", "value": "1\nx = 2"}}`, "not a single bare value"}, } for _, c := range cases { var stdout, stderr bytes.Buffer code := Run([]string{"-encode"}, strings.NewReader(c.in), &stdout, &stderr) if code != 2 { t.Errorf("%s: Run returned %d, want 2; stderr = %q", c.name, code, stderr.String()) continue } if !strings.Contains(stderr.String(), c.want) { t.Errorf("%s: stderr = %q, want it to mention %q", c.name, stderr.String(), c.want) } if stdout.Len() != 0 { t.Errorf("%s: stdout should be empty, got %q", c.name, stdout.String()) } } } func TestRunEncoderFlagConflicts(t *testing.T) { var stdout, stderr bytes.Buffer if code := Run([]string{"-encode", "-validate"}, strings.NewReader(""), &stdout, &stderr); code != 2 { t.Errorf("Run returned %d, want 2 for the two modes together", code) } if !strings.Contains(stderr.String(), "cannot be combined") { t.Errorf("stderr = %q, want it to explain the conflict", stderr.String()) } stdout.Reset() stderr.Reset() if code := Run([]string{"-encode", "file.json"}, strings.NewReader(""), &stdout, &stderr); code != 2 { t.Errorf("Run returned %d, want 2 for an argument", code) } } func TestEncodeAfterDecodeRoundTrip(t *testing.T) { doc := `title = "x" flt = 1.5 whole = 7.0 big = 9223372036854775807 when = 1979-05-27T07:32:00-07:00 day = 1979-05-27 clock = 07:32:00.999 list = [1, "two"] multi = "a\nb" [tbl] x = true [[items]] n = "a" ` var tagged, stderr bytes.Buffer if code := Run(nil, strings.NewReader(doc), &tagged, &stderr); code != 0 { t.Fatalf("decode returned %d, stderr = %q", code, stderr.String()) } var out bytes.Buffer if code := Run([]string{"-encode"}, bytes.NewReader(tagged.Bytes()), &out, &stderr); code != 0 { t.Fatalf("encode returned %d, stderr = %q", code, stderr.String()) } want, err := interpres.ParseMap([]byte(doc)) if err != nil { t.Fatalf("parse of the original: %v", err) } got, err := interpres.ParseMap(out.Bytes()) if err != nil { t.Fatalf("parse of the encoder output (%q): %v", out.String(), err) } if !reflect.DeepEqual(want, got) { t.Errorf("round trip changed the document:\noriginal: %#v\nencoded: %#v\noutput: %q", want, got, out.String()) } } func TestRunVersion(t *testing.T) { var stdout, stderr bytes.Buffer code := Run([]string{"-version"}, strings.NewReader(""), &stdout, &stderr) if code != 0 { t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String()) } out := stdout.String() if !strings.HasPrefix(out, "interpres-decode ") { t.Errorf("output = %q, want the version prefix", out) } } func TestRunPlainJSON(t *testing.T) { var stdout, stderr bytes.Buffer in := strings.NewReader("host = \"db\"\nwhen = 1979-05-27T07:32:00-07:00\nitems = [1, 2]\n") code := Run([]string{"-json"}, in, &stdout, &stderr) if code != 0 { t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String()) } out := stdout.String() if !strings.Contains(out, "\"host\": \"db\"") { t.Errorf("output = %q, want plain JSON keys", out) } if strings.Contains(out, "\"type\"") { t.Errorf("output = %q, want no tags", out) } if !strings.Contains(out, "\n \"") { t.Errorf("output = %q, want indentation", out) } } func TestValidateDirectorySummary(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "good.toml"), []byte("a = 1\n"), 0o644) os.WriteFile(filepath.Join(dir, "bad.toml"), []byte("a =\n"), 0o644) sub := filepath.Join(dir, "nested") os.Mkdir(sub, 0o755) os.WriteFile(filepath.Join(sub, "deep.toml"), []byte("b = true\n"), 0o644) var stdout, stderr bytes.Buffer code := Run([]string{"-validate", dir}, strings.NewReader(""), &stdout, &stderr) if code != 1 { t.Fatalf("Run returned %d, want 1 for a directory with an invalid file", code) } if !strings.Contains(stderr.String(), "checked 3 documents, 1 invalid") { t.Errorf("stderr = %q, want the summary", stderr.String()) } } func TestInferStruct(t *testing.T) { var stdout, stderr bytes.Buffer in := strings.NewReader("host = \"db\"\nport = 5432\ntags = [\"a\"]\n\n[server]\nname = \"edge\"\n\n[[items]]\nn = 1\n") code := Run([]string{"-struct"}, in, &stdout, &stderr) if code != 0 { t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String()) } out := stdout.String() for _, want := range []string{ "type inferred struct {", "Host string `toml:\"host\"`", "Port int64 `toml:\"port\"`", "Tags []string `toml:\"tags\"`", "Server struct {", "Items []struct {", } { if !strings.Contains(out, want) { t.Errorf("output missing %q:\n%s", want, out) } } } func TestRunSchemaTemplate(t *testing.T) { src := filepath.Join(t.TempDir(), "config.go") body := `package cfg type Server struct { Host string ` + "`toml:\"host,comment=The host to dial,default=example.org\"`" + ` Port int ` + "`toml:\"port,default=8080\"`" + ` } type Config struct { Name string ` + "`toml:\"name\"`" + ` Rate float64 ` + "`toml:\"rate,default=0.5\"`" + ` On bool ` + "`toml:\"on\"`" + ` Started time.Time ` + "`toml:\"started\"`" + ` Server Server ` + "`toml:\"server,comment=The server section\"`" + ` Items []Item ` + "`toml:\"items\"`" + ` } type Item struct { N int ` + "`toml:\"n\"`" + ` } ` if err := os.WriteFile(src, []byte(body), 0o644); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := Run([]string{"-schema", "Config", src}, strings.NewReader(""), &stdout, &stderr) if code != 0 { t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String()) } out := stdout.String() for _, want := range []string{ "# The server section", "[server]", "# The host to dial", "host = \"example.org\"", "port = 8080", "rate = 0.5", "on = false", "started = 1979-05-27T00:00:00Z", "[[items]]", "n = 0", } { if !strings.Contains(out, want) { t.Errorf("output missing %q:\n%s", want, out) } } }