├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitmodules ├── LICENSE ├── README.md ├── cmd └── mqtt-shell │ └── main.go ├── doc └── example.gif ├── go.mod ├── go.sum └── internal ├── config ├── config.go ├── config_test.go ├── directory.go ├── directory_test.go ├── help.go └── model.go └── io ├── commands.go ├── help.go ├── interpreter.go ├── interpreter_test.go ├── macros.go ├── macros_test.go ├── mocks ├── ioWriter.go ├── mqttClient.go ├── mqttMessage.go └── mqttToken.go ├── processor.go ├── processor_test.go ├── rich.go ├── richt_test.go ├── shell.go ├── shell_test.go ├── writer.go └── writer_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | strategy: 14 | matrix: 15 | go-os: [linux, windows] 16 | go-arch: [386, amd64, arm, arm64] 17 | exclude: 18 | - go-os: windows 19 | go-arch: arm64 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Set up Go 1.x 23 | uses: actions/setup-go@v2 24 | with: 25 | go-version: ^1.14 26 | id: go 27 | - name: Check out code into the Go module directory 28 | uses: actions/checkout@v2 29 | - name: Get dependencies 30 | run: | 31 | go get -v -t -d ./... 32 | if [ -f Gopkg.toml ]; then 33 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 34 | dep ensure 35 | fi 36 | 37 | - name: Test 38 | run: go test -cover -coverprofile=coverage.txt -covermode=atomic ./... 39 | 40 | - name: Upload coverage report 41 | uses: codecov/codecov-action@v1.0.2 42 | with: 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | file: ./coverage.txt 45 | flags: unittests 46 | name: codecov-umbrella 47 | 48 | - name: Build 49 | env: 50 | CGO_ENABLED: 0 51 | GOOS: ${{ matrix.go-os }} 52 | GOARCH: ${{ matrix.go-arch }} 53 | run: | 54 | BINARY=mqtt-shell-${{ matrix.go-os }}-${{ matrix.go-arch }} 55 | if [ "$GOOS" = "windows" ]; then 56 | BINARY=$BINARY.exe 57 | fi 58 | go build -a -installsuffix cgo -o $BINARY -v -ldflags "-X main.ApplicationVersion=$(git describe --tags --abbrev=0) -X main.ApplicationCodeRev=$(git rev-parse --short HEAD)" ./cmd/mqtt-shell/ -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | release: 10 | name: Create Github Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Create Release 14 | id: create_release 15 | uses: actions/create-release@v1 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | with: 19 | tag_name: ${{ github.ref }} 20 | release_name: Release ${{ github.ref }} 21 | draft: false 22 | prerelease: false 23 | - name: Output Release URL File 24 | run: echo "${{ steps.create_release.outputs.upload_url }}" > release_url.txt 25 | - name: Save Release URL File for publish 26 | uses: actions/upload-artifact@v1 27 | with: 28 | name: release_url 29 | path: release_url.txt 30 | 31 | build: 32 | name: Build 33 | needs: [release] 34 | strategy: 35 | matrix: 36 | go-os: [linux, windows] 37 | go-arch: [386, amd64, arm, arm64] 38 | exclude: 39 | - go-os: windows 40 | go-arch: arm64 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Load Release URL File from release job 44 | uses: actions/download-artifact@v1 45 | with: 46 | name: release_url 47 | - name: Get Release Upload URL 48 | id: get_release_info 49 | run: | 50 | value=`cat release_url/release_url.txt` 51 | echo ::set-output name=upload_url::$value 52 | - name: Set up Go 1.x 53 | uses: actions/setup-go@v2 54 | with: 55 | go-version: ^1.14 56 | id: go 57 | - name: Check out code into the Go module directory 58 | uses: actions/checkout@v2 59 | - name: Get dependencies 60 | run: | 61 | go get -v -t -d ./... 62 | if [ -f Gopkg.toml ]; then 63 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 64 | dep ensure 65 | fi 66 | - name: Build 67 | id: binary_build 68 | env: 69 | CGO_ENABLED: 0 70 | GOOS: ${{ matrix.go-os }} 71 | GOARCH: ${{ matrix.go-arch }} 72 | run: | 73 | BINARY=mqtt-shell-${{ matrix.go-os }}-${{ matrix.go-arch }} 74 | if [ "$GOOS" = "windows" ]; then 75 | BINARY=$BINARY.exe 76 | fi 77 | go build -a -installsuffix cgo -o $BINARY -v -ldflags "-X main.ApplicationVersion=$(git describe --tags --abbrev=0) -X main.ApplicationCodeRev=$(git rev-parse --short HEAD)" ./cmd/mqtt-shell/ 78 | 79 | #transfer BINARY env variable to following steps 80 | echo "::set-output name=binary_name::$BINARY" 81 | - name: Upload Release Asset 82 | id: upload-release-asset 83 | uses: actions/upload-release-asset@v1 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.get_release_info.outputs.upload_url }} 88 | asset_path: ./${{ steps.binary_build.outputs.binary_name }} 89 | asset_name: ${{ steps.binary_build.outputs.binary_name }} 90 | asset_content_type: application/octet-stream 91 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "pkg"] 2 | path = pkg 3 | url = ssh://aur.archlinux.org/mqtt-shell.git 4 | -------------------------------------------------------------------------------- /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/mqtt-shell/actions/workflows/build.yml/badge.svg)](https://github.com/rainu/mqtt-shell/actions/workflows/build.yml) 2 | [![codecov](https://codecov.io/gh/rainu/mqtt-shell/branch/main/graph/badge.svg)](https://codecov.io/gh/rainu/mqtt-shell) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/rainu/mqtt-shell)](https://goreportcard.com/report/github.com/rainu/mqtt-shell) 4 | [![Go Reference](https://pkg.go.dev/badge/github.com/rainu/mqtt-shell.svg)](https://pkg.go.dev/github.com/rainu/mqtt-shell) 5 | # mqtt-shell 6 | 7 | A shell like command line interface for MQTT written in go. With it, you can easily subscribe and publish MQTT topics. 8 | It is also possible to pass through the incoming messages to external applications. Such like piping in shells! 9 | 10 | ![](./doc/example.gif) 11 | 12 | Features: 13 | * Colored output 14 | * Subscribe (multiple) MQTT topics 15 | * Publish messages to MQTT topic 16 | * Pipe the incoming messages to external applications 17 | * Command history (such like other shells) 18 | * Configuration support via yaml-files 19 | * so you can use handle multiple environments easily 20 | * Macro support 21 | 22 | # Get the Binary 23 | You can build it on your own (you will need [golang](https://golang.org/) installed): 24 | ```bash 25 | go build -a -installsuffix cgo ./cmd/mqtt-shell/ 26 | ``` 27 | 28 | Or you can download the release binaries: [here](https://github.com/rainu/mqtt-shell/releases/latest) 29 | 30 | Or for Arch-Linux you can install the AUR-Package [mqtt-shell](https://aur.archlinux.org/packages/mqtt-shell/) 31 | ```bash 32 | yay -S mqtt-shell 33 | ``` 34 | 35 | # Usage 36 | 37 | To see all command options, simple type 38 | ```bash 39 | ./mqtt-shell -h 40 | 41 | Usage of ./mqtt-shell: 42 | -b string 43 | The broker URI. ex: tcp://127.0.0.1:1883 44 | -c string 45 | The ClientID (default "mqtt-shell") 46 | -ca string 47 | MQTT ca file path (if tls is used) 48 | -cb value 49 | This color(s) will not be used 50 | -cmd value 51 | The command(s) which should be executed at the beginning 52 | -cs 53 | Indicating that no messages saved by the broker for this client should be delivered (default true) 54 | -e string 55 | The environment which should be used 56 | -ed string 57 | The environment directory (default "~/.config/mqtt-shell") 58 | -hf string 59 | The history file path (default "~/.config/mqtt-shell/.history") 60 | -hh 61 | Show detailed help text 62 | -m value 63 | The macro file(s) which should be loaded (default [~/.config/mqtt-shell/.macros.yml]) 64 | -ni 65 | Should this shell be non interactive. Only useful in combination with 'cmd' option 66 | -p string 67 | The password 68 | -pq int 69 | The default Quality of Service for publishing 0,1,2 (default 1) 70 | -sp string 71 | The prompt of the shell (default "\\033[36m»\\033[0m ") 72 | -sq int 73 | The default Quality of Service for subscription 0,1,2 74 | -u string 75 | The username 76 | -v Show the version 77 | ``` 78 | 79 | ## Setting files 80 | 81 | All options can be written in separate environment files (one per environment) or for global settings in the `.global.yml` file. 82 | These files must be stores inside the shell-config directory (`~/.config/mqtt-shell`). 83 | 84 | ## Environment configurations 85 | 86 | You can create yaml files where you can configure predefined configuration. This can be helpful for different MQTT environments. 87 | This files must be stored in the environment directory (by default ~/.config/mqtt-shell/). 88 | 89 | For example: 90 | ```yaml 91 | # example.yml 92 | 93 | broker: tls://127.0.0.1:8883 94 | ca: /tmp/my.ca 95 | subscribe-qos: 1 96 | publish-qos: 2 97 | username: user 98 | password: secret 99 | client-id: my-mqtt-shell 100 | clean-session: true 101 | commands: 102 | - sub # 103 | non-interactive: false 104 | history-file: ~/.config/mqtt-shell/history 105 | prompt: "\033[36mmsh>\033[0m " 106 | macros: 107 | my-macro: 108 | description: Awesome description of my macro 109 | arguments: 110 | - message 111 | commands: 112 | - pub test $1 113 | color-blacklist: 114 | - "38;5;237" 115 | ``` 116 | 117 | ```bash 118 | $ ./mqtt-shell -e example 119 | ``` 120 | 121 | # multiline publishing 122 | 123 | If you want to publish a multiline message to topic: 124 | ```bash 125 | pub test/topic <> /tmp/test.msg 167 | ``` 168 | 169 | ### only last incoming message 170 | 171 | If you want to write only the latest incoming message to file: 172 | ```bash 173 | sub test/topic > /tmp/last.msg 174 | ``` 175 | 176 | # Macros 177 | 178 | Macros can be a list of commands which should be executed. Or it can be a more complex but more powerful script. 179 | Macros can have their own arguments. They can be defined in the environment file (`~/.config/mqtt-shell/my-env.yml`), 180 | the global settings (`~/.config/mqtt-shell/.global.yml`) or the global macro file (`~/.config/mqtt-shell/.macros.yml`) 181 | 182 | ## Macros - list of commands 183 | ```yaml 184 | # ~/.config/mqtt-shell/.macros.yml 185 | 186 | my-macro: 187 | description: Awesome description of my macro 188 | arguments: 189 | - message 190 | varargs: true 191 | commands: 192 | - pub test $1 193 | ``` 194 | 195 | Then you can use it in the mqtt-shell: 196 | ```bash 197 | > sub test 198 | > my-macro "Message#1" "Message#2" 199 | test | Message#1 200 | test | Message#2 201 | ``` 202 | 203 | ## Macros - a complex script 204 | 205 | The [golang text templating](https://pkg.go.dev/text/template) is used for the scripts. The macro arguments can be read 206 | by **Arg** following by the **number of the argument**. So for the first argument: **Arg1** and for the second **Arg2** and 207 | so on. 208 | 209 | Furthermore there are two custom functions available: 210 | 211 | | name | argument | description | example | 212 | |---|---|---|---| 213 | | exec | <cmdLine> | Run the given command and return the result. You can also use pipes! | exec "date | cut -d\ -f1" | 214 | | log | <format string> [<argument>, ...] | Write the given content to the shell stdout. | log "Argument#1: %s" .Arg1 | 215 | 216 | ```yaml 217 | # ~/.config/mqtt-shell/.macros.yml 218 | 219 | my-macro: 220 | description: Awesome description of my macro 221 | arguments: 222 | - message 223 | varargs: true 224 | script: |- 225 | {{ log "Publish to topic" }} 226 | pub test {{ .Arg1 }} {{ exec "date" }} 227 | ``` 228 | 229 | Then you can use it in the mqtt-shell: 230 | ```bash 231 | > sub test 232 | > my-macro "Message#1" "Message#2" 233 | test | Message#1 So 15. Aug 16:15:00 CEST 2021 234 | test | Message#2 So 15. Aug 16:15:00 CEST 2021 235 | ``` 236 | 237 | # Color output 238 | 239 | This shell is able to write colored output. Each time a new subscription is made, the messages for that subscription will have 240 | a colored prefix. Each subscription (not topic!) should have an own color schema. Internally the shell will have a pool with 241 | color codes. Each time a new subscription was made, the next color will get from that pool. After the pool is exhausted, 242 | the color choosing will start again. The color pool can be shown with the `color` command in the shell. 243 | 244 | If you want to disable some colors, you have to put them in your yaml config file(s). Or use the option `-cb` for the current 245 | session. 246 | 247 | ## Why chained applications will not show any color? 248 | 249 | Because the mqtt-shell itself will start the chained application, the application can not detect if it operates on a tty. 250 | So normally the applications will think that their stdin is no tty. Most of the application have an option to force print 251 | the color codes. For example grep: 252 | 253 | ```bash 254 | sub test/topic | grep --color=always Message 255 | ``` -------------------------------------------------------------------------------- /cmd/mqtt-shell/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | MQTT "github.com/eclipse/paho.mqtt.golang" 7 | "github.com/rainu/mqtt-shell/internal/config" 8 | internalIo "github.com/rainu/mqtt-shell/internal/io" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "os" 13 | "os/signal" 14 | "syscall" 15 | ) 16 | 17 | var ApplicationVersion = "dev" 18 | var ApplicationCodeRev = "revision" 19 | 20 | var mqttReconnectListener interface { 21 | OnMqttReconnect() 22 | } 23 | 24 | func main() { 25 | cfg, rc := config.ReadConfig(ApplicationVersion, ApplicationCodeRev) 26 | if cfg == nil { 27 | os.Exit(rc) 28 | } 29 | 30 | applyColorBlacklist(cfg) 31 | 32 | interactive := !cfg.NonInteractive 33 | 34 | mqttClient := establishMqtt(cfg) 35 | 36 | var output io.Writer 37 | var inputChan chan string 38 | var subInformer interface { 39 | GetSubscriptions() []string 40 | } 41 | signals := make(chan os.Signal, 1) 42 | macroManager := &internalIo.MacroManager{ 43 | MacroSpecs: cfg.Macros, 44 | } 45 | 46 | if interactive { 47 | shell, err := internalIo.NewShell(cfg.Prompt, cfg.HistoryFile, macroManager, func(s string) []string { 48 | return subInformer.GetSubscriptions() 49 | }) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | output = shell 54 | inputChan = shell.Start() 55 | } else { 56 | //non interactive mean that there is no shell open 57 | inputChan = make(chan string) 58 | output = os.Stdout 59 | 60 | //reacting to signals (interrupt) 61 | signal.Notify(signals, os.Interrupt, syscall.SIGTERM) 62 | } 63 | macroManager.Output = output 64 | 65 | if err := macroManager.ValidateAndInitMacros(); err != nil { 66 | log.Fatal(err) 67 | } 68 | 69 | //execute the start commands 70 | go func() { 71 | for _, command := range cfg.StartCommands { 72 | if macroManager.IsMacro(command) { 73 | for _, line := range macroManager.ResolveMacro(command) { 74 | inputChan <- line 75 | } 76 | } else { 77 | inputChan <- command 78 | } 79 | } 80 | 81 | if cfg.NonInteractive { 82 | close(inputChan) 83 | } 84 | }() 85 | 86 | processor := internalIo.NewProcessor(output, mqttClient) 87 | subInformer = processor 88 | mqttReconnectListener = processor 89 | 90 | //process loop 91 | processor.Process(inputChan) 92 | 93 | if !interactive && processor.HasSubscriptions() { 94 | //wait for interrupt 95 | <-signals 96 | } 97 | } 98 | 99 | func applyColorBlacklist(cfg *config.Config) { 100 | for _, colorCode := range cfg.ColorBlacklist { 101 | internalIo.RemoveDecoratorFromPool(colorCode) 102 | } 103 | } 104 | 105 | func establishMqtt(cfg *config.Config) MQTT.Client { 106 | opts := MQTT.NewClientOptions() 107 | opts.AddBroker(cfg.Broker) 108 | opts.SetClientID(cfg.ClientId) 109 | if cfg.Username != "" { 110 | opts.SetUsername(cfg.Username) 111 | } 112 | if cfg.Password != "" { 113 | opts.SetPassword(cfg.Password) 114 | } 115 | 116 | if cfg.CaFile != "" { 117 | certPool := x509.NewCertPool() 118 | certFile, err := ioutil.ReadFile(cfg.CaFile) 119 | if err != nil { 120 | log.Fatal(err) 121 | } 122 | ok := certPool.AppendCertsFromPEM(certFile) 123 | if !ok { 124 | log.Fatal("Failed to parse ca certificate!") 125 | } 126 | 127 | opts.SetTLSConfig(&tls.Config{ 128 | RootCAs: certPool, 129 | }) 130 | } 131 | 132 | opts.SetAutoReconnect(true) 133 | opts.SetCleanSession(cfg.CleanSession) 134 | 135 | firstConnect := true 136 | opts.SetOnConnectHandler(func(_ MQTT.Client) { 137 | if firstConnect { 138 | println("Successfully connected to mqtt broker.") 139 | } else { 140 | println("Successfully re-connected to mqtt broker.") 141 | } 142 | if mqttReconnectListener != nil { 143 | mqttReconnectListener.OnMqttReconnect() 144 | } 145 | 146 | firstConnect = false 147 | }) 148 | opts.SetConnectionLostHandler(func(_ MQTT.Client, err error) { 149 | println("Connection to broker lost. Reconnecting...") 150 | }) 151 | 152 | client := MQTT.NewClient(opts) 153 | if t := client.Connect(); !t.Wait() || t.Error() != nil { 154 | log.Fatal(t.Error()) 155 | } 156 | return client 157 | } 158 | -------------------------------------------------------------------------------- /doc/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainu/mqtt-shell/f6c6a5214a0f29037c52f577e23a15cde4bcb89f/doc/example.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rainu/mqtt-shell 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/chzyer/logex v1.1.10 // indirect 7 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect 8 | github.com/eclipse/paho.mqtt.golang v1.3.4 9 | github.com/golang/mock v1.6.0 10 | github.com/gookit/color v1.4.2 11 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 12 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f 13 | github.com/rainu/go-command-chain v0.4.0 14 | github.com/rainu/readline v1.4.1 15 | github.com/stretchr/testify v1.7.0 16 | gopkg.in/yaml.v2 v2.4.0 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 2 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 3 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 4 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/eclipse/paho.mqtt.golang v1.3.4 h1:/sS2PA+PgomTO1bfJSDJncox+U7X5Boa3AfhEywYdgI= 8 | github.com/eclipse/paho.mqtt.golang v1.3.4/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= 9 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 10 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 11 | github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk= 12 | github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= 13 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 14 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 16 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 17 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= 18 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/rainu/go-command-chain v0.4.0 h1:qgrNbNsqkTfJHdwGzVuGPPK+p+XSnGAhAT/8x1A8SLE= 22 | github.com/rainu/go-command-chain v0.4.0/go.mod h1:RvLsDKnTGD9XoUY7nmBz73ayffI0bFCDH/EVJPRgfks= 23 | github.com/rainu/readline v1.4.1 h1:F/7l1XdEd6MWI6g7BS+ebZjAvZNl1PfUJsrN+YilHpw= 24 | github.com/rainu/readline v1.4.1/go.mod h1:R7mMuorsyrdaon0VtBFErn8BlrCnFCZ+Yh/SzIMAVTg= 25 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 27 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 28 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= 30 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= 31 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 33 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 34 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 35 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 36 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 37 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 38 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= 39 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 40 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 42 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 43 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 45 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 47 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= 48 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 50 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 51 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 52 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 53 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 54 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 55 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 56 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 57 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 61 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 62 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 63 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "gopkg.in/yaml.v2" 7 | "log" 8 | "os" 9 | "path" 10 | "strconv" 11 | ) 12 | 13 | func ReadConfig(version, revision string) (*Config, int) { 14 | cfg := Config{} 15 | 16 | env := "" 17 | envDir := getConfigDirectory() 18 | 19 | var moreHelp, showVersion bool 20 | flag.BoolVar(&moreHelp, "hh", false, "Show detailed help text") 21 | flag.BoolVar(&showVersion, "v", false, "Show the version") 22 | 23 | flag.StringVar(&env, "e", "", "The environment which should be used") 24 | flag.StringVar(&envDir, "ed", envDir, "The environment directory") 25 | flag.StringVar(&cfg.Broker, "b", "", "The broker URI. ex: tcp://127.0.0.1:1883") 26 | flag.StringVar(&cfg.CaFile, "ca", "", "MQTT ca file path (if tls is used)") 27 | flag.IntVar(&cfg.SubscribeQOS, "sq", 0, "The default Quality of Service for subscription 0,1,2") 28 | flag.IntVar(&cfg.PublishQOS, "pq", 1, "The default Quality of Service for publishing 0,1,2") 29 | flag.StringVar(&cfg.Username, "u", "", "The username") 30 | flag.StringVar(&cfg.Password, "p", "", "The password") 31 | flag.StringVar(&cfg.ClientId, "c", "mqtt-shell", "The ClientID") 32 | flag.BoolVar(&cfg.CleanSession, "cs", true, "Indicating that no messages saved by the broker for this client should be delivered") 33 | flag.BoolVar(&cfg.NonInteractive, "ni", false, "Should this shell be non interactive. Only useful in combination with 'cmd' option") 34 | flag.StringVar(&cfg.HistoryFile, "hf", path.Join(envDir, ".history"), "The history file path") 35 | flag.StringVar(&cfg.Prompt, "sp", `\033[36m»\033[0m `, "The prompt of the shell") 36 | 37 | var startCommands, macroFiles, colorBlacklist varArgs 38 | macroFiles.Set(path.Join(envDir, ".macros.yml")) 39 | 40 | flag.Var(&startCommands, "cmd", "The command(s) which should be executed at the beginning") 41 | flag.Var(¯oFiles, "m", "The macro file(s) which should be loaded") 42 | flag.Var(&colorBlacklist, "cb", "This color(s) will not be used") 43 | flag.Parse() 44 | 45 | if moreHelp { 46 | fmt.Fprint(os.Stderr, helpText) 47 | return nil, 1 48 | } 49 | 50 | if showVersion { 51 | fmt.Printf("%s - %s\n", version, revision) 52 | return nil, 0 53 | } 54 | 55 | if _, err := os.Stat(path.Join(envDir, ".global.yml")); err == nil { 56 | handleFile(envDir, ".global", &cfg) 57 | } 58 | if _, err := os.Stat(path.Join(envDir, ".global.yaml")); err == nil { 59 | handleFile(envDir, ".global", &cfg) 60 | } 61 | 62 | if env != "" { 63 | handleFile(envDir, env, &cfg) 64 | } 65 | 66 | // overwrite potential config values with argument values 67 | startCommands.Reset() 68 | colorBlacklist.Reset() 69 | flag.Parse() 70 | if len(startCommands) > 0 { 71 | cfg.StartCommands = startCommands 72 | } 73 | if len(colorBlacklist) > 0 { 74 | cfg.ColorBlacklist = colorBlacklist 75 | } 76 | 77 | if cfg.Broker == "" { 78 | fmt.Fprint(os.Stderr, "Broker is missing!") 79 | return nil, 1 80 | } 81 | loadMacroFiles(&cfg, macroFiles) 82 | 83 | return &cfg, -1 84 | } 85 | 86 | func handleFile(envDir, env string, cfg *Config) { 87 | var suffix string 88 | if _, err := os.Stat(path.Join(envDir, env+".yaml")); os.IsNotExist(err) { 89 | if _, err := os.Stat(path.Join(envDir, env+".yml")); os.IsNotExist(err) { 90 | log.Fatal("No environment file found") 91 | } else { 92 | suffix = ".yml" 93 | } 94 | } else { 95 | suffix = ".yaml" 96 | } 97 | 98 | envFile, err := os.Open(path.Join(envDir, env+suffix)) 99 | if err != nil { 100 | log.Fatal("Can not open environment file: ", err) 101 | } 102 | defer envFile.Close() 103 | 104 | if err := yaml.NewDecoder(envFile).Decode(&cfg); err != nil { 105 | log.Fatal(fmt.Sprintf("Unable to parse environment file (%s): ", envFile.Name()), err) 106 | } 107 | } 108 | 109 | func loadMacroFiles(cfg *Config, macroFiles varArgs) { 110 | var err error 111 | 112 | cfg.Prompt, err = strconv.Unquote(`"` + cfg.Prompt + `"`) 113 | if err != nil { 114 | log.Fatal("Unable to parse prompt: ", err) 115 | } 116 | 117 | for _, filePath := range macroFiles { 118 | loadMacroFile(cfg, filePath) 119 | } 120 | } 121 | 122 | func loadMacroFile(cfg *Config, filePath string) { 123 | file, err := os.Open(filePath) 124 | if err != nil { 125 | if os.IsNotExist(err) { 126 | //skip this file 127 | return 128 | } 129 | 130 | log.Fatal("Can not open macro file: ", err) 131 | } 132 | defer file.Close() 133 | 134 | macros := map[string]Macro{} 135 | if err := yaml.NewDecoder(file).Decode(¯os); err != nil { 136 | log.Fatal(fmt.Sprintf("Unable to parse macro file '%s': ", filePath), err) 137 | } 138 | 139 | //merge macros 140 | for macroName, macroSpec := range macros { 141 | cfg.Macros[macroName] = macroSpec 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "github.com/stretchr/testify/assert" 6 | "os" 7 | "path" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestReadConfig_moreHelp(t *testing.T) { 13 | resetFlags() 14 | 15 | origGetConfigDirectory := getConfigDirectory 16 | defer func() { 17 | getConfigDirectory = origGetConfigDirectory 18 | }() 19 | getConfigDirectory = func() string { 20 | return t.TempDir() 21 | } 22 | 23 | os.Args = []string{"mqtt-shell", "-hh"} 24 | os.Stderr, _ = os.OpenFile(path.Join(t.TempDir(), "stderr"), os.O_RDWR|os.O_CREATE, 0755) 25 | 26 | result, rc := ReadConfig("", "") 27 | 28 | assert.Nil(t, result) 29 | assert.Equal(t, 1, rc) 30 | 31 | content, err := os.ReadFile(os.Stderr.Name()) 32 | assert.NoError(t, err) 33 | assert.Equal(t, helpText, string(content)) 34 | } 35 | 36 | func TestReadConfig_showVersion(t *testing.T) { 37 | resetFlags() 38 | 39 | origGetConfigDirectory := getConfigDirectory 40 | defer func() { 41 | getConfigDirectory = origGetConfigDirectory 42 | }() 43 | getConfigDirectory = func() string { 44 | return t.TempDir() 45 | } 46 | 47 | os.Args = []string{"mqtt-shell", "-v"} 48 | os.Stdout, _ = os.OpenFile(path.Join(t.TempDir(), "stdout"), os.O_RDWR|os.O_CREATE, 0755) 49 | 50 | result, rc := ReadConfig("", "") 51 | 52 | assert.Nil(t, result) 53 | assert.Equal(t, 0, rc) 54 | 55 | content, err := os.ReadFile(os.Stdout.Name()) 56 | assert.NoError(t, err) 57 | assert.Equal(t, " - \n", string(content)) 58 | } 59 | 60 | func TestReadConfig_brokerIsMandatory(t *testing.T) { 61 | resetFlags() 62 | 63 | origGetConfigDirectory := getConfigDirectory 64 | defer func() { 65 | getConfigDirectory = origGetConfigDirectory 66 | }() 67 | getConfigDirectory = func() string { 68 | return t.TempDir() 69 | } 70 | 71 | os.Args = []string{"mqtt-shell"} 72 | os.Stderr, _ = os.OpenFile(path.Join(t.TempDir(), "stderr"), os.O_RDWR|os.O_CREATE, 0755) 73 | 74 | result, rc := ReadConfig("", "") 75 | 76 | assert.Nil(t, result) 77 | assert.Equal(t, 1, rc) 78 | 79 | content, err := os.ReadFile(os.Stderr.Name()) 80 | assert.NoError(t, err) 81 | assert.Equal(t, "Broker is missing!", string(content)) 82 | } 83 | 84 | func TestReadConfig_defaultValues(t *testing.T) { 85 | resetFlags() 86 | 87 | origGetConfigDirectory := getConfigDirectory 88 | defer func() { 89 | getConfigDirectory = origGetConfigDirectory 90 | }() 91 | cfgDir := t.TempDir() 92 | getConfigDirectory = func() string { 93 | return cfgDir 94 | } 95 | 96 | os.Args = []string{"mqtt-shell", "-b", "tcp://127.0.0.1:1883"} 97 | result, rc := ReadConfig("", "") 98 | 99 | assert.Equal(t, -1, rc) 100 | assert.Equal(t, Config{ 101 | Broker: "tcp://127.0.0.1:1883", 102 | CaFile: "", 103 | SubscribeQOS: 0, 104 | PublishQOS: 1, 105 | Username: "", 106 | Password: "", 107 | ClientId: "mqtt-shell", 108 | CleanSession: true, 109 | StartCommands: nil, 110 | NonInteractive: false, 111 | HistoryFile: path.Join(cfgDir, ".history"), 112 | Prompt: "\x1b[36m»\x1b[0m ", 113 | Macros: nil, 114 | ColorBlacklist: nil, 115 | }, *result) 116 | } 117 | 118 | func TestReadConfig_readGlobal(t *testing.T) { 119 | testReadConfig_readGlobal(t, ".global.yml") 120 | } 121 | 122 | func TestReadConfig_readGlobal2(t *testing.T) { 123 | testReadConfig_readGlobal(t, ".global.yaml") 124 | } 125 | 126 | func testReadConfig_readGlobal(t *testing.T, fileName string) { 127 | resetFlags() 128 | 129 | origGetConfigDirectory := getConfigDirectory 130 | defer func() { 131 | getConfigDirectory = origGetConfigDirectory 132 | }() 133 | cfgDir := t.TempDir() 134 | getConfigDirectory = func() string { 135 | return cfgDir 136 | } 137 | 138 | err := os.WriteFile(path.Join(cfgDir, fileName), []byte(strings.ReplaceAll(strings.TrimSpace(` 139 | broker: tcp://127.0.0.1:1883 140 | ca: /tmp/ca.pam 141 | subscribe-qos: 1 142 | publish-qos: 2 143 | username: rainu 144 | password: secret 145 | client-id: rainu-shell 146 | clean-session: false 147 | commands: 148 | - help 149 | non-interactive: true 150 | history-file: /tmp/history 151 | prompt: => 152 | macros: 153 | test: 154 | description: some test 155 | arguments: 156 | - argN 157 | varargs: true 158 | commands: 159 | - help 160 | script: its a script 161 | color-blacklist: 162 | - "00,11,22" 163 | `), "\t", " ")), 0755) 164 | assert.Nil(t, err) 165 | 166 | err = os.WriteFile(path.Join(cfgDir, ".macros.yml"), []byte(strings.ReplaceAll(strings.TrimSpace(` 167 | example: 168 | description: some example 169 | arguments: 170 | - argN 171 | commands: 172 | - help 173 | script: its a example script 174 | `), "\t", " ")), 0755) 175 | assert.Nil(t, err) 176 | 177 | os.Args = []string{"mqtt-shell"} 178 | result, rc := ReadConfig("", "") 179 | 180 | assert.Equal(t, -1, rc) 181 | assert.Equal(t, Config{ 182 | Broker: "tcp://127.0.0.1:1883", 183 | CaFile: "/tmp/ca.pam", 184 | SubscribeQOS: 1, 185 | PublishQOS: 2, 186 | Username: "rainu", 187 | Password: "secret", 188 | ClientId: "rainu-shell", 189 | CleanSession: false, 190 | StartCommands: []string{"help"}, 191 | NonInteractive: true, 192 | HistoryFile: "/tmp/history", 193 | Prompt: "=>", 194 | Macros: map[string]Macro{ 195 | "test": { 196 | Description: "some test", 197 | Arguments: []string{"argN"}, 198 | Varargs: true, 199 | Commands: []string{"help"}, 200 | Script: "its a script", 201 | }, 202 | "example": { 203 | Description: "some example", 204 | Arguments: []string{"argN"}, 205 | Varargs: false, 206 | Commands: []string{"help"}, 207 | Script: "its a example script", 208 | }, 209 | }, 210 | ColorBlacklist: []string{"00,11,22"}, 211 | }, *result) 212 | } 213 | 214 | func TestReadConfig_argsOverridesConfigFiles(t *testing.T) { 215 | resetFlags() 216 | 217 | origGetConfigDirectory := getConfigDirectory 218 | defer func() { 219 | getConfigDirectory = origGetConfigDirectory 220 | }() 221 | cfgDir := t.TempDir() 222 | getConfigDirectory = func() string { 223 | return cfgDir 224 | } 225 | 226 | err := os.WriteFile(path.Join(cfgDir, ".global.yml"), []byte(strings.ReplaceAll(strings.TrimSpace(` 227 | broker: tcp://127.0.0.1:1883 228 | ca: /tmp/ca.pam 229 | subscribe-qos: 1 230 | publish-qos: 2 231 | username: rainu 232 | password: secret 233 | client-id: rainu-shell 234 | clean-session: false 235 | commands: 236 | - help 237 | non-interactive: false 238 | history-file: /tmp/history 239 | prompt: => 240 | color-blacklist: 241 | - "00,11,22" 242 | `), "\t", " ")), 0755) 243 | assert.Nil(t, err) 244 | 245 | os.Args = []string{ 246 | "mqtt-shell", 247 | "-b", "tcp://8.8.8.8:1883", 248 | "-ca", "/home/ca.pam", 249 | "-sq", "2", 250 | "-pq", "1", 251 | "-u", "admin", 252 | "-p", "password", 253 | "-c", "test-shell", 254 | "-cs", 255 | "-cmd", "test", 256 | "-ni", 257 | "-hf", "/home/history", 258 | "-sp", "$>", 259 | "-cb", "13,12,89", 260 | } 261 | result, rc := ReadConfig("", "") 262 | 263 | assert.Equal(t, -1, rc) 264 | assert.Equal(t, Config{ 265 | Broker: "tcp://8.8.8.8:1883", 266 | CaFile: "/home/ca.pam", 267 | SubscribeQOS: 2, 268 | PublishQOS: 1, 269 | Username: "admin", 270 | Password: "password", 271 | ClientId: "test-shell", 272 | CleanSession: true, 273 | StartCommands: []string{"test"}, 274 | NonInteractive: true, 275 | HistoryFile: "/home/history", 276 | Prompt: "$>", 277 | Macros: nil, 278 | ColorBlacklist: []string{"13,12,89"}, 279 | }, *result) 280 | } 281 | 282 | func resetFlags() { 283 | flag.CommandLine = flag.NewFlagSet("mqtt-shell-test", flag.ExitOnError) 284 | flag.CommandLine.Usage = flag.Usage 285 | } 286 | -------------------------------------------------------------------------------- /internal/config/directory.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/kirsle/configdir" 5 | "os" 6 | "path" 7 | ) 8 | 9 | var osUserHomeDir = os.UserHomeDir 10 | 11 | var getConfigDirectory = func() string { 12 | home, err := osUserHomeDir() 13 | if err != nil { 14 | home = "./" 15 | } 16 | 17 | // legacy directory 18 | envDir := path.Join(home, ".mqtt-shell") 19 | if _, err := os.Stat(envDir); err == nil { 20 | return envDir 21 | } 22 | 23 | return configdir.LocalConfig("mqtt-shell") 24 | } 25 | -------------------------------------------------------------------------------- /internal/config/directory_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "os" 6 | "path" 7 | "testing" 8 | ) 9 | 10 | func Test_getConfigDirectory(t *testing.T) { 11 | osUserHomeDir = func() (string, error) { 12 | return os.TempDir(), nil 13 | } 14 | 15 | home, err := os.UserHomeDir() 16 | assert.NoError(t, err) 17 | 18 | assert.Equal(t, path.Join(home, ".config", "mqtt-shell"), getConfigDirectory()) 19 | } 20 | 21 | func Test_getConfigDirectory_legacy(t *testing.T) { 22 | fakeHome := t.TempDir() 23 | 24 | osUserHomeDir = func() (string, error) { 25 | return fakeHome, nil 26 | } 27 | defer func() { 28 | osUserHomeDir = os.UserHomeDir 29 | }() 30 | 31 | assert.NoError(t, os.Mkdir(path.Join(fakeHome, ".mqtt-shell"), 0660)) 32 | assert.Equal(t, path.Join(fakeHome, ".mqtt-shell"), getConfigDirectory()) 33 | } 34 | -------------------------------------------------------------------------------- /internal/config/help.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "strings" 4 | 5 | var helpText = `\u001b[7mSetting files\u001b[0m 6 | All options can be written in separate environment files (one per environment) or for global settings 7 | in the \u001b[1m.global.yml\u001b[0m file. These files must be stored inside the shell-config directory (\u001b[1m__CONFIG_DIR__\u001b[0m). 8 | 9 | \u001b[7mEnvironment configurations\u001b[0m 10 | You can create yaml files where you can configure predefined configuration. This can be helpful for 11 | different mqtt environments. This files must be stored in the environment directory (by default \u001b[1m__CONFIG_DIR__\u001b[0m). 12 | 13 | For example (\u001b[1mexample.yml\u001b[0m): 14 | 15 | broker: tls://127.0.0.1:8883 16 | ca: /tmp/my.ca 17 | subscribe-qos: 1 18 | publish-qos: 2 19 | username: user 20 | password: secret 21 | client-id: my-mqtt-shell 22 | clean-session: true 23 | commands: 24 | - sub # 25 | non-interactive: false 26 | history-file: __CONFIG_DIR__/history 27 | prompt: "\033[36mmsh>\033[0m " 28 | macros: 29 | my-macro: 30 | description: Awesome description of my macro 31 | arguments: 32 | - message 33 | commands: 34 | - pub test $1 35 | color-blacklist: 36 | - "38;5;237" 37 | 38 | \u001b[4m$ ./mqtt-shell -e example\u001b[0m 39 | 40 | \u001b[7mMacros\u001b[0m 41 | Macros can be a list of commands which should be executed. Or it can be a more complex but more powerful script. 42 | Macros can have their own arguments. They can be defined in the environment file (\u001b[1m__CONFIG_DIR__/my-env.yml\u001b[0m), the 43 | global settings (\u001b[1m__CONFIG_DIR__/.global.yml\u001b[0m) or the global macro file (\u001b[1m__CONFIG_DIR__/.macros.yml\u001b[0m) 44 | 45 | \u001b[7mMacros - list of commands\u001b[0m 46 | 47 | # __CONFIG_DIR__/.macros.yml 48 | my-macro: 49 | description: Awesome description of my macro 50 | arguments: 51 | - message 52 | varargs: true 53 | commands: 54 | - pub test $1 55 | 56 | Then you can use it in the mqtt-shell: 57 | 58 | > sub test 59 | > my-macro "Message#1" "Message#2" 60 | test | Message#1 61 | test | Message#2 62 | 63 | \u001b[7mMacros - a complex script\u001b[0m 64 | 65 | The \u001b[4mgolang text templating\u001b[0m(https://pkg.go.dev/text/template) is used for the scripts. 66 | The macro arguments can be read by \u001b[1mArg\u001b[0m following by the \u001b[1mnumber of the argument\u001b[0m. 67 | So for the first argument: \u001b[1mArg1\u001b[0m and for the second \u001b[1mArg2\u001b[0m and so on. 68 | 69 | Furthermore there are two custom functions available: 70 | 71 | +---------------------------------------------------------------------------------------------------------------------+ 72 | | \u001b[4mname\u001b[0m | \u001b[4margument\u001b[0m | \u001b[4mdescription\u001b[0m | \u001b[4mexample\u001b[0m | 73 | +---------------------------------------------------------------------------------------------------------------------+ 74 | | exec | | Run the given command and return the result. | exec "date cut -d\ -f1" | 75 | | | | You can also use pipes! | | 76 | +---------------------------------------------------------------------------------------------------------------------+ 77 | | log | [, ...] | Write the given content to the shell stdout. | log "Argument#1: %s" .Arg1| 78 | +---------------------------------------------------------------------------------------------------------------------+ 79 | 80 | # __CONFIG_DIR__/.macros.yml 81 | my-macro: 82 | description: Awesome description of my macro 83 | arguments: 84 | - message 85 | varargs: true 86 | script: |- 87 | {{ log "Publish to topic" }} 88 | pub test {{ .Arg1 }} {{ exec "date" }} 89 | 90 | Then you can use it in the mqtt-shell: 91 | 92 | > sub test 93 | > my-macro "Message#1" "Message#2" 94 | test | Message#1 So 15. Aug 16:15:00 CEST 2021 95 | test | Message#2 So 15. Aug 16:15:00 CEST 2021 96 | 97 | For more information see https://github.com/rainu/mqtt-shell 98 | ` 99 | 100 | func init() { 101 | helpText = strings.Replace(helpText, `\u001b`, "\u001b", -1) 102 | helpText = strings.Replace(helpText, `__CONFIG_DIR__`, getConfigDirectory(), -1) 103 | } 104 | -------------------------------------------------------------------------------- /internal/config/model.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "fmt" 4 | 5 | type Config struct { 6 | Broker string `yaml:"broker"` 7 | CaFile string `yaml:"ca"` 8 | SubscribeQOS int `yaml:"subscribe-qos"` 9 | PublishQOS int `yaml:"publish-qos"` 10 | Username string `yaml:"username"` 11 | Password string `yaml:"password"` 12 | ClientId string `yaml:"client-id"` 13 | CleanSession bool `yaml:"clean-session"` 14 | 15 | StartCommands []string `yaml:"commands"` 16 | NonInteractive bool `yaml:"non-interactive"` 17 | HistoryFile string `yaml:"history-file"` 18 | Prompt string `yaml:"prompt"` 19 | Macros map[string]Macro `yaml:"macros"` 20 | ColorBlacklist []string `yaml:"color-blacklist"` 21 | } 22 | 23 | type Macro struct { 24 | Description string `yaml:"description"` 25 | Arguments []string `yaml:"arguments,flow"` 26 | Varargs bool `yaml:"varargs"` 27 | Commands []string `yaml:"commands,flow"` 28 | Script string `yaml:"script"` 29 | } 30 | 31 | type varArgs []string 32 | 33 | func (i *varArgs) String() string { 34 | return fmt.Sprintf("%v", []string(*i)) 35 | } 36 | 37 | func (i *varArgs) Set(value string) error { 38 | *i = append(*i, value) 39 | return nil 40 | } 41 | 42 | func (i *varArgs) Reset() { 43 | *i = []string{} 44 | } 45 | -------------------------------------------------------------------------------- /internal/io/commands.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | const ( 4 | commandExit = "exit" 5 | commandHelp = "help" 6 | commandList = ".ls" 7 | commandPub = "pub" 8 | commandSub = "sub" 9 | commandUnsub = "unsub" 10 | commandMacro = ".macro" 11 | commandListColors = ".lsc" 12 | ) 13 | -------------------------------------------------------------------------------- /internal/io/help.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import "strings" 4 | 5 | var helpText = `\u001b[7mPublishing a message\u001b[0m 6 | 7 | \u001b[1mpub [-r] [-q 0|1|2] \u001b[0m 8 | 9 | -r retained 10 | -q [0|1|2] QualityOfService (QoS) level 11 | 12 | \u001b[4mPublishing a multiline message\u001b[0m 13 | 14 | \u001b[1mpub my/topic < [...topicN]\u001b[0m 28 | 29 | -q [0|1|2] QualityOfService (QoS) level 30 | 31 | \u001b[7mCommand chaining\u001b[0m 32 | One powerful feature of this shell is to chain incoming messages to external applications. 33 | It works like the other unix shells. 34 | 35 | \u001b[4mThis will pass through all incoming messages in topic test/topic to grep\u001b[0m 36 | 37 | \u001b[1msub test/topic | grep "Message"\u001b[0m 38 | 39 | \u001b[4mIf you want to push stdout and stderr to the stdin of the next application:\u001b[0m 40 | 41 | \u001b[1msub test/topic | myExternalApplication |& grep "Message"\u001b[0m 42 | 43 | \u001b[4mNormally the external applications will be started on each incoming Message.\u001b[0m 44 | \u001b[4mIf you want to stream all incoming messages to a single started application:\u001b[0m 45 | 46 | \u001b[1msub test/topic | grep "Message" &\u001b[0m 47 | 48 | \u001b[4mIf you want to write all incoming messages into files:\u001b[0m 49 | 50 | \u001b[1msub test/topic >> /tmp/test.msg\u001b[0m 51 | 52 | \u001b[7mUnsubscribe a topic\u001b[0m 53 | 54 | \u001b[1munsub [...topicN]\u001b[0m 55 | 56 | \u001b[7mList all available commands\u001b[0m 57 | 58 | \u001b[1m.ls\u001b[0m 59 | 60 | \u001b[7mList all available macros\u001b[0m 61 | 62 | \u001b[1m.macro\u001b[0m 63 | 64 | \u001b[7mList all available colors schemas\u001b[0m 65 | 66 | \u001b[1m.lsc\u001b[0m 67 | 68 | \u001b[7mExit the shell\u001b[0m 69 | 70 | \u001b[1mexit\u001b[0m 71 | 72 | For more information see https://github.com/rainu/mqtt-shell 73 | ` 74 | 75 | func init() { 76 | helpText = strings.Replace(helpText, `\u001b`, "\u001b", -1) 77 | } 78 | -------------------------------------------------------------------------------- /internal/io/interpreter.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "errors" 5 | "github.com/kballard/go-shellquote" 6 | cmdchain "github.com/rainu/go-command-chain" 7 | "io" 8 | "os" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | const ( 14 | linkOut = "|" 15 | linkOutAndErr = "|&" 16 | linkRedirect = ">" 17 | linkRedirectErr = ">&" 18 | linkAppend = ">>" 19 | linkAppendErr = ">>&" 20 | ) 21 | 22 | var links = map[string]bool{ 23 | linkOut: true, linkOutAndErr: true, 24 | linkRedirect: true, linkRedirectErr: true, 25 | linkAppend: true, linkAppendErr: true, 26 | } 27 | 28 | type Command struct { 29 | Name string 30 | Arguments []string 31 | } 32 | 33 | type Chain struct { 34 | Commands []Command 35 | Links []string 36 | RawLine []string 37 | } 38 | 39 | var interpretLine = func(line string) (Chain, error) { 40 | var err error 41 | chain := Chain{} 42 | 43 | multilineArg := "" 44 | if strings.Contains(line, "\n") { 45 | //newlines should only exists if there is a multiline argument available! 46 | //in that case this multiline argument is always the last argument 47 | 48 | //the multiline argument must not interpret by shellquote! the argument 49 | //should not be manipulated (quote replacement etc.) 50 | 51 | firstLine := strings.Split(line, "\n")[0] 52 | eofWord := multilineRegex.FindStringSubmatch(firstLine)[1] 53 | multilineArgRegex := regexp.MustCompile("(?s)<<" + eofWord + "(.*)" + eofWord + "$") 54 | result := multilineArgRegex.FindStringSubmatch(line) 55 | multilineArg = strings.Trim(result[1], "\n") 56 | 57 | line = firstLine 58 | } 59 | 60 | chain.RawLine, err = shellquote.Split(line) 61 | if err != nil { 62 | return chain, err 63 | } 64 | 65 | if multilineArg != "" { 66 | chain.RawLine[len(chain.RawLine)-1] = multilineArg 67 | } 68 | 69 | cmdParts := [][]string{{}} 70 | for _, part := range chain.RawLine { 71 | if links[part] { 72 | chain.Links = append(chain.Links, part) 73 | cmdParts = append(cmdParts, []string{}) 74 | } else { 75 | cmdParts[len(cmdParts)-1] = append(cmdParts[len(cmdParts)-1], part) 76 | } 77 | } 78 | 79 | if len(chain.Links) > 1 { 80 | for i := 0; i < len(chain.Links)-1; i++ { 81 | if strings.HasPrefix(chain.Links[i], ">") { 82 | return chain, errors.New("invalid syntax") 83 | } 84 | } 85 | } 86 | 87 | for _, part := range cmdParts { 88 | if len(part) > 0 { 89 | cmd := Command{} 90 | cmd.Name = part[0] 91 | 92 | if len(part) > 1 { 93 | cmd.Arguments = part[1:] 94 | } 95 | 96 | chain.Commands = append(chain.Commands, cmd) 97 | } 98 | } 99 | 100 | if len(chain.Commands) > 0 && chain.RawLine[len(chain.RawLine)-1] == "&" { 101 | //remove this from last commands last argument 102 | lastCommand := chain.Commands[len(chain.Commands)-1] 103 | lastCommand.Arguments = lastCommand.Arguments[:len(lastCommand.Arguments)-1] 104 | 105 | chain.Commands[len(chain.Commands)-1] = lastCommand 106 | } 107 | 108 | return chain, nil 109 | } 110 | 111 | func (c *Chain) ToCommand(input io.Reader, outputs ...io.Writer) (cmdchain.FinalizedBuilder, func(), error) { 112 | appending := c.IsAppending() 113 | 114 | b := cmdchain.Builder().WithInput(input) 115 | to := len(c.Commands) 116 | if appending { 117 | //the last "command" is not a command but an output file target 118 | to-- 119 | } 120 | 121 | for i := 1; i < to; i++ { 122 | cmd := b.Join(c.Commands[i].Name, c.Commands[i].Arguments...) 123 | 124 | //is not last command, check the link to the next command 125 | if i+1 < len(c.Commands) { 126 | if c.Links[i] == linkOutAndErr { 127 | cmd.ForwardError() 128 | } 129 | } 130 | b = cmd 131 | } 132 | 133 | //callback func will be called after the command is finished 134 | callbackFn := func() {} 135 | errOutputs := make([]io.Writer, 0, 1) 136 | 137 | if appending { 138 | flags := os.O_WRONLY | os.O_CREATE 139 | 140 | if strings.HasPrefix(c.Links[len(c.Links)-1], ">>") { 141 | flags = flags | os.O_APPEND 142 | } else { 143 | flags = flags | os.O_TRUNC 144 | } 145 | 146 | outFile, err := os.OpenFile(c.Commands[len(c.Commands)-1].Name, flags, 0644) 147 | if err != nil { 148 | return nil, callbackFn, err 149 | } 150 | 151 | if strings.Contains(c.Commands[len(c.Commands)-1].Name, "&") { 152 | errOutputs = append(errOutputs, outFile) 153 | } 154 | 155 | //let close the file if execution is finished 156 | callbackFn = func() { 157 | outFile.Close() 158 | } 159 | outputs = append(outputs, outFile) 160 | } 161 | 162 | return b.Finalize(). 163 | WithGlobalErrorChecker(cmdchain.IgnoreExitErrors()). 164 | WithOutput(outputs...). 165 | WithError(errOutputs...), 166 | callbackFn, nil 167 | } 168 | 169 | func (c *Chain) IsAppending() bool { 170 | if len(c.Links) > 0 { 171 | return strings.HasPrefix(c.Links[len(c.Links)-1], ">") 172 | } 173 | return false 174 | } 175 | 176 | func (c *Chain) IsLongTerm() bool { 177 | //if the last sign is "&" 178 | return c.RawLine[len(c.RawLine)-1] == "&" 179 | } 180 | -------------------------------------------------------------------------------- /internal/io/interpreter_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestInterpretLine(t *testing.T) { 11 | tests := []struct { 12 | line string 13 | expected Chain 14 | err string 15 | }{ 16 | {"name ", Chain{ 17 | []Command{{"name", nil}}, 18 | nil, 19 | []string{"name"}, 20 | }, ""}, 21 | {"name cmd1 cmd2", Chain{ 22 | []Command{{"name", []string{"cmd1", "cmd2"}}}, 23 | nil, 24 | []string{"name", "cmd1", "cmd2"}, 25 | }, ""}, 26 | {" name cmd1 cmd2 ", Chain{ 27 | []Command{{"name", []string{"cmd1", "cmd2"}}}, 28 | nil, 29 | []string{"name", "cmd1", "cmd2"}, 30 | }, ""}, 31 | {` name "cmd with spaces"`, Chain{ 32 | []Command{{"name", []string{"cmd with spaces"}}}, 33 | nil, 34 | []string{"name", "cmd with spaces"}, 35 | }, ""}, 36 | {` name "cmd with escaped \""`, Chain{ 37 | []Command{{"name", []string{"cmd with escaped \""}}}, 38 | nil, 39 | []string{"name", `cmd with escaped "`}, 40 | }, ""}, 41 | {` name 'cmd with "'`, Chain{ 42 | []Command{{"name", []string{"cmd with \""}}}, 43 | nil, 44 | []string{"name", `cmd with "`}, 45 | }, ""}, 46 | {`echo test | grep t`, Chain{ 47 | []Command{{"echo", []string{"test"}}, {"grep", []string{"t"}}}, 48 | []string{"|"}, 49 | []string{"echo", "test", "|", "grep", "t"}, 50 | }, ""}, 51 | {`echo test |& grep t | wc -l`, Chain{ 52 | []Command{{"echo", []string{"test"}}, {"grep", []string{"t"}}, {"wc", []string{"-l"}}}, 53 | []string{"|&", "|"}, 54 | []string{"echo", "test", "|&", "grep", "t", "|", "wc", "-l"}, 55 | }, ""}, 56 | {`unfinished "quote`, Chain{RawLine: []string{"unfinished"}}, "Unterminated double-quoted string"}, 57 | {`unfinished 'quote`, Chain{RawLine: []string{"unfinished"}}, "Unterminated single-quoted string"}, 58 | {"multiline arg1 < /tmp/test`) 100 | assert.NoError(t, err) 101 | 102 | testInput := &bytes.Buffer{} 103 | testOutput := &bytes.Buffer{} 104 | 105 | cmd, fn, err := toTest.ToCommand(testInput, testOutput) 106 | defer fn() 107 | 108 | assert.NoError(t, err) 109 | 110 | assert.Equal(t, 111 | `[IS] *bytes.Buffer ╮ 112 | [OS] │ ╭ *bytes.Buffer, *os.File 113 | [SO] │ ╭╮ ╭╮ │ 114 | [CM] ╰ /usr/bin/echo "test" ╡╞ /usr/bin/grep "t" ╡╰ /usr/bin/wc "-l" ╡ 115 | [SE] ╰╯ ╽ ╽`, cmd.String()) 116 | } 117 | 118 | func TestChain_IsLongTerm(t *testing.T) { 119 | toTest, err := interpretLine(`sub test | echo test |& grep t | wc -l &`) 120 | assert.NoError(t, err) 121 | 122 | assert.True(t, toTest.IsLongTerm()) 123 | } 124 | -------------------------------------------------------------------------------- /internal/io/macros.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | cmdchain "github.com/rainu/go-command-chain" 7 | "github.com/rainu/mqtt-shell/internal/config" 8 | "io" 9 | "strings" 10 | "text/template" 11 | ) 12 | 13 | type MacroManager struct { 14 | MacroSpecs map[string]config.Macro 15 | macroTemplates map[string]*template.Template 16 | macroFunctions map[string]interface{} 17 | Output io.Writer 18 | } 19 | 20 | func (m *MacroManager) ResolveMacro(line string) []string { 21 | chain, err := interpretLine(line) 22 | if err != nil { 23 | return []string{line} 24 | } 25 | 26 | macroName := chain.Commands[0].Name 27 | if _, ok := m.MacroSpecs[macroName]; !ok { 28 | m.Output.Write([]byte("unknown macro\n")) 29 | return nil 30 | } 31 | 32 | macroSpec := m.MacroSpecs[macroName] 33 | if len(chain.Commands[0].Arguments) < len(macroSpec.Arguments) || (!macroSpec.Varargs && len(chain.Commands[0].Arguments) != len(macroSpec.Arguments)) { 34 | m.Output.Write([]byte("invalid macro arguments\n")) 35 | m.Output.Write([]byte("usage: " + macroName + " " + strings.Join(macroSpec.Arguments, " ") + "\n")) 36 | return nil 37 | } 38 | 39 | splitLine := strings.SplitN(line, "|", 2) 40 | pipe := "" 41 | if len(splitLine) >= 2 { 42 | pipe = splitLine[1] 43 | } 44 | 45 | if len(macroSpec.Arguments) == 0 { 46 | if pipe == "" { 47 | return macroSpec.Commands 48 | } 49 | 50 | lines := make([]string, len(macroSpec.Commands)) 51 | for i := 0; i < len(lines); i++ { 52 | lines[i] = macroSpec.Commands[i] 53 | if strings.HasPrefix(macroSpec.Commands[i], commandSub+" ") { 54 | lines[i] += " | " + pipe 55 | } 56 | } 57 | return lines 58 | } 59 | 60 | if len(macroSpec.Commands) > 0 { 61 | return m.resolveSimpleMacro(macroSpec, pipe, &chain) 62 | } else { 63 | return m.resolveScriptMacro(macroSpec, pipe, &chain) 64 | } 65 | } 66 | 67 | func (m *MacroManager) resolveSimpleMacro(macroSpec config.Macro, pipe string, chain *Chain) []string { 68 | staticArgs := chain.Commands[0].Arguments[:len(macroSpec.Arguments)-1] 69 | varArgs := chain.Commands[0].Arguments[len(macroSpec.Arguments)-1:] 70 | lines := make([]string, 0, len(macroSpec.Commands)*len(varArgs)) 71 | 72 | for _, arg := range varArgs { 73 | for _, macroCommand := range macroSpec.Commands { 74 | line := strings.Replace(macroCommand, "\\$", "__DOLLAR_ESCAPE__", -1) 75 | 76 | i := 0 77 | for ; i < len(staticArgs); i++ { 78 | line = strings.Replace(line, fmt.Sprintf("$%d", i+1), staticArgs[i], -1) 79 | } 80 | line = strings.Replace(line, fmt.Sprintf("$%d", i+1), arg, -1) 81 | line = strings.Replace(line, "__DOLLAR_ESCAPE__", "$", -1) 82 | 83 | if pipe != "" && strings.HasPrefix(line, commandSub+" ") { 84 | line += " | " + pipe 85 | } 86 | 87 | lines = append(lines, line) 88 | } 89 | } 90 | 91 | return lines 92 | } 93 | 94 | func (m *MacroManager) resolveScriptMacro(macroSpec config.Macro, pipe string, chain *Chain) []string { 95 | staticArgs := chain.Commands[0].Arguments[:len(macroSpec.Arguments)-1] 96 | varArgs := chain.Commands[0].Arguments[len(macroSpec.Arguments)-1:] 97 | lines := make([]string, 0, len(macroSpec.Commands)*len(varArgs)) 98 | macroName := chain.Commands[0].Name 99 | 100 | tmplData := map[string]string{} 101 | 102 | i := 0 103 | for ; i < len(staticArgs); i++ { 104 | tmplData[fmt.Sprintf("Arg%d", i+1)] = staticArgs[i] 105 | } 106 | 107 | for _, arg := range varArgs { 108 | buf := bytes.NewBufferString("") 109 | tmplData[fmt.Sprintf("Arg%d", i+1)] = arg 110 | 111 | if err := m.macroTemplates[macroName].Execute(buf, tmplData); err != nil { 112 | m.Output.Write([]byte(fmt.Sprintf("Error while execute macro script: %s\n", err.Error()))) 113 | continue 114 | } 115 | 116 | for _, line := range strings.Split(buf.String(), "\n") { 117 | if pipe != "" && strings.HasPrefix(line, commandSub+" ") { 118 | line += " | " + pipe 119 | } 120 | lines = append(lines, line) 121 | } 122 | } 123 | 124 | return lines 125 | } 126 | 127 | func (m *MacroManager) PrintMacros() { 128 | for macroName, macroSpec := range m.MacroSpecs { 129 | m.Output.Write([]byte(fmt.Sprintf("%s - %s\n", macroName, macroSpec.Description))) 130 | } 131 | } 132 | 133 | func (m *MacroManager) ValidateAndInitMacros() error { 134 | m.macroTemplates = map[string]*template.Template{} 135 | m.macroFunctions = map[string]interface{}{ 136 | "exec": m.macroFuncExec, 137 | "log": m.macroFuncLog, 138 | } 139 | 140 | for macroName, macroSpec := range m.MacroSpecs { 141 | if !m.IsMacro(macroName) || !m.IsMacro(macroName+" ") { 142 | //the given macroName is already in use of internal commands 143 | return fmt.Errorf(`invalid macro name '%s': reserved`, macroName) 144 | } 145 | if len(macroSpec.Commands) == 0 && macroSpec.Script == "" { 146 | return fmt.Errorf(`invalid macro '%s': there is no 'commands' nor 'script'`, macroName) 147 | } else if len(macroSpec.Commands) > 0 && macroSpec.Script != "" { 148 | return fmt.Errorf(`invalid macro '%s': only 'commands' or 'script' must be used`, macroName) 149 | } 150 | 151 | if macroSpec.Script != "" { 152 | tmpl, err := template.New(macroName).Funcs(m.macroFunctions).Parse(macroSpec.Script) 153 | if err != nil { 154 | return fmt.Errorf(`invalid macro '%s': unable to parse script: %w`, macroName, err) 155 | } 156 | m.macroTemplates[macroName] = tmpl 157 | } 158 | } 159 | 160 | return nil 161 | } 162 | 163 | func (m *MacroManager) macroFuncExec(line string) string { 164 | chain, err := interpretLine(line) 165 | if err != nil { 166 | panic(err) 167 | } 168 | 169 | buf := bytes.NewBufferString("") 170 | 171 | var cmdChainBuilder cmdchain.ChainBuilder = cmdchain.Builder() 172 | for _, command := range chain.Commands { 173 | cmdChainBuilder = cmdChainBuilder.Join(command.Name, command.Arguments...) 174 | } 175 | 176 | err = cmdChainBuilder.Finalize(). 177 | WithOutput(buf). 178 | WithError(buf). 179 | Run() 180 | 181 | if err != nil { 182 | panic(err) 183 | } 184 | 185 | return buf.String() 186 | } 187 | 188 | func (m *MacroManager) macroFuncLog(format string, args ...interface{}) string { 189 | output := fmt.Sprintf(format, args...) 190 | 191 | if !strings.HasSuffix(output, "\n") { 192 | output += "\n" 193 | } 194 | 195 | m.Output.Write([]byte(output)) 196 | return "" 197 | } 198 | 199 | func (m *MacroManager) IsMacro(line string) bool { 200 | switch { 201 | case line == commandExit: 202 | fallthrough 203 | case line == commandHelp: 204 | fallthrough 205 | case line == commandList: 206 | fallthrough 207 | case line == commandListColors: 208 | fallthrough 209 | case strings.HasPrefix(line, commandPub+" "): 210 | fallthrough 211 | case strings.HasPrefix(line, commandSub+" "): 212 | fallthrough 213 | case strings.HasPrefix(line, commandUnsub+" "): 214 | return false 215 | default: 216 | return true 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /internal/io/macros_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "github.com/rainu/mqtt-shell/internal/config" 8 | "github.com/stretchr/testify/assert" 9 | "testing" 10 | ) 11 | 12 | func TestMacroManager_ResolveMacro_interpretError(t *testing.T) { 13 | oInterpretLine := interpretLine 14 | interpretLine = func(line string) (Chain, error) { 15 | assert.Equal(t, "someLine", line) 16 | return Chain{}, errors.New("someError") 17 | } 18 | defer func() { 19 | interpretLine = oInterpretLine 20 | }() 21 | 22 | toTest := MacroManager{} 23 | 24 | result := toTest.ResolveMacro("someLine") 25 | 26 | assert.Equal(t, []string{"someLine"}, result) 27 | } 28 | 29 | func TestMacroManager_ResolveMacro_unknownMacro(t *testing.T) { 30 | oInterpretLine := interpretLine 31 | interpretLine = func(line string) (Chain, error) { 32 | return Chain{ 33 | Commands: []Command{{Name: "invalid"}}, 34 | }, nil 35 | } 36 | defer func() { 37 | interpretLine = oInterpretLine 38 | }() 39 | 40 | output := &bytes.Buffer{} 41 | toTest := MacroManager{Output: output} 42 | 43 | result := toTest.ResolveMacro("someLine") 44 | assert.Nil(t, result) 45 | assert.Equal(t, "unknown macro\n", output.String()) 46 | } 47 | 48 | func TestMacroManager_ResolveMacro_tooLessArguments(t *testing.T) { 49 | oInterpretLine := interpretLine 50 | interpretLine = func(line string) (Chain, error) { 51 | return Chain{ 52 | Commands: []Command{{Name: "macro"}}, 53 | }, nil 54 | } 55 | defer func() { 56 | interpretLine = oInterpretLine 57 | }() 58 | 59 | output := &bytes.Buffer{} 60 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 61 | "macro": { 62 | Arguments: []string{"arg0", "arg1"}, 63 | Varargs: false, 64 | }, 65 | }} 66 | 67 | result := toTest.ResolveMacro("macro") 68 | assert.Nil(t, result) 69 | assert.Equal(t, "invalid macro arguments\nusage: macro arg0 arg1\n", output.String()) 70 | } 71 | 72 | func TestMacroManager_ResolveMacro_tooMuchArguments(t *testing.T) { 73 | oInterpretLine := interpretLine 74 | interpretLine = func(line string) (Chain, error) { 75 | return Chain{ 76 | Commands: []Command{{Name: "macro", Arguments: []string{"arg0", "arg1"}}}, 77 | }, nil 78 | } 79 | defer func() { 80 | interpretLine = oInterpretLine 81 | }() 82 | 83 | output := &bytes.Buffer{} 84 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 85 | "macro": { 86 | Arguments: []string{}, 87 | Varargs: false, 88 | }, 89 | }} 90 | 91 | result := toTest.ResolveMacro("macro") 92 | assert.Nil(t, result) 93 | assert.Equal(t, "invalid macro arguments\nusage: macro \n", output.String()) 94 | } 95 | 96 | func TestMacroManager_ResolveMacro_simple(t *testing.T) { 97 | oInterpretLine := interpretLine 98 | interpretLine = func(line string) (Chain, error) { 99 | return Chain{ 100 | Commands: []Command{{Name: "macro"}}, 101 | }, nil 102 | } 103 | defer func() { 104 | interpretLine = oInterpretLine 105 | }() 106 | 107 | output := &bytes.Buffer{} 108 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 109 | "macro": { 110 | Arguments: []string{}, 111 | Commands: []string{"cmd1", "cmd2"}, 112 | }, 113 | }} 114 | 115 | result := toTest.ResolveMacro("macro") 116 | assert.Equal(t, []string{"cmd1", "cmd2"}, result) 117 | assert.Equal(t, "", output.String()) 118 | } 119 | 120 | func TestMacroManager_ResolveMacro_simplePiped(t *testing.T) { 121 | oInterpretLine := interpretLine 122 | interpretLine = func(line string) (Chain, error) { 123 | return Chain{ 124 | Commands: []Command{{Name: "macro"}}, 125 | }, nil 126 | } 127 | defer func() { 128 | interpretLine = oInterpretLine 129 | }() 130 | 131 | output := &bytes.Buffer{} 132 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 133 | "macro": { 134 | Arguments: []string{}, 135 | Commands: []string{"sub topic/#", "cmd2"}, 136 | }, 137 | }} 138 | 139 | result := toTest.ResolveMacro("macro | wc -l") 140 | assert.Equal(t, []string{"sub topic/# | wc -l", "cmd2"}, result) 141 | assert.Equal(t, "", output.String()) 142 | } 143 | 144 | func TestMacroManager_ResolveMacro_withArguments(t *testing.T) { 145 | oInterpretLine := interpretLine 146 | interpretLine = func(line string) (Chain, error) { 147 | return Chain{ 148 | Commands: []Command{{Name: "macro", Arguments: []string{"test/#"}}}, 149 | }, nil 150 | } 151 | defer func() { 152 | interpretLine = oInterpretLine 153 | }() 154 | 155 | output := &bytes.Buffer{} 156 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 157 | "macro": { 158 | Arguments: []string{"topic"}, 159 | Commands: []string{"sub $1", "cmd2"}, 160 | }, 161 | }} 162 | 163 | result := toTest.ResolveMacro("macro test/#") 164 | assert.Equal(t, []string{"sub test/#", "cmd2"}, result) 165 | assert.Equal(t, "", output.String()) 166 | } 167 | 168 | func TestMacroManager_ResolveMacro_withVarArguments(t *testing.T) { 169 | oInterpretLine := interpretLine 170 | interpretLine = func(line string) (Chain, error) { 171 | return Chain{ 172 | Commands: []Command{{Name: "macro", Arguments: []string{"test/1", "test/2"}}}, 173 | }, nil 174 | } 175 | defer func() { 176 | interpretLine = oInterpretLine 177 | }() 178 | 179 | output := &bytes.Buffer{} 180 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 181 | "macro": { 182 | Arguments: []string{"topic"}, 183 | Varargs: true, 184 | Commands: []string{"sub $1", "cmd2"}, 185 | }, 186 | }} 187 | 188 | result := toTest.ResolveMacro("macro test/1 test/2") 189 | assert.Equal(t, []string{ 190 | "sub test/1", 191 | "cmd2", 192 | "sub test/2", 193 | "cmd2", 194 | }, result) 195 | assert.Equal(t, "", output.String()) 196 | } 197 | 198 | func TestMacroManager_ResolveMacro_withMixedArguments(t *testing.T) { 199 | oInterpretLine := interpretLine 200 | interpretLine = func(line string) (Chain, error) { 201 | return Chain{ 202 | Commands: []Command{{Name: "macro", Arguments: []string{"test/1", "test/2", "test/3"}}}, 203 | }, nil 204 | } 205 | defer func() { 206 | interpretLine = oInterpretLine 207 | }() 208 | 209 | output := &bytes.Buffer{} 210 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 211 | "macro": { 212 | Arguments: []string{"fixed topic", "topic"}, 213 | Varargs: true, 214 | Commands: []string{"sub $1", "sub $2"}, 215 | }, 216 | }} 217 | 218 | result := toTest.ResolveMacro("macro test/1 test/2 test/3") 219 | assert.Equal(t, []string{ 220 | "sub test/1", 221 | "sub test/2", 222 | "sub test/1", 223 | "sub test/3", 224 | }, result) 225 | assert.Equal(t, "", output.String()) 226 | } 227 | 228 | func TestMacroManager_ResolveMacro_withArguments_piped(t *testing.T) { 229 | oInterpretLine := interpretLine 230 | interpretLine = func(line string) (Chain, error) { 231 | return Chain{ 232 | Commands: []Command{{Name: "macro", Arguments: []string{"test/#"}}}, 233 | }, nil 234 | } 235 | defer func() { 236 | interpretLine = oInterpretLine 237 | }() 238 | 239 | output := &bytes.Buffer{} 240 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 241 | "macro": { 242 | Arguments: []string{"topic"}, 243 | Commands: []string{"sub $1", "cmd2"}, 244 | }, 245 | }} 246 | 247 | result := toTest.ResolveMacro("macro test/# | wc -l") 248 | assert.Equal(t, []string{"sub test/# | wc -l", "cmd2"}, result) 249 | assert.Equal(t, "", output.String()) 250 | } 251 | 252 | func TestMacroManager_ResolveMacro_withVarArgumentsPiped(t *testing.T) { 253 | oInterpretLine := interpretLine 254 | interpretLine = func(line string) (Chain, error) { 255 | return Chain{ 256 | Commands: []Command{{Name: "macro", Arguments: []string{"test/1", "test/2"}}}, 257 | }, nil 258 | } 259 | defer func() { 260 | interpretLine = oInterpretLine 261 | }() 262 | 263 | output := &bytes.Buffer{} 264 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 265 | "macro": { 266 | Arguments: []string{"topic"}, 267 | Varargs: true, 268 | Commands: []string{"sub $1", "cmd2"}, 269 | }, 270 | }} 271 | 272 | result := toTest.ResolveMacro("macro test/1 test/2 | wc -l") 273 | assert.Equal(t, []string{ 274 | "sub test/1 | wc -l", 275 | "cmd2", 276 | "sub test/2 | wc -l", 277 | "cmd2", 278 | }, result) 279 | assert.Equal(t, "", output.String()) 280 | } 281 | 282 | func TestMacroManager_ResolveMacro_script(t *testing.T) { 283 | oInterpretLine := interpretLine 284 | interpretLine = func(line string) (Chain, error) { 285 | return Chain{ 286 | Commands: []Command{{Name: "macro", Arguments: []string{"test/#"}}}, 287 | }, nil 288 | } 289 | defer func() { 290 | interpretLine = oInterpretLine 291 | }() 292 | 293 | output := &bytes.Buffer{} 294 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 295 | "macro": { 296 | Arguments: []string{"topic"}, 297 | Script: "sub {{.Arg1}}\ncmd2", 298 | }, 299 | }} 300 | toTest.ValidateAndInitMacros() 301 | 302 | result := toTest.ResolveMacro("macro test/#") 303 | assert.Equal(t, []string{"sub test/#", "cmd2"}, result) 304 | assert.Equal(t, "", output.String()) 305 | } 306 | 307 | func TestMacroManager_ResolveMacro_script_piped(t *testing.T) { 308 | oInterpretLine := interpretLine 309 | interpretLine = func(line string) (Chain, error) { 310 | return Chain{ 311 | Commands: []Command{{Name: "macro", Arguments: []string{"test/#"}}}, 312 | }, nil 313 | } 314 | defer func() { 315 | interpretLine = oInterpretLine 316 | }() 317 | 318 | output := &bytes.Buffer{} 319 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 320 | "macro": { 321 | Arguments: []string{"topic"}, 322 | Script: "sub {{.Arg1}}\ncmd2", 323 | }, 324 | }} 325 | toTest.ValidateAndInitMacros() 326 | 327 | result := toTest.ResolveMacro("macro test/# | wc -l") 328 | assert.Equal(t, []string{"sub test/# | wc -l", "cmd2"}, result) 329 | assert.Equal(t, "", output.String()) 330 | } 331 | 332 | func TestMacroManager_ResolveMacro_scriptVarArgs(t *testing.T) { 333 | oInterpretLine := interpretLine 334 | interpretLine = func(line string) (Chain, error) { 335 | return Chain{ 336 | Commands: []Command{{Name: "macro", Arguments: []string{"test/1", "test/2"}}}, 337 | }, nil 338 | } 339 | defer func() { 340 | interpretLine = oInterpretLine 341 | }() 342 | 343 | output := &bytes.Buffer{} 344 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 345 | "macro": { 346 | Arguments: []string{"topic"}, 347 | Varargs: true, 348 | Script: "sub {{.Arg1}}\ncmd2", 349 | }, 350 | }} 351 | toTest.ValidateAndInitMacros() 352 | 353 | result := toTest.ResolveMacro("macro test/1 test/2") 354 | assert.Equal(t, []string{ 355 | "sub test/1", 356 | "cmd2", 357 | "sub test/2", 358 | "cmd2", 359 | }, result) 360 | assert.Equal(t, "", output.String()) 361 | } 362 | 363 | func TestMacroManager_ResolveMacro_scriptVarArgs_piped(t *testing.T) { 364 | oInterpretLine := interpretLine 365 | interpretLine = func(line string) (Chain, error) { 366 | return Chain{ 367 | Commands: []Command{{Name: "macro", Arguments: []string{"test/1", "test/2"}}}, 368 | }, nil 369 | } 370 | defer func() { 371 | interpretLine = oInterpretLine 372 | }() 373 | 374 | output := &bytes.Buffer{} 375 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 376 | "macro": { 377 | Arguments: []string{"topic"}, 378 | Varargs: true, 379 | Script: "sub {{.Arg1}}\ncmd2", 380 | }, 381 | }} 382 | toTest.ValidateAndInitMacros() 383 | 384 | result := toTest.ResolveMacro("macro test/1 test/2 | wc -l") 385 | assert.Equal(t, []string{ 386 | "sub test/1 | wc -l", 387 | "cmd2", 388 | "sub test/2 | wc -l", 389 | "cmd2", 390 | }, result) 391 | assert.Equal(t, "", output.String()) 392 | } 393 | 394 | func TestMacroManager_ResolveMacro_scriptMixedArgs(t *testing.T) { 395 | oInterpretLine := interpretLine 396 | interpretLine = func(line string) (Chain, error) { 397 | return Chain{ 398 | Commands: []Command{{Name: "macro", Arguments: []string{"test/1", "test/2", "test/3"}}}, 399 | }, nil 400 | } 401 | defer func() { 402 | interpretLine = oInterpretLine 403 | }() 404 | 405 | output := &bytes.Buffer{} 406 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 407 | "macro": { 408 | Arguments: []string{"fixed topic", "topic"}, 409 | Varargs: true, 410 | Script: "sub {{.Arg1}}\nsub {{.Arg2}}", 411 | }, 412 | }} 413 | toTest.ValidateAndInitMacros() 414 | 415 | result := toTest.ResolveMacro("macro test/1 test/2") 416 | assert.Equal(t, []string{ 417 | "sub test/1", 418 | "sub test/2", 419 | "sub test/1", 420 | "sub test/3", 421 | }, result) 422 | assert.Equal(t, "", output.String()) 423 | } 424 | 425 | func TestMacroManager_PrintMacros(t *testing.T) { 426 | output := &bytes.Buffer{} 427 | toTest := MacroManager{Output: output, MacroSpecs: map[string]config.Macro{ 428 | "macro": {Description: "this will do something"}, 429 | "macro2": {Description: "this will do something else"}, 430 | }} 431 | 432 | toTest.PrintMacros() 433 | assert.Contains(t, output.String(), "macro - this will do something\n") 434 | assert.Contains(t, output.String(), "macro2 - this will do something else\n") 435 | } 436 | 437 | func TestMacroManager_ValidateAndInitMacros_reservedMacroName(t *testing.T) { 438 | toTest := MacroManager{MacroSpecs: map[string]config.Macro{ 439 | commandSub: {Description: "this will do something"}, 440 | }} 441 | 442 | err := toTest.ValidateAndInitMacros() 443 | assert.Error(t, err) 444 | assert.Equal(t, "invalid macro name '"+commandSub+"': reserved", err.Error()) 445 | } 446 | 447 | func TestMacroManager_ValidateAndInitMacros_noScriptOrCommand(t *testing.T) { 448 | toTest := MacroManager{MacroSpecs: map[string]config.Macro{ 449 | "macro": {Description: "this will do something"}, 450 | }} 451 | 452 | err := toTest.ValidateAndInitMacros() 453 | assert.Error(t, err) 454 | assert.Equal(t, "invalid macro 'macro': there is no 'commands' nor 'script'", err.Error()) 455 | } 456 | 457 | func TestMacroManager_ValidateAndInitMacros_scriptAndCommand(t *testing.T) { 458 | toTest := MacroManager{MacroSpecs: map[string]config.Macro{ 459 | "macro": {Description: "this will do something", Script: "script", Commands: []string{"cmd"}}, 460 | }} 461 | 462 | err := toTest.ValidateAndInitMacros() 463 | assert.Error(t, err) 464 | assert.Equal(t, "invalid macro 'macro': only 'commands' or 'script' must be used", err.Error()) 465 | } 466 | 467 | func TestMacroManager_ValidateAndInitMacros_invalidScript(t *testing.T) { 468 | toTest := MacroManager{MacroSpecs: map[string]config.Macro{ 469 | "macro": {Description: "this will do something", Script: "{{"}, 470 | }} 471 | 472 | err := toTest.ValidateAndInitMacros() 473 | assert.Error(t, err) 474 | assert.Equal(t, "invalid macro 'macro': unable to parse script: template: macro:1: unclosed action", err.Error()) 475 | } 476 | 477 | func TestMacroManager_macroFuncExec(t *testing.T) { 478 | output := &bytes.Buffer{} 479 | toTest := MacroManager{Output: output} 480 | 481 | assert.Equal(t, "hello world\n", toTest.macroFuncExec("echo hello world")) 482 | } 483 | 484 | func TestMacroManager_macroFuncLog(t *testing.T) { 485 | output := &bytes.Buffer{} 486 | toTest := MacroManager{Output: output} 487 | 488 | assert.Equal(t, "", toTest.macroFuncLog("hello %s", "world")) 489 | assert.Equal(t, "hello world\n", output.String()) 490 | } 491 | 492 | func TestMacroManager_IsMacro(t *testing.T) { 493 | tests := []struct { 494 | line string 495 | expected bool 496 | }{ 497 | {commandExit, false}, 498 | {commandHelp, false}, 499 | {commandList, false}, 500 | {commandListColors, false}, 501 | {commandPub + " test/topic content", false}, 502 | {commandSub + " test/topic", false}, 503 | {commandUnsub + " test/topic", false}, 504 | {"macro", true}, 505 | } 506 | for i, test := range tests { 507 | t.Run(fmt.Sprintf("TestMacroManager_IsMacro_%d", i), func(t *testing.T) { 508 | toTest := MacroManager{} 509 | if test.expected { 510 | assert.True(t, toTest.IsMacro(test.line)) 511 | } else { 512 | assert.False(t, toTest.IsMacro(test.line)) 513 | } 514 | }) 515 | } 516 | } 517 | -------------------------------------------------------------------------------- /internal/io/mocks/ioWriter.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: io (interfaces: Closer) 3 | 4 | // Package mock_io is a generated GoMock package. 5 | package mock_io 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | ) 12 | 13 | // MockCloser is a mock of Closer interface. 14 | type MockCloser struct { 15 | ctrl *gomock.Controller 16 | recorder *MockCloserMockRecorder 17 | } 18 | 19 | // MockCloserMockRecorder is the mock recorder for MockCloser. 20 | type MockCloserMockRecorder struct { 21 | mock *MockCloser 22 | } 23 | 24 | // NewMockCloser creates a new mock instance. 25 | func NewMockCloser(ctrl *gomock.Controller) *MockCloser { 26 | mock := &MockCloser{ctrl: ctrl} 27 | mock.recorder = &MockCloserMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use. 32 | func (m *MockCloser) EXPECT() *MockCloserMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Close mocks base method. 37 | func (m *MockCloser) Close() error { 38 | m.ctrl.T.Helper() 39 | ret := m.ctrl.Call(m, "Close") 40 | ret0, _ := ret[0].(error) 41 | return ret0 42 | } 43 | 44 | // Close indicates an expected call of Close. 45 | func (mr *MockCloserMockRecorder) Close() *gomock.Call { 46 | mr.mock.ctrl.T.Helper() 47 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockCloser)(nil).Close)) 48 | } 49 | -------------------------------------------------------------------------------- /internal/io/mocks/mqttClient.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/eclipse/paho.mqtt.golang (interfaces: Client) 3 | 4 | // Package mock_io is a generated GoMock package. 5 | package mock_io 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | mqtt "github.com/eclipse/paho.mqtt.golang" 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockClient is a mock of Client interface. 15 | type MockClient struct { 16 | ctrl *gomock.Controller 17 | recorder *MockClientMockRecorder 18 | } 19 | 20 | // MockClientMockRecorder is the mock recorder for MockClient. 21 | type MockClientMockRecorder struct { 22 | mock *MockClient 23 | } 24 | 25 | // NewMockClient creates a new mock instance. 26 | func NewMockClient(ctrl *gomock.Controller) *MockClient { 27 | mock := &MockClient{ctrl: ctrl} 28 | mock.recorder = &MockClientMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockClient) EXPECT() *MockClientMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // AddRoute mocks base method. 38 | func (m *MockClient) AddRoute(arg0 string, arg1 mqtt.MessageHandler) { 39 | m.ctrl.T.Helper() 40 | m.ctrl.Call(m, "AddRoute", arg0, arg1) 41 | } 42 | 43 | // AddRoute indicates an expected call of AddRoute. 44 | func (mr *MockClientMockRecorder) AddRoute(arg0, arg1 interface{}) *gomock.Call { 45 | mr.mock.ctrl.T.Helper() 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRoute", reflect.TypeOf((*MockClient)(nil).AddRoute), arg0, arg1) 47 | } 48 | 49 | // Connect mocks base method. 50 | func (m *MockClient) Connect() mqtt.Token { 51 | m.ctrl.T.Helper() 52 | ret := m.ctrl.Call(m, "Connect") 53 | ret0, _ := ret[0].(mqtt.Token) 54 | return ret0 55 | } 56 | 57 | // Connect indicates an expected call of Connect. 58 | func (mr *MockClientMockRecorder) Connect() *gomock.Call { 59 | mr.mock.ctrl.T.Helper() 60 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockClient)(nil).Connect)) 61 | } 62 | 63 | // Disconnect mocks base method. 64 | func (m *MockClient) Disconnect(arg0 uint) { 65 | m.ctrl.T.Helper() 66 | m.ctrl.Call(m, "Disconnect", arg0) 67 | } 68 | 69 | // Disconnect indicates an expected call of Disconnect. 70 | func (mr *MockClientMockRecorder) Disconnect(arg0 interface{}) *gomock.Call { 71 | mr.mock.ctrl.T.Helper() 72 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockClient)(nil).Disconnect), arg0) 73 | } 74 | 75 | // IsConnected mocks base method. 76 | func (m *MockClient) IsConnected() bool { 77 | m.ctrl.T.Helper() 78 | ret := m.ctrl.Call(m, "IsConnected") 79 | ret0, _ := ret[0].(bool) 80 | return ret0 81 | } 82 | 83 | // IsConnected indicates an expected call of IsConnected. 84 | func (mr *MockClientMockRecorder) IsConnected() *gomock.Call { 85 | mr.mock.ctrl.T.Helper() 86 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsConnected", reflect.TypeOf((*MockClient)(nil).IsConnected)) 87 | } 88 | 89 | // IsConnectionOpen mocks base method. 90 | func (m *MockClient) IsConnectionOpen() bool { 91 | m.ctrl.T.Helper() 92 | ret := m.ctrl.Call(m, "IsConnectionOpen") 93 | ret0, _ := ret[0].(bool) 94 | return ret0 95 | } 96 | 97 | // IsConnectionOpen indicates an expected call of IsConnectionOpen. 98 | func (mr *MockClientMockRecorder) IsConnectionOpen() *gomock.Call { 99 | mr.mock.ctrl.T.Helper() 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsConnectionOpen", reflect.TypeOf((*MockClient)(nil).IsConnectionOpen)) 101 | } 102 | 103 | // OptionsReader mocks base method. 104 | func (m *MockClient) OptionsReader() mqtt.ClientOptionsReader { 105 | m.ctrl.T.Helper() 106 | ret := m.ctrl.Call(m, "OptionsReader") 107 | ret0, _ := ret[0].(mqtt.ClientOptionsReader) 108 | return ret0 109 | } 110 | 111 | // OptionsReader indicates an expected call of OptionsReader. 112 | func (mr *MockClientMockRecorder) OptionsReader() *gomock.Call { 113 | mr.mock.ctrl.T.Helper() 114 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OptionsReader", reflect.TypeOf((*MockClient)(nil).OptionsReader)) 115 | } 116 | 117 | // Publish mocks base method. 118 | func (m *MockClient) Publish(arg0 string, arg1 byte, arg2 bool, arg3 interface{}) mqtt.Token { 119 | m.ctrl.T.Helper() 120 | ret := m.ctrl.Call(m, "Publish", arg0, arg1, arg2, arg3) 121 | ret0, _ := ret[0].(mqtt.Token) 122 | return ret0 123 | } 124 | 125 | // Publish indicates an expected call of Publish. 126 | func (mr *MockClientMockRecorder) Publish(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 127 | mr.mock.ctrl.T.Helper() 128 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Publish", reflect.TypeOf((*MockClient)(nil).Publish), arg0, arg1, arg2, arg3) 129 | } 130 | 131 | // Subscribe mocks base method. 132 | func (m *MockClient) Subscribe(arg0 string, arg1 byte, arg2 mqtt.MessageHandler) mqtt.Token { 133 | m.ctrl.T.Helper() 134 | ret := m.ctrl.Call(m, "Subscribe", arg0, arg1, arg2) 135 | ret0, _ := ret[0].(mqtt.Token) 136 | return ret0 137 | } 138 | 139 | // Subscribe indicates an expected call of Subscribe. 140 | func (mr *MockClientMockRecorder) Subscribe(arg0, arg1, arg2 interface{}) *gomock.Call { 141 | mr.mock.ctrl.T.Helper() 142 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Subscribe", reflect.TypeOf((*MockClient)(nil).Subscribe), arg0, arg1, arg2) 143 | } 144 | 145 | // SubscribeMultiple mocks base method. 146 | func (m *MockClient) SubscribeMultiple(arg0 map[string]byte, arg1 mqtt.MessageHandler) mqtt.Token { 147 | m.ctrl.T.Helper() 148 | ret := m.ctrl.Call(m, "SubscribeMultiple", arg0, arg1) 149 | ret0, _ := ret[0].(mqtt.Token) 150 | return ret0 151 | } 152 | 153 | // SubscribeMultiple indicates an expected call of SubscribeMultiple. 154 | func (mr *MockClientMockRecorder) SubscribeMultiple(arg0, arg1 interface{}) *gomock.Call { 155 | mr.mock.ctrl.T.Helper() 156 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubscribeMultiple", reflect.TypeOf((*MockClient)(nil).SubscribeMultiple), arg0, arg1) 157 | } 158 | 159 | // Unsubscribe mocks base method. 160 | func (m *MockClient) Unsubscribe(arg0 ...string) mqtt.Token { 161 | m.ctrl.T.Helper() 162 | varargs := []interface{}{} 163 | for _, a := range arg0 { 164 | varargs = append(varargs, a) 165 | } 166 | ret := m.ctrl.Call(m, "Unsubscribe", varargs...) 167 | ret0, _ := ret[0].(mqtt.Token) 168 | return ret0 169 | } 170 | 171 | // Unsubscribe indicates an expected call of Unsubscribe. 172 | func (mr *MockClientMockRecorder) Unsubscribe(arg0 ...interface{}) *gomock.Call { 173 | mr.mock.ctrl.T.Helper() 174 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unsubscribe", reflect.TypeOf((*MockClient)(nil).Unsubscribe), arg0...) 175 | } 176 | -------------------------------------------------------------------------------- /internal/io/mocks/mqttMessage.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/eclipse/paho.mqtt.golang (interfaces: Message) 3 | 4 | // Package mock_io is a generated GoMock package. 5 | package mock_io 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | ) 12 | 13 | // MockMessage is a mock of Message interface. 14 | type MockMessage struct { 15 | ctrl *gomock.Controller 16 | recorder *MockMessageMockRecorder 17 | } 18 | 19 | // MockMessageMockRecorder is the mock recorder for MockMessage. 20 | type MockMessageMockRecorder struct { 21 | mock *MockMessage 22 | } 23 | 24 | // NewMockMessage creates a new mock instance. 25 | func NewMockMessage(ctrl *gomock.Controller) *MockMessage { 26 | mock := &MockMessage{ctrl: ctrl} 27 | mock.recorder = &MockMessageMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use. 32 | func (m *MockMessage) EXPECT() *MockMessageMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Ack mocks base method. 37 | func (m *MockMessage) Ack() { 38 | m.ctrl.T.Helper() 39 | m.ctrl.Call(m, "Ack") 40 | } 41 | 42 | // Ack indicates an expected call of Ack. 43 | func (mr *MockMessageMockRecorder) Ack() *gomock.Call { 44 | mr.mock.ctrl.T.Helper() 45 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ack", reflect.TypeOf((*MockMessage)(nil).Ack)) 46 | } 47 | 48 | // Duplicate mocks base method. 49 | func (m *MockMessage) Duplicate() bool { 50 | m.ctrl.T.Helper() 51 | ret := m.ctrl.Call(m, "Duplicate") 52 | ret0, _ := ret[0].(bool) 53 | return ret0 54 | } 55 | 56 | // Duplicate indicates an expected call of Duplicate. 57 | func (mr *MockMessageMockRecorder) Duplicate() *gomock.Call { 58 | mr.mock.ctrl.T.Helper() 59 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Duplicate", reflect.TypeOf((*MockMessage)(nil).Duplicate)) 60 | } 61 | 62 | // MessageID mocks base method. 63 | func (m *MockMessage) MessageID() uint16 { 64 | m.ctrl.T.Helper() 65 | ret := m.ctrl.Call(m, "MessageID") 66 | ret0, _ := ret[0].(uint16) 67 | return ret0 68 | } 69 | 70 | // MessageID indicates an expected call of MessageID. 71 | func (mr *MockMessageMockRecorder) MessageID() *gomock.Call { 72 | mr.mock.ctrl.T.Helper() 73 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MessageID", reflect.TypeOf((*MockMessage)(nil).MessageID)) 74 | } 75 | 76 | // Payload mocks base method. 77 | func (m *MockMessage) Payload() []byte { 78 | m.ctrl.T.Helper() 79 | ret := m.ctrl.Call(m, "Payload") 80 | ret0, _ := ret[0].([]byte) 81 | return ret0 82 | } 83 | 84 | // Payload indicates an expected call of Payload. 85 | func (mr *MockMessageMockRecorder) Payload() *gomock.Call { 86 | mr.mock.ctrl.T.Helper() 87 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Payload", reflect.TypeOf((*MockMessage)(nil).Payload)) 88 | } 89 | 90 | // Qos mocks base method. 91 | func (m *MockMessage) Qos() byte { 92 | m.ctrl.T.Helper() 93 | ret := m.ctrl.Call(m, "Qos") 94 | ret0, _ := ret[0].(byte) 95 | return ret0 96 | } 97 | 98 | // Qos indicates an expected call of Qos. 99 | func (mr *MockMessageMockRecorder) Qos() *gomock.Call { 100 | mr.mock.ctrl.T.Helper() 101 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Qos", reflect.TypeOf((*MockMessage)(nil).Qos)) 102 | } 103 | 104 | // Retained mocks base method. 105 | func (m *MockMessage) Retained() bool { 106 | m.ctrl.T.Helper() 107 | ret := m.ctrl.Call(m, "Retained") 108 | ret0, _ := ret[0].(bool) 109 | return ret0 110 | } 111 | 112 | // Retained indicates an expected call of Retained. 113 | func (mr *MockMessageMockRecorder) Retained() *gomock.Call { 114 | mr.mock.ctrl.T.Helper() 115 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Retained", reflect.TypeOf((*MockMessage)(nil).Retained)) 116 | } 117 | 118 | // Topic mocks base method. 119 | func (m *MockMessage) Topic() string { 120 | m.ctrl.T.Helper() 121 | ret := m.ctrl.Call(m, "Topic") 122 | ret0, _ := ret[0].(string) 123 | return ret0 124 | } 125 | 126 | // Topic indicates an expected call of Topic. 127 | func (mr *MockMessageMockRecorder) Topic() *gomock.Call { 128 | mr.mock.ctrl.T.Helper() 129 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Topic", reflect.TypeOf((*MockMessage)(nil).Topic)) 130 | } 131 | -------------------------------------------------------------------------------- /internal/io/mocks/mqttToken.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/eclipse/paho.mqtt.golang (interfaces: Token) 3 | 4 | // Package mock_io is a generated GoMock package. 5 | package mock_io 6 | 7 | import ( 8 | reflect "reflect" 9 | time "time" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockToken is a mock of Token interface. 15 | type MockToken struct { 16 | ctrl *gomock.Controller 17 | recorder *MockTokenMockRecorder 18 | } 19 | 20 | // MockTokenMockRecorder is the mock recorder for MockToken. 21 | type MockTokenMockRecorder struct { 22 | mock *MockToken 23 | } 24 | 25 | // NewMockToken creates a new mock instance. 26 | func NewMockToken(ctrl *gomock.Controller) *MockToken { 27 | mock := &MockToken{ctrl: ctrl} 28 | mock.recorder = &MockTokenMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockToken) EXPECT() *MockTokenMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Done mocks base method. 38 | func (m *MockToken) Done() <-chan struct{} { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Done") 41 | ret0, _ := ret[0].(<-chan struct{}) 42 | return ret0 43 | } 44 | 45 | // Done indicates an expected call of Done. 46 | func (mr *MockTokenMockRecorder) Done() *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Done", reflect.TypeOf((*MockToken)(nil).Done)) 49 | } 50 | 51 | // Error mocks base method. 52 | func (m *MockToken) Error() error { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "Error") 55 | ret0, _ := ret[0].(error) 56 | return ret0 57 | } 58 | 59 | // Error indicates an expected call of Error. 60 | func (mr *MockTokenMockRecorder) Error() *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockToken)(nil).Error)) 63 | } 64 | 65 | // Wait mocks base method. 66 | func (m *MockToken) Wait() bool { 67 | m.ctrl.T.Helper() 68 | ret := m.ctrl.Call(m, "Wait") 69 | ret0, _ := ret[0].(bool) 70 | return ret0 71 | } 72 | 73 | // Wait indicates an expected call of Wait. 74 | func (mr *MockTokenMockRecorder) Wait() *gomock.Call { 75 | mr.mock.ctrl.T.Helper() 76 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockToken)(nil).Wait)) 77 | } 78 | 79 | // WaitTimeout mocks base method. 80 | func (m *MockToken) WaitTimeout(arg0 time.Duration) bool { 81 | m.ctrl.T.Helper() 82 | ret := m.ctrl.Call(m, "WaitTimeout", arg0) 83 | ret0, _ := ret[0].(bool) 84 | return ret0 85 | } 86 | 87 | // WaitTimeout indicates an expected call of WaitTimeout. 88 | func (mr *MockTokenMockRecorder) WaitTimeout(arg0 interface{}) *gomock.Call { 89 | mr.mock.ctrl.T.Helper() 90 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WaitTimeout", reflect.TypeOf((*MockToken)(nil).WaitTimeout), arg0) 91 | } 92 | -------------------------------------------------------------------------------- /internal/io/processor.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | mqtt "github.com/eclipse/paho.mqtt.golang" 8 | "io" 9 | "sort" 10 | "strconv" 11 | "sync" 12 | ) 13 | 14 | type subscription struct { 15 | qos byte 16 | callback mqtt.MessageHandler 17 | } 18 | 19 | type commandHandle struct { 20 | w io.Closer 21 | closeChan chan interface{} 22 | } 23 | 24 | type processor struct { 25 | client mqtt.Client 26 | out io.Writer 27 | 28 | longTermCommands map[string]commandHandle 29 | subscribedTopics map[string]subscription 30 | } 31 | 32 | func NewProcessor(out io.Writer, client mqtt.Client) *processor { 33 | return &processor{ 34 | client: client, 35 | out: out, 36 | longTermCommands: map[string]commandHandle{}, 37 | subscribedTopics: map[string]subscription{}, 38 | } 39 | } 40 | 41 | func (p *processor) Process(input chan string) { 42 | for line := range input { 43 | chain, err := interpretLine(line) 44 | if err != nil { 45 | p.out.Write([]byte(err.Error() + "\n")) 46 | continue 47 | } 48 | err = p.handleCommand(chain) 49 | if err != nil { 50 | p.out.Write([]byte(err.Error() + "\n")) 51 | } 52 | } 53 | 54 | //close all long term chain inputs (will cause the normally exiting of underlying commands) 55 | for _, input := range p.longTermCommands { 56 | input.w.Close() 57 | } 58 | } 59 | 60 | func (p *processor) GetSubscriptions() []string { 61 | topics := make([]string, 0, len(p.subscribedTopics)) 62 | for topic := range p.subscribedTopics { 63 | topics = append(topics, topic) 64 | } 65 | 66 | sort.Strings(topics) 67 | return topics 68 | } 69 | 70 | func (p *processor) HasSubscriptions() bool { 71 | return len(p.subscribedTopics) > 0 72 | } 73 | 74 | func (p *processor) OnMqttReconnect() { 75 | for topic, subscription := range p.subscribedTopics { 76 | p.client.Subscribe(topic, subscription.qos, subscription.callback) 77 | } 78 | } 79 | 80 | func (p *processor) handleCommand(chain Chain) error { 81 | if len(chain.Commands) == 0 { 82 | return nil 83 | } 84 | 85 | switch chain.Commands[0].Name { 86 | case commandHelp: 87 | return p.handleHelp(chain) 88 | case commandListColors: 89 | return p.handleColors(chain) 90 | case commandPub: 91 | return p.handlePub(chain) 92 | case commandSub: 93 | return p.handleSub(chain) 94 | case commandUnsub: 95 | return p.handleUnsub(chain) 96 | case commandList: 97 | return p.handleList(chain) 98 | default: 99 | return errors.New("unknown command") 100 | } 101 | } 102 | 103 | func (p *processor) handleHelp(chain Chain) error { 104 | p.out.Write([]byte(helpText)) 105 | return nil 106 | } 107 | 108 | func (p *processor) handleColors(chain Chain) error { 109 | for _, dec := range decoratorPool { 110 | p.out.Write([]byte(fmt.Sprintf("%s\n", decorate(dec.String(), dec...)))) 111 | } 112 | 113 | return nil 114 | } 115 | 116 | func (p *processor) handlePub(chain Chain) (err error) { 117 | defer func() { 118 | if err != nil { 119 | err = fmt.Errorf("%s\nUsage: "+commandPub+" [-r] [-q 0|1|2] ", err.Error()) 120 | } 121 | }() 122 | 123 | if len(chain.Commands[0].Arguments) < 2 { 124 | return errors.New("invalid arguments") 125 | } 126 | 127 | var topic, payload string 128 | qos := 0 129 | retained := false 130 | 131 | for i := 0; i < len(chain.Commands[0].Arguments); i++ { 132 | arg := chain.Commands[0].Arguments[i] 133 | 134 | switch arg { 135 | case "-r": 136 | retained = true 137 | case "-q": 138 | if i+1 < len(chain.Commands[0].Arguments) { 139 | var err error 140 | qos, err = strconv.Atoi(chain.Commands[0].Arguments[i+1]) 141 | if err != nil { 142 | return fmt.Errorf("invalid qos level: %w", err) 143 | } 144 | if qos < 0 || qos > 3 { 145 | return errors.New("invalid qos level") 146 | } 147 | i++ 148 | } else { 149 | return errors.New("invalid arguments") 150 | } 151 | default: 152 | if topic == "" { 153 | topic = arg 154 | } else if payload == "" { 155 | payload = arg 156 | } else { 157 | payload += " " + arg 158 | } 159 | } 160 | } 161 | 162 | if topic == "" || payload == "" { 163 | return errors.New("invalid arguments") 164 | } 165 | 166 | if token := p.client.Publish(topic, byte(qos), retained, payload); !token.Wait() { 167 | return token.Error() 168 | } 169 | return nil 170 | } 171 | 172 | func (p *processor) handleList(chain Chain) error { 173 | for _, topic := range p.GetSubscriptions() { 174 | p.out.Write([]byte(topic + "\n")) 175 | } 176 | return nil 177 | } 178 | 179 | func (p *processor) handleUnsub(chain Chain) error { 180 | for _, topic := range chain.Commands[0].Arguments { 181 | if ltWriter, ok := p.longTermCommands[topic]; ok { 182 | //close the command-input-stream (will end the underlying cmdchain) 183 | ltWriter.w.Close() 184 | } 185 | 186 | if token := p.client.Unsubscribe(topic); !token.Wait() { 187 | return token.Error() 188 | } 189 | delete(p.subscribedTopics, topic) 190 | } 191 | 192 | return nil 193 | } 194 | 195 | func (p *processor) handleSub(chain Chain) (err error) { 196 | defer func() { 197 | if err != nil { 198 | err = fmt.Errorf("%s\nUsage: "+commandSub+" [-q 0|1|2] [...topicN]", err.Error()) 199 | } 200 | }() 201 | 202 | topics := make([]string, 0, 1) 203 | qos := 0 204 | 205 | for i := 0; i < len(chain.Commands[0].Arguments); i++ { 206 | arg := chain.Commands[0].Arguments[i] 207 | 208 | switch arg { 209 | case "-q": 210 | if i+1 < len(chain.Commands[0].Arguments) { 211 | var err error 212 | qos, err = strconv.Atoi(chain.Commands[0].Arguments[i+1]) 213 | if err != nil { 214 | return fmt.Errorf("invalid qos level: %w", err) 215 | } 216 | if qos < 0 || qos > 3 { 217 | return errors.New("invalid qos level") 218 | } 219 | i++ 220 | } else { 221 | return errors.New("invalid arguments") 222 | } 223 | default: 224 | topics = append(topics, arg) 225 | } 226 | } 227 | 228 | if len(topics) == 0 { 229 | return errors.New("invalid arguments") 230 | } 231 | 232 | for _, topic := range topics { 233 | clb, err := genSubHandler(p, topic, chain) 234 | if err != nil { 235 | return err 236 | } 237 | 238 | if token := p.client.Subscribe(topic, byte(qos), clb); !token.Wait() { 239 | return token.Error() 240 | } 241 | p.subscribedTopics[topic] = subscription{qos: byte(qos), callback: clb} 242 | } 243 | 244 | return nil 245 | } 246 | 247 | var genSubHandler = func(p *processor, topic string, chain Chain) (func(mqtt.Client, mqtt.Message), error) { 248 | if len(chain.Commands) == 1 { 249 | //the decorator will be saved because of inline func 250 | //so each message for the current sub have the same decorator 251 | decorators := getNextDecorator() 252 | 253 | return func(_ mqtt.Client, message mqtt.Message) { 254 | p.out.Write([]byte(decorate(message.Topic()+" |", decorators...) + " " + string(message.Payload()) + "\n")) 255 | }, nil 256 | } 257 | 258 | //long term chains with shell output work not very well together - so ignore this combination 259 | if chain.IsLongTerm() && chain.IsAppending() { 260 | return p.longTermSub(topic, chain) 261 | } 262 | 263 | //each new input will cause executing a new chain (short term) 264 | return p.shortTermSub(chain), nil 265 | } 266 | 267 | func (p *processor) longTermSub(topic string, chain Chain) (func(mqtt.Client, mqtt.Message), error) { 268 | //long term commands are commands which are running permanently in background 269 | //each new message will be written in ONE input pipe to that command 270 | r, w := io.Pipe() 271 | 272 | if prevWriter, ok := p.longTermCommands[topic]; ok { 273 | //close the previous command-input-stream 274 | prevWriter.w.Close() 275 | } 276 | p.longTermCommands[topic] = commandHandle{ 277 | w: w, 278 | closeChan: make(chan interface{}), 279 | } 280 | cmd, clb, err := chain.ToCommand(r) 281 | if err != nil { 282 | return nil, err 283 | } 284 | 285 | //start the chain in background 286 | go func() { 287 | defer r.Close() 288 | defer clb() 289 | defer close(p.longTermCommands[topic].closeChan) 290 | 291 | //the command chain will be finished if the underlying pipe is closed 292 | if err := cmd.Run(); err != nil { 293 | p.out.Write([]byte(err.Error() + "\n")) 294 | } 295 | }() 296 | 297 | return func(client mqtt.Client, message mqtt.Message) { 298 | //every time a new message will come, push them to the pipe of that chain 299 | w.Write(message.Payload()) 300 | w.Write([]byte("\n")) 301 | }, nil 302 | } 303 | 304 | func (p *processor) shortTermSub(chain Chain) func(mqtt.Client, mqtt.Message) { 305 | //the decorator will be saved because of inline func 306 | //so each message for the current sub have the same decorator 307 | decorators := getNextDecorator() 308 | 309 | return func(client mqtt.Client, message mqtt.Message) { 310 | wg := sync.WaitGroup{} 311 | wg.Add(1) 312 | 313 | writeError := func(err error) { 314 | p.out.Write([]byte(decorate(message.Topic()+" |", decorators...) + " " + err.Error() + "\n")) 315 | } 316 | 317 | go func() { 318 | defer wg.Done() 319 | 320 | writer := make([]io.Writer, 0, 1) 321 | if !chain.IsAppending() { 322 | writer = append(writer, &prefixWriter{ 323 | Prefix: decorate(message.Topic()+" |", decorators...) + " ", 324 | Delegate: p.out, 325 | }) 326 | } 327 | 328 | cmd, clb, err := chain.ToCommand(bytes.NewReader(message.Payload()), writer...) 329 | defer clb() 330 | 331 | if err != nil { 332 | writeError(err) 333 | return 334 | } 335 | 336 | if err = cmd.Run(); err != nil { 337 | writeError(err) 338 | } 339 | }() 340 | 341 | wg.Wait() 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /internal/io/processor_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | mqtt "github.com/eclipse/paho.mqtt.golang" 8 | "github.com/golang/mock/gomock" 9 | mock_io "github.com/rainu/mqtt-shell/internal/io/mocks" 10 | "github.com/stretchr/testify/assert" 11 | "os" 12 | "path" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | //go:generate mockgen -destination=mocks/mqttClient.go -package=mock_io github.com/eclipse/paho.mqtt.golang Client 18 | //go:generate mockgen -destination=mocks/mqttMessage.go -package=mock_io github.com/eclipse/paho.mqtt.golang Message 19 | //go:generate mockgen -destination=mocks/mqttToken.go -package=mock_io github.com/eclipse/paho.mqtt.golang Token 20 | //go:generate mockgen -destination=mocks/ioWriter.go -package=mock_io io Closer 21 | 22 | func TestProcessor_Process_interpretError(t *testing.T) { 23 | oil := interpretLine 24 | defer func() { 25 | interpretLine = oil 26 | }() 27 | 28 | interpretLine = func(line string) (Chain, error) { 29 | assert.Equal(t, "", line) 30 | return Chain{}, errors.New("someError") 31 | } 32 | 33 | output := &bytes.Buffer{} 34 | toTest := NewProcessor(output, nil) 35 | 36 | toTest.Process(filledChan("")) 37 | 38 | assert.Equal(t, "someError\n", output.String()) 39 | } 40 | 41 | func TestProcessor_Process_unknownCommand(t *testing.T) { 42 | oil := interpretLine 43 | defer func() { 44 | interpretLine = oil 45 | }() 46 | 47 | interpretLine = func(line string) (Chain, error) { 48 | return Chain{Commands: []Command{{Name: "UNKOWN"}}}, nil 49 | } 50 | 51 | output := &bytes.Buffer{} 52 | toTest := NewProcessor(output, nil) 53 | 54 | toTest.Process(filledChan("")) 55 | 56 | assert.Equal(t, "unknown command\n", output.String()) 57 | } 58 | 59 | func TestProcessor_Process_noCommands(t *testing.T) { 60 | oil := interpretLine 61 | defer func() { 62 | interpretLine = oil 63 | }() 64 | 65 | interpretLine = func(line string) (Chain, error) { 66 | return Chain{}, nil 67 | } 68 | 69 | output := &bytes.Buffer{} 70 | toTest := NewProcessor(output, nil) 71 | 72 | toTest.Process(filledChan("")) 73 | 74 | assert.Equal(t, "", output.String()) 75 | } 76 | 77 | func TestProcessor_Process_helpCommand(t *testing.T) { 78 | oil := interpretLine 79 | defer func() { 80 | interpretLine = oil 81 | }() 82 | 83 | interpretLine = func(line string) (Chain, error) { 84 | return Chain{Commands: []Command{{Name: commandHelp}}}, nil 85 | } 86 | 87 | output := &bytes.Buffer{} 88 | toTest := NewProcessor(output, nil) 89 | 90 | toTest.Process(filledChan("")) 91 | 92 | assert.Equal(t, helpText, output.String()) 93 | } 94 | 95 | func TestProcessor_Process_listColorCommand(t *testing.T) { 96 | oil := interpretLine 97 | defer func() { 98 | interpretLine = oil 99 | }() 100 | 101 | odp := decoratorPool 102 | defer func() { 103 | decoratorPool = odp 104 | }() 105 | 106 | interpretLine = func(line string) (Chain, error) { 107 | return Chain{Commands: []Command{{Name: commandListColors}}}, nil 108 | } 109 | decoratorPool = []decorator{{"32"}} 110 | 111 | output := &bytes.Buffer{} 112 | toTest := NewProcessor(output, nil) 113 | 114 | toTest.Process(filledChan("")) 115 | 116 | assert.Equal(t, "\x1b[32m32\x1b[0m\n", output.String()) 117 | } 118 | 119 | func TestProcessor_Process_listCommand(t *testing.T) { 120 | oil := interpretLine 121 | defer func() { 122 | interpretLine = oil 123 | }() 124 | 125 | interpretLine = func(line string) (Chain, error) { 126 | return Chain{Commands: []Command{{Name: commandList}}}, nil 127 | } 128 | 129 | output := &bytes.Buffer{} 130 | toTest := NewProcessor(output, nil) 131 | toTest.subscribedTopics["a/topic"] = subscription{} 132 | toTest.subscribedTopics["b/topic"] = subscription{} 133 | toTest.subscribedTopics["c/topic"] = subscription{} 134 | 135 | toTest.Process(filledChan("")) 136 | 137 | assert.Equal(t, "a/topic\nb/topic\nc/topic\n", output.String()) 138 | } 139 | 140 | func TestProcessor_Process_unsubCommand(t *testing.T) { 141 | ctrl := gomock.NewController(t) 142 | defer ctrl.Finish() 143 | 144 | oil := interpretLine 145 | defer func() { 146 | interpretLine = oil 147 | }() 148 | 149 | interpretLine = func(line string) (Chain, error) { 150 | return Chain{Commands: []Command{{Name: commandUnsub, Arguments: []string{"a/topic"}}}}, nil 151 | } 152 | 153 | mockToken := mock_io.NewMockToken(ctrl) 154 | mockToken.EXPECT().Wait().Return(true) 155 | mockMqtt := mock_io.NewMockClient(ctrl) 156 | mockMqtt.EXPECT().Unsubscribe(gomock.Eq("a/topic")).Return(mockToken) 157 | 158 | output := &bytes.Buffer{} 159 | toTest := NewProcessor(output, mockMqtt) 160 | toTest.subscribedTopics["a/topic"] = subscription{} 161 | toTest.subscribedTopics["b/topic"] = subscription{} 162 | 163 | toTest.Process(filledChan("")) 164 | 165 | assert.Equal(t, "", output.String()) 166 | 167 | _, exists := toTest.subscribedTopics["a/topic"] 168 | assert.False(t, exists) 169 | } 170 | 171 | func TestProcessor_Process_unsubCommand_errorWhileUnsub(t *testing.T) { 172 | ctrl := gomock.NewController(t) 173 | defer ctrl.Finish() 174 | 175 | oil := interpretLine 176 | defer func() { 177 | interpretLine = oil 178 | }() 179 | 180 | interpretLine = func(line string) (Chain, error) { 181 | return Chain{Commands: []Command{{Name: commandUnsub, Arguments: []string{"a/topic"}}}}, nil 182 | } 183 | 184 | mockToken := mock_io.NewMockToken(ctrl) 185 | mockToken.EXPECT().Wait().Return(false) 186 | mockToken.EXPECT().Error().Return(errors.New("someError")) 187 | mockMqtt := mock_io.NewMockClient(ctrl) 188 | mockMqtt.EXPECT().Unsubscribe(gomock.Eq("a/topic")).Return(mockToken) 189 | 190 | output := &bytes.Buffer{} 191 | toTest := NewProcessor(output, mockMqtt) 192 | toTest.subscribedTopics["a/topic"] = subscription{} 193 | toTest.subscribedTopics["b/topic"] = subscription{} 194 | 195 | toTest.Process(filledChan("")) 196 | 197 | assert.Equal(t, "someError\n", output.String()) 198 | 199 | _, exists := toTest.subscribedTopics["a/topic"] 200 | assert.True(t, exists) 201 | } 202 | 203 | func TestProcessor_Process_pubCommand_insufficientArguments(t *testing.T) { 204 | ctrl := gomock.NewController(t) 205 | defer ctrl.Finish() 206 | 207 | oil := interpretLine 208 | defer func() { 209 | interpretLine = oil 210 | }() 211 | 212 | interpretLine = func(line string) (Chain, error) { 213 | return Chain{Commands: []Command{{Name: commandPub, Arguments: []string{}}}}, nil 214 | } 215 | 216 | mockMqtt := mock_io.NewMockClient(ctrl) 217 | 218 | output := &bytes.Buffer{} 219 | toTest := NewProcessor(output, mockMqtt) 220 | 221 | toTest.Process(filledChan("")) 222 | 223 | assert.Equal(t, "invalid arguments\nUsage: pub [-r] [-q 0|1|2] \n", output.String()) 224 | } 225 | 226 | func TestProcessor_Process_pubCommand_invalidQoS(t *testing.T) { 227 | tests := []struct { 228 | qos string 229 | expected string 230 | }{ 231 | {"NAN", "invalid qos level: strconv.Atoi: parsing \"NAN\": invalid syntax\nUsage: pub [-r] [-q 0|1|2] \n"}, 232 | {"-1", "invalid qos level\nUsage: pub [-r] [-q 0|1|2] \n"}, 233 | {"4", "invalid qos level\nUsage: pub [-r] [-q 0|1|2] \n"}, 234 | } 235 | for i, test := range tests { 236 | t.Run(fmt.Sprintf("TestProcessor_Process_pubCommand_invalidQoS_%d", i), func(t *testing.T) { 237 | ctrl := gomock.NewController(t) 238 | defer ctrl.Finish() 239 | 240 | oil := interpretLine 241 | defer func() { 242 | interpretLine = oil 243 | }() 244 | 245 | interpretLine = func(line string) (Chain, error) { 246 | return Chain{Commands: []Command{{Name: commandPub, Arguments: []string{"-q", test.qos, "test/topic", "PAYLOAD"}}}}, nil 247 | } 248 | 249 | mockMqtt := mock_io.NewMockClient(ctrl) 250 | 251 | output := &bytes.Buffer{} 252 | toTest := NewProcessor(output, mockMqtt) 253 | 254 | toTest.Process(filledChan("")) 255 | 256 | assert.Equal(t, test.expected, output.String()) 257 | }) 258 | } 259 | } 260 | 261 | func TestProcessor_Process_pubCommand_missingPayload(t *testing.T) { 262 | oil := interpretLine 263 | defer func() { 264 | interpretLine = oil 265 | }() 266 | 267 | interpretLine = func(line string) (Chain, error) { 268 | return Chain{Commands: []Command{{Name: commandPub, Arguments: []string{"-r", "-q", "1"}}}}, nil 269 | } 270 | 271 | output := &bytes.Buffer{} 272 | toTest := NewProcessor(output, nil) 273 | 274 | toTest.Process(filledChan("")) 275 | 276 | assert.Equal(t, "invalid arguments\nUsage: pub [-r] [-q 0|1|2] \n", output.String()) 277 | } 278 | 279 | func TestProcessor_Process_pubCommand_errorOnPublishing(t *testing.T) { 280 | ctrl := gomock.NewController(t) 281 | defer ctrl.Finish() 282 | 283 | oil := interpretLine 284 | defer func() { 285 | interpretLine = oil 286 | }() 287 | 288 | interpretLine = func(line string) (Chain, error) { 289 | return Chain{Commands: []Command{{Name: commandPub, Arguments: []string{"-r", "-q", "1", "test/topic", "PAY", "LOAD"}}}}, nil 290 | } 291 | 292 | mockToken := mock_io.NewMockToken(ctrl) 293 | mockToken.EXPECT().Wait().Return(false) 294 | mockToken.EXPECT().Error().Return(errors.New("someError")) 295 | mockMqtt := mock_io.NewMockClient(ctrl) 296 | mockMqtt.EXPECT().Publish(gomock.Eq("test/topic"), gomock.Eq(byte(1)), gomock.Eq(true), gomock.Eq("PAY LOAD")).Return(mockToken) 297 | 298 | output := &bytes.Buffer{} 299 | toTest := NewProcessor(output, mockMqtt) 300 | 301 | toTest.Process(filledChan("")) 302 | 303 | assert.Equal(t, "someError\nUsage: pub [-r] [-q 0|1|2] \n", output.String()) 304 | } 305 | 306 | func TestProcessor_Process_pubCommand_success(t *testing.T) { 307 | ctrl := gomock.NewController(t) 308 | defer ctrl.Finish() 309 | 310 | oil := interpretLine 311 | defer func() { 312 | interpretLine = oil 313 | }() 314 | 315 | interpretLine = func(line string) (Chain, error) { 316 | return Chain{Commands: []Command{{Name: commandPub, Arguments: []string{"-r", "-q", "1", "test/topic", "PAY", "LOAD"}}}}, nil 317 | } 318 | 319 | mockToken := mock_io.NewMockToken(ctrl) 320 | mockToken.EXPECT().Wait().Return(true) 321 | mockMqtt := mock_io.NewMockClient(ctrl) 322 | mockMqtt.EXPECT().Publish(gomock.Eq("test/topic"), gomock.Eq(byte(1)), gomock.Eq(true), gomock.Eq("PAY LOAD")).Return(mockToken) 323 | 324 | output := &bytes.Buffer{} 325 | toTest := NewProcessor(output, mockMqtt) 326 | 327 | toTest.Process(filledChan("")) 328 | 329 | assert.Equal(t, "", output.String()) 330 | } 331 | 332 | func TestProcessor_Process_subCommand_invalidArguments(t *testing.T) { 333 | tests := []struct { 334 | args []string 335 | expected string 336 | }{ 337 | {[]string{}, "invalid arguments\nUsage: sub [-q 0|1|2] [...topicN]\n"}, 338 | {[]string{"-q"}, "invalid arguments\nUsage: sub [-q 0|1|2] [...topicN]\n"}, 339 | {[]string{"-q", "NAN"}, "invalid qos level: strconv.Atoi: parsing \"NAN\": invalid syntax\nUsage: sub [-q 0|1|2] [...topicN]\n"}, 340 | {[]string{"-q", "-1"}, "invalid qos level\nUsage: sub [-q 0|1|2] [...topicN]\n"}, 341 | {[]string{"-q", "4"}, "invalid qos level\nUsage: sub [-q 0|1|2] [...topicN]\n"}, 342 | } 343 | for i, test := range tests { 344 | t.Run(fmt.Sprintf("TestProcessor_Process_subCommand_invalidArguments_%d", i), func(t *testing.T) { 345 | 346 | ctrl := gomock.NewController(t) 347 | defer ctrl.Finish() 348 | 349 | oil := interpretLine 350 | defer func() { 351 | interpretLine = oil 352 | }() 353 | 354 | ogsh := genSubHandler 355 | defer func() { 356 | genSubHandler = ogsh 357 | }() 358 | 359 | interpretLine = func(line string) (Chain, error) { 360 | return Chain{Commands: []Command{{Name: commandSub, Arguments: test.args}}}, nil 361 | } 362 | 363 | mockMqtt := mock_io.NewMockClient(ctrl) 364 | 365 | output := &bytes.Buffer{} 366 | toTest := NewProcessor(output, mockMqtt) 367 | 368 | genSubHandler = func(p *processor, topic string, chain Chain) (func(mqtt.Client, mqtt.Message), error) { 369 | assert.Same(t, toTest, p) 370 | assert.Equal(t, "test/topic", topic) 371 | 372 | testChain, _ := interpretLine("") 373 | assert.Equal(t, testChain, chain) 374 | 375 | return func(mqtt.Client, mqtt.Message) {}, nil 376 | } 377 | 378 | toTest.Process(filledChan("")) 379 | 380 | assert.Equal(t, test.expected, output.String()) 381 | }) 382 | } 383 | } 384 | 385 | func TestProcessor_Process_subCommand_errorOnSubscribe(t *testing.T) { 386 | ctrl := gomock.NewController(t) 387 | defer ctrl.Finish() 388 | 389 | oil := interpretLine 390 | defer func() { 391 | interpretLine = oil 392 | }() 393 | 394 | ogsh := genSubHandler 395 | defer func() { 396 | genSubHandler = ogsh 397 | }() 398 | 399 | interpretLine = func(line string) (Chain, error) { 400 | return Chain{Commands: []Command{{Name: commandSub, Arguments: []string{"-q", "1", "test/topic"}}}}, nil 401 | } 402 | 403 | mockToken := mock_io.NewMockToken(ctrl) 404 | mockToken.EXPECT().Wait().Return(false) 405 | mockToken.EXPECT().Error().Return(errors.New("someError")) 406 | mockMqtt := mock_io.NewMockClient(ctrl) 407 | mockMqtt.EXPECT().Subscribe(gomock.Eq("test/topic"), gomock.Eq(byte(1)), gomock.Any()).Return(mockToken) 408 | 409 | output := &bytes.Buffer{} 410 | toTest := NewProcessor(output, mockMqtt) 411 | 412 | genSubHandler = func(p *processor, topic string, chain Chain) (func(mqtt.Client, mqtt.Message), error) { 413 | assert.Same(t, toTest, p) 414 | assert.Equal(t, "test/topic", topic) 415 | 416 | testChain, _ := interpretLine("") 417 | assert.Equal(t, testChain, chain) 418 | 419 | return func(mqtt.Client, mqtt.Message) {}, nil 420 | } 421 | 422 | toTest.Process(filledChan("")) 423 | 424 | assert.Equal(t, "someError\nUsage: sub [-q 0|1|2] [...topicN]\n", output.String()) 425 | } 426 | 427 | func TestProcessor_Process_subCommand_genSub(t *testing.T) { 428 | ctrl := gomock.NewController(t) 429 | defer ctrl.Finish() 430 | 431 | oil := interpretLine 432 | defer func() { 433 | interpretLine = oil 434 | }() 435 | 436 | ogsh := genSubHandler 437 | defer func() { 438 | genSubHandler = ogsh 439 | }() 440 | 441 | interpretLine = func(line string) (Chain, error) { 442 | return Chain{Commands: []Command{{Name: commandSub, Arguments: []string{"-q", "1", "test/topic"}}}}, nil 443 | } 444 | mockMqtt := mock_io.NewMockClient(ctrl) 445 | 446 | output := &bytes.Buffer{} 447 | toTest := NewProcessor(output, mockMqtt) 448 | 449 | genSubHandler = func(p *processor, topic string, chain Chain) (func(mqtt.Client, mqtt.Message), error) { 450 | assert.Same(t, toTest, p) 451 | assert.Equal(t, "test/topic", topic) 452 | 453 | testChain, _ := interpretLine("") 454 | assert.Equal(t, testChain, chain) 455 | 456 | return nil, errors.New("someError") 457 | } 458 | 459 | toTest.Process(filledChan("")) 460 | 461 | assert.Equal(t, "someError\nUsage: sub [-q 0|1|2] [...topicN]\n", output.String()) 462 | } 463 | 464 | func TestProcessor_Process_subCommand_success(t *testing.T) { 465 | ctrl := gomock.NewController(t) 466 | defer ctrl.Finish() 467 | 468 | oil := interpretLine 469 | defer func() { 470 | interpretLine = oil 471 | }() 472 | 473 | ogsh := genSubHandler 474 | defer func() { 475 | genSubHandler = ogsh 476 | }() 477 | 478 | interpretLine = func(line string) (Chain, error) { 479 | return Chain{Commands: []Command{{Name: commandSub, Arguments: []string{"-q", "1", "test/topic"}}}}, nil 480 | } 481 | 482 | mockToken := mock_io.NewMockToken(ctrl) 483 | mockToken.EXPECT().Wait().Return(true) 484 | mockMqtt := mock_io.NewMockClient(ctrl) 485 | mockMqtt.EXPECT().Subscribe(gomock.Eq("test/topic"), gomock.Eq(byte(1)), gomock.Any()).Return(mockToken) 486 | 487 | output := &bytes.Buffer{} 488 | toTest := NewProcessor(output, mockMqtt) 489 | 490 | genSubHandler = func(p *processor, topic string, chain Chain) (func(mqtt.Client, mqtt.Message), error) { 491 | assert.Same(t, toTest, p) 492 | assert.Equal(t, "test/topic", topic) 493 | 494 | testChain, _ := interpretLine("") 495 | assert.Equal(t, testChain, chain) 496 | 497 | return func(mqtt.Client, mqtt.Message) {}, nil 498 | } 499 | 500 | toTest.Process(filledChan("")) 501 | 502 | assert.Equal(t, "", output.String()) 503 | assert.Equal(t, byte(1), toTest.subscribedTopics["test/topic"].qos) 504 | assert.NotNil(t, toTest.subscribedTopics["test/topic"].callback) 505 | } 506 | 507 | func TestGenSubHandler_simple(t *testing.T) { 508 | ctrl := gomock.NewController(t) 509 | defer ctrl.Finish() 510 | 511 | ognd := getNextDecorator 512 | defer func() { 513 | getNextDecorator = ognd 514 | }() 515 | getNextDecorator = func() decorator { 516 | return []string{"1"} 517 | } 518 | 519 | output := &bytes.Buffer{} 520 | toTest := NewProcessor(output, nil) 521 | 522 | fn, err := genSubHandler(toTest, "a/topic", Chain{Commands: []Command{{Name: "sub"}}}) 523 | assert.NoError(t, err) 524 | 525 | //call the generated handler and see what he does 526 | testMessage := mock_io.NewMockMessage(ctrl) 527 | testMessage.EXPECT().Topic().Return("a/topic") 528 | testMessage.EXPECT().Payload().Return([]byte("PAYLOAD")) 529 | fn(nil, testMessage) 530 | 531 | assert.Equal(t, "\x1b[1ma/topic |\x1b[0m PAYLOAD\n", output.String()) 532 | } 533 | 534 | func TestGenSubHandler_longTermSub(t *testing.T) { 535 | ctrl := gomock.NewController(t) 536 | defer ctrl.Finish() 537 | 538 | output := &bytes.Buffer{} 539 | toTest := NewProcessor(output, nil) 540 | 541 | outputFile := path.Join(os.TempDir(), "longTermSub.txt") 542 | defer os.Remove(outputFile) 543 | 544 | testChain, err := interpretLine(fmt.Sprintf(`%s a/topic | grep "a" > %s &`, commandSub, outputFile)) 545 | assert.NoError(t, err) 546 | 547 | mockCloser := mock_io.NewMockCloser(ctrl) 548 | mockCloser.EXPECT().Close() 549 | toTest.longTermCommands["a/topic"] = commandHandle{w: mockCloser} 550 | 551 | fn, err := genSubHandler(toTest, "a/topic", testChain) 552 | assert.NoError(t, err) 553 | 554 | //call the generated handler and see what he does 555 | testMessage := mock_io.NewMockMessage(ctrl) 556 | firstCall := testMessage.EXPECT().Payload().Return([]byte("PAYLOAD")) 557 | testMessage.EXPECT().Payload().After(firstCall).Return([]byte("payload")) 558 | fn(nil, testMessage) 559 | fn(nil, testMessage) 560 | 561 | cmdHandle, exists := toTest.longTermCommands["a/topic"] 562 | assert.True(t, exists) 563 | assert.NoError(t, cmdHandle.w.Close()) 564 | 565 | // wait until command is finished 566 | select { 567 | case <-time.After(1 * time.Second): 568 | assert.Fail(t, "timout reached while waiting for command to end") 569 | case <-cmdHandle.closeChan: 570 | } 571 | 572 | fileContent, err := os.ReadFile(outputFile) 573 | assert.NoError(t, err) 574 | 575 | assert.Equal(t, "payload\n", string(fileContent)) 576 | assert.Equal(t, "", output.String()) 577 | } 578 | 579 | func TestGenSubHandler_longTermSub_invalidCommand(t *testing.T) { 580 | ctrl := gomock.NewController(t) 581 | defer ctrl.Finish() 582 | 583 | output := &bytes.Buffer{} 584 | toTest := NewProcessor(output, nil) 585 | 586 | outputFile := path.Join(os.TempDir(), "longTermSub.txt") 587 | defer os.Remove(outputFile) 588 | 589 | testChain, err := interpretLine(fmt.Sprintf(`%s a/topic | iNvAlIdC0mManD "a" > %s &`, commandSub, outputFile)) 590 | assert.NoError(t, err) 591 | 592 | fn, err := genSubHandler(toTest, "a/topic", testChain) 593 | assert.NoError(t, err) 594 | 595 | //call the generated handler and see what he does 596 | testMessage := mock_io.NewMockMessage(ctrl) 597 | firstCall := testMessage.EXPECT().Payload().Return([]byte("PAYLOAD")) 598 | testMessage.EXPECT().Payload().After(firstCall).Return([]byte("payload")) 599 | fn(nil, testMessage) 600 | fn(nil, testMessage) 601 | 602 | cmdHandle, exists := toTest.longTermCommands["a/topic"] 603 | assert.True(t, exists) 604 | assert.NoError(t, cmdHandle.w.Close()) 605 | 606 | // wait until command is finished 607 | select { 608 | case <-time.After(1 * time.Second): 609 | assert.Fail(t, "timout reached while waiting for command to end") 610 | case <-cmdHandle.closeChan: 611 | } 612 | 613 | fileContent, err := os.ReadFile(outputFile) 614 | assert.NoError(t, err) 615 | 616 | assert.Equal(t, "", string(fileContent)) 617 | assert.Equal(t, "failed to start command: exec: \"iNvAlIdC0mManD\": executable file not found in $PATH\n", output.String()) 618 | } 619 | 620 | func TestGenSubHandler_shortTermSub(t *testing.T) { 621 | ctrl := gomock.NewController(t) 622 | defer ctrl.Finish() 623 | 624 | ognd := getNextDecorator 625 | defer func() { 626 | getNextDecorator = ognd 627 | }() 628 | getNextDecorator = func() decorator { 629 | return []string{"1"} 630 | } 631 | 632 | output := &bytes.Buffer{} 633 | toTest := NewProcessor(output, nil) 634 | 635 | testChain, err := interpretLine(fmt.Sprintf(`%s a/topic | grep "a"`, commandSub)) 636 | assert.NoError(t, err) 637 | 638 | fn, err := genSubHandler(toTest, "a/topic", testChain) 639 | assert.NoError(t, err) 640 | 641 | //call the generated handler and see what he does 642 | testMessage := mock_io.NewMockMessage(ctrl) 643 | testMessage.EXPECT().Topic().Return("a/topic").AnyTimes() 644 | firstCall := testMessage.EXPECT().Payload().Return([]byte("PAYLOAD")) 645 | testMessage.EXPECT().Payload().After(firstCall).Return([]byte("payload")) 646 | fn(nil, testMessage) 647 | fn(nil, testMessage) 648 | 649 | assert.Equal(t, "\x1b[1ma/topic |\x1b[0m payload\n", output.String(), `only the "little" payload should be matched (grep)`) 650 | } 651 | 652 | func TestGenSubHandler_shortTermSub_invalidCommand(t *testing.T) { 653 | ctrl := gomock.NewController(t) 654 | defer ctrl.Finish() 655 | 656 | ognd := getNextDecorator 657 | defer func() { 658 | getNextDecorator = ognd 659 | }() 660 | getNextDecorator = func() decorator { 661 | return []string{"1"} 662 | } 663 | 664 | output := &bytes.Buffer{} 665 | toTest := NewProcessor(output, nil) 666 | 667 | testChain, err := interpretLine(fmt.Sprintf(`%s a/topic | iNvAlIdC0mManD "a"`, commandSub)) 668 | assert.NoError(t, err) 669 | 670 | fn, err := genSubHandler(toTest, "a/topic", testChain) 671 | assert.NoError(t, err) 672 | 673 | //call the generated handler and see what he does 674 | testMessage := mock_io.NewMockMessage(ctrl) 675 | testMessage.EXPECT().Topic().Return("a/topic").AnyTimes() 676 | testMessage.EXPECT().Payload().Return([]byte("PAYLOAD")) 677 | fn(nil, testMessage) 678 | 679 | assert.Equal(t, "\x1b[1ma/topic |\x1b[0m failed to start command: exec: \"iNvAlIdC0mManD\": executable file not found in $PATH\n", output.String()) 680 | } 681 | 682 | func filledChan(content ...string) chan string { 683 | result := make(chan string, len(content)) 684 | for _, c := range content { 685 | result <- c 686 | } 687 | close(result) 688 | 689 | return result 690 | } 691 | -------------------------------------------------------------------------------- /internal/io/rich.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gookit/color" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | type decorator []string 11 | 12 | var decoratorPool []decorator 13 | var nextDecorator = 0 14 | var decoratorPoolLock = sync.Mutex{} 15 | 16 | func init() { 17 | colorLevel := color.DetectColorLevel() 18 | 19 | if colorLevel == color.Level16 { 20 | for i := 30; i <= 37; i++ { 21 | decoratorPool = append(decoratorPool, []string{fmt.Sprintf("%d", i)}) 22 | } 23 | for i := 30; i <= 37; i++ { 24 | decoratorPool = append(decoratorPool, []string{fmt.Sprintf("%d", i), "1"}) 25 | } 26 | } else if colorLevel >= color.Level256 { 27 | for i := 15; i >= 0; i-- { 28 | for j := 0; j < 16; j++ { 29 | decoratorPool = append(decoratorPool, []string{fmt.Sprintf("38;5;%d", j*16+i)}) 30 | } 31 | } 32 | for i := 15; i >= 0; i-- { 33 | for j := 0; j < 16; j++ { 34 | decoratorPool = append(decoratorPool, []string{fmt.Sprintf("38;5;%d", j*16+i), "1"}) 35 | } 36 | } 37 | } 38 | } 39 | 40 | func (d decorator) String() string { 41 | return strings.Join(d, " ") 42 | } 43 | 44 | func RemoveDecoratorFromPool(decorator string) { 45 | toRemove := -1 46 | 47 | for i, dec := range decoratorPool { 48 | if dec.String() == decorator { 49 | toRemove = i 50 | break 51 | } 52 | } 53 | 54 | if toRemove != -1 { 55 | decoratorPool = append(decoratorPool[:toRemove], decoratorPool[toRemove+1:]...) 56 | } 57 | } 58 | 59 | var getNextDecorator = func() decorator { 60 | decoratorPoolLock.Lock() 61 | defer decoratorPoolLock.Unlock() 62 | 63 | if len(decoratorPool) == 0 { 64 | return nil 65 | } 66 | 67 | if nextDecorator >= len(decoratorPool) { 68 | nextDecorator = 0 69 | } 70 | defer func() { 71 | nextDecorator++ 72 | }() 73 | 74 | return decoratorPool[nextDecorator] 75 | } 76 | 77 | func decorate(text string, richCodes ...string) string { 78 | sb := strings.Builder{} 79 | 80 | for _, richCode := range richCodes { 81 | sb.WriteString("\u001B[") 82 | sb.WriteString(richCode) 83 | sb.WriteString("m") 84 | } 85 | sb.WriteString(text) 86 | sb.WriteString("\u001B[0m") 87 | 88 | return sb.String() 89 | } 90 | -------------------------------------------------------------------------------- /internal/io/richt_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestRemoveDecoratorFromPool(t *testing.T) { 9 | odp := decoratorPool 10 | defer func() { 11 | decoratorPool = odp 12 | }() 13 | 14 | decoratorPool = []decorator{ 15 | []string{"DEC", "1"}, 16 | []string{"DEC", "2"}, 17 | []string{"DEC", "3"}, 18 | } 19 | 20 | RemoveDecoratorFromPool("DEC 2") 21 | 22 | assert.Equal(t, []decorator{ 23 | []string{"DEC", "1"}, 24 | []string{"DEC", "3"}, 25 | }, decoratorPool) 26 | } 27 | 28 | func TestGetNextDecorator(t *testing.T) { 29 | odp := decoratorPool 30 | defer func() { 31 | decoratorPool = odp 32 | }() 33 | 34 | decoratorPool = []decorator{ 35 | []string{"DEC", "1"}, 36 | []string{"DEC", "2"}, 37 | } 38 | 39 | assert.Equal(t, decorator{"DEC", "1"}, getNextDecorator()) 40 | assert.Equal(t, decorator{"DEC", "2"}, getNextDecorator()) 41 | assert.Equal(t, decorator{"DEC", "1"}, getNextDecorator()) 42 | } 43 | 44 | func TestDecorate(t *testing.T) { 45 | assert.Equal(t, "\x1b[1mTEXT\x1b[0m", decorate("TEXT", "1")) 46 | assert.Equal(t, "\x1b[1m\x1b[2mTEXT\x1b[0m", decorate("TEXT", "1", "2")) 47 | } 48 | -------------------------------------------------------------------------------- /internal/io/shell.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "fmt" 5 | "github.com/rainu/mqtt-shell/internal/config" 6 | "github.com/rainu/readline" 7 | "io" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "unicode" 12 | ) 13 | 14 | var multilineRegex = regexp.MustCompile(`<<([a-zA-Z0-9]+)$`) 15 | 16 | type shell struct { 17 | rlInstance *readline.Instance 18 | macroManager *MacroManager 19 | 20 | targetOut io.Writer 21 | 22 | // wrap function for monkey patching purposes (unit tests) 23 | readline func() (string, error) 24 | } 25 | 26 | func NewShell(prompt, historyFile string, 27 | macroManager *MacroManager, 28 | unsubCompletionClb readline.DynamicCompleteFunc) (instance *shell, err error) { 29 | 30 | instance = &shell{ 31 | macroManager: macroManager, 32 | targetOut: os.Stdout, 33 | } 34 | 35 | qosItem := readline.PcItem("-q", 36 | readline.PcItem("0"), 37 | readline.PcItem("1"), 38 | readline.PcItem("2"), 39 | ) 40 | completer := generateMacroCompleter(macroManager.MacroSpecs) 41 | completer = append(completer, 42 | readline.PcItem(commandListColors), 43 | readline.PcItem(commandMacro), 44 | readline.PcItem(commandExit), 45 | readline.PcItem(commandHelp), 46 | readline.PcItem(commandList), 47 | readline.PcItem(commandPub, 48 | readline.PcItem("-r", 49 | qosItem, 50 | ), 51 | readline.PcItem("-q", 52 | readline.PcItem("0", readline.PcItem("-r")), 53 | readline.PcItem("1", readline.PcItem("-r")), 54 | readline.PcItem("2", readline.PcItem("-r")), 55 | ), 56 | ), 57 | readline.PcItem(commandSub, qosItem), 58 | readline.PcItem(commandUnsub, readline.PcItemDynamic(unsubCompletionClb)), 59 | ) 60 | 61 | instance.rlInstance, err = readline.NewEx(&readline.Config{ 62 | Prompt: prompt, 63 | HistoryFile: historyFile, 64 | AutoComplete: readline.NewPrefixCompleter(completer...), 65 | InterruptPrompt: "^C", 66 | EOFPrompt: commandExit, 67 | 68 | HistorySearchFold: true, 69 | }) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | instance.readline = instance.rlInstance.Readline 75 | 76 | return instance, nil 77 | } 78 | 79 | func generateMacroCompleter(macros map[string]config.Macro) []readline.PrefixCompleterInterface { 80 | macroItems := make([]readline.PrefixCompleterInterface, len(macros)) 81 | 82 | i := 0 83 | for macroName, macroSpec := range macros { 84 | macroItems[i] = readline.PcItem(macroName, make([]readline.PrefixCompleterInterface, len(macroSpec.Arguments))...) 85 | 86 | for j, arg := range macroSpec.Arguments { 87 | macroItems[i].GetChildren()[j] = readline.PcItem(arg) 88 | } 89 | 90 | i++ 91 | } 92 | return macroItems 93 | } 94 | 95 | func (s *shell) Start() chan string { 96 | lineChannel := make(chan string) 97 | 98 | go func() { 99 | defer close(lineChannel) 100 | defer s.Close() 101 | 102 | for { 103 | line, err := s.readline() 104 | if err != nil { 105 | if err == readline.ErrInterrupt { 106 | continue 107 | } 108 | break 109 | } 110 | 111 | //remove non-printable characters to prevent possible strange bugs ;) 112 | line = strings.Map(func(r rune) rune { 113 | if unicode.IsGraphic(r) { 114 | return r 115 | } 116 | return -1 117 | }, line) 118 | line = strings.TrimSpace(line) 119 | 120 | if line == commandExit { 121 | break 122 | } 123 | 124 | if multilineRegex.MatchString(line) { 125 | line = s.readMultilines(line) 126 | } 127 | 128 | if line == "" { 129 | continue 130 | } 131 | 132 | if line == commandMacro { 133 | //if only ".macro" is typed, list all available macroSpecs 134 | s.macroManager.PrintMacros() 135 | continue 136 | } 137 | 138 | lines := []string{line} 139 | if s.macroManager.IsMacro(line) { 140 | lines = s.macroManager.ResolveMacro(line) 141 | } 142 | 143 | for _, line := range lines { 144 | lineChannel <- line 145 | } 146 | } 147 | }() 148 | 149 | return lineChannel 150 | } 151 | 152 | func (s *shell) readMultilines(line string) string { 153 | sb := strings.Builder{} 154 | sb.WriteString(line) 155 | 156 | eofWord := multilineRegex.FindStringSubmatch(line)[1] 157 | 158 | //do not safe the lines hin history 159 | s.rlInstance.HistoryDisable() 160 | defer s.rlInstance.HistoryEnable() 161 | 162 | for { 163 | newLine, err := s.readline() 164 | if err != nil { 165 | return "" //if user cancel the read 166 | } 167 | 168 | sb.WriteString("\n") 169 | sb.WriteString(newLine) 170 | 171 | if strings.HasSuffix(newLine, eofWord) { 172 | break 173 | } 174 | } 175 | 176 | return sb.String() 177 | } 178 | 179 | func (s *shell) Close() error { 180 | return s.rlInstance.Close() 181 | } 182 | 183 | func (s *shell) Write(b []byte) (n int, err error) { 184 | defer func() { 185 | if s.rlInstance != nil { 186 | s.rlInstance.Refresh() 187 | } 188 | }() 189 | 190 | _, err = fmt.Fprintf(s.targetOut, "\r\033[2K%s", string(b)) 191 | if err == nil { 192 | //in happy case we have to make sure that the correct amount of read bytes 193 | //are returned -> otherwise this will cause many io trouble 194 | n = len(b) 195 | } 196 | 197 | return 198 | } 199 | -------------------------------------------------------------------------------- /internal/io/shell_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "github.com/rainu/mqtt-shell/internal/config" 7 | "github.com/rainu/readline" 8 | "github.com/stretchr/testify/assert" 9 | "io" 10 | "os" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestShell_Write(t *testing.T) { 16 | sSource := `some test text` 17 | source := strings.NewReader(sSource) 18 | target := bytes.NewBuffer([]byte{}) 19 | 20 | toTest := &shell{ 21 | targetOut: target, 22 | } 23 | 24 | n, err := io.Copy(toTest, source) 25 | 26 | assert.NoError(t, err) 27 | assert.EqualValues(t, len(sSource), n) 28 | assert.Equal(t, "\r\x1b[2K"+sSource, target.String()) 29 | } 30 | 31 | func TestNewShell(t *testing.T) { 32 | macroManager := &MacroManager{ 33 | MacroSpecs: map[string]config.Macro{ 34 | "test": { 35 | Arguments: []string{"arg1", "arg2"}, 36 | }, 37 | }, 38 | } 39 | unsubClb := func(string) []string { 40 | return []string{"a/topic", "b/topic/#"} 41 | } 42 | 43 | toTest, err := NewShell("PROMPT>", "/tmp/history", macroManager, unsubClb) 44 | 45 | assert.NoError(t, err) 46 | 47 | assert.Same(t, os.Stdout, toTest.targetOut) 48 | assert.Same(t, macroManager, toTest.macroManager) 49 | assert.Equal(t, "PROMPT>", toTest.rlInstance.Config.Prompt) 50 | assert.Equal(t, commandExit, toTest.rlInstance.Config.EOFPrompt) 51 | assert.Equal(t, "^C", toTest.rlInstance.Config.InterruptPrompt) 52 | assert.Equal(t, "/tmp/history", toTest.rlInstance.Config.HistoryFile) 53 | assert.Equal(t, true, toTest.rlInstance.Config.HistorySearchFold) 54 | 55 | suggestions, _ := toTest.rlInstance.Config.AutoComplete.Do([]rune(""), 0) 56 | assert.Equal(t, []string{ 57 | "test ", //macro 58 | commandListColors + " ", 59 | commandMacro + " ", 60 | commandExit + " ", 61 | commandHelp + " ", 62 | commandList + " ", 63 | commandPub + " ", 64 | commandSub + " ", 65 | commandUnsub + " ", 66 | }, rc(suggestions), "the default commands and macros should be suggested") 67 | 68 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune("test "), 5) 69 | assert.Equal(t, []string{ 70 | "arg1 ", //macro argument #1 71 | "arg2 ", //macro argument #2 72 | }, rc(suggestions), "the macro arguments should be suggested") 73 | 74 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune(commandPub+" "), len(commandPub)+1) 75 | assert.Equal(t, []string{"-r ", "-q "}, rc(suggestions), "the pub arguments should be suggested") 76 | 77 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune(commandPub+" -q "), len(commandPub)+4) 78 | assert.Equal(t, []string{"0 ", "1 ", "2 "}, rc(suggestions), "the qos levels should be suggested") 79 | 80 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune(commandPub+" -q 0 "), len(commandPub)+6) 81 | assert.Equal(t, []string{"-r "}, rc(suggestions), "the retained flag should be suggested") 82 | 83 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune(commandPub+" -r "), len(commandPub)+4) 84 | assert.Equal(t, []string{"-q "}, rc(suggestions), "the qos flag should be suggested") 85 | 86 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune(commandSub+" "), len(commandSub)+1) 87 | assert.Equal(t, []string{"-q "}, rc(suggestions), "the qos flag should be suggested") 88 | 89 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune(commandSub+" -q "), len(commandSub)+4) 90 | assert.Equal(t, []string{"0 ", "1 ", "2 "}, rc(suggestions), "the qos levels should be suggested") 91 | 92 | suggestions, _ = toTest.rlInstance.Config.AutoComplete.Do([]rune(commandUnsub+" "), len(commandUnsub)+1) 93 | assert.Equal(t, []string{"a/topic ", "b/topic/# "}, rc(suggestions), "the already subscribed topics should be suggested") 94 | 95 | } 96 | 97 | func rc(in [][]rune) []string { 98 | outer := make([]string, len(in)) 99 | 100 | for i, runes := range in { 101 | outer[i] = string(runes) 102 | } 103 | 104 | return outer 105 | } 106 | 107 | func TestShell_Start_normalLine(t *testing.T) { 108 | toTest, _ := NewShell("PROMPT>", "/tmp/history", &MacroManager{}, nil) 109 | toTest.readline = func() (string, error) { 110 | return "sub a/topic", nil 111 | } 112 | 113 | lines := toTest.Start() 114 | 115 | assert.Equal(t, "sub a/topic", <-lines) 116 | 117 | _, ok := <-lines 118 | assert.True(t, ok) 119 | } 120 | 121 | func TestShell_Start_exit(t *testing.T) { 122 | toTest, _ := NewShell("PROMPT>", "/tmp/history", &MacroManager{}, nil) 123 | toTest.readline = func() (string, error) { 124 | return commandExit, nil 125 | } 126 | 127 | lines := toTest.Start() 128 | 129 | l, ok := <-lines 130 | assert.Equal(t, "", l) 131 | assert.False(t, ok) 132 | } 133 | 134 | func TestShell_Start_interrupt(t *testing.T) { 135 | toTest, _ := NewShell("PROMPT>", "/tmp/history", &MacroManager{}, nil) 136 | 137 | firstCall := true 138 | toTest.readline = func() (string, error) { 139 | if firstCall { 140 | firstCall = false 141 | return "", readline.ErrInterrupt 142 | } 143 | return commandList, nil 144 | } 145 | 146 | lines := toTest.Start() 147 | 148 | assert.Equal(t, commandList, <-lines) 149 | } 150 | 151 | func TestShell_Start_eof(t *testing.T) { 152 | toTest, _ := NewShell("PROMPT>", "/tmp/history", &MacroManager{}, nil) 153 | 154 | firstCall := true 155 | toTest.readline = func() (string, error) { 156 | if firstCall { 157 | firstCall = false 158 | return "", errors.New("someError") 159 | } 160 | return commandList, nil 161 | } 162 | 163 | lines := toTest.Start() 164 | 165 | _, ok := <-lines 166 | assert.False(t, ok) 167 | } 168 | 169 | func TestShell_Start_purging(t *testing.T) { 170 | toTest, _ := NewShell("PROMPT>", "/tmp/history", &MacroManager{}, nil) 171 | toTest.readline = func() (string, error) { 172 | return "\x00 " + commandList + " ", nil 173 | } 174 | 175 | lines := toTest.Start() 176 | 177 | assert.Equal(t, commandList, <-lines, "the escape characters should be removed and the line should be trimmed") 178 | 179 | _, ok := <-lines 180 | assert.True(t, ok) 181 | } 182 | 183 | func TestShell_Start_multiline(t *testing.T) { 184 | toTest, _ := NewShell("PROMPT>", "/tmp/history", &MacroManager{}, nil) 185 | 186 | inputs := []string{ 187 | "pub a/topic <= len(inputs) { 194 | return commandList, nil 195 | } 196 | 197 | l := inputs[curLine] 198 | curLine++ 199 | 200 | return l, nil 201 | } 202 | 203 | lines := toTest.Start() 204 | 205 | assert.Equal(t, "pub a/topic <", "/tmp/history", macroManager, nil) 223 | 224 | firstCall := true 225 | toTest.readline = func() (string, error) { 226 | if firstCall { 227 | firstCall = false 228 | return commandMacro, nil 229 | } 230 | return commandList, nil 231 | } 232 | 233 | lines := toTest.Start() 234 | 235 | _, ok := <-lines 236 | assert.True(t, ok) 237 | assert.Equal(t, "Test - test macro description\n", output.String(), "all macros should be listed") 238 | } 239 | 240 | func TestShell_Start_execMacro(t *testing.T) { 241 | macroManager := &MacroManager{ 242 | MacroSpecs: map[string]config.Macro{ 243 | "Test": { 244 | Description: "test macro description", 245 | Commands: []string{"first line", "second line"}, 246 | }, 247 | }, 248 | } 249 | 250 | toTest, _ := NewShell("PROMPT>", "/tmp/history", macroManager, nil) 251 | 252 | firstCall := true 253 | toTest.readline = func() (string, error) { 254 | if firstCall { 255 | firstCall = false 256 | return "Test", nil 257 | } 258 | return commandList, nil 259 | } 260 | 261 | lines := toTest.Start() 262 | 263 | assert.Equal(t, "first line", <-lines) 264 | assert.Equal(t, "second line", <-lines) 265 | 266 | _, ok := <-lines 267 | assert.True(t, ok) 268 | } 269 | -------------------------------------------------------------------------------- /internal/io/writer.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import "io" 4 | 5 | type prefixWriter struct { 6 | Prefix string 7 | prefixWritten bool 8 | Delegate io.Writer 9 | } 10 | 11 | func (p *prefixWriter) Write(b []byte) (n int, err error) { 12 | givenLen := len(b) 13 | writePrefix := !p.prefixWritten 14 | if writePrefix { 15 | b2 := make([]byte, 0, len(b)+len(p.Prefix)) 16 | b2 = append(b2, []byte(p.Prefix)...) 17 | b2 = append(b2, b...) 18 | b = b2 19 | 20 | p.prefixWritten = true 21 | } 22 | 23 | n, err = p.Delegate.Write(b) 24 | if writePrefix && err == nil { 25 | //in happy case we have to make sure that the correct amount of read bytes 26 | //are returned -> otherwise this will cause many io trouble 27 | n = givenLen 28 | } 29 | 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /internal/io/writer_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "github.com/stretchr/testify/assert" 6 | "io" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestPrefixWriter_Write(t *testing.T) { 12 | sSource := `some test text` 13 | source := strings.NewReader(sSource) 14 | target := bytes.NewBuffer([]byte{}) 15 | 16 | toTest := &prefixWriter{ 17 | Prefix: "prefix | ", 18 | Delegate: target, 19 | } 20 | 21 | n, err := io.Copy(toTest, source) 22 | 23 | assert.NoError(t, err) 24 | assert.EqualValues(t, len(sSource), n) 25 | assert.Equal(t, toTest.Prefix+sSource, target.String()) 26 | } 27 | --------------------------------------------------------------------------------