From 50d87fbe95d9d3a3c9ef24b6c0665df22ad3a9da Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Fri, 2 Jan 2026 19:04:45 -0600 Subject: [PATCH 1/4] feat: decoder pipe --- README.md | 201 +++++++++++++++++++++- decode.go | 304 +++++++++++++++++++++++++++++++++ decode_test.go | 226 ++++++++++++++++++++++++ docs/examplegen/main.go | 3 + examples/creationflags/main.go | 2 +- examples/decode/main.go | 37 ++++ examples/decodejson/main.go | 24 +++ examples/decodewith/main.go | 24 +++ examples/decodeyaml/main.go | 24 +++ examples/fromcombined/main.go | 25 +++ examples/fromstderr/main.go | 38 +++++ examples/fromstdout/main.go | 25 +++ examples/hidewindow/main.go | 2 +- examples/into/main.go | 24 +++ examples/shadowprint/main.go | 4 +- examples/trim/main.go | 25 +++ go.mod | 2 + go.sum | 4 + 18 files changed, 984 insertions(+), 10 deletions(-) create mode 100644 decode.go create mode 100644 decode_test.go create mode 100644 examples/decode/main.go create mode 100644 examples/decodejson/main.go create mode 100644 examples/decodewith/main.go create mode 100644 examples/decodeyaml/main.go create mode 100644 examples/fromcombined/main.go create mode 100644 examples/fromstderr/main.go create mode 100644 examples/fromstdout/main.go create mode 100644 examples/into/main.go create mode 100644 examples/trim/main.go create mode 100644 go.sum diff --git a/README.md b/README.md index a05f408..8e8b4f0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Latest tag - Tests + Tests Go Report Card

