diff --git a/cli_json.go b/cli_json.go new file mode 100644 index 0000000..d624e64 --- /dev/null +++ b/cli_json.go @@ -0,0 +1,76 @@ +package commandkit + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" +) + +// ParseCLIJSON parses exactly one JSON value for use in CLI inputs. Integer +// tokens become int64, or uint64 for larger nonnegative integers; decimal and +// exponent tokens become float64. Nested values follow the same rules. Syntax +// errors and numbers outside the selected Go types' ranges are returned. +func ParseCLIJSON(input string) (any, error) { + decoder := json.NewDecoder(strings.NewReader(input)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err != nil { + return nil, err + } + return nil, fmt.Errorf("unexpected additional JSON value") + } + return convertCLIJSON(value) +} + +// CLI inputs use ordinary Go numbers for upstream consumers. Classification +// follows the token's syntax, so 1.0 and 1e0 remain float64. Unlike report +// serialization, this boundary deliberately accepts float64 approximation. +func convertCLIJSON(value any) (any, error) { + switch v := value.(type) { + case json.Number: + token := string(v) + if strings.ContainsAny(token, ".eE") { + result, err := strconv.ParseFloat(token, 64) + if err != nil { + return nil, fmt.Errorf("JSON number %q: %w", token, err) + } + return result, nil + } + result, err := strconv.ParseInt(token, 10, 64) + if err == nil { + return result, nil + } + if !strings.HasPrefix(token, "-") { + unsigned, unsignedErr := strconv.ParseUint(token, 10, 64) + if unsignedErr == nil { + return unsigned, nil + } + return nil, fmt.Errorf("JSON integer %q: %w", token, unsignedErr) + } + return nil, fmt.Errorf("JSON integer %q: %w", token, err) + case []any: + for index, item := range v { + converted, err := convertCLIJSON(item) + if err != nil { + return nil, err + } + v[index] = converted + } + case map[string]any: + for key, item := range v { + converted, err := convertCLIJSON(item) + if err != nil { + return nil, err + } + v[key] = converted + } + } + return value, nil +} diff --git a/cli_json_test.go b/cli_json_test.go new file mode 100644 index 0000000..bfba47b --- /dev/null +++ b/cli_json_test.go @@ -0,0 +1,44 @@ +package commandkit + +import ( + "errors" + "math" + "reflect" + "strconv" + "testing" +) + +func TestParseCLIJSONValues(t *testing.T) { + for _, tc := range []struct { + input string + want any + }{ + {`{"a":[null,true,"x",-9223372036854775808,9223372036854775807,9223372036854775808,18446744073709551615],"b":{"n":1.0,"exp":1e3}}`, map[string]any{ + "a": []any{nil, true, "x", int64(math.MinInt64), int64(math.MaxInt64), uint64(1 << 63), uint64(math.MaxUint64)}, + "b": map[string]any{"n": float64(1), "exp": float64(1000)}, + }}, + {`0`, int64(0)}, {`-0`, int64(0)}, {`42.0`, float64(42)}, {`1e0`, float64(1)}, + {`0.1`, float64(0.1)}, {`"1"`, "1"}, {`false`, false}, {`null`, nil}, + } { + t.Run(tc.input, func(t *testing.T) { + got, err := ParseCLIJSON(tc.input) + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %#v, %v; want %#v", got, err, tc.want) + } + }) + } +} + +func TestParseCLIJSONRejectsInvalidInput(t *testing.T) { + for _, input := range []string{"", "[", "01", `1 2`, `true false`, `{} garbage`, `-9223372036854775809`, `18446744073709551616`, `1e400`, `[-1e400]`} { + t.Run(input, func(t *testing.T) { + if _, err := ParseCLIJSON(input); err == nil { + t.Fatalf("accepted %q", input) + } + }) + } + _, err := ParseCLIJSON(`18446744073709551616`) + if !errors.Is(err, strconv.ErrRange) { + t.Fatalf("range error = %v", err) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d5bc8c5..a09edc9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shared reporting and PromptKit v0.20.0 implementation plan -Status: implementation in progress; Stages 1–4 complete. +Status: implementation in progress; Stages 1–5 complete. ## Scope, authority, and execution @@ -193,6 +193,10 @@ boundary; later documentation stages complete the integration inventory. ## Stage 5: Public CLI JSON parsing +**Complete.** CLI JSON parsing and numeric conversion are implemented. Focused +boundary tests and the root workspace suite (`GOTOOLCHAIN=local go test ./...` +with Go 1.27.0) passed. + Add `ParseCLIJSON(string) (any, error)` in core. Decode exactly one JSON value with `UseNumber`, require completion of that value, and recursively convert numbers under [OD-2](promptkit-v0.20.0.md#od-2-request-controls-and-provider-parameter-grammar).