├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── builder_chain.go ├── builder_command.go ├── builder_finalized.go ├── builder_streams.go ├── chain_test.go ├── codecov.yml ├── error_checker.go ├── error_checker_test.go ├── errors.go ├── example_test.go ├── go.mod ├── go.sum ├── hook.go ├── interfaces.go ├── lazy_file.go ├── lazy_file_test.go ├── shell.go ├── shell_err.go ├── shell_test.go ├── string.go ├── string_test.go └── test_helper └── main.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: go 2 | on: [ push ] 3 | 4 | jobs: 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Set up Go 1.23 10 | uses: actions/setup-go@v1 11 | with: 12 | go-version: 1.23 13 | id: go 14 | 15 | - name: Check out code into the Go module directory 16 | uses: actions/checkout@v2 17 | 18 | - name: Get dependencies 19 | run: | 20 | go get -v -t -d ./... 21 | 22 | - name: Test 23 | run: go test -cover -coverprofile=coverage.txt -covermode=atomic ./ 24 | 25 | - name: Upload coverage report 26 | uses: codecov/codecov-action@v1.0.2 27 | with: 28 | token: ${{ secrets.CODECOV_TOKEN }} 29 | file: ./coverage.txt 30 | flags: unittests 31 | name: codecov-umbrella 32 | 33 | - name: Vet 34 | run: go vet ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | testHelper 18 | coverage.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 rainu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go](https://github.com/rainu/go-command-chain/actions/workflows/build.yml/badge.svg)](https://github.com/rainu/go-command-chain/actions/workflows/build.yml) 2 | [![codecov](https://codecov.io/gh/rainu/go-command-chain/branch/main/graph/badge.svg)](https://codecov.io/gh/rainu/go-command-chain) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/rainu/go-command-chain)](https://goreportcard.com/report/github.com/rainu/go-command-chain) 4 | [![Go Reference](https://pkg.go.dev/badge/github.com/rainu/go-command-chain.svg)](https://pkg.go.dev/github.com/rainu/go-command-chain) 5 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 6 | 7 | # go-command-chain 8 | ![](https://media.discordapp.net/attachments/1101609055094575192/1102605830592925717/rainu_cyberpunkt_netrunner_cat_1_dad93401-86aa-4ea2-b4d6-c171077d401d.png) 9 | 10 | A go library for easy configure and run command chains. Such like pipelining in unix shells. 11 | 12 | # Example 13 | ```sh 14 | cat log_file.txt | grep error | wc -l 15 | ``` 16 | 17 | ```go 18 | package main 19 | 20 | import ( 21 | "fmt" 22 | "github.com/rainu/go-command-chain" 23 | ) 24 | 25 | func main() { 26 | stdOut, stdErr, err := cmdchain.Builder(). 27 | Join("cat", "log_file.txt"). 28 | Join("grep", "error"). 29 | Join("wc", "-l"). 30 | Finalize().RunAndGet() 31 | 32 | if err != nil { 33 | panic(err) 34 | } 35 | if stdErr != "" { 36 | panic(stdErr) 37 | } 38 | fmt.Printf("Errors found: %s", stdOut) 39 | } 40 | ``` 41 | 42 | ```go 43 | package main 44 | 45 | import ( 46 | "fmt" 47 | "github.com/rainu/go-command-chain" 48 | ) 49 | 50 | func main() { 51 | stdOut, stdErr, err := cmdchain.Builder(). 52 | JoinShellCmd(`cat log_file.txt | grep error | wc -l`). 53 | Finalize().RunAndGet() 54 | 55 | if err != nil { 56 | panic(err) 57 | } 58 | if stdErr != "" { 59 | panic(stdErr) 60 | } 61 | fmt.Printf("Errors found: %s", stdOut) 62 | } 63 | ``` 64 | 65 | For more examples how to use the command chain see [examples](example_test.go). 66 | 67 | # Why you should use this library? 68 | 69 | If you want to execute a complex command pipeline you could come up with the idea of just execute **one** command: the 70 | shell itself such like to following code: 71 | 72 | ```go 73 | package main 74 | 75 | import ( 76 | "os/exec" 77 | ) 78 | 79 | func main() { 80 | exec.Command("sh", "-c", "cat log_file.txt | grep error | wc -l").Run() 81 | } 82 | ``` 83 | 84 | But this procedure has some negative points: 85 | * you must have installed the shell - in correct version - on the system itself 86 | * so you are dependent on the shell 87 | * you have no control over the individual commands - only the parent process (shell command itself) 88 | * pipelining can be complex (redirection of stderr etc.) - so you have to know the pipeline syntax 89 | * maybe this syntax is different for shell versions 90 | 91 | ## (advanced) features 92 | 93 | ### input injections 94 | **Multiple** different input stream for each command can be configured. This can be useful if you want to 95 | forward multiple input sources to one command. 96 | 97 | ```go 98 | package main 99 | 100 | import ( 101 | "github.com/rainu/go-command-chain" 102 | "strings" 103 | ) 104 | 105 | func main() { 106 | inputContent1 := strings.NewReader("content from application itself\n") 107 | inputContent2 := strings.NewReader("another content from application itself\n") 108 | 109 | err := cmdchain.Builder(). 110 | Join("echo", "test").WithInjections(inputContent1, inputContent2). 111 | Join("grep", "test"). 112 | Join("wc", "-l"). 113 | Finalize().Run() 114 | 115 | if err != nil { 116 | panic(err) 117 | } 118 | } 119 | ``` 120 | 121 | ### forking of stdout and stderr 122 | 123 | Stdout and stderr of **each** command can be **forked** to different io.Writer. 124 | 125 | ```go 126 | package main 127 | 128 | import ( 129 | "bytes" 130 | "github.com/rainu/go-command-chain" 131 | ) 132 | 133 | func main() { 134 | echoErr := &bytes.Buffer{} 135 | echoOut := &bytes.Buffer{} 136 | grepErr := &bytes.Buffer{} 137 | 138 | err := cmdchain.Builder(). 139 | Join("echo", "test").WithOutputForks(echoOut).WithErrorForks(echoErr). 140 | Join("grep", "test").WithErrorForks(grepErr). 141 | Join("wc", "-l"). 142 | Finalize().Run() 143 | 144 | if err != nil { 145 | panic(err) 146 | } 147 | } 148 | ``` 149 | -------------------------------------------------------------------------------- /builder_chain.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os/exec" 7 | "sync" 8 | ) 9 | 10 | type chain struct { 11 | cmdDescriptors []cmdDescriptor 12 | inputs []io.Reader 13 | buildErrors MultipleErrors 14 | streamErrors MultipleErrors 15 | 16 | streamRoutinesWg sync.WaitGroup 17 | errorChecker ErrorChecker 18 | 19 | hooks []hook 20 | } 21 | 22 | type cmdDescriptor struct { 23 | command *exec.Cmd 24 | outToIn bool 25 | errToIn bool 26 | outFork io.Writer 27 | errFork io.Writer 28 | commandApplier []CommandApplier 29 | errorChecker ErrorChecker 30 | 31 | inputStreams []io.Reader 32 | outputStreams []io.Writer 33 | errorStreams []io.Writer 34 | } 35 | 36 | // Builder creates a new command chain builder. This build flow will configure 37 | // the commands more or less instantaneously. If any error occurs while building 38 | // the chain you will receive them when you finally call Run of this chain. 39 | func Builder() FirstCommandBuilder { 40 | return &chain{ 41 | buildErrors: buildErrors(), 42 | streamErrors: streamErrors(), 43 | streamRoutinesWg: sync.WaitGroup{}, 44 | } 45 | } 46 | 47 | func (c *chain) WithInput(sources ...io.Reader) ChainBuilder { 48 | c.inputs = sources 49 | return c 50 | } 51 | 52 | func (c *chain) JoinCmd(cmd *exec.Cmd) CommandBuilder { 53 | if cmd == nil { 54 | return c 55 | } 56 | 57 | c.cmdDescriptors = append(c.cmdDescriptors, cmdDescriptor{ 58 | command: cmd, 59 | outToIn: true, 60 | }) 61 | c.streamErrors.addError(nil) 62 | 63 | if len(c.cmdDescriptors) > 1 { 64 | c.linkStreams(cmd) 65 | } 66 | 67 | return c 68 | } 69 | 70 | func (c *chain) Join(name string, args ...string) CommandBuilder { 71 | return c.JoinCmd(exec.Command(name, args...)) 72 | } 73 | 74 | func (c *chain) JoinWithContext(ctx context.Context, name string, args ...string) CommandBuilder { 75 | return c.JoinCmd(exec.CommandContext(ctx, name, args...)) 76 | } 77 | 78 | func (c *chain) Finalize() FinalizedBuilder { 79 | if len(c.cmdDescriptors) == 0 { 80 | return c 81 | } 82 | 83 | firstCmdDesc := &(c.cmdDescriptors[0]) 84 | 85 | is := firstCmdDesc.inputStreams 86 | firstCmdDesc.inputStreams = append([]io.Reader{}, c.inputs...) 87 | firstCmdDesc.inputStreams = append(firstCmdDesc.inputStreams, is...) 88 | 89 | if len(c.inputs) == 1 { 90 | firstCmdDesc.command.Stdin = c.inputs[0] 91 | } else if len(c.inputs) > 1 { 92 | var err error 93 | firstCmdDesc.command.Stdin, err = c.combineStreamForCommand(0, c.inputs...) 94 | if c.streamErrors.Errors()[0] == nil { 95 | c.streamErrors.setError(0, err) 96 | } 97 | } 98 | 99 | lastCmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 100 | if lastCmdDesc.outFork != nil { 101 | lastCmdDesc.command.Stdout = lastCmdDesc.outFork 102 | } 103 | if lastCmdDesc.errFork != nil { 104 | lastCmdDesc.command.Stderr = lastCmdDesc.errFork 105 | } 106 | 107 | return c 108 | } 109 | -------------------------------------------------------------------------------- /builder_command.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | func (c *chain) Apply(applier CommandApplier) CommandBuilder { 10 | applier(len(c.cmdDescriptors)-1, c.cmdDescriptors[len(c.cmdDescriptors)-1].command) 11 | return c 12 | } 13 | 14 | func (c *chain) ApplyBeforeStart(applier CommandApplier) CommandBuilder { 15 | i := len(c.cmdDescriptors) - 1 16 | c.cmdDescriptors[i].commandApplier = append(c.cmdDescriptors[i].commandApplier, applier) 17 | 18 | return c 19 | } 20 | 21 | func (c *chain) ForwardError() CommandBuilder { 22 | c.cmdDescriptors[len(c.cmdDescriptors)-1].errToIn = true 23 | return c 24 | } 25 | 26 | func (c *chain) DiscardStdOut() CommandBuilder { 27 | c.cmdDescriptors[len(c.cmdDescriptors)-1].outToIn = false 28 | return c 29 | } 30 | 31 | func (c *chain) WithOutputForks(targets ...io.Writer) CommandBuilder { 32 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 33 | cmdDesc.outputStreams = targets 34 | 35 | if len(targets) > 1 { 36 | cmdDesc.outFork = io.MultiWriter(targets...) 37 | } else if len(targets) == 1 { 38 | cmdDesc.outFork = targets[0] 39 | } 40 | 41 | return c 42 | } 43 | 44 | func (c *chain) WithAdditionalOutputForks(targets ...io.Writer) CommandBuilder { 45 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 46 | cmdDesc.outputStreams = append(cmdDesc.outputStreams, targets...) 47 | 48 | if len(cmdDesc.outputStreams) > 1 { 49 | cmdDesc.outFork = io.MultiWriter(cmdDesc.outputStreams...) 50 | } else if len(cmdDesc.outputStreams) == 1 { 51 | cmdDesc.outFork = cmdDesc.outputStreams[0] 52 | } 53 | 54 | return c 55 | } 56 | 57 | func (c *chain) WithErrorForks(targets ...io.Writer) CommandBuilder { 58 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 59 | cmdDesc.errorStreams = targets 60 | 61 | if len(targets) > 1 { 62 | cmdDesc.errFork = io.MultiWriter(targets...) 63 | } else if len(targets) == 1 { 64 | cmdDesc.errFork = targets[0] 65 | } 66 | return c 67 | } 68 | 69 | func (c *chain) WithAdditionalErrorForks(targets ...io.Writer) CommandBuilder { 70 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 71 | cmdDesc.errorStreams = append(cmdDesc.errorStreams, targets...) 72 | 73 | if len(cmdDesc.errorStreams) > 1 { 74 | cmdDesc.errFork = io.MultiWriter(cmdDesc.errorStreams...) 75 | } else if len(cmdDesc.errorStreams) == 1 { 76 | cmdDesc.errFork = cmdDesc.errorStreams[0] 77 | } 78 | return c 79 | } 80 | 81 | func (c *chain) WithInjections(sources ...io.Reader) CommandBuilder { 82 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 83 | cmdDesc.inputStreams = append(cmdDesc.inputStreams, sources...) 84 | 85 | if len(sources) > 0 { 86 | combineSrc := make([]io.Reader, 0, len(sources)+1) 87 | if cmdDesc.command.Stdin != nil { 88 | combineSrc = append(combineSrc, cmdDesc.command.Stdin) 89 | } 90 | 91 | for _, source := range sources { 92 | if source != nil { 93 | combineSrc = append(combineSrc, source) 94 | } 95 | } 96 | 97 | if len(combineSrc) == 1 { 98 | cmdDesc.command.Stdin = combineSrc[0] 99 | } else if len(combineSrc) > 1 { 100 | var err error 101 | cmdDesc.command.Stdin, err = c.combineStream(combineSrc...) 102 | if err != nil { 103 | c.streamErrors.setError(len(c.cmdDescriptors)-1, err) 104 | } 105 | } 106 | } 107 | 108 | return c 109 | } 110 | 111 | func (c *chain) WithEmptyEnvironment() CommandBuilder { 112 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 113 | cmdDesc.command.Env = []string{} 114 | 115 | return c 116 | } 117 | 118 | func (c *chain) WithEnvironmentMap(envMap map[interface{}]interface{}) CommandBuilder { 119 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 120 | 121 | for key, value := range envMap { 122 | cmdDesc.command.Env = append(cmdDesc.command.Env, fmt.Sprintf("%v=%v", key, value)) 123 | } 124 | return c 125 | } 126 | 127 | func (c *chain) WithEnvironment(envMap ...interface{}) CommandBuilder { 128 | if len(envMap)%2 != 0 { 129 | c.buildErrors.addError(fmt.Errorf("invalid count of environment arguments")) 130 | return c 131 | } 132 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 133 | 134 | for i := 0; i < len(envMap); i += 2 { 135 | cmdDesc.command.Env = append(cmdDesc.command.Env, fmt.Sprintf("%v=%v", envMap[i], envMap[i+1])) 136 | } 137 | 138 | return c 139 | } 140 | 141 | func (c *chain) WithEnvironmentPairs(envMap ...string) CommandBuilder { 142 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 143 | 144 | for _, entry := range envMap { 145 | cmdDesc.command.Env = append(cmdDesc.command.Env, entry) 146 | } 147 | 148 | return c 149 | } 150 | 151 | func (c *chain) WithAdditionalEnvironmentMap(envMap map[interface{}]interface{}) CommandBuilder { 152 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 153 | if len(cmdDesc.command.Env) == 0 { 154 | cmdDesc.command.Env = os.Environ() 155 | } 156 | 157 | return c.WithEnvironmentMap(envMap) 158 | } 159 | 160 | func (c *chain) WithAdditionalEnvironment(envMap ...interface{}) CommandBuilder { 161 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 162 | if len(cmdDesc.command.Env) == 0 { 163 | cmdDesc.command.Env = os.Environ() 164 | } 165 | 166 | return c.WithEnvironment(envMap...) 167 | } 168 | 169 | func (c *chain) WithAdditionalEnvironmentPairs(envMap ...string) CommandBuilder { 170 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 171 | pairs := cmdDesc.command.Env 172 | 173 | if len(pairs) == 0 { 174 | pairs = os.Environ() 175 | } 176 | pairs = append(pairs, envMap...) 177 | 178 | return c.WithEnvironmentPairs(pairs...) 179 | } 180 | 181 | func (c *chain) WithWorkingDirectory(workingDir string) CommandBuilder { 182 | cmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-1] 183 | cmdDesc.command.Dir = workingDir 184 | return c 185 | } 186 | 187 | func (c *chain) WithErrorChecker(errChecker ErrorChecker) CommandBuilder { 188 | c.cmdDescriptors[len(c.cmdDescriptors)-1].errorChecker = errChecker 189 | return c 190 | } 191 | -------------------------------------------------------------------------------- /builder_finalized.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | func (c *chain) WithOutput(targets ...io.Writer) FinalizedBuilder { 10 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 11 | cmdDesc.outputStreams = targets 12 | 13 | if len(targets) == 1 { 14 | cmdDesc.command.Stdout = targets[0] 15 | } else if len(targets) > 1 { 16 | cmdDesc.command.Stdout = io.MultiWriter(targets...) 17 | } 18 | 19 | return c 20 | } 21 | 22 | func (c *chain) WithAdditionalOutput(targets ...io.Writer) FinalizedBuilder { 23 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 24 | cmdDesc.outputStreams = append(cmdDesc.outputStreams, targets...) 25 | 26 | if len(cmdDesc.outputStreams) == 1 { 27 | cmdDesc.command.Stdout = cmdDesc.outputStreams[0] 28 | } else if len(cmdDesc.outputStreams) > 1 { 29 | cmdDesc.command.Stdout = io.MultiWriter(cmdDesc.outputStreams...) 30 | } 31 | 32 | return c 33 | } 34 | 35 | func (c *chain) WithError(targets ...io.Writer) FinalizedBuilder { 36 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 37 | cmdDesc.errorStreams = targets 38 | 39 | if len(targets) == 1 { 40 | cmdDesc.command.Stderr = targets[0] 41 | } else if len(targets) > 1 { 42 | cmdDesc.command.Stderr = io.MultiWriter(targets...) 43 | } 44 | 45 | return c 46 | } 47 | 48 | func (c *chain) WithAdditionalError(targets ...io.Writer) FinalizedBuilder { 49 | cmdDesc := &(c.cmdDescriptors[len(c.cmdDescriptors)-1]) 50 | cmdDesc.errorStreams = append(cmdDesc.errorStreams, targets...) 51 | 52 | if len(cmdDesc.errorStreams) == 1 { 53 | cmdDesc.command.Stderr = cmdDesc.errorStreams[0] 54 | } else if len(cmdDesc.errorStreams) > 1 { 55 | cmdDesc.command.Stderr = io.MultiWriter(cmdDesc.errorStreams...) 56 | } 57 | 58 | return c 59 | } 60 | 61 | func (c *chain) WithGlobalErrorChecker(errorChecker ErrorChecker) FinalizedBuilder { 62 | c.errorChecker = errorChecker 63 | return c 64 | } 65 | 66 | func (c *chain) RunAndGet() (string, string, error) { 67 | streamOut := &bytes.Buffer{} 68 | streamErr := &bytes.Buffer{} 69 | 70 | err := c.WithAdditionalOutput(streamOut).WithAdditionalError(streamErr).Run() 71 | 72 | return streamOut.String(), streamErr.String(), err 73 | } 74 | 75 | func (c *chain) Run() error { 76 | if c.buildErrors.hasError { 77 | return c.buildErrors 78 | } 79 | 80 | c.executeBeforeRunHooks() 81 | defer c.executeAfterRunHooks() 82 | 83 | //we have to start all commands (non blocking!) 84 | for cmdIndex, cmdDescriptor := range c.cmdDescriptors { 85 | for _, applier := range cmdDescriptor.commandApplier { 86 | applier(cmdIndex, cmdDescriptor.command) 87 | } 88 | 89 | //here we can free the applier (we don't need them anymore) 90 | //and such functions have the potential to "lock" some memory 91 | cmdDescriptor.commandApplier = nil 92 | 93 | err := cmdDescriptor.command.Start() 94 | if err != nil { 95 | return fmt.Errorf("failed to start command: %w", err) 96 | } 97 | } 98 | 99 | runErrors := runErrors() 100 | runErrors.errors = make([]error, len(c.cmdDescriptors)) 101 | 102 | // here we have to wait in reversed order because if the last command will not read their stdin anymore 103 | // the previous command will wait endless for continuing writing to stdout 104 | for cmdIndex := len(c.cmdDescriptors) - 1; cmdIndex >= 0; cmdIndex-- { 105 | cmdDescriptor := c.cmdDescriptors[cmdIndex] 106 | 107 | err := cmdDescriptor.command.Wait() 108 | if closer, isCloser := cmdDescriptor.command.Stdin.(io.Closer); isCloser { 109 | // This is little hard to understand. Let's assume we have the chain: cmd1->cmd2 110 | // 111 | // For pipelining the commands together we will use the "StdoutPipe()"-Method of the cmd1. The result of 112 | // this method will be used as the Input-Stream of cmd2. But this pipe (cmd1.stdout -> cmd2.stdin) will be 113 | // closed normally only after cmd1 will be exited. And cmd1 will only exit after their job is done! But if 114 | // cmd2 will exit earlier (this can be happen if cmd2 will not consume the complete stdin-stream), cmd1 will 115 | // wait for eternity! To avoid that, we have to close the cmd2' input-stream manually! 116 | 117 | _ = closer.Close() // dont care about closing error 118 | } 119 | 120 | if err == nil { 121 | runErrors.setError(cmdIndex, nil) 122 | } else { 123 | shouldAdd := true 124 | 125 | if cmdDescriptor.errorChecker != nil { 126 | // let the corresponding error check decide if the error is "relevant" or not 127 | shouldAdd = cmdDescriptor.errorChecker(cmdIndex, cmdDescriptor.command, err) 128 | } else if c.errorChecker != nil { 129 | // let the global error check decide if the error is "relevant" or not 130 | shouldAdd = c.errorChecker(cmdIndex, cmdDescriptor.command, err) 131 | } 132 | 133 | if shouldAdd { 134 | runErrors.setError(cmdIndex, err) 135 | } else { 136 | runErrors.setError(cmdIndex, nil) 137 | } 138 | } 139 | } 140 | 141 | //according to documentation of command's StdoutPipe()/StderrPipe() we have to wait for all stream reads are done 142 | //after that we can wait for the commands: 143 | // "[...] It is thus incorrect to call Wait before all reads from the pipe have completed. [...]" 144 | c.streamRoutinesWg.Wait() 145 | 146 | switch { 147 | case runErrors.hasError && c.streamErrors.hasError: 148 | return MultipleErrors{ 149 | errorMessage: "run and stream errors occurred", 150 | errors: []error{runErrors, c.streamErrors}, 151 | hasError: true, 152 | } 153 | case runErrors.hasError: 154 | return runErrors 155 | case c.streamErrors.hasError: 156 | return c.streamErrors 157 | default: 158 | return nil 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /builder_streams.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "sync" 9 | ) 10 | 11 | func (c *chain) linkStreams(cmd *exec.Cmd) { 12 | //link this command's input with the previous command's output (cmd1 -> cmd2) 13 | prevCmdDesc := c.cmdDescriptors[len(c.cmdDescriptors)-2] 14 | 15 | var prevOut, prevErr io.ReadCloser 16 | var err error 17 | 18 | defer func() { 19 | c.buildErrors.addError(err) 20 | }() 21 | 22 | prevOut, prevErr, err = c.linkOutAndErr(&prevCmdDesc) 23 | if err != nil { 24 | return 25 | } 26 | 27 | if prevCmdDesc.outToIn && !prevCmdDesc.errToIn { 28 | if prevCmdDesc.outFork == nil { 29 | cmd.Stdin = prevOut 30 | } else { 31 | cmd.Stdin, err = c.forkStream(prevOut, prevCmdDesc.outFork) 32 | } 33 | } else if !prevCmdDesc.outToIn && prevCmdDesc.errToIn { 34 | if prevCmdDesc.errFork == nil { 35 | cmd.Stdin = prevErr 36 | } else { 37 | cmd.Stdin, err = c.forkStream(prevErr, prevCmdDesc.errFork) 38 | } 39 | } else if prevCmdDesc.outToIn && prevCmdDesc.errToIn { 40 | var outR io.Reader = prevOut 41 | var errR io.Reader = prevErr 42 | 43 | if prevCmdDesc.outFork != nil { 44 | outR, err = c.forkStream(prevOut, prevCmdDesc.outFork) 45 | if err != nil { 46 | return 47 | } 48 | } 49 | if prevCmdDesc.errFork != nil { 50 | errR, err = c.forkStream(prevErr, prevCmdDesc.errFork) 51 | if err != nil { 52 | return 53 | } 54 | } 55 | 56 | cmd.Stdin, err = c.combineStream(outR, errR) 57 | } else { 58 | //this should never be happen! 59 | err = errors.New("invalid stream configuration") 60 | } 61 | } 62 | 63 | func (c *chain) linkOutAndErr(prevCmd *cmdDescriptor) (outStream io.ReadCloser, errStream io.ReadCloser, err error) { 64 | if prevCmd.outToIn { 65 | outStream, err = prevCmd.command.StdoutPipe() 66 | if err != nil { 67 | return 68 | } 69 | } else if prevCmd.outFork != nil { 70 | prevCmd.command.Stdout = prevCmd.outFork 71 | } 72 | 73 | if prevCmd.errToIn { 74 | errStream, err = prevCmd.command.StderrPipe() 75 | if err != nil { 76 | return 77 | } 78 | } else if prevCmd.errFork != nil { 79 | prevCmd.command.Stderr = prevCmd.errFork 80 | } 81 | 82 | return 83 | } 84 | 85 | func (c *chain) forkStream(src io.ReadCloser, target io.Writer) (io.Reader, error) { 86 | //initialise pipe and copy content inside own goroutine 87 | pipeReader, pipeWriter, err := os.Pipe() 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | /* 93 | +------+ +------+ 94 | | cmd1 | ---+---> | cmd2 | 95 | +------+ | +------+ 96 | V 97 | +---------+ 98 | | outFork | 99 | +---------+ 100 | */ 101 | 102 | c.streamRoutinesWg.Add(1) 103 | go func(cmdIndex int, src io.Reader) { 104 | //we have to make sure, the pipe will be closed after the prevCommand 105 | //have closed their output stream - otherwise this will cause a never 106 | //ending wait for finishing the command execution! 107 | defer pipeWriter.Close() 108 | defer c.streamRoutinesWg.Done() 109 | 110 | //the cmdOut must be written into both writer: outFork and pipeWriter. 111 | //input from pipeWriter will redirected to pipeReader (the input for 112 | //the next command) 113 | _, err := io.Copy(io.MultiWriter(pipeWriter, target), src) 114 | c.streamErrors.setError(cmdIndex, err) 115 | }(len(c.cmdDescriptors)-1, src) 116 | 117 | return pipeReader, nil 118 | } 119 | 120 | func (c *chain) combineStream(sources ...io.Reader) (*os.File, error) { 121 | cmdIndex := len(c.cmdDescriptors) - 1 122 | return c.combineStreamForCommand(cmdIndex, sources...) 123 | } 124 | 125 | func (c *chain) combineStreamForCommand(cmdIndex int, sources ...io.Reader) (*os.File, error) { 126 | pipeReader, pipeWriter, err := os.Pipe() 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | streamErrors := MultipleErrors{ 132 | errors: make([]error, len(sources)), 133 | } 134 | 135 | wg := sync.WaitGroup{} 136 | wg.Add(len(sources)) 137 | 138 | for i, src := range sources { 139 | 140 | //spawn goroutine for each stream to ensure the sources 141 | //will read in parallel 142 | go func(i int, src io.Reader) { 143 | defer wg.Done() 144 | 145 | _, err := io.Copy(pipeWriter, src) 146 | if err != nil { 147 | streamErrors.setError(i, err) 148 | } 149 | }(i, src) 150 | } 151 | 152 | c.streamRoutinesWg.Add(1) 153 | go func() { 154 | //we have to make sure that the pipe will be closed after all source streams 155 | //are read. otherwise this will cause a never ending wait for finishing the command execution! 156 | defer pipeWriter.Close() 157 | defer c.streamErrors.setError(cmdIndex, streamErrors) 158 | defer c.streamRoutinesWg.Done() 159 | 160 | //wait until all streams are read 161 | wg.Wait() 162 | }() 163 | 164 | return pipeReader, nil 165 | } 166 | -------------------------------------------------------------------------------- /chain_test.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "github.com/stretchr/testify/assert" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "path" 12 | "strings" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | var testHelper string 18 | 19 | func init() { 20 | wd, err := os.Getwd() 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | testHelper = path.Join(wd, "testHelper") 26 | 27 | //build a little go binary which can be executed and process some stdOut/stdErr output 28 | err = exec.Command("go", "build", "-ldflags", "-w -s", "-o", testHelper, "./test_helper/main.go").Run() 29 | if err != nil { 30 | panic(err) 31 | } 32 | } 33 | 34 | func TestSimple(t *testing.T) { 35 | toTest := Builder(). 36 | Join("ls", "-l"). 37 | Join("grep", "README"). 38 | Join("wc", "-l") 39 | 40 | runAndCompare(t, toTest, "1\n") 41 | } 42 | 43 | func TestSimple_runAndGet(t *testing.T) { 44 | stdout, stderr, err := Builder(). 45 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 46 | Finalize().RunAndGet() 47 | 48 | assert.NoError(t, err) 49 | assert.Equal(t, "TEST\n", stdout) 50 | assert.Equal(t, "ERROR\n", stderr) 51 | } 52 | 53 | func TestSimple_runAndGet_withForks(t *testing.T) { 54 | outFork := &bytes.Buffer{} 55 | errFork := &bytes.Buffer{} 56 | 57 | stdout, stderr, err := Builder(). 58 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithOutputForks(outFork).WithErrorForks(errFork). 59 | Finalize().RunAndGet() 60 | 61 | assert.NoError(t, err) 62 | assert.Equal(t, "TEST\n", outFork.String()) 63 | assert.Equal(t, "TEST\n", stdout) 64 | assert.Equal(t, "ERROR\n", errFork.String()) 65 | assert.Equal(t, "ERROR\n", stderr) 66 | } 67 | 68 | func TestSimple_apply(t *testing.T) { 69 | toTest := Builder(). 70 | Join(testHelper, "-pwd").Apply(func(_ int, command *exec.Cmd) { 71 | command.Dir = os.TempDir() 72 | }) 73 | 74 | runAndCompare(t, toTest, os.TempDir()+"\n") 75 | } 76 | 77 | func TestCombined_applyBeforeStart(t *testing.T) { 78 | outViaBuilder := &bytes.Buffer{} 79 | outViaApplier := &bytes.Buffer{} 80 | 81 | Builder(). 82 | Join("echo", "test").ApplyBeforeStart(func(_ int, cmd *exec.Cmd) { 83 | assert.Same(t, outViaBuilder, cmd.Stdout) 84 | cmd.Stdout = outViaApplier 85 | }). 86 | Finalize().WithOutput(outViaBuilder).Run() 87 | 88 | assert.Equal(t, "", outViaBuilder.String()) 89 | assert.Equal(t, "test\n", outViaApplier.String()) 90 | } 91 | 92 | func TestSimple_stderr(t *testing.T) { 93 | output := &bytes.Buffer{} 94 | 95 | err := Builder(). 96 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 97 | Finalize().WithError(output).Run() 98 | 99 | assert.NoError(t, err) 100 | assert.Equal(t, "ERROR\n", output.String()) 101 | } 102 | 103 | func TestSimple_with_error(t *testing.T) { 104 | output := &bytes.Buffer{} 105 | output2 := &bytes.Buffer{} 106 | 107 | err := Builder(). 108 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 109 | Finalize().WithError(output).WithError(output2).Run() 110 | 111 | assert.NoError(t, err) 112 | assert.Equal(t, "", output.String(), "first output stream should be overwritten") 113 | assert.Equal(t, "ERROR\n", output2.String()) 114 | } 115 | 116 | func TestSimple_with_additional_error_fork(t *testing.T) { 117 | output := &bytes.Buffer{} 118 | output2 := &bytes.Buffer{} 119 | 120 | err := Builder(). 121 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithErrorForks(output).WithAdditionalErrorForks(output2). 122 | Finalize().Run() 123 | 124 | assert.NoError(t, err) 125 | assert.Equal(t, "ERROR\n", output.String()) 126 | assert.Equal(t, "ERROR\n", output2.String()) 127 | } 128 | 129 | func TestSimple_with_additional_error_fork2(t *testing.T) { 130 | output := &bytes.Buffer{} 131 | 132 | err := Builder(). 133 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithAdditionalErrorForks(output). 134 | Finalize().Run() 135 | 136 | assert.NoError(t, err) 137 | assert.Equal(t, "ERROR\n", output.String()) 138 | } 139 | 140 | func TestSimple_with_additional_error(t *testing.T) { 141 | output := &bytes.Buffer{} 142 | output2 := &bytes.Buffer{} 143 | 144 | err := Builder(). 145 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 146 | Finalize().WithAdditionalError(output).WithAdditionalError(output2).Run() 147 | 148 | assert.NoError(t, err) 149 | assert.Equal(t, "ERROR\n", output.String()) 150 | assert.Equal(t, "ERROR\n", output2.String()) 151 | } 152 | 153 | func TestSimple_with_output(t *testing.T) { 154 | output := &bytes.Buffer{} 155 | output2 := &bytes.Buffer{} 156 | 157 | err := Builder(). 158 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 159 | Finalize().WithOutput(output).WithOutput(output2).Run() 160 | 161 | assert.NoError(t, err) 162 | assert.Equal(t, "", output.String(), "first output stream should be overwritten") 163 | assert.Equal(t, "TEST\n", output2.String()) 164 | } 165 | 166 | func TestSimple_with_additional_output_fork(t *testing.T) { 167 | output := &bytes.Buffer{} 168 | output2 := &bytes.Buffer{} 169 | 170 | err := Builder(). 171 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithOutputForks(output).WithAdditionalOutputForks(output2). 172 | Finalize().Run() 173 | 174 | assert.NoError(t, err) 175 | assert.Equal(t, "TEST\n", output.String()) 176 | assert.Equal(t, "TEST\n", output2.String()) 177 | } 178 | 179 | func TestSimple_with_additional_output_fork2(t *testing.T) { 180 | output := &bytes.Buffer{} 181 | 182 | err := Builder(). 183 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithAdditionalOutputForks(output). 184 | Finalize().Run() 185 | 186 | assert.NoError(t, err) 187 | assert.Equal(t, "TEST\n", output.String()) 188 | } 189 | 190 | func TestSimple_with_additional_output(t *testing.T) { 191 | output := &bytes.Buffer{} 192 | output2 := &bytes.Buffer{} 193 | 194 | err := Builder(). 195 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 196 | Finalize().WithAdditionalOutput(output).WithAdditionalOutput(output2).Run() 197 | 198 | assert.NoError(t, err) 199 | assert.Equal(t, "TEST\n", output.String()) 200 | assert.Equal(t, "TEST\n", output2.String()) 201 | } 202 | 203 | func TestSimple_multi_stdout(t *testing.T) { 204 | output1 := &bytes.Buffer{} 205 | output2 := &bytes.Buffer{} 206 | 207 | err := Builder(). 208 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 209 | Finalize().WithOutput(output1, output2).Run() 210 | 211 | assert.NoError(t, err) 212 | assert.Equal(t, output1.String(), output2.String()) 213 | } 214 | 215 | func TestSimple_multi_stdout_mixed(t *testing.T) { 216 | output1 := &bytes.Buffer{} 217 | output2 := &bytes.Buffer{} 218 | 219 | err := Builder(). 220 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithOutputForks(output1). 221 | Finalize().WithAdditionalOutput(output2).Run() 222 | 223 | assert.NoError(t, err) 224 | assert.Equal(t, "TEST\n", output1.String()) 225 | assert.Equal(t, "TEST\n", output2.String()) 226 | } 227 | 228 | func TestSimple_multi_stderr(t *testing.T) { 229 | output1 := &bytes.Buffer{} 230 | output2 := &bytes.Buffer{} 231 | 232 | err := Builder(). 233 | Join(testHelper, "-e", "ERROR", "-o", "TEST"). 234 | Finalize().WithError(output1, output2).Run() 235 | 236 | assert.NoError(t, err) 237 | assert.Equal(t, output1.String(), output2.String()) 238 | } 239 | 240 | func TestSimple_multi_stderr_mixed(t *testing.T) { 241 | output1 := &bytes.Buffer{} 242 | output2 := &bytes.Buffer{} 243 | 244 | err := Builder(). 245 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithErrorForks(output1). 246 | Finalize().WithAdditionalError(output2).Run() 247 | 248 | assert.NoError(t, err) 249 | assert.Equal(t, "ERROR\n", output1.String()) 250 | assert.Equal(t, "ERROR\n", output2.String()) 251 | } 252 | 253 | func TestSimple_withOutput_overrides_prev(t *testing.T) { 254 | output1 := &bytes.Buffer{} 255 | output2 := &bytes.Buffer{} 256 | 257 | err := Builder(). 258 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithOutputForks(output1). 259 | Finalize().WithOutput(output2).Run() 260 | 261 | assert.NoError(t, err) 262 | assert.Equal(t, "", output1.String(), "first output stream should be overwritten") 263 | assert.Equal(t, "TEST\n", output2.String()) 264 | } 265 | 266 | func TestSimple_withError_overrides_prev(t *testing.T) { 267 | output1 := &bytes.Buffer{} 268 | output2 := &bytes.Buffer{} 269 | 270 | err := Builder(). 271 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithErrorForks(output1). 272 | Finalize().WithError(output2).Run() 273 | 274 | assert.NoError(t, err) 275 | assert.Equal(t, "", output1.String(), "first output stream should be overwritten") 276 | assert.Equal(t, "ERROR\n", output2.String()) 277 | } 278 | 279 | func TestSimple_WithInput(t *testing.T) { 280 | toTest := Builder(). 281 | WithInput(strings.NewReader("TEST\nOUTPUT")). 282 | Join("grep", "TEST"). 283 | Join("wc", "-l") 284 | 285 | runAndCompare(t, toTest, "1\n") 286 | } 287 | 288 | func TestSimple_WithMultiInput(t *testing.T) { 289 | toTest := Builder(). 290 | WithInput(strings.NewReader("TEST\nOUTPUT"), strings.NewReader("TEST\n")). 291 | Join("grep", "TEST"). 292 | Join("wc", "-l") 293 | 294 | runAndCompare(t, toTest, "2\n") 295 | } 296 | 297 | func TestSimple_WithProcessEnvironment(t *testing.T) { 298 | chainWithEnv := Builder().Join(testHelper, "-pe") 299 | chainWithoutEnv := Builder().Join(testHelper, "-pe").WithEmptyEnvironment() 300 | 301 | out1, _, err := chainWithEnv.Finalize().RunAndGet() 302 | assert.NoError(t, err) 303 | 304 | out2, _, err := chainWithoutEnv.Finalize().RunAndGet() 305 | assert.NoError(t, err) 306 | 307 | assert.NotEqual(t, out1, out2) 308 | } 309 | 310 | func TestSimple_WithEnvironment(t *testing.T) { 311 | toTest := Builder(). 312 | Join(testHelper, "-pe").WithEnvironment("TEST", "VALUE", "TEST2", 2) 313 | 314 | runAndCompare(t, toTest, "TEST=VALUE\nTEST2=2\n") 315 | } 316 | 317 | func TestSimple_WithEnvironmentMap(t *testing.T) { 318 | toTest := Builder(). 319 | Join(testHelper, "-pe").WithEnvironmentMap(map[interface{}]interface{}{"GO_COMMAND_CHAIN_TEST": "VALUE", "GO_COMMAND_CHAIN_TEST2": 2}). 320 | Join("sort") 321 | 322 | runAndCompare(t, toTest, "GO_COMMAND_CHAIN_TEST2=2\nGO_COMMAND_CHAIN_TEST=VALUE\n") 323 | } 324 | 325 | func TestSimple_WithEnvironmentPairs(t *testing.T) { 326 | toTest := Builder(). 327 | Join(testHelper, "-pe").WithEnvironmentPairs("GO_COMMAND_CHAIN_TEST=VALUE", "GO_COMMAND_CHAIN_TEST2=2"). 328 | Join("sort") 329 | 330 | runAndCompare(t, toTest, "GO_COMMAND_CHAIN_TEST2=2\nGO_COMMAND_CHAIN_TEST=VALUE\n") 331 | } 332 | 333 | func TestSimple_WithAdditionalEnvironment(t *testing.T) { 334 | toTest := Builder(). 335 | Join(testHelper, "-pe").WithAdditionalEnvironment("GO_COMMAND_CHAIN_TEST", "VALUE", "GO_COMMAND_CHAIN_TEST2", 2). 336 | Join("grep", "GO_COMMAND_CHAIN_TEST"). 337 | Join("sort") 338 | 339 | runAndCompare(t, toTest, "GO_COMMAND_CHAIN_TEST2=2\nGO_COMMAND_CHAIN_TEST=VALUE\n") 340 | } 341 | 342 | func TestSimple_WithAdditionalEnvironmentMap(t *testing.T) { 343 | toTest := Builder(). 344 | Join(testHelper, "-pe").WithAdditionalEnvironmentMap(map[interface{}]interface{}{"GO_COMMAND_CHAIN_TEST": "VALUE", "GO_COMMAND_CHAIN_TEST2": 2}). 345 | Join("grep", "GO_COMMAND_CHAIN_TEST"). 346 | Join("sort") 347 | 348 | runAndCompare(t, toTest, "GO_COMMAND_CHAIN_TEST2=2\nGO_COMMAND_CHAIN_TEST=VALUE\n") 349 | } 350 | 351 | func TestSimple_WithAdditionalEnvironmentPairs(t *testing.T) { 352 | toTest := Builder(). 353 | Join(testHelper, "-pe").WithAdditionalEnvironmentPairs("GO_COMMAND_CHAIN_TEST=VALUE", "GO_COMMAND_CHAIN_TEST2=2"). 354 | Join("grep", "GO_COMMAND_CHAIN_TEST"). 355 | Join("sort") 356 | 357 | runAndCompare(t, toTest, "GO_COMMAND_CHAIN_TEST2=2\nGO_COMMAND_CHAIN_TEST=VALUE\n") 358 | } 359 | 360 | func TestSimple_WithAdditionalEnvironment_butNotProcessEnv(t *testing.T) { 361 | cmd := exec.Command(testHelper, "-pe") 362 | cmd.Env = []string{"TEST=VALUE"} 363 | 364 | toTest := Builder(). 365 | JoinCmd(cmd).WithAdditionalEnvironment("TEST2", 2) 366 | 367 | runAndCompare(t, toTest, "TEST=VALUE\nTEST2=2\n") 368 | } 369 | 370 | func TestSimple_WithAdditionalEnvironmentMap_butNotProcessEnv(t *testing.T) { 371 | cmd := exec.Command(testHelper, "-pe") 372 | cmd.Env = []string{"TEST=VALUE"} 373 | 374 | toTest := Builder(). 375 | JoinCmd(cmd).WithAdditionalEnvironmentMap(map[interface{}]interface{}{"TEST2": 2}) 376 | 377 | runAndCompare(t, toTest, "TEST=VALUE\nTEST2=2\n") 378 | } 379 | 380 | func TestSimple_WithAdditionalEnvironmentPairs_butNotProcessEnv(t *testing.T) { 381 | cmd := exec.Command(testHelper, "-pe") 382 | cmd.Env = []string{"TEST=VALUE"} 383 | 384 | toTest := Builder(). 385 | JoinCmd(cmd).WithAdditionalEnvironmentPairs("TEST2=2") 386 | 387 | runAndCompare(t, toTest, "TEST=VALUE\nTEST2=2\n") 388 | } 389 | 390 | func TestSimple_WithEnvironment_InvalidArguments(t *testing.T) { 391 | err := Builder(). 392 | Join(testHelper, "-pe").WithEnvironment("TEST", "VALUE", "TEST2"). 393 | Finalize().Run() 394 | 395 | assert.Error(t, err) 396 | assert.Equal(t, "one or more chain build errors occurred: [0 - invalid count of environment arguments]", err.Error()) 397 | } 398 | 399 | func TestSimple_WithWorkingDirectory(t *testing.T) { 400 | toTest := Builder(). 401 | Join(testHelper, "-pwd").WithWorkingDirectory(os.TempDir()) 402 | 403 | runAndCompare(t, toTest, os.TempDir()+"\n") 404 | } 405 | 406 | func TestCombined(t *testing.T) { 407 | output := &bytes.Buffer{} 408 | 409 | err := Builder(). 410 | Join(testHelper, "-to", "100ms", "-te", "100ms", "-ti", "1ms").ForwardError(). 411 | Join("grep", `OUT\|ERR`). 412 | Finalize().WithOutput(output).Run() 413 | 414 | assert.NoError(t, err) 415 | 416 | assert.Contains(t, output.String(), "OUT\nERR\nOUT\n", "It seams that the streams will not processed parallel!") 417 | } 418 | 419 | func TestCombined_forked(t *testing.T) { 420 | output := &bytes.Buffer{} 421 | outFork := &bytes.Buffer{} 422 | errFork := &bytes.Buffer{} 423 | 424 | err := Builder(). 425 | Join(testHelper, "-to", "100ms", "-te", "100ms", "-ti", "1ms").ForwardError().WithOutputForks(outFork).WithErrorForks(errFork). 426 | Join("grep", `OUT\|ERR`). 427 | Finalize().WithOutput(output).Run() 428 | 429 | assert.NoError(t, err) 430 | 431 | assert.Contains(t, output.String(), "OUT\nERR\nOUT\n", "It seams that the streams will not processed parallel!") 432 | assert.Contains(t, outFork.String(), "OUT\nOUT\n") 433 | assert.NotContains(t, outFork.String(), "ERR\n") 434 | assert.Contains(t, errFork.String(), "ERR\nERR\n") 435 | assert.NotContains(t, errFork.String(), "OUT\n") 436 | } 437 | 438 | func TestWithContext(t *testing.T) { 439 | output := &bytes.Buffer{} 440 | 441 | ctx, cancel := context.WithTimeout(context.Background(), 110*time.Millisecond) 442 | defer cancel() 443 | 444 | err := Builder(). 445 | JoinWithContext(ctx, testHelper, "-to", "1s", "-ti", "100ms"). 446 | Join("grep", `OUT\|ERR`). 447 | Finalize().WithOutput(output).Run() 448 | 449 | assert.Error(t, err) 450 | assert.Error(t, err.(MultipleErrors).errors[0]) 451 | assert.NoError(t, err.(MultipleErrors).errors[1]) 452 | 453 | assert.Equal(t, "OUT\n", output.String(), "It seams that the process was not interrupted.") 454 | } 455 | 456 | func TestSimple_ErrorForked(t *testing.T) { 457 | output := &bytes.Buffer{} 458 | 459 | toTest := Builder(). 460 | Join(testHelper, "-e", "ERROR", "-o", "TEST").WithErrorForks(output). 461 | Join("grep", "TEST"). 462 | Join("wc", "-l") 463 | 464 | runAndCompare(t, toTest, "1\n") 465 | 466 | assert.Contains(t, output.String(), "ERROR", "The error of 'testHelper' seams not to be forked!") 467 | } 468 | 469 | func TestStdErr_OnlyError(t *testing.T) { 470 | toTest := Builder(). 471 | Join(testHelper, "-e", "TEST").DiscardStdOut().ForwardError(). 472 | Join("grep", "TEST"). 473 | Join("wc", "-l") 474 | 475 | runAndCompare(t, toTest, "1\n") 476 | } 477 | 478 | func TestStdErr_OnlyErrorButOutForked(t *testing.T) { 479 | output := &bytes.Buffer{} 480 | 481 | toTest := Builder(). 482 | Join(testHelper, "-e", "TEST", "-o", "TEST_OUT").DiscardStdOut().WithOutputForks(output).ForwardError(). 483 | Join("grep", "TEST"). 484 | Join("wc", "-l") 485 | 486 | runAndCompare(t, toTest, "1\n") 487 | 488 | assert.Contains(t, output.String(), "TEST_OUT", "The output of 'testHelper' seams not to be forked!") 489 | } 490 | 491 | func TestOutputFork_single(t *testing.T) { 492 | output := &bytes.Buffer{} 493 | 494 | toTest := Builder(). 495 | Join("ls", "-l"). 496 | Join("grep", "README").WithOutputForks(output). 497 | Join("wc", "-l") 498 | 499 | runAndCompare(t, toTest, "1\n") 500 | 501 | assert.Contains(t, output.String(), "README.md", "The output of 'ls -l' seams not to be forked!") 502 | } 503 | 504 | func TestOutputFork_multiple(t *testing.T) { 505 | output1 := &bytes.Buffer{} 506 | output2 := &bytes.Buffer{} 507 | 508 | toTest := Builder(). 509 | Join("ls", "-l"). 510 | Join("grep", "README").WithOutputForks(output1, output2). 511 | Join("wc", "-l") 512 | 513 | runAndCompare(t, toTest, "1\n") 514 | 515 | assert.Equal(t, output1.String(), output2.String(), "The output seams not to be forked to both forks!") 516 | } 517 | 518 | func TestErrorFork_single(t *testing.T) { 519 | output := &bytes.Buffer{} 520 | 521 | toTest := Builder(). 522 | Join(testHelper, "-e", "TEST").DiscardStdOut().ForwardError().WithErrorForks(output). 523 | Join("grep", "TEST"). 524 | Join("wc", "-l") 525 | 526 | runAndCompare(t, toTest, "1\n") 527 | 528 | assert.Equal(t, output.String(), "TEST\n", "The error of './testHelper' seams not to be forked!") 529 | } 530 | 531 | func TestErrorFork_multiple(t *testing.T) { 532 | output1 := &bytes.Buffer{} 533 | output2 := &bytes.Buffer{} 534 | 535 | toTest := Builder(). 536 | Join(testHelper, "-e", "TEST").DiscardStdOut().ForwardError().WithErrorForks(output1, output2). 537 | Join("grep", "TEST"). 538 | Join("wc", "-l") 539 | 540 | runAndCompare(t, toTest, "1\n") 541 | 542 | assert.Equal(t, output1.String(), output2.String(), "The error seams not to be forked to both forks!") 543 | } 544 | 545 | func TestInputInjection(t *testing.T) { 546 | toTest := Builder(). 547 | Join(testHelper, "-o", "TEST"). 548 | Join("grep", "TEST"). 549 | WithInjections(strings.NewReader("TEST\n")). 550 | Join("wc", "-l") 551 | 552 | runAndCompare(t, toTest, "2\n") 553 | } 554 | 555 | func TestInputInjectionWithoutStdin(t *testing.T) { 556 | input := strings.NewReader("Hello") 557 | output := bytes.NewBuffer([]byte{}) 558 | err := Builder(). 559 | Join("cat").WithInjections(input). 560 | Finalize(). 561 | WithOutput(output). 562 | Run() 563 | 564 | assert.NoError(t, err) 565 | assert.Equal(t, "Hello", output.String()) 566 | } 567 | 568 | func TestInvalidStreamLink(t *testing.T) { 569 | err := Builder(). 570 | Join("ls", "-l").DiscardStdOut(). 571 | Join("grep", "TEST"). 572 | Join("wc", "-l"). 573 | Finalize().Run() 574 | 575 | assert.Error(t, err) 576 | mError := err.(MultipleErrors) 577 | assert.Equal(t, "invalid stream configuration", mError.Errors()[0].Error()) 578 | } 579 | 580 | func TestBrokenStream(t *testing.T) { 581 | out, _ := os.CreateTemp("", ".txt") 582 | defer os.Remove(out.Name()) 583 | 584 | //close the file so the stream can not be written -> this should cause a stream error! 585 | out.Close() 586 | 587 | err := Builder(). 588 | Join("ls", "-l").WithOutputForks(out). 589 | Join("grep", "README"). 590 | Join("wc", "-l"). 591 | Finalize().Run() 592 | 593 | assert.Error(t, err) 594 | mError := err.(MultipleErrors) 595 | assert.Contains(t, mError.Errors()[1].Error(), "file already closed") 596 | } 597 | 598 | func TestInvalidCommand(t *testing.T) { 599 | err := Builder(). 600 | Join("ls", "-l"). 601 | Join("invalidApplication"). 602 | Finalize().Run() 603 | 604 | assert.Error(t, err) 605 | assert.Contains(t, err.Error(), "failed to start command") 606 | } 607 | 608 | func TestBrokenStreamAndRunError(t *testing.T) { 609 | out, _ := os.CreateTemp("", ".txt") 610 | defer os.Remove(out.Name()) 611 | 612 | //close the file so the stream can not be written -> this should cause a stream error! 613 | out.Close() 614 | 615 | err := Builder(). 616 | Join("ls", "-l").WithOutputForks(out). 617 | Join("grep", "aslnaslkdnan"). 618 | Finalize().Run() 619 | 620 | assert.Error(t, err) 621 | mError := err.(MultipleErrors) 622 | assert.Equal(t, 2, len(mError.Errors())) 623 | assert.Contains(t, mError.Errors()[0].Error(), "one or more command has returned an error") 624 | assert.Contains(t, mError.Errors()[1].Error(), "one or more command stream copies failed") 625 | } 626 | 627 | func TestWithErrorChecker_IgnoreExitCode(t *testing.T) { 628 | err := Builder(). 629 | Join(testHelper, "-o", "test", "-x", "1").WithErrorChecker(IgnoreExitCode(1)). 630 | Join("grep", "test"). 631 | Finalize().Run() 632 | 633 | assert.NoError(t, err) 634 | } 635 | 636 | func TestWithErrorChecker_IgnoreExitCode_global(t *testing.T) { 637 | err := Builder(). 638 | Join(testHelper, "-o", "test", "-x", "1"). 639 | Join("grep", "test"). 640 | Finalize().WithGlobalErrorChecker(IgnoreExitCode(1)).Run() 641 | 642 | assert.NoError(t, err) 643 | } 644 | 645 | func TestWithErrorChecker_IgnoreExitCode_globalAndSpecific(t *testing.T) { 646 | err := Builder(). 647 | Join(testHelper, "-o", "test", "-x", "1").WithErrorChecker(IgnoreExitCode(1)). 648 | Join("grep", "test"). 649 | Finalize().WithGlobalErrorChecker(IgnoreNothing()).Run() 650 | 651 | assert.NoError(t, err, "it seams that the specific error checker was not called") 652 | } 653 | 654 | func TestHeadWillInterruptPreviousCommand(t *testing.T) { 655 | output := &bytes.Buffer{} 656 | 657 | //the "testHelper" command will permanently print output at their stdout 658 | //but the command "head" will only print the FIRST line and exit after that 659 | //the previous command (testhelper) should be interrupted because there is no one 660 | //who reads their output 661 | 662 | err := Builder(). 663 | Join(testHelper, "-ti", "1ms", "-to", "10s"). 664 | Join("head", "-1"). 665 | Finalize().WithOutput(output).Run() 666 | 667 | assert.Error(t, err) 668 | assert.Error(t, err.(MultipleErrors).errors[0]) 669 | assert.NoError(t, err.(MultipleErrors).errors[1]) 670 | assert.Equal(t, "OUT", strings.Trim(output.String(), "\n")) 671 | } 672 | 673 | func TestHeadWillInterruptPreviousCommand_withForkedOutput(t *testing.T) { 674 | output := &bytes.Buffer{} 675 | outputFork := &bytes.Buffer{} 676 | 677 | //the "testHelper" command will permanently print output at their stdout 678 | //but the command "head" will only print the FIRST line and exit after that 679 | //the previous command (testhelper) should be interrupted because there is no one 680 | //who reads their output 681 | 682 | err := Builder(). 683 | Join(testHelper, "-ti", "1ms", "-to", "10s").WithOutputForks(outputFork). 684 | Join("head", "-1"). 685 | Finalize().WithOutput(output).Run() 686 | 687 | assert.Error(t, err) 688 | assert.Equal(t, "OUT", strings.Trim(output.String(), "\n")) 689 | assert.Contains(t, strings.Trim(outputFork.String(), "\n"), "OUT") 690 | } 691 | 692 | func TestHeadWillInterruptPreviousCommand_withStderr(t *testing.T) { 693 | output := &bytes.Buffer{} 694 | 695 | //the "testHelper" command will permanently print output at their stderr 696 | //but the command "head" will only print the FIRST line and exit after that 697 | //the previous command (testhelper) should be interrupted because there is no one 698 | //who reads their output 699 | 700 | err := Builder(). 701 | Join(testHelper, "-ti", "1ms", "-te", "10s").DiscardStdOut().ForwardError(). 702 | Join("head", "-1"). 703 | Finalize().WithOutput(output).Run() 704 | 705 | assert.Error(t, err) 706 | assert.Error(t, err.(MultipleErrors).errors[0]) 707 | assert.NoError(t, err.(MultipleErrors).errors[1]) 708 | assert.Equal(t, "ERR", strings.Trim(output.String(), "\n")) 709 | } 710 | 711 | func TestHeadWillInterruptPreviousCommand_withStderrFork(t *testing.T) { 712 | output := &bytes.Buffer{} 713 | outputFork := &bytes.Buffer{} 714 | 715 | //the "testHelper" command will permanently print output at their stderr 716 | //but the command "head" will only print the FIRST line and exit after that 717 | //the previous command (testhelper) should be interrupted because there is no one 718 | //who reads their output 719 | 720 | err := Builder(). 721 | Join(testHelper, "-ti", "1ms", "-te", "10s").DiscardStdOut().ForwardError().WithErrorForks(outputFork). 722 | Join("head", "-1"). 723 | Finalize().WithOutput(output).Run() 724 | 725 | assert.Error(t, err) 726 | assert.Equal(t, "ERR", strings.Trim(output.String(), "\n")) 727 | assert.Contains(t, strings.Trim(outputFork.String(), "\n"), "ERR") 728 | } 729 | 730 | func TestHeadWillInterruptPreviousCommand_withCombined(t *testing.T) { 731 | output := &bytes.Buffer{} 732 | 733 | //the "testHelper" command will permanently print output at their stderr 734 | //but the command "head" will only print the FIRST line and exit after that 735 | //the previous command (testhelper) should be interrupted because there is no one 736 | //who reads their output 737 | 738 | err := Builder(). 739 | Join(testHelper, "-ti", "1ms", "-te", "10s").ForwardError(). 740 | Join("head", "-1"). 741 | Finalize().WithOutput(output).Run() 742 | 743 | assert.Error(t, err) 744 | assert.Error(t, err.(MultipleErrors).errors[0]) 745 | assert.NoError(t, err.(MultipleErrors).errors[1]) 746 | assert.Equal(t, "ERR", strings.Trim(output.String(), "\n")) 747 | } 748 | 749 | func TestShellCommand(t *testing.T) { 750 | toTest := Builder().JoinShellCmd("echo 'Hello, World!' | wc -l") 751 | 752 | runAndCompare(t, toTest, "1\n") 753 | } 754 | 755 | func TestShellCommand_redirection(t *testing.T) { 756 | tmpDir := t.TempDir() 757 | err := Builder(). 758 | JoinShellCmd(fmt.Sprintf("%s -e error -o output > %s/out 2> %s/err", testHelper, tmpDir, tmpDir)). 759 | Finalize().Run() 760 | 761 | assert.NoError(t, err) 762 | 763 | outFile, err := os.Open(path.Join(tmpDir, "out")) 764 | assert.NoError(t, err) 765 | 766 | content, err := io.ReadAll(outFile) 767 | assert.NoError(t, err) 768 | assert.Equal(t, "output", strings.TrimSpace(string(content))) 769 | 770 | errFile, err := os.Open(path.Join(tmpDir, "err")) 771 | assert.NoError(t, err) 772 | 773 | content, err = io.ReadAll(errFile) 774 | assert.NoError(t, err) 775 | assert.Equal(t, "error", strings.TrimSpace(string(content))) 776 | } 777 | 778 | func TestShellCommand_redirectionAppending(t *testing.T) { 779 | tmpDir := t.TempDir() 780 | err := Builder(). 781 | JoinShellCmd(fmt.Sprintf("%s -e error -o output >> %s/out 2>> %s/err", testHelper, tmpDir, tmpDir)). 782 | Finalize().Run() 783 | 784 | assert.NoError(t, err) 785 | 786 | err = Builder(). 787 | JoinShellCmd(fmt.Sprintf("%s -e error -o output >> %s/out 2>> %s/err", testHelper, tmpDir, tmpDir)). 788 | Finalize().Run() 789 | 790 | assert.NoError(t, err) 791 | 792 | outFile, err := os.Open(path.Join(tmpDir, "out")) 793 | assert.NoError(t, err) 794 | 795 | content, err := io.ReadAll(outFile) 796 | assert.NoError(t, err) 797 | assert.Equal(t, "output\noutput", strings.TrimSpace(string(content))) 798 | 799 | errFile, err := os.Open(path.Join(tmpDir, "err")) 800 | assert.NoError(t, err) 801 | 802 | content, err = io.ReadAll(errFile) 803 | assert.NoError(t, err) 804 | assert.Equal(t, "error\nerror", strings.TrimSpace(string(content))) 805 | } 806 | 807 | func TestShellCommand_touchOnly(t *testing.T) { 808 | tmpDir := t.TempDir() 809 | err := Builder(). 810 | JoinShellCmd(fmt.Sprintf("%s > %s/out 2> %s/err", testHelper, tmpDir, tmpDir)). 811 | Finalize().Run() 812 | 813 | assert.NoError(t, err) 814 | 815 | outFile, err := os.Open(path.Join(tmpDir, "out")) 816 | assert.NoError(t, err) 817 | 818 | content, err := io.ReadAll(outFile) 819 | assert.NoError(t, err) 820 | assert.Equal(t, "", strings.TrimSpace(string(content))) 821 | 822 | errFile, err := os.Open(path.Join(tmpDir, "err")) 823 | assert.NoError(t, err) 824 | 825 | content, err = io.ReadAll(errFile) 826 | assert.NoError(t, err) 827 | assert.Equal(t, "", strings.TrimSpace(string(content))) 828 | } 829 | 830 | func runAndCompare(t *testing.T, toTest interface{ Finalize() FinalizedBuilder }, expected string) { 831 | output := &bytes.Buffer{} 832 | 833 | err := toTest.Finalize().WithOutput(output).Run() 834 | assert.NoError(t, err) 835 | assert.Equal(t, expected, output.String()) 836 | } 837 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 80..90 # coverage lower than 50 is red, higher than 90 green, between color code 3 | 4 | status: 5 | project: # settings affecting project coverage 6 | enabled: yes 7 | target: auto # auto % coverage target 8 | threshold: 5% # allow for 5% reduction of coverage without failing 9 | 10 | # do not run coverage on patch nor changes 11 | patch: no 12 | changes: no -------------------------------------------------------------------------------- /error_checker.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import "os/exec" 4 | 5 | // ErrorChecker is a function which will receive the command's error. His purposes is to check if the given error can 6 | // be ignored. If the function return true the given error is a "real" error and will NOT be ignored! 7 | type ErrorChecker func(index int, command *exec.Cmd, err error) bool 8 | 9 | // IgnoreExitCode will return an ErrorChecker. This will ignore all exec.ExitError which have any of the given exit codes. 10 | func IgnoreExitCode(allowedCodes ...int) ErrorChecker { 11 | return func(_ int, _ *exec.Cmd, err error) bool { 12 | if exitErr, ok := err.(*exec.ExitError); ok { 13 | exitCode := exitErr.ExitCode() 14 | 15 | for _, allowedCode := range allowedCodes { 16 | if allowedCode == exitCode { 17 | return false 18 | } 19 | } 20 | } 21 | 22 | // its a "true" error 23 | return true 24 | } 25 | } 26 | 27 | // IgnoreExitErrors will return an ErrorChecker. This will ignore all exec.ExitError. 28 | func IgnoreExitErrors() ErrorChecker { 29 | return func(_ int, _ *exec.Cmd, err error) bool { 30 | _, isExitError := err.(*exec.ExitError) 31 | 32 | return !isExitError 33 | } 34 | } 35 | 36 | // IgnoreAll will return an ErrorChecker. This will ignore all error. 37 | func IgnoreAll() ErrorChecker { 38 | return func(_ int, _ *exec.Cmd, _ error) bool { 39 | return false 40 | } 41 | } 42 | 43 | // IgnoreNothing will return an ErrorChecker. This will ignore no error. 44 | func IgnoreNothing() ErrorChecker { 45 | return func(_ int, _ *exec.Cmd, _ error) bool { 46 | return true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /error_checker_test.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "os/exec" 7 | "testing" 8 | ) 9 | 10 | func TestIgnoreExitCode(t *testing.T) { 11 | err := exec.Command(testHelper, "-x", "13").Run() 12 | 13 | assert.False(t, IgnoreExitCode(13)(0, nil, err)) 14 | assert.True(t, IgnoreExitCode(1)(0, nil, err)) 15 | } 16 | 17 | func TestIgnoreExitErrors(t *testing.T) { 18 | err := exec.Command(testHelper, "-x", "13").Run() 19 | 20 | assert.False(t, IgnoreExitErrors()(0, nil, err)) 21 | assert.True(t, IgnoreExitErrors()(0, nil, fmt.Errorf("someOtherError"))) 22 | } 23 | 24 | func TestIgnoreAll(t *testing.T) { 25 | err := exec.Command(testHelper, "-x", "13").Run() 26 | 27 | assert.False(t, IgnoreAll()(0, nil, err)) 28 | assert.False(t, IgnoreAll()(0, nil, fmt.Errorf("someOtherError"))) 29 | } 30 | 31 | func TestIgnoreNothing(t *testing.T) { 32 | err := exec.Command(testHelper, "-x", "13").Run() 33 | 34 | assert.True(t, IgnoreNothing()(0, nil, err)) 35 | assert.True(t, IgnoreNothing()(0, nil, fmt.Errorf("someOtherError"))) 36 | } 37 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // MultipleErrors fusions multiple errors into one error. All underlying errors can be accessed. 9 | // Normally the errors are saved by commands sequence. So if the first command in the chain occurs an 10 | // error, this error will be placed at first in the error list. 11 | type MultipleErrors struct { 12 | errorMessage string 13 | errors []error 14 | hasError bool 15 | } 16 | 17 | // Errors returns the underlying errors. 18 | func (e MultipleErrors) Errors() []error { 19 | return e.errors 20 | } 21 | 22 | // Error fusions all error messages of the underlying errors and return them. 23 | func (e MultipleErrors) Error() string { 24 | sb := strings.Builder{} 25 | 26 | sb.WriteString(e.errorMessage) 27 | sb.WriteString(": [") 28 | for i, err := range e.errors { 29 | sb.WriteString(fmt.Sprintf("%d - ", i)) 30 | if err != nil { 31 | sb.WriteString(err.Error()) 32 | } 33 | 34 | if i+1 != len(e.errors) { 35 | sb.WriteString("; ") 36 | } 37 | } 38 | sb.WriteString("]") 39 | 40 | return sb.String() 41 | } 42 | 43 | func (e *MultipleErrors) addError(err error) { 44 | e.errors = append(e.errors, err) 45 | if err != nil { 46 | if mError, ok := err.(MultipleErrors); ok { 47 | e.hasError = mError.hasError 48 | } else { 49 | e.hasError = true 50 | } 51 | } 52 | } 53 | 54 | func (e *MultipleErrors) setError(i int, err error) { 55 | e.errors[i] = err 56 | if err != nil { 57 | if mError, ok := err.(MultipleErrors); ok { 58 | e.hasError = mError.hasError 59 | } else { 60 | e.hasError = true 61 | } 62 | } 63 | } 64 | 65 | func runErrors() MultipleErrors { 66 | return MultipleErrors{ 67 | errorMessage: "one or more command has returned an error", 68 | } 69 | } 70 | 71 | func buildErrors() MultipleErrors { 72 | return MultipleErrors{ 73 | errorMessage: "one or more chain build errors occurred", 74 | } 75 | } 76 | 77 | func streamErrors() MultipleErrors { 78 | return MultipleErrors{ 79 | errorMessage: "one or more command stream copies failed", 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package cmdchain_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "github.com/rainu/go-command-chain" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func ExampleBuilder() { 14 | output := &bytes.Buffer{} 15 | 16 | //it's the same as in shell: ls -l | grep README | wc -l 17 | err := cmdchain.Builder(). 18 | Join("ls", "-l"). 19 | Join("grep", "README"). 20 | Join("wc", "-l"). 21 | Finalize(). 22 | WithOutput(output). 23 | Run() 24 | 25 | if err != nil { 26 | panic(err) 27 | } 28 | println(output.String()) 29 | } 30 | 31 | func ExampleBuilder_join() { 32 | //it's the same as in shell: ls -l | grep README 33 | err := cmdchain.Builder(). 34 | Join("ls", "-l"). 35 | Join("grep", "README"). 36 | Finalize().Run() 37 | 38 | if err != nil { 39 | panic(err) 40 | } 41 | } 42 | 43 | func ExampleBuilder_finalize() { 44 | //it's the same as in shell: ls -l | grep README 45 | err := cmdchain.Builder(). 46 | Join("ls", "-l"). 47 | Join("grep", "README"). 48 | Finalize().Run() 49 | 50 | if err != nil { 51 | panic(err) 52 | } 53 | } 54 | 55 | func ExampleBuilder_joinCmd() { 56 | //it's the same as in shell: ls -l | grep README 57 | grepCmd := exec.Command("grep", "README") 58 | 59 | //do NOT manipulate the command's streams! 60 | 61 | err := cmdchain.Builder(). 62 | Join("ls", "-l"). 63 | JoinCmd(grepCmd). 64 | Finalize().Run() 65 | 66 | if err != nil { 67 | panic(err) 68 | } 69 | } 70 | 71 | func ExampleBuilder_joinWithContext() { 72 | //the "ls" command will be killed after 1 second 73 | ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) 74 | defer cancelFn() 75 | 76 | //it's the same as in shell: ls -l | grep README 77 | err := cmdchain.Builder(). 78 | JoinWithContext(ctx, "ls", "-l"). 79 | Join("grep", "README"). 80 | Finalize().Run() 81 | 82 | if err != nil { 83 | panic(err) 84 | } 85 | } 86 | 87 | func ExampleBuilder_joinShellCmd() { 88 | //it's the same as in shell: ls -l | grep README 89 | err := cmdchain.Builder(). 90 | JoinShellCmd(`ls -l | grep README`). 91 | Finalize().Run() 92 | 93 | if err != nil { 94 | panic(err) 95 | } 96 | } 97 | 98 | func ExampleBuilder_withInput() { 99 | inputContent := strings.NewReader("test\n") 100 | 101 | //it's the same as in shell: echo "test" | grep test 102 | err := cmdchain.Builder(). 103 | WithInput(inputContent). 104 | Join("grep", "test"). 105 | Finalize().Run() 106 | 107 | if err != nil { 108 | panic(err) 109 | } 110 | } 111 | 112 | func ExampleBuilder_forwardError() { 113 | //it's the same as in shell: echoErr "test" |& grep test 114 | err := cmdchain.Builder(). 115 | Join("echoErr", "test").ForwardError(). 116 | Join("grep", "test"). 117 | Join("wc", "-l"). 118 | Finalize().Run() 119 | 120 | if err != nil { 121 | panic(err) 122 | } 123 | } 124 | 125 | func ExampleBuilder_discardStdOut() { 126 | //this will drop the stdout from echo .. so grep will receive no input 127 | //Attention: it must be used in combination with ForwardError - otherwise 128 | //it will cause a invalid stream configuration error! 129 | err := cmdchain.Builder(). 130 | Join("echo", "test").DiscardStdOut().ForwardError(). 131 | Join("grep", "test"). 132 | Join("wc", "-l"). 133 | Finalize().Run() 134 | 135 | if err != nil { 136 | panic(err) 137 | } 138 | } 139 | 140 | func ExampleBuilder_withOutputForks() { 141 | //it's the same as in shell: echo "test" | tee | grep test | wc -l 142 | outputFork := &bytes.Buffer{} 143 | 144 | err := cmdchain.Builder(). 145 | Join("echo", "test").WithOutputForks(outputFork). 146 | Join("grep", "test"). 147 | Join("wc", "-l"). 148 | Finalize().Run() 149 | 150 | if err != nil { 151 | panic(err) 152 | } 153 | println(outputFork.String()) 154 | } 155 | 156 | func ExampleBuilder_withErrorForks() { 157 | //it's the same as in shell: echoErr "test" |& tee | grep test | wc -l 158 | errorFork := &bytes.Buffer{} 159 | 160 | err := cmdchain.Builder(). 161 | Join("echoErr", "test").ForwardError().WithErrorForks(errorFork). 162 | Join("grep", "test"). 163 | Join("wc", "-l"). 164 | Finalize().Run() 165 | 166 | if err != nil { 167 | panic(err) 168 | } 169 | println(errorFork.String()) 170 | } 171 | 172 | func ExampleBuilder_withInjections() { 173 | //it's the same as in shell: echo -e "test\ntest" | grep test | wc -l 174 | inputContent := strings.NewReader("test\n") 175 | 176 | err := cmdchain.Builder(). 177 | Join("echoErr", "test").WithInjections(inputContent). 178 | Join("grep", "test"). 179 | Join("wc", "-l"). 180 | Finalize().Run() 181 | 182 | if err != nil { 183 | panic(err) 184 | } 185 | } 186 | 187 | func ExampleBuilder_withAdditionalEnvironment() { 188 | //it's the same as in shell: TEST=VALUE TEST2=2 env | grep TEST | wc -l 189 | err := cmdchain.Builder(). 190 | Join("env").WithAdditionalEnvironment("TEST", "VALUE", "TEST2", 2). 191 | Join("grep", "TEST"). 192 | Finalize().Run() 193 | 194 | if err != nil { 195 | panic(err) 196 | } 197 | } 198 | 199 | func ExampleBuilder_withOutput() { 200 | //it's the same as in shell: echo "test" | grep test > /tmp/output 201 | 202 | target, err := os.OpenFile("/tmp/output", os.O_RDWR|os.O_CREATE, 0755) 203 | if err != nil { 204 | panic(err) 205 | } 206 | 207 | err = cmdchain.Builder(). 208 | Join("echo", "test"). 209 | Join("grep", "test"). 210 | Finalize().WithOutput(target).Run() 211 | 212 | if err != nil { 213 | panic(err) 214 | } 215 | } 216 | 217 | func ExampleBuilder_withError() { 218 | //it's the same as in shell: echoErr "test" 2> /tmp/error 219 | 220 | target, err := os.OpenFile("/tmp/error", os.O_RDWR|os.O_CREATE, 0755) 221 | if err != nil { 222 | panic(err) 223 | } 224 | 225 | err = cmdchain.Builder(). 226 | Join("echoErr", "test"). 227 | Finalize().WithError(target).Run() 228 | 229 | if err != nil { 230 | panic(err) 231 | } 232 | } 233 | 234 | func ExampleBuilder_run() { 235 | output := &bytes.Buffer{} 236 | 237 | //it's the same as in shell: ls -l | grep README | wc -l 238 | err := cmdchain.Builder(). 239 | Join("ls", "-l"). 240 | Join("grep", "README"). 241 | Join("wc", "-l"). 242 | Finalize(). 243 | WithOutput(output). 244 | Run() 245 | 246 | if err != nil { 247 | panic(err) 248 | } 249 | println(output.String()) 250 | } 251 | 252 | func ExampleBuilder_runAndGet() { 253 | //it's the same as in shell: ls -l | grep README | wc -l 254 | sout, serr, err := cmdchain.Builder(). 255 | Join("ls", "-l"). 256 | Join("grep", "README"). 257 | Join("wc", "-l"). 258 | Finalize(). 259 | RunAndGet() 260 | 261 | if err != nil { 262 | panic(err) 263 | } 264 | println("OUTPUT: " + sout) 265 | println("ERROR: " + serr) 266 | } 267 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rainu/go-command-chain 2 | 3 | go 1.23 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/stretchr/testify v1.10.0 9 | mvdan.cc/sh/v3 v3.11.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= 4 | github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 5 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 6 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 7 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 8 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 9 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 10 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 14 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 15 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 16 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 20 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw= 22 | mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg= 23 | -------------------------------------------------------------------------------- /hook.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | type hook interface { 4 | BeforeRun() 5 | AfterRun() 6 | } 7 | 8 | func (c *chain) addHook(h hook) { 9 | c.hooks = append(c.hooks, h) 10 | } 11 | 12 | func (c *chain) executeBeforeRunHooks() { 13 | for _, h := range c.hooks { 14 | h.BeforeRun() 15 | } 16 | } 17 | 18 | func (c *chain) executeAfterRunHooks() { 19 | for _, h := range c.hooks { 20 | h.AfterRun() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /interfaces.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os/exec" 7 | ) 8 | 9 | // ChainBuilder contains methods for joining new commands to the current cain or finalize them. 10 | type ChainBuilder interface { 11 | // Join create a new command by the given name and the given arguments. This command then will join 12 | // the chain. If there is a command which joined before, their stdout/stderr will redirected to this 13 | // command in stdin (depending of its configuration). After calling Join the command can be more 14 | // configured. After calling another Join this command can not be configured again. Instead the 15 | // configuration of the next command will begin. 16 | Join(name string, args ...string) CommandBuilder 17 | 18 | // JoinCmd takes the given command and join them to the chain. If there is a command which joined 19 | // before, their stdout/stderr will redirected to this command in stdin (depending of its configuration). 20 | // Therefore the input (stdin) and output (stdout/stderr) will be manipulated by the chain building process. 21 | // The streams must not be configured outside the chain builder. Otherwise the chain building process will 22 | // be failed after Run will be called. After calling JoinCmd the command can be more configured. After 23 | // calling another Join this command can not be configured again. Instead the configuration of the 24 | // next command will begin. 25 | JoinCmd(cmd *exec.Cmd) CommandBuilder 26 | 27 | // JoinWithContext is like Join but includes a context to the created command. The provided context is used 28 | // to kill the process (by calling os.Process.Kill) if the context becomes done before the command completes 29 | // on its own. 30 | JoinWithContext(ctx context.Context, name string, args ...string) CommandBuilder 31 | 32 | // JoinShellCmd will take a shell command line, parse it into single commands and join them to this chain. 33 | // So this is not a single command, which will be interpreted by any shell! 34 | // If there is a command, which joined before, their stdout/stderr will redirected to the first 35 | // command from the command line in stdin (depending of its configuration). 36 | // 37 | // Supported features: 38 | // - Piping (|) between commands 39 | // - Piping all output (stdout and stderr) to the next command's stdin (|&) 40 | // - Redirection of stdout (>) and stderr (2>) to files 41 | // - Redirection of stdout (>>) and stderr (2>>) to files (appending) 42 | // - Environment variables (e.g. `VAR=value command`) 43 | // 44 | // Unsupported features: 45 | // - Background execution (&) 46 | // - Conditional execution (e.g. `command1 && command2` or `command1 || command2`) 47 | // 48 | // Example: 49 | // JoinShellCmd("echo Hello, World! | grep Hello | wc -c") 50 | // will create a chain with three commands: 51 | // 1. /usr/bin/echo "Hello," "World!" 52 | // 2. /usr/bin/grep "Hello" 53 | // 3. /usr/bin/wc -c 54 | JoinShellCmd(command string) ShellCommandBuilder 55 | 56 | // JoinShellCmdWithContext is like JoinShellCmd but includes the given context to all created commands. 57 | JoinShellCmdWithContext(ctx context.Context, command string) ShellCommandBuilder 58 | 59 | // Finalize will finish the command joining process. After calling this method no command can be joined anymore. 60 | // Instead final configurations can be made and the chain is ready to run. 61 | Finalize() FinalizedBuilder 62 | } 63 | 64 | // FirstCommandBuilder contains methods for building the chain. Especially it contains configuration which can be 65 | // made only for the first command in the chain. 66 | type FirstCommandBuilder interface { 67 | ChainBuilder 68 | 69 | // WithInput configures the input stream(s) for the first command in the chain. If multiple streams are 70 | // configured, this streams will read in parallel (not sequential!). So be aware of concurrency issues. 71 | // If this behavior is not wanted, me the io.MultiReader is a better choice. 72 | WithInput(sources ...io.Reader) ChainBuilder 73 | } 74 | 75 | // CommandApplier is a function which will get the command's index and the command's reference 76 | type CommandApplier func(index int, command *exec.Cmd) 77 | 78 | type outputBuilder interface { 79 | // ForwardError will configure the previously joined command to redirect all its stderr output to the next 80 | // command's input. If WithErrorForks is also used, the stderr output of the previously joined command will 81 | // be redirected to both: stdin of the next command AND the configured fork(s). 82 | // If ForwardError is not used, the stderr output of the previously joined command will be dropped. But if 83 | // WithErrorForks is used, the stderr output will be redirected to the configured fork(s). 84 | ForwardError() CommandBuilder 85 | 86 | // DiscardStdOut will configure the previously joined command to drop all its stdout output. So the stdout does NOT 87 | // redirect to the next command's stdin. If WithOutputForks is also used, the output of the previously joined 88 | // command will be redirected to this fork(s). It will cause an invalid stream configuration error if the stderr is 89 | // also discarded (which is the default case)! So it should be used in combination of ForwardError. 90 | DiscardStdOut() CommandBuilder 91 | 92 | // WithOutputForks will configure the previously joined command to redirect their stdout output to the configured 93 | // target(s). The configured writer will be written in parallel so streaming is possible. If the previously 94 | // joined command is also configured to redirect its stdout to the next command's input, the stdout output will 95 | // redirected to both: stdin of the next command AND the configured fork(s). 96 | // ATTENTION: If one of the given writer will be closed before the command ends the command will be exited. This is 97 | // because of the this method uses the io.MultiWriter. And it will close the writer if on of them is closed. 98 | WithOutputForks(targets ...io.Writer) CommandBuilder 99 | 100 | // WithAdditionalOutputForks is similar to WithOutputForks except that the given targets will be added to the 101 | // command and not be overwritten. 102 | WithAdditionalOutputForks(targets ...io.Writer) CommandBuilder 103 | 104 | // WithErrorForks will configure the previously joined command to redirect their stderr output to the configured 105 | // target(s). The configured writer will be written in parallel so streaming is possible. If the previously 106 | // joined command is also configured to redirect its stderr to the next command's input, the stderr output will 107 | // redirected to both: stdin of the next command AND the configured fork(s). 108 | // ATTENTION: If one of the given writer will be closed before the command ends the command will be exited. This is 109 | // because of the this method uses the io.MultiWriter. And it will close the writer if on of them is closed. 110 | WithErrorForks(targets ...io.Writer) CommandBuilder 111 | 112 | // WithAdditionalErrorForks is similar to WithErrorForks except that the given targets will be added to the 113 | // command and not be overwritten. 114 | WithAdditionalErrorForks(targets ...io.Writer) CommandBuilder 115 | } 116 | 117 | // CommandBuilder contains methods for configuring the previous joined command. 118 | type CommandBuilder interface { 119 | ChainBuilder 120 | outputBuilder 121 | 122 | // Apply will call the given CommandApplier with the previously joined command. The CommandApplier can do anything 123 | // with the previously joined command. The CommandApplier will be called directly so the command which the applier 124 | // will be received has included all changes which made before this function call. 125 | // ATTENTION: Be aware of the changes the CommandApplier will make. This can clash with the changes the building 126 | // pipeline will make! 127 | Apply(CommandApplier) CommandBuilder 128 | 129 | // ApplyBeforeStart will call the given CommandApplier with the previously joined command. The CommandApplier can do 130 | // anything with the previously joined command. The CommandApplier will be called before the command will be started 131 | // so the command is almost finished (all streams are configured and so on). 132 | // ATTENTION: Be aware of the changes the CommandApplier will make. This can clash with the changes the building 133 | // pipeline will make! 134 | ApplyBeforeStart(CommandApplier) CommandBuilder 135 | 136 | // WithInjections will configure the previously joined command to read from the given sources AND the predecessor 137 | // command's stdout or stderr (depending on the configuration). This streams (stdout/stderr of predecessor command 138 | // and the given sources) will read in parallel (not sequential!). So be aware of concurrency issues. 139 | // If this behavior is not wanted, me the io.MultiReader is a better choice. 140 | WithInjections(sources ...io.Reader) CommandBuilder 141 | 142 | // WithEmptyEnvironment will use an empty environment for the previously joined command. The default behavior is to 143 | // use the current process's environment. 144 | WithEmptyEnvironment() CommandBuilder 145 | 146 | // WithEnvironment will configure the previously joined command to use the given environment variables. Key-value 147 | // pair(s) must be passed as arguments. Where the first represents the key and the second the value of the 148 | // environment variable. 149 | WithEnvironment(envMap ...interface{}) CommandBuilder 150 | 151 | // WithEnvironmentMap will configure the previously joined command to use the given environment variables. 152 | WithEnvironmentMap(envMap map[interface{}]interface{}) CommandBuilder 153 | 154 | // WithEnvironmentPairs will configure the previously joined command to use the given environment variables. 155 | // Each entry must have the form "key=value" 156 | WithEnvironmentPairs(envMap ...string) CommandBuilder 157 | 158 | // WithAdditionalEnvironment will do almost the same thing as WithEnvironment expecting that the given key-value 159 | // pairs will be joined with the environment variables of the current process. 160 | WithAdditionalEnvironment(envMap ...interface{}) CommandBuilder 161 | 162 | // WithAdditionalEnvironmentMap will do almost the same thing as WithEnvironmentMap expecting that the given 163 | // values will be joined with the environment variables of the current process. 164 | WithAdditionalEnvironmentMap(envMap map[interface{}]interface{}) CommandBuilder 165 | 166 | // WithAdditionalEnvironmentPairs will do almost the same thing as WithEnvironmentPairs expecting that the given 167 | // values will be joined with the environment variables of the current process. 168 | WithAdditionalEnvironmentPairs(envMap ...string) CommandBuilder 169 | 170 | // WithWorkingDirectory will configure the previously joined command to use the specifies the working directory. 171 | // Without setting the working directory, the calling process's current directory will be used. 172 | WithWorkingDirectory(workingDir string) CommandBuilder 173 | 174 | // WithErrorChecker will configure the previously joined command to use the given error checker. In some cases 175 | // the commands will return a non-zero exit code, which will normally cause an error at the FinalizedBuilder.Run(). 176 | // To avoid that you can use a ErrorChecker to ignore these kind of errors. There exists a set of functions which 177 | // create a such ErrorChecker: IgnoreExitCode, IgnoreExitErrors, IgnoreAll, IgnoreNothing 178 | WithErrorChecker(ErrorChecker) CommandBuilder 179 | } 180 | 181 | // ShellCommandBuilder contains methods for configuring the previous joined shell command. 182 | type ShellCommandBuilder interface { 183 | ChainBuilder 184 | outputBuilder 185 | } 186 | 187 | // FinalizedBuilder contains methods for configuration the the finalized chain. At this step the chain can be running. 188 | type FinalizedBuilder interface { 189 | 190 | // WithOutput configures the stdout stream(s) for the last command in the chain. If there is more than one target 191 | // given io.MultiWriter will be used as command's stdout. So in that case if there was one of the given targets 192 | // closed before the chain normally ends, the chain will be exited. This is because of the behavior of the 193 | // io.MultiWriter. 194 | WithOutput(targets ...io.Writer) FinalizedBuilder 195 | 196 | // WithAdditionalOutput is similar to WithOutput except that the given targets will be added to the 197 | // command and not be overwritten. 198 | WithAdditionalOutput(targets ...io.Writer) FinalizedBuilder 199 | 200 | // WithError configures the stderr stream(s) for the last command in the chain. If there is more than one target 201 | // given io.MultiWriter will be used as command's stdout. So in that case if there was one of the given targets 202 | // closed before the chain normally ends, the chain will be exited. This is because of the behavior of the 203 | // io.MultiWriter. 204 | WithError(targets ...io.Writer) FinalizedBuilder 205 | 206 | // WithAdditionalError is similar to WithError except that the given targets will be added to the 207 | // command and not be overwritten. 208 | WithAdditionalError(targets ...io.Writer) FinalizedBuilder 209 | 210 | // WithGlobalErrorChecker will configure the complete chain to use the given error checker. If there is an error 211 | // checker configured for a special command, this error checker will be skipped for these one. In some cases 212 | // the commands will return a non-zero exit code, which will normally cause an error at the Run(). 213 | // To avoid that you can use a ErrorChecker to ignore these kind of errors. There exists a set of functions which 214 | // create a such ErrorChecker: IgnoreExitCode, IgnoreExitErrors, IgnoreAll, IgnoreNothing 215 | WithGlobalErrorChecker(ErrorChecker) FinalizedBuilder 216 | 217 | // Run will execute the command chain. It will start all underlying commands and wait after completion of all of 218 | // them. If the building of the chain was failed, an error will returned before the commands are started! In that 219 | // case an MultipleErrors will be returned. If any command starting failed, the run will the error (single) of 220 | // starting. All previously started commands should be exited in that case. Following commands will not be started. 221 | // If any error occurs while commands are running, a MultipleErrors will return within all errors per 222 | // command. 223 | Run() error 224 | 225 | // RunAndGet works like Run in addition the function will return the stdout and stderr of the command chain. Be 226 | // careful with this convenience function because the stdout and stderr will be stored in memory! 227 | RunAndGet() (string, string, error) 228 | 229 | // String returns a string representation of the command chain. 230 | String() string 231 | } 232 | -------------------------------------------------------------------------------- /lazy_file.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // lazyFile is a wrapper around os.File that lazily opens the file when the first write operation is performed. 8 | type lazyFile struct { 9 | name string 10 | flag int 11 | perm os.FileMode 12 | 13 | file *os.File 14 | fileErr error 15 | } 16 | 17 | func newLazyFile(name string, flag int, perm os.FileMode) *lazyFile { 18 | return &lazyFile{ 19 | name: name, 20 | flag: flag, 21 | perm: perm, 22 | } 23 | } 24 | 25 | func (l *lazyFile) Write(p []byte) (n int, err error) { 26 | l.BeforeRun() 27 | 28 | if l.fileErr != nil { 29 | return 0, l.fileErr 30 | } 31 | 32 | return l.file.Write(p) 33 | } 34 | 35 | func (l *lazyFile) BeforeRun() { 36 | if l.file == nil { 37 | l.file, l.fileErr = os.OpenFile(l.name, l.flag, l.perm) 38 | } 39 | } 40 | 41 | func (l *lazyFile) AfterRun() { 42 | l.Close() 43 | } 44 | 45 | func (l *lazyFile) Close() (err error) { 46 | if l.file != nil { 47 | err = l.file.Close() 48 | 49 | // reset to nil to ensure it is reopened on next write operation 50 | l.file = nil 51 | l.fileErr = nil 52 | } 53 | 54 | return 55 | } 56 | 57 | func (l *lazyFile) String() string { 58 | if l.flag&os.O_APPEND != 0 { 59 | return l.name + " (appending)" 60 | } 61 | return l.name 62 | } 63 | -------------------------------------------------------------------------------- /lazy_file_test.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "io" 7 | "os" 8 | "path" 9 | "testing" 10 | ) 11 | 12 | func TestLazyFile(t *testing.T) { 13 | toTest := newLazyFile(path.Join(t.TempDir(), "lazy_file_test"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 14 | 15 | s, _ := os.Stat(toTest.name) 16 | assert.Nil(t, s, "file should not exist") 17 | 18 | toTest.BeforeRun() 19 | s, _ = os.Stat(toTest.name) 20 | assert.NotNil(t, s, "file should exist") 21 | 22 | _, err := toTest.Write([]byte("first write")) 23 | require.NoError(t, err) 24 | toTest.AfterRun() 25 | 26 | f, err := os.Open(toTest.name) 27 | require.NoError(t, err) 28 | defer f.Close() 29 | 30 | content, err := io.ReadAll(f) 31 | assert.NoError(t, err) 32 | assert.Equal(t, "first write", string(content)) 33 | 34 | // second write should reopen the file 35 | toTest.BeforeRun() 36 | _, err = toTest.Write([]byte("second write")) 37 | require.NoError(t, err) 38 | toTest.AfterRun() 39 | 40 | f.Seek(0, 0) 41 | content, err = io.ReadAll(f) 42 | assert.NoError(t, err) 43 | assert.Equal(t, "second write", string(content)) 44 | } 45 | -------------------------------------------------------------------------------- /shell.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "mvdan.cc/sh/v3/syntax" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | func (c *chain) JoinShellCmd(command string) ShellCommandBuilder { 13 | c.parseAndJoinShell(nil, command) 14 | return c 15 | } 16 | 17 | func (c *chain) JoinShellCmdWithContext(ctx context.Context, command string) ShellCommandBuilder { 18 | c.parseAndJoinShell(ctx, command) 19 | return c 20 | } 21 | 22 | func (c *chain) parseAndJoinShell(ctx context.Context, command string) { 23 | var err error 24 | defer func() { 25 | if err != nil { 26 | c.buildErrors.addError(fmt.Errorf("error parsing shell command: %w", err)) 27 | } 28 | }() 29 | 30 | parser := &shellParser{ 31 | chain: c, 32 | ctx: ctx, 33 | } 34 | 35 | parser.program, err = syntax.NewParser().Parse(strings.NewReader(command), "") 36 | if err != nil { 37 | return 38 | } 39 | 40 | err = parser.Parse() 41 | } 42 | 43 | type shellParser struct { 44 | program *syntax.File 45 | ctx context.Context 46 | chain *chain 47 | } 48 | 49 | func (s *shellParser) Parse() error { 50 | if len(s.program.Stmts) == 0 { 51 | return fmt.Errorf("no statements") 52 | } 53 | if len(s.program.Stmts) > 1 { 54 | return fmt.Errorf("multiple statements are not supported, found %d statements", len(s.program.Stmts)) 55 | } 56 | if s.program.Stmts[0].Background { 57 | return fmt.Errorf("background execution is not supported") 58 | } 59 | 60 | err := s.handleCommand(s.program.Stmts[0].Cmd, s.program.Stmts[0].Redirs) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (s *shellParser) handleCommand(cmd syntax.Command, redirs []*syntax.Redirect) error { 69 | switch c := cmd.(type) { 70 | case *syntax.CallExpr: 71 | return s.handleCall(c, redirs) 72 | case *syntax.BinaryCmd: 73 | return s.handleBinary(c) 74 | default: 75 | return errorWithPos(c, "unsupported command") 76 | } 77 | } 78 | 79 | func (s *shellParser) handleCall(c *syntax.CallExpr, redirs []*syntax.Redirect) error { 80 | commandName, arguments, err := s.extractCommandAndArgs(c.Args) 81 | if err != nil { 82 | return errorWithPos(c, "error extracting command and arguments", err) 83 | } 84 | 85 | if s.ctx == nil { 86 | s.chain = s.chain.Join(commandName, arguments...).(*chain) 87 | } else { 88 | s.chain = s.chain.JoinWithContext(s.ctx, commandName, arguments...).(*chain) 89 | } 90 | 91 | err = s.handleAssigns(c.Assigns) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | return s.handleRedirects(redirs) 97 | } 98 | 99 | func (s *shellParser) handleRedirects(redirs []*syntax.Redirect) (err error) { 100 | var outputStreams []io.Writer 101 | var errorStreams []io.Writer 102 | 103 | for _, redir := range redirs { 104 | switch redir.Op { 105 | case syntax.RdrAll: // &> 106 | case syntax.AppAll: // &>> 107 | case syntax.RdrOut: // > 108 | case syntax.AppOut: // >> 109 | default: 110 | return errorWithPos(redir, fmt.Sprintf("unsupported redirection operator '%s'", redir.Op.String())) 111 | } 112 | 113 | var targetFile *lazyFile 114 | targetFile, err = s.setupStream(redir) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | // register file-hook to ensure the file is closed after command execution 120 | s.chain.addHook(targetFile) 121 | 122 | if redir.Op == syntax.RdrAll || redir.Op == syntax.AppAll { 123 | errorStreams = append(errorStreams, targetFile) 124 | outputStreams = append(outputStreams, targetFile) 125 | } else if redir.N != nil && redir.N.Value == "2" { 126 | errorStreams = append(errorStreams, targetFile) 127 | } else { 128 | outputStreams = append(outputStreams, targetFile) 129 | } 130 | } 131 | 132 | s.chain.WithOutputForks(outputStreams...) 133 | s.chain.WithErrorForks(errorStreams...) 134 | 135 | return nil 136 | } 137 | 138 | func (s *shellParser) setupStream(redir *syntax.Redirect) (*lazyFile, error) { 139 | target, err := s.convertWord(redir.Word) 140 | if err != nil { 141 | return nil, errorWithPos(redir, "error converting output redirection target", err) 142 | } 143 | if target == "" { 144 | return nil, errorWithPos(redir, "missing output redirection target") 145 | } 146 | 147 | flag := os.O_WRONLY | os.O_CREATE 148 | 149 | switch redir.Op { 150 | case syntax.RdrAll: 151 | fallthrough 152 | case syntax.RdrOut: 153 | flag |= os.O_TRUNC 154 | case syntax.AppAll: 155 | fallthrough 156 | case syntax.AppOut: 157 | flag |= os.O_APPEND 158 | default: 159 | } 160 | 161 | return newLazyFile(target, flag, 0644), nil 162 | } 163 | 164 | func (s *shellParser) handleAssigns(assigns []*syntax.Assign) error { 165 | var env []string 166 | 167 | for _, assign := range assigns { 168 | if assign.Value == nil && assign.Array == nil && assign.Index == nil { 169 | // This is a simple assignment without value, e.g., `VAR=` 170 | env = append(env, fmt.Sprintf("%s=", assign.Name.Value)) 171 | } else if assign.Value != nil { 172 | value, err := s.convertWord(assign.Value) 173 | if err != nil { 174 | return errorWithPos(assign, "error converting assignment value", err) 175 | } 176 | env = append(env, fmt.Sprintf("%s=%s", assign.Name.Value, value)) 177 | } else { 178 | return errorWithPos(assign, "unsupported assignment") 179 | } 180 | } 181 | 182 | s.chain.WithAdditionalEnvironmentPairs(env...) 183 | return nil 184 | } 185 | 186 | func (s *shellParser) handleBinary(b *syntax.BinaryCmd) error { 187 | if err := s.handleCommand(b.X.Cmd, b.X.Redirs); err != nil { 188 | return err 189 | } 190 | 191 | switch b.Op { 192 | case syntax.Pipe: // | 193 | case syntax.PipeAll: // |& 194 | s.chain = s.chain.ForwardError().(*chain) 195 | default: 196 | return errorWithPos(b, fmt.Sprintf("unsupported binary operator '%s' at '%s'", b.Op.String(), b.OpPos.String())) 197 | } 198 | 199 | return s.handleCommand(b.Y.Cmd, b.Y.Redirs) 200 | } 201 | 202 | func (s *shellParser) extractCommandAndArgs(words []*syntax.Word) (commandName string, arguments []string, err error) { 203 | for i := range words { 204 | if i == 0 { 205 | commandName, err = s.convertWord(words[i]) 206 | if err != nil { 207 | return 208 | } 209 | } else { 210 | var argument string 211 | argument, err = s.convertWord(words[i]) 212 | if err != nil { 213 | return 214 | } 215 | 216 | arguments = append(arguments, argument) 217 | } 218 | } 219 | 220 | return 221 | } 222 | 223 | func (s *shellParser) convertWord(word *syntax.Word) (string, error) { 224 | if word == nil { 225 | return "", nil 226 | } 227 | 228 | result := word.Lit() 229 | if result != "" { 230 | return result, nil 231 | } 232 | 233 | return s.convertWordParts(word.Parts) 234 | } 235 | 236 | func (s *shellParser) convertWordParts(parts []syntax.WordPart) (result string, err error) { 237 | for i := range parts { 238 | switch part := parts[i].(type) { 239 | case *syntax.Lit: 240 | result += part.Value 241 | case *syntax.SglQuoted: 242 | result += part.Value 243 | case *syntax.DblQuoted: 244 | var r string 245 | r, err = s.convertWordParts(part.Parts) 246 | if err != nil { 247 | return 248 | } 249 | 250 | result += r 251 | default: 252 | err = errorWithPos(part, "unsupported word") 253 | return 254 | } 255 | } 256 | return 257 | } 258 | -------------------------------------------------------------------------------- /shell_err.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "mvdan.cc/sh/v3/syntax" 7 | ) 8 | 9 | type positionProvider interface { 10 | Pos() syntax.Pos 11 | End() syntax.Pos 12 | } 13 | 14 | type errorWithPosition struct { 15 | Position positionProvider 16 | Message string 17 | } 18 | 19 | func (e *errorWithPosition) Error() string { 20 | return fmt.Sprintf("[%s - %s] %s", e.Position.Pos().String(), e.Position.End().String(), e.Message) 21 | } 22 | 23 | func errorWithPos(pos positionProvider, message string, cause ...error) error { 24 | err := &errorWithPosition{ 25 | Position: pos, 26 | Message: message, 27 | } 28 | 29 | if len(cause) > 0 { 30 | return errors.Join(err, cause[0]) 31 | } 32 | 33 | return err 34 | } 35 | -------------------------------------------------------------------------------- /shell_test.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestJoinShellCmd(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | command string 15 | expectedString string 16 | expectError string 17 | check func(*testing.T, *chain) 18 | }{ 19 | { 20 | name: "simple", 21 | command: `date`, 22 | expectedString: ` 23 | [SO] ╿ 24 | [CM] /usr/bin/date ╡ 25 | [SE] ╽ 26 | `, 27 | }, 28 | { 29 | name: "simple double quoted", 30 | command: `echo "Hello, World!"`, 31 | expectedString: ` 32 | [SO] ╿ 33 | [CM] /usr/bin/echo "Hello, World!" ╡ 34 | [SE] ╽ 35 | `, 36 | }, 37 | { 38 | name: "simple single quoted", 39 | command: `echo 'Hello, World!'`, 40 | expectedString: ` 41 | [SO] ╿ 42 | [CM] /usr/bin/echo "Hello, World!" ╡ 43 | [SE] ╽ 44 | `, 45 | }, 46 | { 47 | name: "simple non-quoted", 48 | command: `echo Hello, World!`, 49 | expectedString: ` 50 | [SO] ╿ 51 | [CM] /usr/bin/echo "Hello," "World!" ╡ 52 | [SE] ╽ 53 | `, 54 | }, 55 | { 56 | name: "simple chain double quoted", 57 | command: `echo "Hello, World!" | grep "Hello" | wc -c`, 58 | expectedString: ` 59 | [SO] ╭╮ ╭╮ ╿ 60 | [CM] /usr/bin/echo "Hello, World!" ╡╰ /usr/bin/grep "Hello" ╡╰ /usr/bin/wc "-c" ╡ 61 | [SE] ╽ ╽ ╽ 62 | `, 63 | }, 64 | { 65 | name: "simple chain single quoted", 66 | command: `echo 'Hello, World!' | grep 'Hello' | wc -c`, 67 | expectedString: ` 68 | [SO] ╭╮ ╭╮ ╿ 69 | [CM] /usr/bin/echo "Hello, World!" ╡╰ /usr/bin/grep "Hello" ╡╰ /usr/bin/wc "-c" ╡ 70 | [SE] ╽ ╽ ╽ 71 | `, 72 | }, 73 | { 74 | name: "simple chain non-quoted", 75 | command: `echo Hello, World! | grep 'Hello' | wc -c`, 76 | expectedString: ` 77 | [SO] ╭╮ ╭╮ ╿ 78 | [CM] /usr/bin/echo "Hello," "World!" ╡╰ /usr/bin/grep "Hello" ╡╰ /usr/bin/wc "-c" ╡ 79 | [SE] ╽ ╽ ╽ 80 | `, 81 | }, 82 | { 83 | name: "forward error chain", 84 | command: `echo Hello, World! |& grep 'Hello' |& wc -c`, 85 | expectedString: ` 86 | [SO] ╭╮ ╭╮ ╿ 87 | [CM] /usr/bin/echo "Hello," "World!" ╡╞ /usr/bin/grep "Hello" ╡╞ /usr/bin/wc "-c" ╡ 88 | [SE] ╰╯ ╰╯ ╽ 89 | `, 90 | }, 91 | { 92 | name: "local environment variable", 93 | command: `MY_VAR=1 date`, 94 | expectedString: ` 95 | [SO] ╿ 96 | [CM] /usr/bin/date ╡ 97 | [SE] ╽ 98 | `, 99 | check: func(t *testing.T, chain *chain) { 100 | assert.Contains(t, chain.cmdDescriptors[0].command.Env, "MY_VAR=1") 101 | }, 102 | }, 103 | { 104 | name: "local environment variables", 105 | command: `MY_VAR=1 MY_SEC_VAR=2 date`, 106 | expectedString: ` 107 | [SO] ╿ 108 | [CM] /usr/bin/date ╡ 109 | [SE] ╽ 110 | `, 111 | check: func(t *testing.T, chain *chain) { 112 | assert.Contains(t, chain.cmdDescriptors[0].command.Env, "MY_VAR=1") 113 | assert.Contains(t, chain.cmdDescriptors[0].command.Env, "MY_SEC_VAR=2") 114 | }, 115 | }, 116 | { 117 | name: "duplicate local environment variables", 118 | command: `MY_VAR=1 MY_VAR=2 date`, 119 | expectedString: ` 120 | [SO] ╿ 121 | [CM] /usr/bin/date ╡ 122 | [SE] ╽ 123 | `, 124 | check: func(t *testing.T, chain *chain) { 125 | assert.Contains(t, chain.cmdDescriptors[0].command.Env, "MY_VAR=1") 126 | assert.Contains(t, chain.cmdDescriptors[0].command.Env, "MY_VAR=2") 127 | }, 128 | }, 129 | { 130 | name: "empty environment variable", 131 | command: `MY_VAR= date`, 132 | expectedString: ` 133 | [SO] ╿ 134 | [CM] /usr/bin/date ╡ 135 | [SE] ╽ 136 | `, 137 | check: func(t *testing.T, chain *chain) { 138 | assert.Contains(t, chain.cmdDescriptors[0].command.Env, "MY_VAR=") 139 | }, 140 | }, 141 | { 142 | name: "no statements", 143 | command: ``, 144 | expectError: "no statements", 145 | }, 146 | { 147 | name: "multiple statements", 148 | command: `date; date`, 149 | expectError: "multiple statements are not supported, found 2 statements", 150 | }, 151 | { 152 | name: "logical OR concatenation", 153 | command: `date || date`, 154 | expectError: "[1:1 - 1:13] unsupported binary operator '||' at '1:6'", 155 | }, 156 | { 157 | name: "logical AND concatenation", 158 | command: `date && date`, 159 | expectError: "[1:1 - 1:13] unsupported binary operator '&&' at '1:6'", 160 | }, 161 | { 162 | name: "background execution", 163 | command: `date &`, 164 | expectError: "background execution is not supported", 165 | }, 166 | { 167 | name: "error redirection", 168 | command: `date 2> /tmp/err | grep 'Hello'`, 169 | expectedString: ` 170 | [SO] ╭╮ ╿ 171 | [CM] /usr/bin/date ╡╰ /usr/bin/grep "Hello" ╡ 172 | [SE] │ ╽ 173 | [ES] ╰ /tmp/err 174 | `, 175 | }, 176 | { 177 | name: "error redirection (appending)", 178 | command: `date 2>> /tmp/err | grep 'Hello'`, 179 | expectedString: ` 180 | [SO] ╭╮ ╿ 181 | [CM] /usr/bin/date ╡╰ /usr/bin/grep "Hello" ╡ 182 | [SE] │ ╽ 183 | [ES] ╰ /tmp/err (appending) 184 | `, 185 | }, 186 | { 187 | name: "file redirection", 188 | command: `date > /tmp/out |& grep 'Hello'`, 189 | expectedString: ` 190 | [OS] ╭ /tmp/out 191 | [SO] ├╮ ╿ 192 | [CM] /usr/bin/date ╡╞ /usr/bin/grep "Hello" ╡ 193 | [SE] ╰╯ ╽ 194 | `, 195 | check: func(t *testing.T, c *chain) { 196 | assert.Empty(t, c.cmdDescriptors[0].errorStreams) 197 | }, 198 | }, 199 | { 200 | name: "file redirection (appending)", 201 | command: `date >> /tmp/out |& grep 'Hello'`, 202 | expectedString: ` 203 | [OS] ╭ /tmp/out (appending) 204 | [SO] ├╮ ╿ 205 | [CM] /usr/bin/date ╡╞ /usr/bin/grep "Hello" ╡ 206 | [SE] ╰╯ ╽ 207 | `, 208 | check: func(t *testing.T, c *chain) { 209 | assert.Empty(t, c.cmdDescriptors[0].errorStreams) 210 | }, 211 | }, 212 | { 213 | name: "file redirection (error)", 214 | command: `date 2> /tmp/out |& grep 'Hello'`, 215 | expectedString: ` 216 | [SO] ╭╮ ╿ 217 | [CM] /usr/bin/date ╡╞ /usr/bin/grep "Hello" ╡ 218 | [SE] ├╯ ╽ 219 | [ES] ╰ /tmp/out 220 | `, 221 | }, 222 | { 223 | name: "file redirection (error appending)", 224 | command: `date 2>> /tmp/out |& grep 'Hello'`, 225 | expectedString: ` 226 | [SO] ╭╮ ╿ 227 | [CM] /usr/bin/date ╡╞ /usr/bin/grep "Hello" ╡ 228 | [SE] ├╯ ╽ 229 | [ES] ╰ /tmp/out (appending) 230 | `, 231 | }, 232 | { 233 | name: "both file redirection", 234 | command: `date &> /tmp/out |& grep 'Hello'`, 235 | expectedString: ` 236 | [OS] ╭ /tmp/out 237 | [SO] ├╮ ╿ 238 | [CM] /usr/bin/date ╡╞ /usr/bin/grep "Hello" ╡ 239 | [SE] ├╯ ╽ 240 | [ES] ╰ /tmp/out 241 | `, 242 | }, 243 | { 244 | name: "both file redirection (appending)", 245 | command: `date &>> /tmp/out |& grep 'Hello'`, 246 | expectedString: ` 247 | [OS] ╭ /tmp/out (appending) 248 | [SO] ├╮ ╿ 249 | [CM] /usr/bin/date ╡╞ /usr/bin/grep "Hello" ╡ 250 | [SE] ├╯ ╽ 251 | [ES] ╰ /tmp/out (appending) 252 | `, 253 | }, 254 | } 255 | 256 | for _, tt := range tests { 257 | t.Run(tt.name, func(t *testing.T) { 258 | result := Builder().JoinShellCmd(tt.command).(*chain) 259 | 260 | var err error 261 | if result.buildErrors.hasError { 262 | err = result.buildErrors 263 | } 264 | 265 | if tt.expectError == "" { 266 | require.NoError(t, err) 267 | assert.Equal(t, strings.TrimSpace(tt.expectedString), strings.TrimSpace(result.String())) 268 | 269 | if tt.check != nil { 270 | tt.check(t, result) 271 | } 272 | } else { 273 | assert.Error(t, err) 274 | assert.Contains(t, err.Error(), tt.expectError) 275 | } 276 | }) 277 | } 278 | } 279 | 280 | func TestJoinShellCmd_Multiple(t *testing.T) { 281 | c := Builder(). 282 | JoinShellCmd("echo 'Hello, World!' | grep 'Hello'"). 283 | JoinShellCmd("wc -l | grep '1'") 284 | 285 | expectedString := ` 286 | [SO] ╭╮ ╭╮ ╭╮ ╿ 287 | [CM] /usr/bin/echo "Hello, World!" ╡╰ /usr/bin/grep "Hello" ╡╰ /usr/bin/wc "-l" ╡╰ /usr/bin/grep "1" ╡ 288 | [SE] ╽ ╽ ╽ ╽ 289 | ` 290 | assert.Equal(t, strings.TrimSpace(expectedString), strings.TrimSpace(c.Finalize().String())) 291 | } 292 | 293 | func TestJoinShellCmdWithContext(t *testing.T) { 294 | result := Builder().JoinShellCmdWithContext(t.Context(), `echo "hello world" | grep "hello" | wc -c`).(*chain) 295 | 296 | assert.False(t, result.buildErrors.hasError) 297 | assert.Len(t, result.cmdDescriptors, 3) 298 | for _, cmd := range result.cmdDescriptors { 299 | ctxValue := reflect.ValueOf(*cmd.command).FieldByName("ctx") 300 | assert.False(t, ctxValue.IsNil()) 301 | assert.True(t, ctxValue.Equal(reflect.ValueOf(t.Context())), "each command should have the same context") 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /string.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func (c *cmdDescriptor) String() string { 12 | out := strings.Builder{} 13 | 14 | out.WriteString(c.command.Path) 15 | for _, arg := range c.command.Args[1:] { 16 | out.WriteString(" " + strconv.Quote(arg)) 17 | } 18 | 19 | return out.String() 20 | } 21 | 22 | type stringModel struct { 23 | Chunks []modelChunk 24 | } 25 | 26 | type modelChunk struct { 27 | InputStream string 28 | OutputStream string 29 | Command string 30 | ErrorStream string 31 | 32 | Pipe pipe 33 | } 34 | 35 | func (c *modelChunk) Space() (space int) { 36 | space = len(c.InputStream) 37 | 38 | if len(c.OutputStream) > space { 39 | space = len(c.OutputStream) 40 | } 41 | if len(c.Command) > space { 42 | space = len(c.Command) 43 | } 44 | if len(c.ErrorStream) > space { 45 | space = len(c.ErrorStream) 46 | } 47 | 48 | return space 49 | } 50 | 51 | type pipe [6]string 52 | type pipeVariation struct { 53 | pipe pipe 54 | isValidFor func(current, next *cmdDescriptor) bool 55 | } 56 | 57 | func (c *cmdDescriptor) hasInputStreams() bool { return len(c.inputStreams) > 0 } 58 | func (c *cmdDescriptor) hasOutputStreams() bool { return len(c.outputStreams) > 0 } 59 | func (c *cmdDescriptor) hasErrorStreams() bool { return len(c.errorStreams) > 0 } 60 | 61 | var availablePipes = []pipeVariation{ 62 | { 63 | pipe{ 64 | " ", 65 | " ", 66 | " ╿ ", 67 | " ╡ ", 68 | " ╽ ", 69 | " ", 70 | }, 71 | func(c, n *cmdDescriptor) bool { 72 | // is last command 73 | if n != nil { 74 | return false 75 | } 76 | 77 | return !c.hasOutputStreams() && !c.hasErrorStreams() 78 | }, 79 | }, 80 | { 81 | pipe{ 82 | " ", 83 | " ╭ ", 84 | " │ ", 85 | " ╡ ", 86 | " ╽ ", 87 | " ", 88 | }, 89 | func(c, n *cmdDescriptor) bool { 90 | // is last command 91 | if n != nil { 92 | return false 93 | } 94 | 95 | return c.hasOutputStreams() && !c.hasInputStreams() && !c.hasErrorStreams() 96 | }, 97 | }, 98 | { 99 | pipe{ 100 | " ", 101 | " ", 102 | " ╿ ", 103 | " ╡ ", 104 | " │ ", 105 | " ╰ ", 106 | }, 107 | func(c, n *cmdDescriptor) bool { 108 | // is last command 109 | if n != nil { 110 | return false 111 | } 112 | 113 | return c.hasErrorStreams() && !c.hasInputStreams() && !c.hasOutputStreams() 114 | }, 115 | }, 116 | { 117 | pipe{ 118 | " ", 119 | " ╭ ", 120 | " │ ", 121 | " ╡ ", 122 | " │ ", 123 | " ╰ ", 124 | }, 125 | func(c, n *cmdDescriptor) bool { 126 | // is last command 127 | if n != nil { 128 | return false 129 | } 130 | 131 | return c.hasErrorStreams() && c.hasOutputStreams() && !c.hasInputStreams() 132 | }, 133 | }, 134 | { 135 | pipe{ 136 | " ╮ ", 137 | " │ ", 138 | " │ ", 139 | " ╰ ", 140 | " ", 141 | " ", 142 | }, 143 | func(c, n *cmdDescriptor) bool { 144 | // is first command 145 | if c != nil { 146 | return false 147 | } 148 | 149 | return n.hasInputStreams() 150 | }, 151 | }, 152 | { 153 | pipe{ 154 | " ", 155 | " ", 156 | " ╿ ", 157 | " ╡ ", 158 | " ╽ ", 159 | " ", 160 | }, 161 | func(c, n *cmdDescriptor) bool { 162 | if c == nil || n == nil { 163 | return false 164 | } 165 | 166 | return !n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && !c.errToIn && !c.hasErrorStreams() //0 167 | }, 168 | }, 169 | { 170 | pipe{ 171 | " ", 172 | " ", 173 | " ╿ ", 174 | " ╡ ", 175 | " │ ", 176 | " ╰ ", 177 | }, 178 | func(c, n *cmdDescriptor) bool { 179 | if c == nil || n == nil { 180 | return false 181 | } 182 | 183 | return !n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && !c.errToIn && c.hasErrorStreams() //1 184 | }, 185 | }, 186 | { 187 | pipe{ 188 | " ", 189 | " ", 190 | " ╿ ", 191 | " ╡╭ ", 192 | " ╰╯ ", 193 | " ", 194 | }, 195 | func(c, n *cmdDescriptor) bool { 196 | if c == nil || n == nil { 197 | return false 198 | } 199 | 200 | return !n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && c.errToIn && !c.hasErrorStreams() //2 201 | }, 202 | }, 203 | { 204 | pipe{ 205 | " ", 206 | " ", 207 | " ╿ ", 208 | " ╡╭ ", 209 | " ├╯ ", 210 | " ╰ ", 211 | }, 212 | func(c, n *cmdDescriptor) bool { 213 | if c == nil || n == nil { 214 | return false 215 | } 216 | 217 | return !n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && c.errToIn && c.hasErrorStreams() //3 218 | }, 219 | }, 220 | { 221 | pipe{ 222 | " ", 223 | " ", 224 | " ╭╮ ", 225 | " ╡╰ ", 226 | " ╽ ", 227 | " ", 228 | }, 229 | func(c, n *cmdDescriptor) bool { 230 | if c == nil || n == nil { 231 | return false 232 | } 233 | 234 | return !n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && !c.errToIn && !c.hasErrorStreams() //4 235 | }, 236 | }, 237 | { 238 | pipe{ 239 | " ", 240 | " ", 241 | " ╭╮ ", 242 | " ╡╰ ", 243 | " │ ", 244 | " ╰ ", 245 | }, 246 | func(c, n *cmdDescriptor) bool { 247 | if c == nil || n == nil { 248 | return false 249 | } 250 | 251 | return !n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && !c.errToIn && c.hasErrorStreams() //5 252 | }, 253 | }, 254 | { 255 | pipe{ 256 | " ", 257 | " ", 258 | " ╭╮ ", 259 | " ╡╞ ", 260 | " ╰╯ ", 261 | " ", 262 | }, 263 | func(c, n *cmdDescriptor) bool { 264 | if c == nil || n == nil { 265 | return false 266 | } 267 | 268 | return !n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && c.errToIn && !c.hasErrorStreams() //6 269 | }, 270 | }, 271 | { 272 | pipe{ 273 | " ", 274 | " ", 275 | " ╭╮ ", 276 | " ╡╞ ", 277 | " ├╯ ", 278 | " ╰ ", 279 | }, 280 | func(c, n *cmdDescriptor) bool { 281 | if c == nil || n == nil { 282 | return false 283 | } 284 | 285 | return !n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && c.errToIn && c.hasErrorStreams() //7 286 | }, 287 | }, 288 | { 289 | pipe{ 290 | " ", 291 | " ╭ ", 292 | " │ ", 293 | " ╡ ", 294 | " ╽ ", 295 | " ", 296 | }, 297 | func(c, n *cmdDescriptor) bool { 298 | if c == nil || n == nil { 299 | return false 300 | } 301 | 302 | return !n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && !c.errToIn && !c.hasErrorStreams() //8 303 | }, 304 | }, 305 | { 306 | pipe{ 307 | " ", 308 | " ╭ ", 309 | " │ ", 310 | " ╡ ", 311 | " │ ", 312 | " ╰ ", 313 | }, 314 | func(c, n *cmdDescriptor) bool { 315 | if c == nil || n == nil { 316 | return false 317 | } 318 | 319 | return !n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && !c.errToIn && c.hasErrorStreams() //9 320 | }, 321 | }, 322 | { 323 | pipe{ 324 | " ", 325 | " ╭ ", 326 | " │ ", 327 | " ╡╭ ", 328 | " ╰╯ ", 329 | " ", 330 | }, 331 | func(c, n *cmdDescriptor) bool { 332 | if c == nil || n == nil { 333 | return false 334 | } 335 | 336 | return !n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && c.errToIn && !c.hasErrorStreams() //10 337 | }, 338 | }, 339 | { 340 | pipe{ 341 | " ", 342 | " ╭ ", 343 | " │ ", 344 | " ╡╭ ", 345 | " ├╯ ", 346 | " ╰ ", 347 | }, 348 | func(c, n *cmdDescriptor) bool { 349 | if c == nil || n == nil { 350 | return false 351 | } 352 | 353 | return !n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && c.errToIn && c.hasErrorStreams() //11 354 | }, 355 | }, 356 | { 357 | pipe{ 358 | " ", 359 | " ╭ ", 360 | " ├╮ ", 361 | " ╡╰ ", 362 | " ╽ ", 363 | " ", 364 | }, 365 | func(c, n *cmdDescriptor) bool { 366 | if c == nil || n == nil { 367 | return false 368 | } 369 | 370 | return !n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && !c.errToIn && !c.hasErrorStreams() //12 371 | }, 372 | }, 373 | { 374 | pipe{ 375 | " ", 376 | " ╭ ", 377 | " ├╮ ", 378 | " ╡╰ ", 379 | " │ ", 380 | " ╰ ", 381 | }, 382 | func(c, n *cmdDescriptor) bool { 383 | if c == nil || n == nil { 384 | return false 385 | } 386 | 387 | return !n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && !c.errToIn && c.hasErrorStreams() //13 388 | }, 389 | }, 390 | { 391 | pipe{ 392 | " ", 393 | " ╭ ", 394 | " ├╮ ", 395 | " ╡╞ ", 396 | " ╰╯ ", 397 | " ", 398 | }, 399 | func(c, n *cmdDescriptor) bool { 400 | if c == nil || n == nil { 401 | return false 402 | } 403 | 404 | return !n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && c.errToIn && !c.hasErrorStreams() //14 405 | }, 406 | }, 407 | { 408 | pipe{ 409 | " ", 410 | " ╭ ", 411 | " ├╮ ", 412 | " ╡╞ ", 413 | " ├╯ ", 414 | " ╰ ", 415 | }, 416 | func(c, n *cmdDescriptor) bool { 417 | if c == nil || n == nil { 418 | return false 419 | } 420 | 421 | return !n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && c.errToIn && c.hasErrorStreams() //15 422 | }, 423 | }, 424 | { 425 | pipe{ 426 | " ╮ ", 427 | " │ ", 428 | " ╿│ ", 429 | " ╡╰ ", 430 | " ╽ ", 431 | " ", 432 | }, 433 | func(c, n *cmdDescriptor) bool { 434 | if c == nil || n == nil { 435 | return false 436 | } 437 | 438 | return n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && !c.errToIn && !c.hasErrorStreams() //16 439 | }, 440 | }, 441 | { 442 | pipe{ 443 | " ╮ ", 444 | " │ ", 445 | " ╿│ ", 446 | " ╡╰ ", 447 | " │ ", 448 | " ╰ ", 449 | }, 450 | func(c, n *cmdDescriptor) bool { 451 | if c == nil || n == nil { 452 | return false 453 | } 454 | 455 | return n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && !c.errToIn && c.hasErrorStreams() //17 456 | }, 457 | }, 458 | { 459 | pipe{ 460 | " ╮ ", 461 | " │ ", 462 | " ╿│ ", 463 | " ╡╞ ", 464 | " ╰╯ ", 465 | " ", 466 | }, 467 | func(c, n *cmdDescriptor) bool { 468 | if c == nil || n == nil { 469 | return false 470 | } 471 | 472 | return n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && c.errToIn && !c.hasErrorStreams() //18 473 | }, 474 | }, 475 | { 476 | pipe{ 477 | " ╮ ", 478 | " │ ", 479 | " ╿│ ", 480 | " ╡╞ ", 481 | " ├╯ ", 482 | " ╰ ", 483 | }, 484 | func(c, n *cmdDescriptor) bool { 485 | if c == nil || n == nil { 486 | return false 487 | } 488 | 489 | return n.hasInputStreams() && !c.hasOutputStreams() && !c.outToIn && c.errToIn && c.hasErrorStreams() //19 490 | }, 491 | }, 492 | { 493 | pipe{ 494 | " ╮ ", 495 | " │ ", 496 | " ├╮ ", 497 | " ╡╰ ", 498 | " ╽ ", 499 | " ", 500 | }, 501 | func(c, n *cmdDescriptor) bool { 502 | if c == nil || n == nil { 503 | return false 504 | } 505 | 506 | return n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && !c.errToIn && !c.hasErrorStreams() //20 507 | }, 508 | }, 509 | { 510 | pipe{ 511 | " ╮ ", 512 | " │ ", 513 | " ├╮ ", 514 | " ╡╰ ", 515 | " │ ", 516 | " ╰ ", 517 | }, 518 | func(c, n *cmdDescriptor) bool { 519 | if c == nil || n == nil { 520 | return false 521 | } 522 | 523 | return n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && !c.errToIn && c.hasErrorStreams() //21 524 | }, 525 | }, 526 | { 527 | pipe{ 528 | " ╮ ", 529 | " │ ", 530 | " ├╮ ", 531 | " ╡╞ ", 532 | " ╰╯ ", 533 | " ", 534 | }, 535 | func(c, n *cmdDescriptor) bool { 536 | if c == nil || n == nil { 537 | return false 538 | } 539 | 540 | return n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && c.errToIn && !c.hasErrorStreams() //22 541 | }, 542 | }, 543 | { 544 | pipe{ 545 | " ╮ ", 546 | " │ ", 547 | " ├╮ ", 548 | " ╡╞ ", 549 | " ├╯ ", 550 | " ╰ ", 551 | }, 552 | func(c, n *cmdDescriptor) bool { 553 | if c == nil || n == nil { 554 | return false 555 | } 556 | 557 | return n.hasInputStreams() && !c.hasOutputStreams() && c.outToIn && c.errToIn && c.hasErrorStreams() //23 558 | }, 559 | }, 560 | { 561 | pipe{ 562 | " ╮ ", 563 | " │╭─ ", 564 | " ╰┿╮ ", 565 | " ═╡╰ ", 566 | " ╽ ", 567 | " ", 568 | }, 569 | func(c, n *cmdDescriptor) bool { 570 | if c == nil || n == nil { 571 | return false 572 | } 573 | 574 | return n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && !c.errToIn && !c.hasErrorStreams() //24 575 | }, 576 | }, 577 | { 578 | pipe{ 579 | " ╮ ", 580 | " │╭─ ", 581 | " ╰┿╮ ", 582 | " ═╡╰ ", 583 | " │ ", 584 | " ╰ ", 585 | }, 586 | func(c, n *cmdDescriptor) bool { 587 | if c == nil || n == nil { 588 | return false 589 | } 590 | 591 | return n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && !c.errToIn && c.hasErrorStreams() //25 592 | }, 593 | }, 594 | { 595 | pipe{ 596 | " ╮ ", 597 | " │╭─ ", 598 | " ╰┿╮ ", 599 | " ═╡╞ ", 600 | " ╰╯ ", 601 | " ", 602 | }, 603 | func(c, n *cmdDescriptor) bool { 604 | if c == nil || n == nil { 605 | return false 606 | } 607 | 608 | return n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && c.errToIn && !c.hasErrorStreams() //26 609 | }, 610 | }, 611 | { 612 | pipe{ 613 | " ╮ ", 614 | " │╭─ ", 615 | " ╰┿╮ ", 616 | " ═╡╞ ", 617 | " ├╯ ", 618 | " ╰ ", 619 | }, 620 | func(c, n *cmdDescriptor) bool { 621 | if c == nil || n == nil { 622 | return false 623 | } 624 | 625 | return n.hasInputStreams() && c.hasOutputStreams() && !c.outToIn && c.errToIn && c.hasErrorStreams() //27 626 | }, 627 | }, 628 | { 629 | pipe{ 630 | " ╮ ", 631 | " │╭─ ", 632 | " ╰┼╮ ", 633 | " ═╡╰ ", 634 | " ╽ ", 635 | " ", 636 | }, 637 | func(c, n *cmdDescriptor) bool { 638 | if c == nil || n == nil { 639 | return false 640 | } 641 | 642 | return n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && !c.errToIn && !c.hasErrorStreams() //28 643 | }, 644 | }, 645 | { 646 | pipe{ 647 | " ╮ ", 648 | " │╭─ ", 649 | " ╰┼╮ ", 650 | " ═╡╰ ", 651 | " │ ", 652 | " ╰ ", 653 | }, 654 | func(c, n *cmdDescriptor) bool { 655 | if c == nil || n == nil { 656 | return false 657 | } 658 | 659 | return n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && !c.errToIn && c.hasErrorStreams() //29 660 | }, 661 | }, 662 | { 663 | pipe{ 664 | " ╮ ", 665 | " │╭─ ", 666 | " ╰┼╮ ", 667 | " ═╡╞ ", 668 | " ╰╯ ", 669 | " ", 670 | }, 671 | func(c, n *cmdDescriptor) bool { 672 | if c == nil || n == nil { 673 | return false 674 | } 675 | 676 | return n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && c.errToIn && !c.hasErrorStreams() //30 677 | }, 678 | }, 679 | { 680 | pipe{ 681 | " ╮ ", 682 | " ├─ ", 683 | " ├╮ ", 684 | " ╡╞ ", 685 | " ├╯ ", 686 | " ╰ ", 687 | }, 688 | func(c, n *cmdDescriptor) bool { 689 | if c == nil || n == nil { 690 | return false 691 | } 692 | 693 | return n.hasInputStreams() && c.hasOutputStreams() && c.outToIn && c.errToIn && c.hasErrorStreams() //31 694 | }, 695 | }, 696 | { 697 | pipe{"", "", "", "", "", ""}, 698 | func(c, n *cmdDescriptor) bool { return true }, 699 | }, 700 | } 701 | 702 | func findPipe(c, n *cmdDescriptor) pipe { 703 | for _, p := range availablePipes { 704 | if p.isValidFor(c, n) { 705 | return p.pipe 706 | } 707 | } 708 | 709 | //should never happen 710 | return pipe{} 711 | } 712 | 713 | func (c *chain) toStringModel() stringModel { 714 | model := stringModel{ 715 | Chunks: make([]modelChunk, len(c.cmdDescriptors)+2, len(c.cmdDescriptors)+2), 716 | } 717 | model.Chunks[0].Pipe = findPipe(nil, &c.cmdDescriptors[0]) 718 | 719 | for i, cmdDesc := range c.cmdDescriptors { 720 | i++ 721 | 722 | //isFirst := i == 1 723 | isLast := i == len(c.cmdDescriptors) 724 | prevChunk := &model.Chunks[i-1] 725 | curChunk := &model.Chunks[i] 726 | nextChunk := &model.Chunks[i+1] 727 | 728 | //// 729 | // input stream line 730 | //// 731 | if len(cmdDesc.inputStreams) > 0 { 732 | streamTypes := make([]string, len(cmdDesc.inputStreams), len(cmdDesc.inputStreams)) 733 | for j, inputStream := range cmdDesc.inputStreams { 734 | streamTypes[j] = streamString(inputStream) 735 | } 736 | prevChunk.InputStream = strings.Join(streamTypes, ", ") 737 | } 738 | 739 | //// 740 | // output stream line 741 | //// 742 | if len(cmdDesc.outputStreams) > 0 { 743 | streamTypes := make([]string, len(cmdDesc.outputStreams), len(cmdDesc.outputStreams)) 744 | for j, outputStream := range cmdDesc.outputStreams { 745 | streamTypes[j] = streamString(outputStream) 746 | } 747 | nextChunk.OutputStream = strings.Join(streamTypes, ", ") 748 | 749 | } 750 | 751 | //// 752 | // command line 753 | //// 754 | curChunk.Command = cmdDesc.String() 755 | 756 | //// 757 | // error stream line 758 | //// 759 | if len(cmdDesc.errorStreams) > 0 { 760 | streamTypes := make([]string, len(cmdDesc.errorStreams), len(cmdDesc.errorStreams)) 761 | for j, errorStream := range cmdDesc.errorStreams { 762 | streamTypes[j] = streamString(errorStream) 763 | } 764 | nextChunk.ErrorStream = strings.Join(streamTypes, ", ") 765 | } 766 | 767 | //// 768 | // pipes 769 | //// 770 | if !isLast { 771 | curChunk.Pipe = findPipe(&cmdDesc, &c.cmdDescriptors[i]) 772 | } else { 773 | curChunk.Pipe = findPipe(&cmdDesc, nil) 774 | } 775 | } 776 | 777 | return model 778 | } 779 | 780 | func streamString(stream any) (s string) { 781 | if stringer, ok := stream.(fmt.Stringer); ok { 782 | s = stringer.String() 783 | } 784 | if len(s) == 0 { 785 | if file, ok := stream.(*os.File); ok { 786 | s = file.Name() 787 | } 788 | } 789 | if len(s) == 0 { 790 | s = fmt.Sprintf("%s", reflect.TypeOf(stream)) 791 | } 792 | 793 | return 794 | } 795 | 796 | func (s *stringModel) String() string { 797 | inStreamLane := &strings.Builder{} 798 | outStreamLane := &strings.Builder{} 799 | outLane := &strings.Builder{} 800 | cmdLane := &strings.Builder{} 801 | errLane := &strings.Builder{} 802 | errStreamLane := &strings.Builder{} 803 | 804 | // we should have at least three chunks 805 | if len(s.Chunks) < 3 { 806 | return "" 807 | } 808 | 809 | for _, chunk := range s.Chunks { 810 | chunkSpace := chunk.Space() 811 | 812 | inStreamLane.WriteString(strings.Repeat(" ", chunkSpace-len(chunk.InputStream))) 813 | inStreamLane.WriteString(chunk.InputStream) 814 | inStreamLane.WriteString(chunk.Pipe[0]) 815 | 816 | outStreamLane.WriteString(chunk.OutputStream) 817 | outStreamLane.WriteString(strings.Repeat(" ", chunkSpace-len(chunk.OutputStream))) 818 | outStreamLane.WriteString(chunk.Pipe[1]) 819 | 820 | outLane.WriteString(strings.Repeat(" ", chunkSpace)) 821 | outLane.WriteString(chunk.Pipe[2]) 822 | 823 | cmdLane.WriteString(strings.Repeat(" ", chunkSpace-len(chunk.Command))) 824 | cmdLane.WriteString(chunk.Command) 825 | cmdLane.WriteString(chunk.Pipe[3]) 826 | 827 | errLane.WriteString(strings.Repeat(" ", chunkSpace)) 828 | errLane.WriteString(chunk.Pipe[4]) 829 | 830 | errStreamLane.WriteString(chunk.ErrorStream) 831 | errStreamLane.WriteString(strings.Repeat(" ", chunkSpace-len(chunk.ErrorStream))) 832 | errStreamLane.WriteString(chunk.Pipe[5]) 833 | } 834 | 835 | result := "" 836 | 837 | if len(strings.TrimSpace(inStreamLane.String())) > 0 { 838 | result += "[IS] " 839 | result += strings.TrimRight(inStreamLane.String(), " ") + "\n" 840 | } 841 | if len(strings.TrimSpace(outStreamLane.String())) > 0 { 842 | result += "[OS] " 843 | result += strings.TrimRight(outStreamLane.String(), " ") + "\n" 844 | } 845 | if len(strings.TrimSpace(outLane.String())) > 0 { 846 | result += "[SO] " 847 | result += strings.TrimRight(outLane.String(), " ") + "\n" 848 | } 849 | result += "[CM] " 850 | result += strings.TrimRight(cmdLane.String(), " ") 851 | if len(strings.TrimSpace(errLane.String())) > 0 { 852 | result += "\n[SE] " 853 | result += strings.TrimRight(errLane.String(), " ") 854 | } 855 | if len(strings.TrimSpace(errStreamLane.String())) > 0 { 856 | result += "\n[ES] " 857 | result += strings.TrimRight(errStreamLane.String(), " ") 858 | } 859 | 860 | return result 861 | } 862 | 863 | func (c *chain) String() string { 864 | model := c.toStringModel() 865 | return model.String() 866 | } 867 | -------------------------------------------------------------------------------- /string_test.go: -------------------------------------------------------------------------------- 1 | package cmdchain 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/stretchr/testify/assert" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestChain_String(t *testing.T) { 12 | tests := []struct { 13 | c FinalizedBuilder 14 | e string 15 | }{ 16 | { 17 | c: Builder().Join("echo", "hello world").Finalize(), 18 | e: ` 19 | [SO] ╿ 20 | [CM] /usr/bin/echo "hello world" ╡ 21 | [SE] ╽ 22 | `, 23 | }, 24 | { 25 | c: Builder().Join("echo", `hello "world"`).Finalize(), 26 | e: ` 27 | [SO] ╿ 28 | [CM] /usr/bin/echo "hello \"world\"" ╡ 29 | [SE] ╽ 30 | `, 31 | }, 32 | { 33 | c: Builder(). 34 | Join("echo", "hello world"). 35 | Finalize().WithOutput(&bytes.Buffer{}), 36 | e: ` 37 | [OS] ╭ *bytes.Buffer 38 | [SO] │ 39 | [CM] /usr/bin/echo "hello world" ╡ 40 | [SE] ╽ 41 | `, 42 | }, 43 | { 44 | c: Builder(). 45 | Join("echo", "hello world"). 46 | Finalize().WithOutput(&bytes.Buffer{}, &bytes.Buffer{}), 47 | e: ` 48 | [OS] ╭ *bytes.Buffer, *bytes.Buffer 49 | [SO] │ 50 | [CM] /usr/bin/echo "hello world" ╡ 51 | [SE] ╽ 52 | `, 53 | }, 54 | { 55 | c: Builder(). 56 | Join("echo", "hello world"). 57 | Finalize().WithError(&bytes.Buffer{}), 58 | e: ` 59 | [SO] ╿ 60 | [CM] /usr/bin/echo "hello world" ╡ 61 | [SE] │ 62 | [ES] ╰ *bytes.Buffer 63 | `, 64 | }, 65 | { 66 | c: Builder(). 67 | Join("echo", "hello world"). 68 | Finalize().WithOutput(&bytes.Buffer{}).WithError(&bytes.Buffer{}), 69 | e: ` 70 | [OS] ╭ *bytes.Buffer 71 | [SO] │ 72 | [CM] /usr/bin/echo "hello world" ╡ 73 | [SE] │ 74 | [ES] ╰ *bytes.Buffer 75 | `, 76 | }, 77 | { 78 | c: Builder(). 79 | Join("echo", "hello world").WithInjections(&bytes.Buffer{}). 80 | Finalize(), 81 | e: ` 82 | [IS] *bytes.Buffer ╮ 83 | [OS] │ 84 | [SO] │ ╿ 85 | [CM] ╰ /usr/bin/echo "hello world" ╡ 86 | [SE] ╽ 87 | `, 88 | }, 89 | { 90 | c: Builder(). 91 | Join("echo", "hello world").WithInjections(&bytes.Buffer{}, &bytes.Buffer{}). 92 | Finalize(), 93 | e: ` 94 | [IS] *bytes.Buffer, *bytes.Buffer ╮ 95 | [OS] │ 96 | [SO] │ ╿ 97 | [CM] ╰ /usr/bin/echo "hello world" ╡ 98 | [SE] ╽ 99 | `, 100 | }, 101 | { 102 | c: Builder().WithInput(&bytes.Buffer{}). 103 | Join("echo", "hello world"). 104 | Finalize(), 105 | e: ` 106 | [IS] *bytes.Buffer ╮ 107 | [OS] │ 108 | [SO] │ ╿ 109 | [CM] ╰ /usr/bin/echo "hello world" ╡ 110 | [SE] ╽ 111 | `, 112 | }, 113 | { 114 | c: Builder(). 115 | Join("echo", "hello world").DiscardStdOut(). 116 | Join("grep", "hello"). 117 | Finalize(), 118 | e: ` 119 | [SO] ╿ ╿ 120 | [CM] /usr/bin/echo "hello world" ╡ /usr/bin/grep "hello" ╡ 121 | [SE] ╽ ╽ 122 | `, 123 | }, 124 | { 125 | c: Builder(). 126 | Join("echo", "hello world").DiscardStdOut().WithErrorForks(&bytes.Buffer{}). 127 | Join("grep", "hello"). 128 | Finalize(), 129 | e: ` 130 | [SO] ╿ ╿ 131 | [CM] /usr/bin/echo "hello world" ╡ /usr/bin/grep "hello" ╡ 132 | [SE] │ ╽ 133 | [ES] ╰ *bytes.Buffer 134 | `, 135 | }, 136 | { 137 | c: Builder(). 138 | Join("echo", "hello world").DiscardStdOut().ForwardError(). 139 | Join("grep", "hello"). 140 | Finalize(), 141 | e: ` 142 | [SO] ╿ ╿ 143 | [CM] /usr/bin/echo "hello world" ╡╭ /usr/bin/grep "hello" ╡ 144 | [SE] ╰╯ ╽ 145 | `, 146 | }, 147 | { 148 | c: Builder(). 149 | Join("echo", "hello world").DiscardStdOut().ForwardError().WithErrorForks(&bytes.Buffer{}). 150 | Join("grep", "hello"). 151 | Finalize(), 152 | e: ` 153 | [SO] ╿ ╿ 154 | [CM] /usr/bin/echo "hello world" ╡╭ /usr/bin/grep "hello" ╡ 155 | [SE] ├╯ ╽ 156 | [ES] ╰ *bytes.Buffer 157 | `, 158 | }, 159 | { 160 | c: Builder(). 161 | Join("echo", "hello world"). 162 | Join("grep", "hello"). 163 | Finalize(), 164 | e: ` 165 | [SO] ╭╮ ╿ 166 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 167 | [SE] ╽ ╽ 168 | `, 169 | }, 170 | { 171 | c: Builder(). 172 | Join("echo", "hello world").WithErrorForks(&bytes.Buffer{}). 173 | Join("grep", "hello"). 174 | Finalize(), 175 | e: ` 176 | [SO] ╭╮ ╿ 177 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 178 | [SE] │ ╽ 179 | [ES] ╰ *bytes.Buffer 180 | `, 181 | }, 182 | { 183 | c: Builder(). 184 | Join("echo", "hello world").ForwardError(). 185 | Join("grep", "hello"). 186 | Finalize(), 187 | e: ` 188 | [SO] ╭╮ ╿ 189 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 190 | [SE] ╰╯ ╽ 191 | `, 192 | }, 193 | { 194 | c: Builder(). 195 | Join("echo", "hello world").ForwardError().WithErrorForks(&bytes.Buffer{}). 196 | Join("grep", "hello"). 197 | Finalize(), 198 | e: ` 199 | [SO] ╭╮ ╿ 200 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 201 | [SE] ├╯ ╽ 202 | [ES] ╰ *bytes.Buffer 203 | `, 204 | }, 205 | { 206 | c: Builder(). 207 | Join("echo", "hello world").DiscardStdOut().WithOutputForks(&bytes.Buffer{}). 208 | Join("grep", "hello"). 209 | Finalize(), 210 | e: ` 211 | [OS] ╭ *bytes.Buffer 212 | [SO] │ ╿ 213 | [CM] /usr/bin/echo "hello world" ╡ /usr/bin/grep "hello" ╡ 214 | [SE] ╽ ╽ 215 | `, 216 | }, 217 | { 218 | c: Builder(). 219 | Join("echo", "hello world").DiscardStdOut().WithOutputForks(&bytes.Buffer{}).WithErrorForks(&bytes.Buffer{}). 220 | Join("grep", "hello"). 221 | Finalize(), 222 | e: ` 223 | [OS] ╭ *bytes.Buffer 224 | [SO] │ ╿ 225 | [CM] /usr/bin/echo "hello world" ╡ /usr/bin/grep "hello" ╡ 226 | [SE] │ ╽ 227 | [ES] ╰ *bytes.Buffer 228 | `, 229 | }, 230 | { 231 | c: Builder(). 232 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).DiscardStdOut().ForwardError(). 233 | Join("grep", "hello"). 234 | Finalize(), 235 | e: ` 236 | [OS] ╭ *bytes.Buffer 237 | [SO] │ ╿ 238 | [CM] /usr/bin/echo "hello world" ╡╭ /usr/bin/grep "hello" ╡ 239 | [SE] ╰╯ ╽ 240 | `, 241 | }, 242 | { 243 | c: Builder(). 244 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).DiscardStdOut().ForwardError().WithErrorForks(&bytes.Buffer{}). 245 | Join("grep", "hello"). 246 | Finalize(), 247 | e: ` 248 | [OS] ╭ *bytes.Buffer 249 | [SO] │ ╿ 250 | [CM] /usr/bin/echo "hello world" ╡╭ /usr/bin/grep "hello" ╡ 251 | [SE] ├╯ ╽ 252 | [ES] ╰ *bytes.Buffer 253 | `, 254 | }, 255 | { 256 | c: Builder(). 257 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}). 258 | Join("grep", "hello"). 259 | Finalize(), 260 | e: ` 261 | [OS] ╭ *bytes.Buffer 262 | [SO] ├╮ ╿ 263 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 264 | [SE] ╽ ╽ 265 | `, 266 | }, 267 | { 268 | c: Builder(). 269 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).WithErrorForks(&bytes.Buffer{}). 270 | Join("grep", "hello"). 271 | Finalize(), 272 | e: ` 273 | [OS] ╭ *bytes.Buffer 274 | [SO] ├╮ ╿ 275 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 276 | [SE] │ ╽ 277 | [ES] ╰ *bytes.Buffer 278 | `, 279 | }, 280 | { 281 | c: Builder(). 282 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).ForwardError(). 283 | Join("grep", "hello"). 284 | Finalize(), 285 | e: ` 286 | [OS] ╭ *bytes.Buffer 287 | [SO] ├╮ ╿ 288 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 289 | [SE] ╰╯ ╽ 290 | `, 291 | }, 292 | { 293 | c: Builder(). 294 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).ForwardError().WithErrorForks(&bytes.Buffer{}). 295 | Join("grep", "hello"). 296 | Finalize(), 297 | e: ` 298 | [OS] ╭ *bytes.Buffer 299 | [SO] ├╮ ╿ 300 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 301 | [SE] ├╯ ╽ 302 | [ES] ╰ *bytes.Buffer 303 | `, 304 | }, 305 | { 306 | c: Builder(). 307 | Join("echo", "hello world").DiscardStdOut(). 308 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 309 | Finalize(), 310 | e: ` 311 | [IS] *bytes.Buffer ╮ 312 | [OS] │ 313 | [SO] ╿│ ╿ 314 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 315 | [SE] ╽ ╽ 316 | `, 317 | }, 318 | { 319 | c: Builder(). 320 | Join("echo", "hello world").DiscardStdOut().WithErrorForks(&bytes.Buffer{}). 321 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 322 | Finalize(), 323 | e: ` 324 | [IS] *bytes.Buffer ╮ 325 | [OS] │ 326 | [SO] ╿│ ╿ 327 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 328 | [SE] │ ╽ 329 | [ES] ╰ *bytes.Buffer 330 | `, 331 | }, 332 | { 333 | c: Builder(). 334 | Join("echo", "hello world").DiscardStdOut().ForwardError(). 335 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 336 | Finalize(), 337 | e: ` 338 | [IS] *bytes.Buffer ╮ 339 | [OS] │ 340 | [SO] ╿│ ╿ 341 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 342 | [SE] ╰╯ ╽ 343 | `, 344 | }, 345 | { 346 | c: Builder(). 347 | Join("echo", "hello world").DiscardStdOut().ForwardError(). 348 | Join("grep", "hello").WithInjections(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}). 349 | Finalize(), 350 | e: ` 351 | [IS] *bytes.Buffer, *bytes.Buffer, *bytes.Buffer ╮ 352 | [OS] │ 353 | [SO] ╿│ ╿ 354 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 355 | [SE] ╰╯ ╽ 356 | `, 357 | }, 358 | { 359 | c: Builder(). 360 | Join("echo", "hello world").DiscardStdOut().ForwardError().WithErrorForks(&bytes.Buffer{}). 361 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 362 | Finalize(), 363 | e: ` 364 | [IS] *bytes.Buffer ╮ 365 | [OS] │ 366 | [SO] ╿│ ╿ 367 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 368 | [SE] ├╯ ╽ 369 | [ES] ╰ *bytes.Buffer 370 | `, 371 | }, 372 | { 373 | c: Builder(). 374 | Join("echo", "hello world"). 375 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 376 | Finalize(), 377 | e: ` 378 | [IS] *bytes.Buffer ╮ 379 | [OS] │ 380 | [SO] ├╮ ╿ 381 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 382 | [SE] ╽ ╽ 383 | `, 384 | }, 385 | { 386 | c: Builder(). 387 | Join("echo", "hello world").WithErrorForks(&bytes.Buffer{}). 388 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 389 | Finalize(), 390 | e: ` 391 | [IS] *bytes.Buffer ╮ 392 | [OS] │ 393 | [SO] ├╮ ╿ 394 | [CM] /usr/bin/echo "hello world" ╡╰ /usr/bin/grep "hello" ╡ 395 | [SE] │ ╽ 396 | [ES] ╰ *bytes.Buffer 397 | `, 398 | }, 399 | { 400 | c: Builder(). 401 | Join("echo", "hello world").ForwardError(). 402 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 403 | Finalize(), 404 | e: ` 405 | [IS] *bytes.Buffer ╮ 406 | [OS] │ 407 | [SO] ├╮ ╿ 408 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 409 | [SE] ╰╯ ╽ 410 | `, 411 | }, 412 | { 413 | c: Builder(). 414 | Join("echo", "hello world").ForwardError().WithErrorForks(&bytes.Buffer{}). 415 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 416 | Finalize(), 417 | e: ` 418 | [IS] *bytes.Buffer ╮ 419 | [OS] │ 420 | [SO] ├╮ ╿ 421 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 422 | [SE] ├╯ ╽ 423 | [ES] ╰ *bytes.Buffer 424 | `, 425 | }, 426 | { 427 | c: Builder(). 428 | Join("echo", "hello world").DiscardStdOut().WithOutputForks(&bytes.Buffer{}). 429 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 430 | Finalize(), 431 | e: ` 432 | [IS] *bytes.Buffer ╮ 433 | [OS] │╭─ *bytes.Buffer 434 | [SO] ╰┿╮ ╿ 435 | [CM] /usr/bin/echo "hello world" ═╡╰ /usr/bin/grep "hello" ╡ 436 | [SE] ╽ ╽ 437 | `, 438 | }, 439 | { 440 | c: Builder(). 441 | Join("echo", "hello world").DiscardStdOut().WithOutputForks(&bytes.Buffer{}).WithErrorForks(&bytes.Buffer{}). 442 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 443 | Finalize(), 444 | e: ` 445 | [IS] *bytes.Buffer ╮ 446 | [OS] │╭─ *bytes.Buffer 447 | [SO] ╰┿╮ ╿ 448 | [CM] /usr/bin/echo "hello world" ═╡╰ /usr/bin/grep "hello" ╡ 449 | [SE] │ ╽ 450 | [ES] ╰ *bytes.Buffer 451 | `, 452 | }, 453 | { 454 | c: Builder(). 455 | Join("echo", "hello world").DiscardStdOut().WithOutputForks(&bytes.Buffer{}).ForwardError(). 456 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 457 | Finalize(), 458 | e: ` 459 | [IS] *bytes.Buffer ╮ 460 | [OS] │╭─ *bytes.Buffer 461 | [SO] ╰┿╮ ╿ 462 | [CM] /usr/bin/echo "hello world" ═╡╞ /usr/bin/grep "hello" ╡ 463 | [SE] ╰╯ ╽ 464 | `, 465 | }, 466 | { 467 | c: Builder(). 468 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).DiscardStdOut().ForwardError().WithErrorForks(&bytes.Buffer{}). 469 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 470 | Finalize(), 471 | e: ` 472 | [IS] *bytes.Buffer ╮ 473 | [OS] │╭─ *bytes.Buffer 474 | [SO] ╰┿╮ ╿ 475 | [CM] /usr/bin/echo "hello world" ═╡╞ /usr/bin/grep "hello" ╡ 476 | [SE] ├╯ ╽ 477 | [ES] ╰ *bytes.Buffer 478 | `, 479 | }, 480 | { 481 | c: Builder(). 482 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}). 483 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 484 | Finalize(), 485 | e: ` 486 | [IS] *bytes.Buffer ╮ 487 | [OS] │╭─ *bytes.Buffer 488 | [SO] ╰┼╮ ╿ 489 | [CM] /usr/bin/echo "hello world" ═╡╰ /usr/bin/grep "hello" ╡ 490 | [SE] ╽ ╽ 491 | `, 492 | }, 493 | { 494 | c: Builder(). 495 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).WithErrorForks(&bytes.Buffer{}). 496 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 497 | Finalize(), 498 | e: ` 499 | [IS] *bytes.Buffer ╮ 500 | [OS] │╭─ *bytes.Buffer 501 | [SO] ╰┼╮ ╿ 502 | [CM] /usr/bin/echo "hello world" ═╡╰ /usr/bin/grep "hello" ╡ 503 | [SE] │ ╽ 504 | [ES] ╰ *bytes.Buffer 505 | `, 506 | }, 507 | { 508 | c: Builder(). 509 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).ForwardError(). 510 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 511 | Finalize(), 512 | e: ` 513 | [IS] *bytes.Buffer ╮ 514 | [OS] │╭─ *bytes.Buffer 515 | [SO] ╰┼╮ ╿ 516 | [CM] /usr/bin/echo "hello world" ═╡╞ /usr/bin/grep "hello" ╡ 517 | [SE] ╰╯ ╽ 518 | `, 519 | }, 520 | { 521 | c: Builder(). 522 | Join("echo", "hello world").WithOutputForks(&bytes.Buffer{}).ForwardError().WithErrorForks(&bytes.Buffer{}). 523 | Join("grep", "hello").WithInjections(&bytes.Buffer{}). 524 | Finalize(), 525 | e: ` 526 | [IS] *bytes.Buffer ╮ 527 | [OS] ├─ *bytes.Buffer 528 | [SO] ├╮ ╿ 529 | [CM] /usr/bin/echo "hello world" ╡╞ /usr/bin/grep "hello" ╡ 530 | [SE] ├╯ ╽ 531 | [ES] ╰ *bytes.Buffer 532 | `, 533 | }, 534 | } 535 | 536 | for i, tt := range tests { 537 | t.Run(fmt.Sprintf("TestChain_String_%d", i), func(t *testing.T) { 538 | expected := "" 539 | 540 | for _, line := range strings.Split(tt.e, "\n") { 541 | if len(strings.TrimSpace(line)) == 0 { 542 | continue 543 | } 544 | 545 | if expected != "" { 546 | expected += "\n" 547 | } 548 | expected += line 549 | } 550 | 551 | given := tt.c.String() 552 | assert.Equal(t, expected, given) 553 | }) 554 | } 555 | } 556 | -------------------------------------------------------------------------------- /test_helper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // This is a helper application which can made some stdout and stderr outputs. 13 | // It will be used in the chain_test.go and is not part of the library. It exists 14 | // only for test purposes. 15 | 16 | func main() { 17 | toErr := flag.String("e", "", "write this value to stderr") 18 | toOut := flag.String("o", "", "write this value to stdout") 19 | tickOut := flag.Duration("to", 0, "write one line at out per interval (see -ti) for X time") 20 | tickErr := flag.Duration("te", 0, "write one line at err per interval (see -ti) for X time") 21 | tickInt := flag.Duration("ti", 1*time.Second, "in which interval should the lines be written") 22 | printEnv := flag.Bool("pe", false, "print environment variables to stdout") 23 | printWorkDir := flag.Bool("pwd", false, "print the current working directory to stdout") 24 | exitCode := flag.Int("x", 0, "the exit code") 25 | 26 | flag.Parse() 27 | 28 | sigs := make(chan os.Signal, 1) 29 | signal.Notify(sigs) 30 | go func() { 31 | <-sigs 32 | os.Exit(125) 33 | }() 34 | 35 | if toErr != nil && *toErr != "" { 36 | println(*toErr) 37 | } 38 | if toOut != nil && *toOut != "" { 39 | fmt.Println(*toOut) 40 | } 41 | if *printEnv { 42 | env := os.Environ() 43 | for _, curEnv := range env { 44 | fmt.Println(curEnv) 45 | } 46 | } 47 | if *printWorkDir { 48 | wd, _ := os.Getwd() 49 | fmt.Println(wd) 50 | } 51 | 52 | wg := sync.WaitGroup{} 53 | 54 | handleOut(tickOut, tickInt, &wg) 55 | handleErr(tickErr, tickInt, &wg) 56 | 57 | wg.Wait() 58 | 59 | if exitCode != nil { 60 | os.Exit(*exitCode) 61 | } 62 | } 63 | 64 | func handleOut(tickOut *time.Duration, tickInt *time.Duration, wg *sync.WaitGroup) { 65 | if tickOut != nil && *tickOut != 0 { 66 | timer := time.NewTimer(*tickOut) 67 | ticker := time.NewTicker(*tickInt) 68 | 69 | wg.Add(1) 70 | go func() { 71 | defer wg.Done() 72 | 73 | outLoop: 74 | for { 75 | select { 76 | case <-ticker.C: 77 | fmt.Fprintf(os.Stdout, "OUT\n") 78 | case <-timer.C: 79 | break outLoop 80 | } 81 | } 82 | }() 83 | } 84 | } 85 | 86 | func handleErr(tickErr *time.Duration, tickInt *time.Duration, wg *sync.WaitGroup) { 87 | if tickErr != nil && *tickErr != 0 { 88 | timer := time.NewTimer(*tickErr) 89 | ticker := time.NewTicker(*tickInt) 90 | 91 | wg.Add(1) 92 | go func() { 93 | defer wg.Done() 94 | 95 | errLoop: 96 | for { 97 | select { 98 | case <-ticker.C: 99 | fmt.Fprintf(os.Stderr, "ERR\n") 100 | case <-timer.C: 101 | break errLoop 102 | } 103 | } 104 | }() 105 | } 106 | } 107 | --------------------------------------------------------------------------------