@@ -167,6 +167,21 @@ res, err := execx. // Duration: 10.123456ms ``` +## Error handling model + +execx returns two error surfaces: + +1) `err` (from `Run`, `Output`, `CombinedOutput`, `Wait`, etc) only reports execution failures: + - start failures (binary not found, not executable, OS start error) + - context cancellations or timeouts (`WithContext`, `WithTimeout`, `WithDeadline`) + - pipeline failures based on `PipeStrict` / `PipeBestEffort` + +2) `Result.Err` mirrors `err` for convenience; it is not for exit status. + +Exit status is always reported via `Result.ExitCode`, even on non-zero exits. A non-zero exit does not automatically produce `err`. + +Use `err` when you want to handle execution failures, and check `Result.ExitCode` (or `Result.OK()` / `Result.IsExitCode`) when you care about command success. + ## Non-goals and design principles Design principles: @@ -194,6 +209,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) [DecodeYAML](#decodeyaml) [FromCombined](#fromcombined) [FromStderr](#fromstderr) [FromStdout](#fromstdout) [Into](#into) [DecodeWith](#decodewith) [Trim](#trim) | | **Environment** | [Env](#env) [EnvAppend](#envappend) [EnvInherit](#envinherit) [EnvList](#envlist) [EnvOnly](#envonly) | | **Errors** | [Error](#error) [Unwrap](#unwrap) | | **Execution** | [CombinedOutput](#combinedoutput) [Output](#output) [OutputBytes](#outputbytes) [OutputTrimmed](#outputtrimmed) [Run](#run) [Start](#start) | @@ -299,6 +315,179 @@ fmt.Println(cmd.String()) // #string echo "hello world" it's ``` +## Decoding + +### Decode + +Decode configures a custom decoder for this command. + +```go +type payload struct { + Name string +} +decoder := execx.DecoderFunc(func(data []byte, dst any) error { + out, ok := dst.(*payload) + if !ok { + return fmt.Errorf("expected *payload") + } + _, val, ok := strings.Cut(string(data), "=") + if !ok { + return fmt.Errorf("invalid payload") + } + out.Name = val + return nil +}) +var out payload +_ = execx.Command("printf", "name=gopher"). + Decode(decoder). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + +### DecodeJSON + +DecodeJSON configures JSON decoding for this command. + +```go +type payload struct { + Name string `json:"name"` +} +var out payload +_ = execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + +### DecodeYAML + +DecodeYAML configures YAML decoding for this command. + +```go +type payload struct { + Name string `yaml:"name"` +} +var out payload +_ = execx.Command("printf", "name: gopher"). + DecodeYAML(). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + +### FromCombined + +FromCombined decodes from combined stdout+stderr. + +```go +type payload struct { + Name string `json:"name"` +} +var out payload +_ = execx.Command("sh", "-c", `printf '{"name":"gopher"}'`). + DecodeJSON(). + FromCombined(). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + +### FromStderr + +FromStderr decodes from stderr. + +```go +type payload struct { + Name string +} +decoder := execx.DecoderFunc(func(data []byte, dst any) error { + out, ok := dst.(*payload) + if !ok { + return fmt.Errorf("expected *payload") + } + _, val, ok := strings.Cut(string(data), "=") + if !ok { + return fmt.Errorf("invalid payload") + } + out.Name = val + return nil +}) +var out payload +_ = execx.Command("sh", "-c", "printf 'name=gopher' 1>&2"). + Decode(decoder). + FromStderr(). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + +### FromStdout + +FromStdout decodes from stdout (default). + +```go +type payload struct { + Name string `json:"name"` +} +var out payload +_ = execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + FromStdout(). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + +### Into + +Into executes the command and decodes into dst. + +```go +type payload struct { + Name string `json:"name"` +} +var out payload +_ = execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + +### DecodeWith + +DecodeWith executes the command and decodes stdout into dst. + +```go +type payload struct { + Name string `json:"name"` +} +var out payload +_ = execx.Command("printf", `{"name":"gopher"}`). + DecodeWith(&out, execx.DecoderFunc(json.Unmarshal)) +fmt.Println(out.Name) +// #string gopher +``` + +### Trim + +Trim trims whitespace before decoding. + +```go +type payload struct { + Name string `json:"name"` +} +var out payload +_ = execx.Command("printf", " {\"name\":\"gopher\"} "). + DecodeJSON(). + Trim(). + Into(&out) +fmt.Println(out.Name) +// #string gopher +``` + ## Environment ### Env @@ -497,7 +686,7 @@ fmt.Println(out) ### CreationFlags -CreationFlags sets Windows process creation flags (for example, create a new process group). +CreationFlags is a no-op on non-Windows platforms; on Windows it sets process creation flags. ```go out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output() @@ -507,7 +696,7 @@ fmt.Print(out) ### HideWindow -HideWindow hides console windows and sets CREATE_NO_WINDOW for console apps. +HideWindow is a no-op on non-Windows platforms; on Windows it hides console windows. ```go out, _ := execx.Command("printf", "ok").HideWindow(true).Output() @@ -517,7 +706,7 @@ fmt.Print(out) ### Pdeathsig -Pdeathsig is a no-op on Windows; on Linux it signals the child when the parent exits. +Pdeathsig sets a parent-death signal on Linux so the child is signaled if the parent exits. ```go out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() @@ -527,7 +716,7 @@ fmt.Print(out) ### Setpgid -Setpgid is a no-op on Windows; on Unix it places the child in a new process group. +Setpgid places the child in a new process group for group signals. ```go out, _ := execx.Command("printf", "ok").Setpgid(true).Output() @@ -537,7 +726,7 @@ fmt.Print(out) ### Setsid -Setsid is a no-op on Windows; on Unix it starts a new session. +Setsid starts the child in a new session, detaching it from the terminal. ```go out, _ := execx.Command("printf", "ok").Setsid(true).Output() diff --git a/decode.go b/decode.go new file mode 100644 index 0000000..7196cc3 --- /dev/null +++ b/decode.go @@ -0,0 +1,304 @@ +package execx + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + + "gopkg.in/yaml.v3" +) + +// Decoder decodes serialized data into a destination value. +type Decoder interface { + Decode(data []byte, dst any) error +} + +// DecoderFunc adapts a function to a Decoder. +type DecoderFunc func(data []byte, dst any) error + +type decodeSource int + +const ( + decodeStdout decodeSource = iota + decodeStderr + decodeCombined +) + +type decodeConfig struct { + source decodeSource + trim bool +} + +func defaultDecodeConfig() decodeConfig { + return decodeConfig{source: decodeStdout} +} + +// DecodeChain configures typed decoding for a command. +type DecodeChain struct { + cmd *Cmd + decoder Decoder + cfg decodeConfig +} + +// Decode configures a custom decoder for this command. +// @group Decoding +// +// Example: decode custom +// +// type payload struct { +// Name string +// } +// decoder := execx.DecoderFunc(func(data []byte, dst any) error { +// out, ok := dst.(*payload) +// if !ok { +// return fmt.Errorf("expected *payload") +// } +// _, val, ok := strings.Cut(string(data), "=") +// if !ok { +// return fmt.Errorf("invalid payload") +// } +// out.Name = val +// return nil +// }) +// var out payload +// _ = execx.Command("printf", "name=gopher"). +// Decode(decoder). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (c *Cmd) Decode(decoder Decoder) *DecodeChain { + return &DecodeChain{ + cmd: c, + decoder: decoder, + cfg: defaultDecodeConfig(), + } +} + +// Decode implements Decoder. +func (f DecoderFunc) Decode(data []byte, dst any) error { + return f(data, dst) +} + +// DecodeJSON configures JSON decoding for this command. +// @group Decoding +// +// Example: decode json +// +// type payload struct { +// Name string `json:"name"` +// } +// var out payload +// _ = execx.Command("printf", `{"name":"gopher"}`). +// DecodeJSON(). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (c *Cmd) DecodeJSON() *DecodeChain { + return c.Decode(DecoderFunc(json.Unmarshal)) +} + +// DecodeYAML configures YAML decoding for this command. +// @group Decoding +// +// Example: decode yaml +// +// type payload struct { +// Name string `yaml:"name"` +// } +// var out payload +// _ = execx.Command("printf", "name: gopher"). +// DecodeYAML(). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (c *Cmd) DecodeYAML() *DecodeChain { + return c.Decode(DecoderFunc(yaml.Unmarshal)) +} + +// FromStdout decodes from stdout (default). +// @group Decoding +// +// Example: decode from stdout +// +// type payload struct { +// Name string `json:"name"` +// } +// var out payload +// _ = execx.Command("printf", `{"name":"gopher"}`). +// DecodeJSON(). +// FromStdout(). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (d *DecodeChain) FromStdout() *DecodeChain { + d.cfg.source = decodeStdout + return d +} + +// FromStderr decodes from stderr. +// @group Decoding +// +// Example: decode from stderr +// +// type payload struct { +// Name string +// } +// decoder := execx.DecoderFunc(func(data []byte, dst any) error { +// out, ok := dst.(*payload) +// if !ok { +// return fmt.Errorf("expected *payload") +// } +// _, val, ok := strings.Cut(string(data), "=") +// if !ok { +// return fmt.Errorf("invalid payload") +// } +// out.Name = val +// return nil +// }) +// var out payload +// _ = execx.Command("sh", "-c", "printf 'name=gopher' 1>&2"). +// Decode(decoder). +// FromStderr(). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (d *DecodeChain) FromStderr() *DecodeChain { + d.cfg.source = decodeStderr + return d +} + +// FromCombined decodes from combined stdout+stderr. +// @group Decoding +// +// Example: decode combined +// +// type payload struct { +// Name string `json:"name"` +// } +// var out payload +// _ = execx.Command("sh", "-c", `printf '{"name":"gopher"}'`). +// DecodeJSON(). +// FromCombined(). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (d *DecodeChain) FromCombined() *DecodeChain { + d.cfg.source = decodeCombined + return d +} + +// Trim trims whitespace before decoding. +// @group Decoding +// +// Example: decode trim +// +// type payload struct { +// Name string `json:"name"` +// } +// var out payload +// _ = execx.Command("printf", " {\"name\":\"gopher\"} "). +// DecodeJSON(). +// Trim(). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (d *DecodeChain) Trim() *DecodeChain { + d.cfg.trim = true + return d +} + +// Into executes the command and decodes into dst. +// @group Decoding +// +// Example: decode into +// +// type payload struct { +// Name string `json:"name"` +// } +// var out payload +// _ = execx.Command("printf", `{"name":"gopher"}`). +// DecodeJSON(). +// Into(&out) +// fmt.Println(out.Name) +// // #string gopher +func (d *DecodeChain) Into(dst any) error { + return decodeInto(d.cmd, dst, d.decoder, d.cfg) +} + +// DecodeWith executes the command and decodes stdout into dst. +// @group Decoding +// +// Example: decode with +// +// type payload struct { +// Name string `json:"name"` +// } +// var out payload +// _ = execx.Command("printf", `{"name":"gopher"}`). +// DecodeWith(&out, execx.DecoderFunc(json.Unmarshal)) +// fmt.Println(out.Name) +// // #string gopher +func (c *Cmd) DecodeWith(dst any, decoder Decoder) error { + return decodeInto(c, dst, decoder, defaultDecodeConfig()) +} + +func decodeInto(c *Cmd, dst any, decoder Decoder, cfg decodeConfig) error { + if c == nil { + return errors.New("command is nil") + } + if decoder == nil { + return errors.New("decoder is nil") + } + if dst == nil { + return errors.New("destination is nil") + } + val := reflect.ValueOf(dst) + if val.Kind() != reflect.Ptr || val.IsNil() { + return errors.New("destination must be a non-nil pointer") + } + data, err := c.decodeSource(cfg.source) + if err != nil { + return err + } + if cfg.trim { + data = bytes.TrimSpace(data) + } + if err := decoder.Decode(data, dst); err != nil { + return fmt.Errorf("decode %s: %w", decodeSourceName(cfg.source), err) + } + return nil +} + +func (c *Cmd) decodeSource(source decodeSource) ([]byte, error) { + switch source { + case decodeCombined: + out, err := c.CombinedOutput() + if err != nil { + return nil, err + } + return []byte(out), nil + case decodeStderr: + res, err := c.Run() + if err != nil { + return nil, err + } + return []byte(res.Stderr), nil + case decodeStdout: + fallthrough + default: + return c.OutputBytes() + } +} + +func decodeSourceName(source decodeSource) string { + switch source { + case decodeCombined: + return "combined output" + case decodeStderr: + return "stderr" + default: + return "stdout" + } +} diff --git a/decode_test.go b/decode_test.go new file mode 100644 index 0000000..f3c8be3 --- /dev/null +++ b/decode_test.go @@ -0,0 +1,226 @@ +package execx + +import ( + "encoding/json" + "errors" + "runtime" + "strings" + "testing" +) + +type testPayload struct { + Name string `json:"name"` +} + +func TestDecodeYAMLInto(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("decode yaml test uses printf") + } + type payload struct { + Name string `yaml:"name"` + } + var out payload + if err := Command("printf", "name: gopher"). + DecodeYAML(). + Into(&out); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Name != "gopher" { + t.Fatalf("unexpected name: %q", out.Name) + } +} + +func TestDecodeFromStdout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("decode stdout test uses printf") + } + var out testPayload + if err := Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + FromStdout(). + Into(&out); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Name != "gopher" { + t.Fatalf("unexpected name: %q", out.Name) + } +} + +func TestDecodeNilCmd(t *testing.T) { + var out testPayload + err := (*Cmd)(nil). + DecodeJSON(). + Into(&out) + if err == nil { + t.Fatalf("expected error for nil command") + } +} + +func TestDecodeWithJSON(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("output into test uses printf") + } + var out testPayload + err := Command("printf", `{"name":"gopher"}`). + DecodeWith(&out, DecoderFunc(json.Unmarshal)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Name != "gopher" { + t.Fatalf("unexpected name: %q", out.Name) + } +} + +func TestDecodeTrim(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("decode trim test uses printf") + } + var out testPayload + err := Command("printf", ` {"name":"gopher"} `). + DecodeJSON(). + Trim(). + Into(&out) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Name != "gopher" { + t.Fatalf("unexpected name: %q", out.Name) + } +} + +func TestDecodeCombined(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("combined output test uses sh") + } + var out testPayload + err := Command("sh", "-c", `printf '{"name":"gopher"}'`). + DecodeJSON(). + FromCombined(). + Into(&out) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Name != "gopher" { + t.Fatalf("unexpected name: %q", out.Name) + } +} + +func TestDecodeCombinedStartError(t *testing.T) { + var out testPayload + err := Command("execx-does-not-exist"). + DecodeJSON(). + FromCombined(). + Into(&out) + if err == nil { + t.Fatalf("expected error for combined start failure") + } +} + +func TestDecodeCombinedDecodeError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("combined output test uses sh") + } + var out testPayload + err := Command("sh", "-c", "printf not-json"). + DecodeJSON(). + FromCombined(). + Into(&out) + if err == nil || !strings.Contains(err.Error(), "combined output") { + t.Fatalf("expected combined output decode error, got %v", err) + } +} + +func TestDecodeStderr(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stderr output test uses sh") + } + type payload struct { + Name string + } + decoder := DecoderFunc(func(data []byte, dst any) error { + out, ok := dst.(*payload) + if !ok { + return errors.New("expected *payload") + } + _, val, ok := strings.Cut(string(data), "=") + if !ok { + return errors.New("invalid payload") + } + out.Name = val + return nil + }) + var out payload + err := Command("sh", "-c", "printf 'name=gopher' 1>&2"). + Decode(decoder). + FromStderr(). + Into(&out) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Name != "gopher" { + t.Fatalf("unexpected name: %q", out.Name) + } +} + +func TestDecodeStderrStartError(t *testing.T) { + var out testPayload + err := Command("execx-does-not-exist"). + DecodeJSON(). + FromStderr(). + Into(&out) + if err == nil { + t.Fatalf("expected error for stderr start failure") + } +} + +func TestDecodeStderrDecodeError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stderr output test uses sh") + } + var out testPayload + err := Command("sh", "-c", "printf not-json 1>&2"). + DecodeJSON(). + FromStderr(). + Into(&out) + if err == nil || !strings.Contains(err.Error(), "stderr") { + t.Fatalf("expected stderr decode error, got %v", err) + } +} + +func TestDecodeWithErrors(t *testing.T) { + err := Command("printf", `{"name":"gopher"}`). + DecodeWith(nil, DecoderFunc(json.Unmarshal)) + if err == nil { + t.Fatalf("expected error for nil destination") + } + + var out testPayload + err = Command("printf", `{"name":"gopher"}`). + DecodeWith(out, DecoderFunc(json.Unmarshal)) + if err == nil { + t.Fatalf("expected error for non-pointer destination") + } + + err = Command("printf", `{"name":"gopher"}`). + DecodeWith(&out, nil) + if err == nil { + t.Fatalf("expected error for nil decoder") + } +} + +func TestDecodeErrorWrap(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("decode error test uses printf") + } + var out testPayload + err := Command("printf", `not-json`). + DecodeJSON(). + Into(&out) + if err == nil { + t.Fatalf("expected decode error") + } + var syntaxErr *json.SyntaxError + if !errors.As(err, &syntaxErr) && err.Error() == "" { + t.Fatalf("expected decode error detail") + } +} diff --git a/docs/examplegen/main.go b/docs/examplegen/main.go index 6a9807e..c48734f 100644 --- a/docs/examplegen/main.go +++ b/docs/examplegen/main.go @@ -382,6 +382,9 @@ func writeMain(base string, fd *FuncDoc, importPath string) error { if strings.Contains(ex.Code, "fmt.") { imports["fmt"] = true } + if strings.Contains(ex.Code, "json.") { + imports["encoding/json"] = true + } if strings.Contains(ex.Code, "strings.") { imports["strings"] = true } diff --git a/examples/creationflags/main.go b/examples/creationflags/main.go index 2556811..3876d16 100644 --- a/examples/creationflags/main.go +++ b/examples/creationflags/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // CreationFlags sets Windows process creation flags (for example, create a new process group). + // CreationFlags is a no-op on non-Windows platforms; on Windows it sets process creation flags. // Example: creation flags out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output() diff --git a/examples/decode/main.go b/examples/decode/main.go new file mode 100644 index 0000000..d0277e4 --- /dev/null +++ b/examples/decode/main.go @@ -0,0 +1,37 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" + "strings" +) + +func main() { + // Decode configures a custom decoder for this command. + + // Example: decode custom + type payload struct { + Name string + } + decoder := execx.DecoderFunc(func(data []byte, dst any) error { + out, ok := dst.(*payload) + if !ok { + return fmt.Errorf("expected *payload") + } + _, val, ok := strings.Cut(string(data), "=") + if !ok { + return fmt.Errorf("invalid payload") + } + out.Name = val + return nil + }) + var out payload + _ = execx.Command("printf", "name=gopher"). + Decode(decoder). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/decodejson/main.go b/examples/decodejson/main.go new file mode 100644 index 0000000..8d09a86 --- /dev/null +++ b/examples/decodejson/main.go @@ -0,0 +1,24 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +func main() { + // DecodeJSON configures JSON decoding for this command. + + // Example: decode json + type payload struct { + Name string `json:"name"` + } + var out payload + _ = execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/decodewith/main.go b/examples/decodewith/main.go new file mode 100644 index 0000000..849a1c6 --- /dev/null +++ b/examples/decodewith/main.go @@ -0,0 +1,24 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "encoding/json" + "fmt" + "github.com/goforj/execx" +) + +func main() { + // DecodeWith executes the command and decodes stdout into dst. + + // Example: decode with + type payload struct { + Name string `json:"name"` + } + var out payload + _ = execx.Command("printf", `{"name":"gopher"}`). + DecodeWith(&out, execx.DecoderFunc(json.Unmarshal)) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/decodeyaml/main.go b/examples/decodeyaml/main.go new file mode 100644 index 0000000..762d36a --- /dev/null +++ b/examples/decodeyaml/main.go @@ -0,0 +1,24 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +func main() { + // DecodeYAML configures YAML decoding for this command. + + // Example: decode yaml + type payload struct { + Name string `yaml:"name"` + } + var out payload + _ = execx.Command("printf", "name: gopher"). + DecodeYAML(). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/fromcombined/main.go b/examples/fromcombined/main.go new file mode 100644 index 0000000..e97cfab --- /dev/null +++ b/examples/fromcombined/main.go @@ -0,0 +1,25 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +func main() { + // FromCombined decodes from combined stdout+stderr. + + // Example: decode combined + type payload struct { + Name string `json:"name"` + } + var out payload + _ = execx.Command("sh", "-c", `printf '{"name":"gopher"}'`). + DecodeJSON(). + FromCombined(). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/fromstderr/main.go b/examples/fromstderr/main.go new file mode 100644 index 0000000..9e6213d --- /dev/null +++ b/examples/fromstderr/main.go @@ -0,0 +1,38 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" + "strings" +) + +func main() { + // FromStderr decodes from stderr. + + // Example: decode from stderr + type payload struct { + Name string + } + decoder := execx.DecoderFunc(func(data []byte, dst any) error { + out, ok := dst.(*payload) + if !ok { + return fmt.Errorf("expected *payload") + } + _, val, ok := strings.Cut(string(data), "=") + if !ok { + return fmt.Errorf("invalid payload") + } + out.Name = val + return nil + }) + var out payload + _ = execx.Command("sh", "-c", "printf 'name=gopher' 1>&2"). + Decode(decoder). + FromStderr(). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/fromstdout/main.go b/examples/fromstdout/main.go new file mode 100644 index 0000000..3b12e64 --- /dev/null +++ b/examples/fromstdout/main.go @@ -0,0 +1,25 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +func main() { + // FromStdout decodes from stdout (default). + + // Example: decode from stdout + type payload struct { + Name string `json:"name"` + } + var out payload + _ = execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + FromStdout(). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/hidewindow/main.go b/examples/hidewindow/main.go index 2ca4166..1f88f6c 100644 --- a/examples/hidewindow/main.go +++ b/examples/hidewindow/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // HideWindow hides console windows and sets CREATE_NO_WINDOW for console apps. + // HideWindow is a no-op on non-Windows platforms; on Windows it hides console windows. // Example: hide window out, _ := execx.Command("printf", "ok").HideWindow(true).Output() diff --git a/examples/into/main.go b/examples/into/main.go new file mode 100644 index 0000000..b1c32db --- /dev/null +++ b/examples/into/main.go @@ -0,0 +1,24 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +func main() { + // Into executes the command and decodes into dst. + + // Example: decode into + type payload struct { + Name string `json:"name"` + } + var out payload + _ = execx.Command("printf", `{"name":"gopher"}`). + DecodeJSON(). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/examples/shadowprint/main.go b/examples/shadowprint/main.go index c2a3b66..d16d9ac 100644 --- a/examples/shadowprint/main.go +++ b/examples/shadowprint/main.go @@ -13,7 +13,7 @@ func main() { // ShadowPrint configures shadow printing for this command chain. // Example: shadow print - _, _ = execx.Command("echo", "hello world"). + _, _ = execx.Command("bash", "-c", `echo "hello world"`). ShadowPrint(). OnStdout(func(line string) { fmt.Println(line) }). Run() @@ -28,7 +28,7 @@ func main() { formatter := func(ev execx.ShadowEvent) string { return fmt.Sprintf("shadow: %s %s", ev.Phase, ev.Command) } - _, _ = execx.Command("echo", "hello world"). + _, _ = execx.Command("bash", "-c", `echo "hello world"`). ShadowPrint( execx.WithPrefix("execx"), execx.WithMask(mask), diff --git a/examples/trim/main.go b/examples/trim/main.go new file mode 100644 index 0000000..baa7f71 --- /dev/null +++ b/examples/trim/main.go @@ -0,0 +1,25 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +func main() { + // Trim trims whitespace before decoding. + + // Example: decode trim + type payload struct { + Name string `json:"name"` + } + var out payload + _ = execx.Command("printf", " {\"name\":\"gopher\"} "). + DecodeJSON(). + Trim(). + Into(&out) + fmt.Println(out.Name) + // #string gopher +} diff --git a/go.mod b/go.mod index c46bc92..42edfb6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/goforj/execx go 1.24.4 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 78f25bdf6a6e69bcb928cce26b6e17797527f3c2 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Fri, 2 Jan 2026 19:14:38 -0600 Subject: [PATCH 2/4] docs: clarify --- README.md | 37 ++++++++++++++++++---------------- decode.go | 3 +++ examples/creationflags/main.go | 2 +- examples/decodejson/main.go | 1 + examples/decodeyaml/main.go | 1 + examples/hidewindow/main.go | 2 +- examples/pdeathsig/main.go | 2 +- examples/setpgid/main.go | 2 +- examples/setsid/main.go | 2 +- 9 files changed, 30 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8e8b4f0..fce23b6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Latest tag - Tests + Tests Go Report Card

