From 8fba19fd54d5a605402fb7481d8ba9893369463c Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Sun, 23 Aug 2026 01:16:28 +0000 Subject: [PATCH 1/2] feat: add typed decode results --- .github/workflows/test.yml | 2 +- README.md | 23 +++++++++++++++++---- benchmark_test.go | 27 ++++++++++++++++++++++++ decode.go | 19 +++++++++++++++++ decode_test.go | 42 ++++++++++++++++++++++++++++++++++++++ docs/go.mod | 2 +- examples/as/main.go | 21 +++++++++++++++++++ examples/go.mod | 2 +- go.mod | 2 +- 9 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 examples/as/main.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f262bdf..dc90775 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.24.4", "stable"] + go-version: ["1.27.x", "stable"] steps: - name: Checkout diff --git a/README.md b/README.md index 82c0ba4..58e7dd5 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ Go Reference License: MIT Go Test - Go version + Go version Latest tag - Tests + Tests

@@ -34,7 +34,7 @@ The core contract is deliberately narrow: ## Installation -execx requires Go 1.24.4 or newer. +execx requires Go 1.27 or newer. Projects on older Go releases can pin the v1.1 release line. ```bash go get github.com/goforj/execx @@ -249,7 +249,7 @@ All public APIs are covered by runnable examples under `./examples`, and the tes | **Construction** | [Command](#command) | | **Context** | [WithContext](#withcontext) · [WithDeadline](#withdeadline) · [WithTimeout](#withtimeout) | | **Debugging** | [Args](#args) · [ShellEscaped](#shellescaped) · [String](#string) | -| **Decoding** | [Decode](#decode) · [DecodeJSON](#decodejson) · [DecodeWith](#decodewith) · [DecodeYAML](#decodeyaml) · [FromCombined](#fromcombined) · [FromStderr](#fromstderr) · [FromStdout](#fromstdout) · [Into](#into) · [Trim](#trim) | +| **Decoding** | [As](#as) · [Decode](#decode) · [DecodeJSON](#decodejson) · [DecodeWith](#decodewith) · [DecodeYAML](#decodeyaml) · [FromCombined](#fromcombined) · [FromStderr](#fromstderr) · [FromStdout](#fromstdout) · [Into](#into) · [Trim](#trim) | | **Environment** | [Env](#env) · [EnvAppend](#envappend) · [EnvInherit](#envinherit) · [EnvList](#envlist) · [EnvOnly](#envonly) | | **Errors** | [Error](#error) · [Unwrap](#unwrap) | | **Execution** | [CombinedOutput](#combinedoutput) · [OnExecCmd](#onexeccmd) · [Output](#output) · [OutputBytes](#outputbytes) · [OutputTrimmed](#outputtrimmed) · [Run](#run) · [Start](#start) | @@ -360,6 +360,21 @@ fmt.Println(cmd.String()) ## Decoding +### As + +As executes the command and decodes its selected output into T. + +```go +type payload struct { + Name string `json:"name"` +} +out, err := execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + As[payload]() +fmt.Println(err == nil, out.Name) +// true gopher +``` + ### Decode Decode configures a custom decoder for this command. diff --git a/benchmark_test.go b/benchmark_test.go index 4393e21..195c64f 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -4,6 +4,9 @@ import "testing" var benchmarkArgs []string +// benchmarkPayload prevents decoded benchmark results from being optimized away. +var benchmarkPayload testPayload + // BenchmarkCommandConstruction measures fluent argument assembly without subprocess noise. func BenchmarkCommandConstruction(b *testing.B) { b.ReportAllocs() @@ -22,3 +25,27 @@ func BenchmarkShellEscaped(b *testing.B) { benchmarkArgs = []string{cmd.ShellEscaped()} } } + +// BenchmarkDecodeResult compares caller-owned and generic result decoding through the same command path. +func BenchmarkDecodeResult(b *testing.B) { + b.Run("Into", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + var out testPayload + if err := Command("printf", `{"name":"gopher"}`).DecodeJSON().Into(&out); err != nil { + b.Fatalf("Into: %v", err) + } + benchmarkPayload = out + } + }) + b.Run("As", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + out, err := Command("printf", `{"name":"gopher"}`).DecodeJSON().As[testPayload]() + if err != nil { + b.Fatalf("As: %v", err) + } + benchmarkPayload = out + } + }) +} diff --git a/decode.go b/decode.go index 696863d..3393ba4 100644 --- a/decode.go +++ b/decode.go @@ -220,6 +220,25 @@ func (d *DecodeChain) Into(dst any) error { return decodeInto(d.cmd, dst, d.decoder, d.cfg) } +// As executes the command and decodes its selected output into T. +// @group Decoding +// +// Example: decode as a value +// +// type payload struct { +// Name string `json:"name"` +// } +// out, err := execx.Command("printf", `{"name":"gopher"}`). +// DecodeJSON(). +// As[payload]() +// fmt.Println(err == nil, out.Name) +// // true gopher +func (d *DecodeChain) As[T any]() (T, error) { + var out T + err := decodeInto(d.cmd, &out, d.decoder, d.cfg) + return out, err +} + // DecodeWith executes the command and decodes stdout into dst. // @group Decoding // diff --git a/decode_test.go b/decode_test.go index 21f77d2..d919f43 100644 --- a/decode_test.go +++ b/decode_test.go @@ -12,6 +12,48 @@ type testPayload struct { Name string `json:"name"` } +// TestDecodeAs returns a typed result while preserving the configured decoder and source. +func TestDecodeAs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("decode as test uses printf") + } + + as := (*DecodeChain).As[testPayload] + chain := Command("printf", ` {"name":"gopher"} `). + DecodeJSON(). + FromStdout(). + Trim() + out, err := as(chain) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Name != "gopher" { + t.Fatalf("unexpected name: %q", out.Name) + } + + bound := Command("printf", `{"name":"value"}`).DecodeJSON().As[testPayload] + out, err = bound() + if err != nil || out.Name != "value" { + t.Fatalf("method value returned %+v, %v", out, err) + } +} + +// TestDecodeAsErrors preserves command, decoder, and decode failures in the result form. +func TestDecodeAsErrors(t *testing.T) { + if _, err := (*Cmd)(nil).DecodeJSON().As[testPayload](); err == nil { + t.Fatal("expected nil command error") + } + if _, err := Command("printf", `{}`).Decode(nil).As[testPayload](); err == nil { + t.Fatal("expected nil decoder error") + } + if runtime.GOOS == "windows" { + t.Skip("decode error test uses printf") + } + if _, err := Command("printf", `not-json`).DecodeJSON().As[testPayload](); err == nil { + t.Fatal("expected decode error") + } +} + // TestDecodeYAMLInto ensures YAML command output can populate a caller-owned destination. func TestDecodeYAMLInto(t *testing.T) { if runtime.GOOS == "windows" { diff --git a/docs/go.mod b/docs/go.mod index 0de05d2..86bb2ae 100644 --- a/docs/go.mod +++ b/docs/go.mod @@ -1,3 +1,3 @@ module github.com/goforj/execx/docs -go 1.24.4 +go 1.27.0 diff --git a/examples/as/main.go b/examples/as/main.go new file mode 100644 index 0000000..756e41d --- /dev/null +++ b/examples/as/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +// main keeps this documented example executable so API drift fails during compilation. +func main() { + // As executes the command and decodes its selected output into T. + + // Example: decode as a value + type payload struct { + Name string `json:"name"` + } + out, err := execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + As[payload]() + fmt.Println(err == nil, out.Name) + // true gopher +} diff --git a/examples/go.mod b/examples/go.mod index 0c47b2e..50112ca 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,6 +1,6 @@ module github.com/goforj/execx/examples -go 1.24.4 +go 1.27.0 require github.com/goforj/execx v1.1.0 diff --git a/go.mod b/go.mod index 69aae6e..cb3af02 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/goforj/execx -go 1.24.4 +go 1.27.0 require ( golang.org/x/term v0.40.0 From 4fcbcad01ee478bfa98eed154f2f61d5851285bd Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Sun, 23 Aug 2026 01:30:04 +0000 Subject: [PATCH 2/2] chore: tidy module requirements --- go.mod | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/go.mod b/go.mod index cb3af02..e6fe658 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,7 @@ module github.com/goforj/execx go 1.27.0 require ( + golang.org/x/sys v0.41.0 golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) - -require golang.org/x/sys v0.41.0