@@ -209,7 +209,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) [DecodeYAML](#decodeyaml) [FromCombined](#fromcombined) [FromStderr](#fromstderr) [FromStdout](#fromstdout) [Into](#into) [DecodeWith](#decodewith) [Trim](#trim) | +| **Decoding** | [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) [Output](#output) [OutputBytes](#outputbytes) [OutputTrimmed](#outputtrimmed) [Run](#run) [Start](#start) | @@ -320,6 +320,7 @@ fmt.Println(cmd.String()) ### Decode Decode configures a custom decoder for this command. +Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. ```go type payload struct { @@ -348,6 +349,7 @@ fmt.Println(out.Name) ### DecodeJSON DecodeJSON configures JSON decoding for this command. +Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. ```go type payload struct { @@ -361,9 +363,25 @@ fmt.Println(out.Name) // #string gopher ``` +### DecodeWith + +DecodeWith executes the command and decodes stdout into dst. + +```go +type payload struct { + Name string `json:"name"` +} +var out payload +_ = execx.Command("printf", `{"name":"gopher"}`). + DecodeWith(&out, execx.DecoderFunc(json.Unmarshal)) +fmt.Println(out.Name) +// #string gopher +``` + ### DecodeYAML DecodeYAML configures YAML decoding for this command. +Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. ```go type payload struct { @@ -456,21 +474,6 @@ fmt.Println(out.Name) // #string gopher ``` -### DecodeWith - -DecodeWith executes the command and decodes stdout into dst. - -```go -type payload struct { - Name string `json:"name"` -} -var out payload -_ = execx.Command("printf", `{"name":"gopher"}`). - DecodeWith(&out, execx.DecoderFunc(json.Unmarshal)) -fmt.Println(out.Name) -// #string gopher -``` - ### Trim Trim trims whitespace before decoding. diff --git a/decode.go b/decode.go index 7196cc3..f381e50 100644 --- a/decode.go +++ b/decode.go @@ -43,6 +43,7 @@ type DecodeChain struct { } // Decode configures a custom decoder for this command. +// Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. // @group Decoding // // Example: decode custom @@ -82,6 +83,7 @@ func (f DecoderFunc) Decode(data []byte, dst any) error { } // DecodeJSON configures JSON decoding for this command. +// Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. // @group Decoding // // Example: decode json @@ -100,6 +102,7 @@ func (c *Cmd) DecodeJSON() *DecodeChain { } // DecodeYAML configures YAML decoding for this command. +// Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. // @group Decoding // // Example: decode yaml diff --git a/examples/creationflags/main.go b/examples/creationflags/main.go index 3876d16..2556811 100644 --- a/examples/creationflags/main.go +++ b/examples/creationflags/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // CreationFlags is a no-op on non-Windows platforms; on Windows it sets process creation flags. + // CreationFlags sets Windows process creation flags (for example, create a new process group). // Example: creation flags out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output() diff --git a/examples/decodejson/main.go b/examples/decodejson/main.go index 8d09a86..6611ac2 100644 --- a/examples/decodejson/main.go +++ b/examples/decodejson/main.go @@ -10,6 +10,7 @@ import ( func main() { // DecodeJSON configures JSON decoding for this command. + // Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. // Example: decode json type payload struct { diff --git a/examples/decodeyaml/main.go b/examples/decodeyaml/main.go index 762d36a..104de3e 100644 --- a/examples/decodeyaml/main.go +++ b/examples/decodeyaml/main.go @@ -10,6 +10,7 @@ import ( func main() { // DecodeYAML configures YAML decoding for this command. + // Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. // Example: decode yaml type payload struct { diff --git a/examples/hidewindow/main.go b/examples/hidewindow/main.go index 1f88f6c..2ca4166 100644 --- a/examples/hidewindow/main.go +++ b/examples/hidewindow/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // HideWindow is a no-op on non-Windows platforms; on Windows it hides console windows. + // HideWindow hides console windows and sets CREATE_NO_WINDOW for console apps. // Example: hide window out, _ := execx.Command("printf", "ok").HideWindow(true).Output() diff --git a/examples/pdeathsig/main.go b/examples/pdeathsig/main.go index 450a6a6..04a4d12 100644 --- a/examples/pdeathsig/main.go +++ b/examples/pdeathsig/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - // Pdeathsig sets a parent-death signal on Linux so the child is signaled if the parent exits. + // Pdeathsig is a no-op on Windows; on Linux it signals the child when the parent exits. // Example: pdeathsig out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() diff --git a/examples/setpgid/main.go b/examples/setpgid/main.go index 8e02196..774652e 100644 --- a/examples/setpgid/main.go +++ b/examples/setpgid/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // Setpgid places the child in a new process group for group signals. + // Setpgid is a no-op on Windows; on Unix it places the child in a new process group. // Example: setpgid out, _ := execx.Command("printf", "ok").Setpgid(true).Output() diff --git a/examples/setsid/main.go b/examples/setsid/main.go index 548a68e..1311a04 100644 --- a/examples/setsid/main.go +++ b/examples/setsid/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // Setsid starts the child in a new session, detaching it from the terminal. + // Setsid is a no-op on Windows; on Unix it starts a new session. // Example: setsid out, _ := execx.Command("printf", "ok").Setsid(true).Output() From 3f998b78cb6e18ad2d652e23fc49fbc1957c8c8f Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Fri, 2 Jan 2026 19:20:22 -0600 Subject: [PATCH 3/4] docs: simplify example --- README.md | 24 ++++++------------------ decode.go | 18 +++--------------- decode_test.go | 22 +++------------------- examples/fromstderr/main.go | 19 +++---------------- examples/pdeathsig/main.go | 2 +- examples/setpgid/main.go | 2 +- examples/setsid/main.go | 2 +- 7 files changed, 18 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index fce23b6..37d3b50 100644 --- a/README.md +++ b/README.md @@ -418,23 +418,11 @@ FromStderr decodes from stderr. ```go type payload struct { - Name string + Name string `json:"name"` } -decoder := execx.DecoderFunc(func(data []byte, dst any) error { - out, ok := dst.(*payload) - if !ok { - return fmt.Errorf("expected *payload") - } - _, val, ok := strings.Cut(string(data), "=") - if !ok { - return fmt.Errorf("invalid payload") - } - out.Name = val - return nil -}) var out payload -_ = execx.Command("sh", "-c", "printf 'name=gopher' 1>&2"). - Decode(decoder). +_ = execx.Command("sh", "-c", `printf '{"name":"gopher"}' 1>&2`). + DecodeJSON(). FromStderr(). Into(&out) fmt.Println(out.Name) @@ -709,7 +697,7 @@ fmt.Print(out) ### Pdeathsig -Pdeathsig sets a parent-death signal on Linux so the child is signaled if the parent exits. +Pdeathsig is a no-op on Windows; on Linux it signals the child when the parent exits. ```go out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() @@ -719,7 +707,7 @@ fmt.Print(out) ### Setpgid -Setpgid places the child in a new process group for group signals. +Setpgid is a no-op on Windows; on Unix it places the child in a new process group. ```go out, _ := execx.Command("printf", "ok").Setpgid(true).Output() @@ -729,7 +717,7 @@ fmt.Print(out) ### Setsid -Setsid starts the child in a new session, detaching it from the terminal. +Setsid is a no-op on Windows; on Unix it starts a new session. ```go out, _ := execx.Command("printf", "ok").Setsid(true).Output() diff --git a/decode.go b/decode.go index f381e50..36c9115 100644 --- a/decode.go +++ b/decode.go @@ -146,23 +146,11 @@ func (d *DecodeChain) FromStdout() *DecodeChain { // Example: decode from stderr // // type payload struct { -// Name string +// Name string `json:"name"` // } -// decoder := execx.DecoderFunc(func(data []byte, dst any) error { -// out, ok := dst.(*payload) -// if !ok { -// return fmt.Errorf("expected *payload") -// } -// _, val, ok := strings.Cut(string(data), "=") -// if !ok { -// return fmt.Errorf("invalid payload") -// } -// out.Name = val -// return nil -// }) // var out payload -// _ = execx.Command("sh", "-c", "printf 'name=gopher' 1>&2"). -// Decode(decoder). +// _ = execx.Command("sh", "-c", `printf '{"name":"gopher"}' 1>&2`). +// DecodeJSON(). // FromStderr(). // Into(&out) // fmt.Println(out.Name) diff --git a/decode_test.go b/decode_test.go index f3c8be3..513e6da 100644 --- a/decode_test.go +++ b/decode_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "runtime" - "strings" "testing" ) @@ -134,24 +133,9 @@ func TestDecodeStderr(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("stderr output test uses sh") } - type payload struct { - Name string - } - decoder := DecoderFunc(func(data []byte, dst any) error { - out, ok := dst.(*payload) - if !ok { - return errors.New("expected *payload") - } - _, val, ok := strings.Cut(string(data), "=") - if !ok { - return errors.New("invalid payload") - } - out.Name = val - return nil - }) - var out payload - err := Command("sh", "-c", "printf 'name=gopher' 1>&2"). - Decode(decoder). + var out testPayload + err := Command("sh", "-c", `printf '{"name":"gopher"}' 1>&2`). + DecodeJSON(). FromStderr(). Into(&out) if err != nil { diff --git a/examples/fromstderr/main.go b/examples/fromstderr/main.go index 9e6213d..8d87be7 100644 --- a/examples/fromstderr/main.go +++ b/examples/fromstderr/main.go @@ -6,7 +6,6 @@ package main import ( "fmt" "github.com/goforj/execx" - "strings" ) func main() { @@ -14,23 +13,11 @@ func main() { // Example: decode from stderr type payload struct { - Name string + Name string `json:"name"` } - decoder := execx.DecoderFunc(func(data []byte, dst any) error { - out, ok := dst.(*payload) - if !ok { - return fmt.Errorf("expected *payload") - } - _, val, ok := strings.Cut(string(data), "=") - if !ok { - return fmt.Errorf("invalid payload") - } - out.Name = val - return nil - }) var out payload - _ = execx.Command("sh", "-c", "printf 'name=gopher' 1>&2"). - Decode(decoder). + _ = execx.Command("sh", "-c", `printf '{"name":"gopher"}' 1>&2`). + DecodeJSON(). FromStderr(). Into(&out) fmt.Println(out.Name) diff --git a/examples/pdeathsig/main.go b/examples/pdeathsig/main.go index 04a4d12..132df7a 100644 --- a/examples/pdeathsig/main.go +++ b/examples/pdeathsig/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - // Pdeathsig is a no-op on Windows; on Linux it signals the child when the parent exits. + // Pdeathsig is a no-op on non-Linux Unix platforms; on Linux it signals the child when the parent exits. // Example: pdeathsig out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() diff --git a/examples/setpgid/main.go b/examples/setpgid/main.go index 774652e..8e02196 100644 --- a/examples/setpgid/main.go +++ b/examples/setpgid/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // Setpgid is a no-op on Windows; on Unix it places the child in a new process group. + // Setpgid places the child in a new process group for group signals. // Example: setpgid out, _ := execx.Command("printf", "ok").Setpgid(true).Output() diff --git a/examples/setsid/main.go b/examples/setsid/main.go index 1311a04..548a68e 100644 --- a/examples/setsid/main.go +++ b/examples/setsid/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // Setsid is a no-op on Windows; on Unix it starts a new session. + // Setsid starts the child in a new session, detaching it from the terminal. // Example: setsid out, _ := execx.Command("printf", "ok").Setsid(true).Output() From cb7efd54c946250757af7a3ad5d54211832a6505 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Fri, 2 Jan 2026 19:22:12 -0600 Subject: [PATCH 4/4] fix: ide --- README.md | 6 +++--- decode_test.go | 1 + examples/pdeathsig/main.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 37d3b50..259debe 100644 --- a/README.md +++ b/README.md @@ -697,7 +697,7 @@ fmt.Print(out) ### Pdeathsig -Pdeathsig is a no-op on Windows; on Linux it signals the child when the parent exits. +Pdeathsig sets a parent-death signal on Linux so the child is signaled if the parent exits. ```go out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() @@ -707,7 +707,7 @@ fmt.Print(out) ### Setpgid -Setpgid is a no-op on Windows; on Unix it places the child in a new process group. +Setpgid places the child in a new process group for group signals. ```go out, _ := execx.Command("printf", "ok").Setpgid(true).Output() @@ -717,7 +717,7 @@ fmt.Print(out) ### Setsid -Setsid is a no-op on Windows; on Unix it starts a new session. +Setsid starts the child in a new session, detaching it from the terminal. ```go out, _ := execx.Command("printf", "ok").Setsid(true).Output() diff --git a/decode_test.go b/decode_test.go index 513e6da..43f353c 100644 --- a/decode_test.go +++ b/decode_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "runtime" + "strings" "testing" ) diff --git a/examples/pdeathsig/main.go b/examples/pdeathsig/main.go index 132df7a..450a6a6 100644 --- a/examples/pdeathsig/main.go +++ b/examples/pdeathsig/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - // Pdeathsig is a no-op on non-Linux Unix platforms; on Linux it signals the child when the parent exits. + // Pdeathsig sets a parent-death signal on Linux so the child is signaled if the parent exits. // Example: pdeathsig out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output()