├── .circleci └── config.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── cli ├── cli.go ├── cli_test.go ├── parsing.go ├── parsing_test.go ├── utils.go └── utils_test.go ├── go.mod ├── go.sum └── main.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | workflows: 4 | version: 2 5 | release-tag: 6 | jobs: 7 | - test 8 | - release: 9 | filters: 10 | branches: 11 | ignore: /.*/ 12 | tags: 13 | only: /^v.*/ 14 | 15 | jobs: 16 | test: 17 | docker: 18 | - image: circleci/golang 19 | working_directory: /go/src/github.com/cv/sd 20 | steps: 21 | - checkout 22 | - run: 23 | name: Get dependencies 24 | command: | 25 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 26 | dep ensure 27 | - run: 28 | name: Tests 29 | command: | 30 | go test ./... 31 | 32 | release: 33 | docker: 34 | - image: circleci/golang 35 | working_directory: /go/src/github.com/cv/sd 36 | steps: 37 | - checkout 38 | - run: 39 | name: Installing dependencies 40 | command: | 41 | sudo apt-get install -y rpm 42 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 43 | dep ensure 44 | - setup_remote_docker: 45 | docker_layer_caching: true 46 | - deploy: 47 | name: goreleaser 48 | command: | 49 | echo $DOCKER_PASSWORD | docker login -u cvillela --password-stdin 50 | curl -sL https://git.io/goreleaser | bash 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | vendor 3 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | 5 | archive: 6 | wrap_in_directory: true 7 | 8 | format_overrides: 9 | - goos: windows 10 | format: zip 11 | 12 | replacements: 13 | darwin: Darwin 14 | linux: Linux 15 | windows: Windows 16 | 386: i386 17 | amd64: x86_64 18 | 19 | files: 20 | - LICENSE 21 | - README.md 22 | 23 | checksum: 24 | name_template: 'checksums.txt' 25 | 26 | snapshot: 27 | name_template: "{{ .Tag }}-snapshot" 28 | 29 | changelog: 30 | sort: asc 31 | 32 | git: 33 | short_hash: true 34 | 35 | nfpm: 36 | maintainer: Carlos Villela 37 | homepage: https://github.com/cv/sd 38 | description: A tool to keep utility scripts neatly organized. 39 | license: MIT 40 | dependencies: 41 | - bash 42 | formats: 43 | - deb 44 | - rpm 45 | 46 | brew: 47 | github: 48 | owner: cv 49 | name: taps 50 | 51 | commit_author: 52 | name: Carlos Villela 53 | email: cv@lixo.org 54 | 55 | homepage: https://github.com/cv/sd 56 | description: A tool to keep utility scripts neatly organized. 57 | 58 | dockers: 59 | - image: cvillela/sd 60 | tag_templates: 61 | - "{{ .Tag }}" 62 | - "v{{ .Major }}" 63 | - "v{{ .Major }}.{{ .Minor }}" 64 | - latest 65 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY sd / 3 | ENTRYPOINT ["/sd"] 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Carlos Villela 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 | # sd: Scripts Dir 2 | 3 | A tool to keep utility scripts neatly organized. 4 | 5 | [![CircleCI](https://circleci.com/gh/cv/sd.svg?style=svg)](https://circleci.com/gh/cv/sd) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/cv/sd)](https://goreportcard.com/report/github.com/cv/sd) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/8225cfac541d3281e160/maintainability)](https://codeclimate.com/github/cv/sd/maintainability) 8 | 9 | 10 | 11 | - [Overview](#overview) 12 | - [Installing](#installing) 13 | + [Homebrew](#homebrew) 14 | + [Go](#go) 15 | + [Other distributions](#other-distributions) 16 | - [Running](#running) 17 | * [Flags](#flags) 18 | * [Aliasing](#aliasing) 19 | * [Completions](#completions) 20 | * [Multiple sources](#multiple-sources) 21 | - [Contributing](#contributing) 22 | - [Thanks](#thanks) 23 | 24 | 25 | 26 | ## Overview 27 | 28 | `sd` scans and provides completion for a nested tree of executable script files. For example, if you want to be able to run: 29 | 30 | ```shell 31 | sd foo bar 123 32 | ``` 33 | 34 | All you need to do is create the following structure: 35 | 36 | ``` 37 | ~/.sd/ 38 | |- foo/ 39 | | |- README 40 | |- bar 41 | ``` 42 | 43 | The first line of `README` is a short description of `foo`. Like this: 44 | 45 | ``` 46 | $ sd --help 47 | Usage: 48 | sd [command] 49 | 50 | Available Commands: 51 | foo Commands related to foo 52 | ``` 53 | 54 | The rest of the `README` file gets displayed when the user asks for further help: 55 | 56 | ``` 57 | $ sd foo --help 58 | Commands related to foo 59 | 60 | This is the longer text description of all the subcommands, switches 61 | and examples of foo. It is displayed when `sd foo --help` is called. 62 | 63 | Usage: 64 | ... 65 | ``` 66 | 67 | The `bar` script *must* be marked executable (`chmod +x`). Any files not marked executable will be ignored. The help text for it looks like this: 68 | 69 | ``` 70 | $ sd foo bar --help 71 | Bars the foos. 72 | 73 | Usage: 74 | sd foo bar [flags] 75 | 76 | Examples: 77 | sd foo bar 123 78 | ``` 79 | 80 | In order to document the script, `sd` pays attention to a few special comments: 81 | 82 | ```shell 83 | #!/bin/sh 84 | # 85 | # bar: Bars the foos. 86 | # usage: bar foo [quux] 87 | # example: bar 12 23 88 | # 89 | echo "sd foo bar has been called" 90 | ``` 91 | 92 | More will be added in the future, so you'll be able to specify and document flags, environment variables, and so on. 93 | 94 | ## Installing 95 | 96 | #### Homebrew 97 | 98 | The easiest way to install and keep `sd` up-to-date for MacOS users is through [Homebrew](https://brew.sh). First, add the `cv/taps` tap to your Homebrew install: 99 | 100 | ``` 101 | $ brew tap cv/taps git@github.com:cv/taps.git 102 | ==> Tapping cv/taps 103 | Cloning into '/usr/local/Homebrew/Library/Taps/cv/homebrew-taps'... 104 | remote: Counting objects: 5, done. 105 | remote: Compressing objects: 100% (5/5), done. 106 | remote: Total 5 (delta 0), reused 0 (delta 0), pack-reused 0 107 | Receiving objects: 100% (5/5), done. 108 | Tapped 1 formula (27 files, 23KB) 109 | ``` 110 | 111 | Then install `sd` with `brew install sd`: 112 | 113 | ``` 114 | $ brew install sd 115 | ==> Installing sd from cv/taps 116 | ==> Downloading https://github.com/cv/sd/releases/download/v0.1.1/sd_0.1.1_Darwin_x86_64.tar.gz 117 | ==> Downloading from https://github-production-release-asset-2e65be.s3.amazonaws.com/128149837/9149f9cc-39b3-11e8-98d8-b5bf16da23b7?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2 118 | ######################################################################## 100.0% 119 | 🍺 /usr/local/Cellar/sd/0.1.1: 5 files, 3MB, built in 7 seconds 120 | ``` 121 | 122 | #### Go 123 | 124 | If you have a Go development environment installed, `go get` should work as expected: 125 | 126 | ```shell 127 | $ go get -u github.com/cv/sd 128 | ``` 129 | 130 | #### Other distributions 131 | 132 | Alternatively, you can grab one of the packages from the [Releases](https://github.com/cv/sd/releases) tab. 133 | 134 | ## Running 135 | 136 | ### Flags 137 | 138 | `sd` pays attention to the following flags in any command: 139 | 140 | * `-d` or `--debug`: Turn on debugging. Especially useful if you are trying to figure out why any given script isn't loading, or isn't loading quite like you'd want it. 141 | * `-e` or `--edit`: Instead of executing a script, `sd` will open it in your favorite editor, as defined by the `VISUAL` or `EDITOR` environment variables. 142 | * `-h` or `--help`: Shows help text for anything. 143 | * `--version`: Displays the version information and exits. 144 | 145 | ### Aliasing 146 | 147 | The hidden `--alias=STRING` flag tells `sd` to behave as if it were called `STRING`. This is useful when aliasing `sd` to something else more memorable, or to scope it to the specific usage of a project. 148 | 149 | For example, if you call it with `sd --alias foo`, it will show the following usage: 150 | 151 | ``` 152 | Usage: 153 | foo [flags] 154 | foo [command] 155 | 156 | Available Commands: 157 | ... 158 | 159 | Use "foo [command] --help" for more information about a command. 160 | ``` 161 | 162 | ### Completions 163 | 164 | To enable shell completions, making `sd` much more pleasant to use, run: 165 | 166 | ```shell 167 | $ source <(sd completions bash) 168 | ``` 169 | 170 | Or add it to `/etc/bash-completion.d`, as documented [in this guide](https://debian-administration.org/article/316/An_introduction_to_bash_completion_part_1). 171 | 172 | Mixing [aliasing](#aliasing) and [completions](#completions) can be very useful in creating a CLI experience that provides inline documentation, good completion and a familiar, integrated, look-and-feel. 173 | 174 | ### Multiple sources 175 | 176 | `sd` loads scripts and dirs in the following order: 177 | 178 | - Your `$HOME/.sd` directory 179 | - Script directories listed in `SD_PATH` 180 | - The `scripts` directory under the current location 181 | 182 | ## Contributing 183 | 184 | Yes, please! Check out the [issues](https://github.com/cv/sd/issues) and [pull requests](https://github.com/cv/sd/pulls). Any feedback is greatly appreciated! 185 | 186 | ## Thanks 187 | 188 | - [Steve Francia](https://github.com/spf13) et al for [Cobra](https://github.com/spf13/cobra) 189 | - [Fabio Rehm](https://github.com/fgrehm) for lots of feedback and ideas 190 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "syscall" 10 | 11 | "github.com/Sirupsen/logrus" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // SD is the main interface to running sd 16 | type SD interface { 17 | Run() error 18 | } 19 | 20 | type sd struct { 21 | root *cobra.Command 22 | initialized bool 23 | } 24 | 25 | // New returns an instance of SD 26 | func New(version string) SD { 27 | s := &sd{ 28 | root: &cobra.Command{ 29 | Use: "sd", 30 | Version: version, 31 | }, 32 | } 33 | s.init() 34 | return s 35 | } 36 | 37 | func (s *sd) init() { 38 | s.initAliasing() 39 | s.initCompletions() 40 | s.initDebugging() 41 | s.initEditing() 42 | 43 | s.initialized = true 44 | } 45 | 46 | func showUsage(cmd *cobra.Command, _ []string) error { 47 | return cmd.Usage() 48 | } 49 | 50 | func (s *sd) Run() error { 51 | if !s.initialized { 52 | return fmt.Errorf("init() not called") 53 | } 54 | 55 | err := s.loadCommands() 56 | if err != nil { 57 | logrus.Debugf("Error loading commands: %v", err) 58 | return err 59 | } 60 | 61 | err = s.root.Execute() 62 | if err != nil { 63 | logrus.Debugf("Error executing command: %v", err) 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (s *sd) initAliasing() { 71 | s.root.PersistentFlags().StringP("alias", "a", "sd", "Use an alias in help text and completions") 72 | err := s.root.PersistentFlags().MarkHidden("alias") 73 | if err != nil { 74 | panic(err) 75 | } 76 | 77 | s.root.Use = "sd" 78 | 79 | // Flags haven't been parsed yet, we need to do it ourselves 80 | for i, arg := range os.Args { 81 | if (arg == "-a" || arg == "--alias") && len(os.Args) >= i+2 { 82 | alias := os.Args[i+1] 83 | if alias == "" { 84 | break 85 | } 86 | s.root.Use = alias 87 | s.root.Version = fmt.Sprintf("%s (aliased to %s)", s.root.Version, alias) 88 | logrus.Debug("Aliasing: sd replaced with ", alias, " in help text") 89 | } 90 | } 91 | 92 | s.root.RunE = showUsage 93 | } 94 | 95 | func (s *sd) initCompletions() { 96 | c := &cobra.Command{ 97 | Use: "completions", 98 | Short: "Generate completion scripts", 99 | RunE: showUsage, 100 | } 101 | 102 | c.AddCommand(&cobra.Command{ 103 | Use: "bash", 104 | Short: "Generate completions for bash", 105 | RunE: func(cmd *cobra.Command, args []string) error { 106 | return cmd.Root().GenBashCompletion(os.Stdout) 107 | }, 108 | }) 109 | 110 | c.AddCommand(&cobra.Command{ 111 | Use: "zsh", 112 | Short: "Generate completions for zsh", 113 | RunE: func(cmd *cobra.Command, args []string) error { 114 | return cmd.Root().GenZshCompletion(os.Stdout) 115 | }, 116 | }) 117 | 118 | logrus.Debug("Completions (bash/zsh) commands added") 119 | s.root.AddCommand(c) 120 | } 121 | 122 | func (s *sd) initDebugging() { 123 | s.root.PersistentFlags().BoolP("debug", "d", false, "Turn debugging on/off") 124 | 125 | // Flags haven't been parsed yet, we need to do it ourselves 126 | for _, arg := range os.Args { 127 | if arg == "-d" || arg == "--debug" { 128 | logrus.SetLevel(logrus.DebugLevel) 129 | } 130 | } 131 | } 132 | 133 | func (s *sd) initEditing() { 134 | s.root.PersistentFlags().BoolP("edit", "e", false, "Edit command") 135 | } 136 | 137 | func (s *sd) loadCommands() error { 138 | logrus.Debug("Loading commands started") 139 | 140 | home := filepath.Join(os.Getenv("HOME"), ".sd") 141 | logrus.Debug("HOME is set to: ", home) 142 | 143 | wd, err := os.Getwd() 144 | if err != nil { 145 | return err 146 | } 147 | logrus.Debug("Current working dir is set to: ", wd) 148 | 149 | current := filepath.Join(wd, "scripts") 150 | logrus.Debug("Looking for ./scripts in: ", current) 151 | 152 | sdPath := os.Getenv("SD_PATH") 153 | paths := filepath.SplitList(sdPath) 154 | logrus.Debug("SD_PATH is set to:", sdPath, ", parsed as: ", paths) 155 | 156 | for _, path := range deduplicate(append([]string{home, current}, paths...)) { 157 | cmds, err := visitDir(path) 158 | if err != nil { 159 | return err 160 | } 161 | 162 | for _, c := range cmds { 163 | s.root.AddCommand(c) 164 | } 165 | } 166 | 167 | logrus.Debug("Loading commands done") 168 | return nil 169 | } 170 | 171 | func visitDir(path string) ([]*cobra.Command, error) { 172 | logrus.Debug("Visiting path: ", path) 173 | var cmds []*cobra.Command 174 | 175 | if _, err := os.Stat(path); os.IsNotExist(err) { 176 | logrus.Debug("Path does not exist: ", path) 177 | return cmds, nil 178 | } 179 | 180 | items, err := ioutil.ReadDir(path) 181 | if err != nil { 182 | return nil, err 183 | } 184 | 185 | for _, item := range items { 186 | switch { 187 | case strings.HasPrefix(item.Name(), "."): 188 | logrus.Debug("Ignoring hidden path: ", filepath.Join(path, item.Name())) 189 | continue 190 | 191 | case item.IsDir(): 192 | logrus.Debug("Found directory: ", filepath.Join(path, item.Name())) 193 | cmd := &cobra.Command{ 194 | Use: fmt.Sprintf("%s [command]", item.Name()), 195 | } 196 | 197 | readmePath := filepath.Join(path, item.Name(), "README") 198 | readme, err := ioutil.ReadFile(readmePath) 199 | if err == nil { 200 | logrus.Debug("Found README at: ", readmePath) 201 | cmd.Short = strings.Split(string(readme), "\n")[0] 202 | cmd.Long = string(readme) 203 | cmd.Args = cobra.NoArgs 204 | cmd.RunE = showUsage 205 | } 206 | 207 | subcmds, err := visitDir(filepath.Join(path, item.Name())) 208 | if err != nil { 209 | return nil, err 210 | } 211 | for _, i := range subcmds { 212 | cmd.AddCommand(i) 213 | } 214 | 215 | if cmd.HasSubCommands() { 216 | logrus.Debug("Directory has scripts (subcommands) inside it: ", filepath.Join(path, item.Name())) 217 | cmd.RunE = showUsage 218 | } 219 | cmds = append(cmds, cmd) 220 | 221 | case item.Mode()&0100 != 0: 222 | logrus.Debug("Script found: ", filepath.Join(path, item.Name())) 223 | 224 | cmd, err := commandFromScript(filepath.Join(path, item.Name())) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | cmds = append(cmds, cmd) 230 | } 231 | } 232 | return cmds, nil 233 | } 234 | 235 | func commandFromScript(path string) (*cobra.Command, error) { 236 | shortDesc, err := shortDescriptionFrom(path) 237 | if err != nil { 238 | return nil, err 239 | } 240 | 241 | usage, args, err := usageFrom(path) 242 | if err != nil { 243 | return nil, err 244 | } 245 | 246 | cmd := &cobra.Command{ 247 | Use: usage, 248 | Short: shortDesc, 249 | Annotations: map[string]string{ 250 | "Source": path, 251 | }, 252 | Args: args, 253 | RunE: execCommand, 254 | } 255 | 256 | example, err := exampleFrom(path) 257 | if err != nil { 258 | return nil, err 259 | } 260 | cmd.Example = example 261 | 262 | logrus.Debug("Created command: ", filepath.Base(path)) 263 | return cmd, nil 264 | } 265 | 266 | // these get mocked in tests 267 | var ( 268 | syscallExec = syscall.Exec 269 | env = os.Getenv 270 | ) 271 | 272 | func execCommand(cmd *cobra.Command, args []string) error { 273 | src := cmd.Annotations["Source"] 274 | edit, err := cmd.Root().PersistentFlags().GetBool("edit") 275 | if err != nil { 276 | return err 277 | } 278 | 279 | if edit { 280 | editor := env("VISUAL") 281 | if editor == "" { 282 | logrus.Debug("$VISUAL not set, trying $EDITOR...") 283 | editor = env("EDITOR") 284 | if editor == "" { 285 | logrus.Debug("$EDITOR not set, trying $(which vim)...") 286 | editor = "$(command -v vim)" 287 | } 288 | } 289 | cmdline := []string{"sh", "-c", strings.Join([]string{editor, src}, " ")} 290 | logrus.Debug("Running ", cmdline) 291 | return syscallExec("/bin/sh", cmdline, os.Environ()) 292 | } 293 | 294 | logrus.Debug("Exec: ", src, " with args: ", args) 295 | return syscallExec(src, append([]string{src}, args...), makeEnv(cmd)) 296 | } 297 | 298 | func makeEnv(cmd *cobra.Command) []string { 299 | out := os.Environ() 300 | out = append(out, fmt.Sprintf("SD_ALIAS=%s", cmd.Root().Use)) 301 | 302 | if debug, _ := cmd.Root().PersistentFlags().GetBool("debug"); debug { 303 | out = append(out, "DEBUG=true") 304 | } 305 | 306 | return out 307 | } 308 | -------------------------------------------------------------------------------- /cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "syscall" 11 | "testing" 12 | 13 | "github.com/Sirupsen/logrus" 14 | "github.com/spf13/cobra" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestInitAliasing(t *testing.T) { 19 | var tests = []struct { 20 | name string 21 | args []string 22 | expected string 23 | }{ 24 | { 25 | "defaults to sd", 26 | []string{}, 27 | "sd", 28 | }, 29 | { 30 | "changes usage string (short)", 31 | []string{"-a", "quack"}, 32 | "quack", 33 | }, 34 | { 35 | "changes usage string (long)", 36 | []string{"--alias", "quack"}, 37 | "quack", 38 | }, 39 | { 40 | "does not change usage when empty", 41 | []string{"--alias", ""}, 42 | "sd", 43 | }, 44 | { 45 | "does not change usage when not given", 46 | []string{"--alias"}, 47 | "sd", 48 | }, 49 | { 50 | "keeps last when multiple given", 51 | []string{"--alias", "foo", "--alias", "bar"}, 52 | "bar", 53 | }, 54 | { 55 | "ignores other params", 56 | []string{"--foo", "foo", "--alias", "quack", "--bar", "bar"}, 57 | "quack", 58 | }, 59 | } 60 | for _, test := range tests { 61 | t.Run(test.name, func(t *testing.T) { 62 | var restore []string 63 | copy(restore, os.Args) 64 | defer func() { 65 | copy(os.Args, restore) 66 | }() 67 | 68 | os.Args = test.args 69 | 70 | sd := &sd{ 71 | root: &cobra.Command{ 72 | Version: "1.0", 73 | }, 74 | } 75 | 76 | sd.initAliasing() 77 | 78 | assert.True(t, sd.root.PersistentFlags().Lookup("alias").Hidden) 79 | assert.Equal(t, test.expected, sd.root.Use) 80 | }) 81 | } 82 | } 83 | 84 | func TestInitCompletions(t *testing.T) { 85 | sd := &sd{ 86 | root: &cobra.Command{}, 87 | } 88 | sd.initCompletions() 89 | 90 | t.Run("adds completion command", func(t *testing.T) { 91 | assert.Len(t, sd.root.Commands(), 1) 92 | cmd := sd.root.Commands()[0] 93 | assert.Equal(t, "completions", cmd.Use) 94 | 95 | t.Run("subcommands", func(t *testing.T) { 96 | assert.Len(t, cmd.Commands(), 2) 97 | }) 98 | 99 | t.Run("bash", func(t *testing.T) { 100 | assert.Equal(t, "bash", cmd.Commands()[0].Use) 101 | }) 102 | 103 | t.Run("zsh", func(t *testing.T) { 104 | assert.Equal(t, "zsh", cmd.Commands()[1].Use) 105 | }) 106 | }) 107 | } 108 | 109 | func TestInitDebugging(t *testing.T) { 110 | logrus.SetOutput(ioutil.Discard) 111 | var restore []string 112 | copy(restore, os.Args) 113 | defer func() { 114 | copy(os.Args, restore) 115 | }() 116 | 117 | sd := &sd{ 118 | root: &cobra.Command{}, 119 | } 120 | 121 | t.Run("sets logrus level", func(t *testing.T) { 122 | assert.Equal(t, logrus.InfoLevel, logrus.GetLevel()) 123 | defer logrus.SetLevel(logrus.InfoLevel) 124 | 125 | os.Args = []string{"-d"} 126 | sd.initDebugging() 127 | 128 | assert.Equal(t, logrus.DebugLevel, logrus.GetLevel()) 129 | }) 130 | } 131 | 132 | func TestInitEditing(t *testing.T) { 133 | sd := &sd{ 134 | root: &cobra.Command{}, 135 | } 136 | sd.initEditing() 137 | 138 | t.Run("creates flag", func(t *testing.T) { 139 | assert.NotNil(t, sd.root.PersistentFlags().Lookup("edit")) 140 | }) 141 | } 142 | 143 | func TestCommandFromScript(t *testing.T) { 144 | t.Run("happy path", func(t *testing.T) { 145 | f, err := ioutil.TempFile("", "test-command-from-script") 146 | assert.NoError(t, err) 147 | 148 | f.WriteString(fmt.Sprintf("#\n# %s: blah\n# example: one two three\n#\n", filepath.Base(f.Name()))) 149 | defer func() { 150 | f.Close() 151 | os.Remove(f.Name()) 152 | }() 153 | 154 | c, err := commandFromScript(f.Name()) 155 | assert.NoError(t, err) 156 | assert.Equal(t, filepath.Base(f.Name()), c.Use) 157 | assert.Equal(t, "blah", c.Short) 158 | assert.Equal(t, " one two three", c.Example) 159 | assert.Equal(t, f.Name(), c.Annotations["Source"]) 160 | }) 161 | } 162 | 163 | func TestExecCommand(t *testing.T) { 164 | t.Run("edit with VISUAL", func(t *testing.T) { 165 | sd := &sd{root: &cobra.Command{}} 166 | sd.initEditing() 167 | sd.root.PersistentFlags().Set("edit", "true") 168 | 169 | defer func() { 170 | syscallExec = syscall.Exec 171 | env = os.Getenv 172 | }() 173 | 174 | env = func(key string) string { 175 | if key == "VISUAL" { 176 | return "some-visual-editor" 177 | } 178 | return "" 179 | } 180 | 181 | called := false 182 | syscallExec = func(argv0 string, argv []string, envv []string) error { 183 | called = true 184 | assert.Equal(t, "/bin/sh", argv0) 185 | assert.Equal(t, []string{"sh", "-c", "some-visual-editor /path/to/foo"}, argv) 186 | return nil 187 | } 188 | 189 | cmd := &cobra.Command{ 190 | Use: "foo", 191 | Annotations: map[string]string{ 192 | "Source": "/path/to/foo", 193 | }, 194 | } 195 | sd.root.AddCommand(cmd) 196 | 197 | err := execCommand(cmd, []string{}) 198 | assert.NoError(t, err) 199 | assert.True(t, called) 200 | }) 201 | 202 | t.Run("edit with EDITOR", func(t *testing.T) { 203 | sd := &sd{root: &cobra.Command{}} 204 | sd.initEditing() 205 | sd.root.PersistentFlags().Set("edit", "true") 206 | 207 | defer func() { 208 | syscallExec = syscall.Exec 209 | env = os.Getenv 210 | }() 211 | 212 | env = func(key string) string { 213 | if key == "VISUAL" { 214 | return "" 215 | } 216 | if key == "EDITOR" { 217 | return "some-editor" 218 | } 219 | return "" 220 | } 221 | 222 | called := false 223 | syscallExec = func(argv0 string, argv []string, envv []string) error { 224 | called = true 225 | assert.Equal(t, "/bin/sh", argv0) 226 | assert.Equal(t, []string{"sh", "-c", "some-editor /path/to/foo"}, argv) 227 | return nil 228 | } 229 | 230 | cmd := &cobra.Command{ 231 | Use: "foo", 232 | Annotations: map[string]string{ 233 | "Source": "/path/to/foo", 234 | }, 235 | } 236 | sd.root.AddCommand(cmd) 237 | 238 | err := execCommand(cmd, []string{}) 239 | assert.NoError(t, err) 240 | assert.True(t, called) 241 | }) 242 | 243 | t.Run("edit with default vim", func(t *testing.T) { 244 | sd := &sd{root: &cobra.Command{}} 245 | sd.initEditing() 246 | sd.root.PersistentFlags().Set("edit", "true") 247 | 248 | defer func() { 249 | syscallExec = syscall.Exec 250 | env = os.Getenv 251 | }() 252 | 253 | env = func(key string) string { 254 | return "" 255 | } 256 | 257 | called := false 258 | syscallExec = func(argv0 string, argv []string, envv []string) error { 259 | called = true 260 | assert.Equal(t, "/bin/sh", argv0) 261 | assert.Equal(t, []string{"sh", "-c", "$(command -v vim) /path/to/foo"}, argv) 262 | return nil 263 | } 264 | 265 | cmd := &cobra.Command{ 266 | Use: "foo", 267 | Annotations: map[string]string{ 268 | "Source": "/path/to/foo", 269 | }, 270 | } 271 | sd.root.AddCommand(cmd) 272 | 273 | err := execCommand(cmd, []string{}) 274 | assert.NoError(t, err) 275 | assert.True(t, called) 276 | }) 277 | 278 | t.Run("exec script", func(t *testing.T) { 279 | sd := &sd{root: &cobra.Command{}} 280 | sd.initEditing() 281 | 282 | defer func() { 283 | syscallExec = syscall.Exec 284 | env = os.Getenv 285 | }() 286 | 287 | env = func(key string) string { 288 | return "" 289 | } 290 | 291 | called := false 292 | syscallExec = func(argv0 string, argv []string, envv []string) error { 293 | called = true 294 | assert.Equal(t, "/path/to/foo", argv0) 295 | assert.Equal(t, []string{"/path/to/foo", "bar"}, argv) 296 | return nil 297 | } 298 | 299 | cmd := &cobra.Command{ 300 | Use: "foo", 301 | Annotations: map[string]string{ 302 | "Source": "/path/to/foo", 303 | }, 304 | } 305 | sd.root.AddCommand(cmd) 306 | 307 | err := execCommand(cmd, []string{"bar"}) 308 | assert.NoError(t, err) 309 | assert.True(t, called) 310 | }) 311 | } 312 | 313 | func TestInit(t *testing.T) { 314 | t.Run("sets the initialized flag", func(t *testing.T) { 315 | sd := &sd{root: &cobra.Command{}} 316 | sd.init() 317 | assert.True(t, sd.initialized) 318 | }) 319 | } 320 | 321 | func TestNew(t *testing.T) { 322 | t.Run("creates valid instane", func(t *testing.T) { 323 | sd := New("1.0.0").(*sd) 324 | assert.True(t, sd.initialized) 325 | assert.Equal(t, "1.0.0", sd.root.Version) 326 | }) 327 | } 328 | 329 | func TestRun(t *testing.T) { 330 | t.Run("error if not initialized", func(t *testing.T) { 331 | s := &sd{} 332 | err := s.Run() 333 | assert.Error(t, err) 334 | }) 335 | } 336 | 337 | func TestShowUsage(t *testing.T) { 338 | cmd := &cobra.Command{} 339 | called := false 340 | cmd.SetUsageFunc(func(c *cobra.Command) error { 341 | called = true 342 | assert.Equal(t, cmd, c) 343 | return nil 344 | }) 345 | showUsage(cmd, []string{}) 346 | assert.True(t, called) 347 | } 348 | 349 | func TestRunCompletionsBash(t *testing.T) { 350 | restore := os.Stdout 351 | r, w, _ := os.Pipe() 352 | os.Stdout = w 353 | outC := make(chan string) 354 | 355 | var out string 356 | go func() { 357 | var buf bytes.Buffer 358 | io.Copy(&buf, r) 359 | outC <- buf.String() 360 | }() 361 | 362 | var args []string 363 | copy(args, os.Args) 364 | defer func() { 365 | copy(os.Args, args) 366 | }() 367 | 368 | os.Args = []string{"sd", "completions", "bash"} 369 | 370 | err := New("1.0").Run() 371 | 372 | func() { 373 | w.Close() 374 | os.Stdout = restore 375 | out = <-outC 376 | }() 377 | 378 | assert.NoError(t, err) 379 | 380 | assert.Contains(t, out, "__sd_debug()") 381 | } 382 | 383 | func TestRunCompletionsZsh(t *testing.T) { 384 | restore := os.Stdout 385 | r, w, _ := os.Pipe() 386 | os.Stdout = w 387 | outC := make(chan string) 388 | 389 | var out string 390 | go func() { 391 | var buf bytes.Buffer 392 | io.Copy(&buf, r) 393 | outC <- buf.String() 394 | }() 395 | 396 | var args []string 397 | copy(args, os.Args) 398 | defer func() { 399 | copy(os.Args, args) 400 | }() 401 | 402 | os.Args = []string{"sd", "completions", "zsh"} 403 | 404 | err := New("1.0").Run() 405 | 406 | func() { 407 | w.Close() 408 | os.Stdout = restore 409 | out = <-outC 410 | }() 411 | 412 | assert.NoError(t, err) 413 | assert.Contains(t, out, "#compdef sd") 414 | } 415 | 416 | func TestMakeEnv(t *testing.T) { 417 | t.Run("sets SD_ALIAS", func(t *testing.T) { 418 | t.Run("when not aliased", func(t *testing.T) { 419 | sd := New("1.0").(*sd) 420 | env := makeEnv(sd.root) 421 | assert.Equal(t, "SD_ALIAS=sd", env[len(env)-1]) 422 | }) 423 | 424 | t.Run("when aliased", func(t *testing.T) { 425 | var restore []string 426 | copy(restore, os.Args) 427 | defer func() { 428 | copy(os.Args, restore) 429 | }() 430 | 431 | os.Args = []string{"-a", "foo"} 432 | 433 | sd := New("1.0").(*sd) 434 | env := makeEnv(sd.root) 435 | assert.Equal(t, "SD_ALIAS=foo", env[len(env)-1]) 436 | }) 437 | }) 438 | 439 | t.Run("sets DEBUG", func(t *testing.T) { 440 | root := &cobra.Command{} 441 | root.PersistentFlags().Bool("debug", false, "Toggle debugging") 442 | root.PersistentFlags().Set("debug", "true") 443 | child := &cobra.Command{} 444 | root.AddCommand(child) 445 | 446 | env := makeEnv(child) 447 | assert.Equal(t, "DEBUG=true", env[len(env)-1]) 448 | }) 449 | } 450 | -------------------------------------------------------------------------------- /cli/parsing.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/Sirupsen/logrus" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | /* 16 | 17 | Looks for a line like this: 18 | 19 | # name-of-the-file: short description. 20 | 21 | */ 22 | func shortDescriptionFrom(path string) (string, error) { 23 | file, err := os.Open(path) 24 | if err != nil { 25 | return "", err 26 | } 27 | defer func() { 28 | err = file.Close() 29 | if err != nil { 30 | logrus.Error(err) 31 | } 32 | }() 33 | 34 | r := regexp.MustCompile(fmt.Sprintf(`^# %s: (.*)$`, regexp.QuoteMeta(filepath.Base(path)))) 35 | scanner := bufio.NewScanner(file) 36 | for scanner.Scan() { 37 | match := r.FindStringSubmatch(scanner.Text()) 38 | if len(match) == 2 { 39 | logrus.Debug("Found short description line: ", filepath.Join(path), ", set to: ", match[1]) 40 | return match[1], nil 41 | } 42 | } 43 | return "", nil 44 | } 45 | 46 | /* 47 | 48 | Looks for lines like this: 49 | 50 | # usage: foo arg1 arg2 51 | # usage: foo [arg1] [arg2] 52 | 53 | */ 54 | func usageFrom(path string) (string, cobra.PositionalArgs, error) { 55 | file, err := os.Open(path) 56 | if err != nil { 57 | return "", cobra.ArbitraryArgs, err 58 | } 59 | defer func() { 60 | err = file.Close() 61 | if err != nil { 62 | logrus.Error(err) 63 | } 64 | }() 65 | 66 | r := regexp.MustCompile(`^# usage: (.*)$`) 67 | scanner := bufio.NewScanner(file) 68 | for scanner.Scan() { 69 | match := r.FindStringSubmatch(scanner.Text()) 70 | if len(match) == 2 { 71 | line := match[1] 72 | logrus.Debug("Found usage line: ", filepath.Join(path), ", set to: ", line) 73 | 74 | parts := strings.Split(line, " ") 75 | if len(parts) == 1 { 76 | logrus.Debug("No args allowed") 77 | return line, cobra.NoArgs, nil 78 | } 79 | 80 | var required, optional int 81 | for _, i := range parts[1:] { 82 | if i == "..." { 83 | continue 84 | } 85 | if strings.HasPrefix(i, "[") && strings.HasSuffix(i, "]") { 86 | logrus.Debug("Found optional arg: ", i) 87 | optional++ 88 | } else { 89 | logrus.Debug("Found required arg: ", i) 90 | required++ 91 | } 92 | } 93 | if parts[len(parts)-1] == "..." { 94 | logrus.Debug("Minimum of ", required, " arguments set") 95 | return match[1], cobra.MinimumNArgs(required), nil 96 | } 97 | logrus.Debug("Arg range of ", required, " and ", required+optional, " set") 98 | return match[1], cobra.RangeArgs(required, required+optional), nil 99 | } 100 | } 101 | logrus.Debug("Any args allowed") 102 | return filepath.Base(path), cobra.ArbitraryArgs, nil 103 | } 104 | 105 | /* 106 | 107 | Looks for a line like this: 108 | 109 | # example: foo bar 1 2 3 110 | 111 | */ 112 | func exampleFrom(path string) (string, error) { 113 | file, err := os.Open(path) 114 | if err != nil { 115 | return "", err 116 | } 117 | defer func() { 118 | err = file.Close() 119 | if err != nil { 120 | logrus.Error(err) 121 | } 122 | }() 123 | 124 | r := regexp.MustCompile(`^# example: (.*)$`) 125 | scanner := bufio.NewScanner(file) 126 | for scanner.Scan() { 127 | match := r.FindStringSubmatch(scanner.Text()) 128 | if len(match) == 2 { 129 | logrus.Debug("Found example line: ", filepath.Join(path), ", set to: ", match[1]) 130 | return fmt.Sprintf(" %s", match[1]), nil 131 | } 132 | } 133 | return "", nil 134 | } 135 | -------------------------------------------------------------------------------- /cli/parsing_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestShortDescriptionFrom(t *testing.T) { 15 | var tests = []struct { 16 | name string 17 | inputFormat string 18 | expected string 19 | }{ 20 | { 21 | "happy path", 22 | "#\n# %s: blah\n#\n", 23 | "blah", 24 | }, 25 | { 26 | "missing", 27 | "#\n#\n#\n", 28 | "", 29 | }, 30 | { 31 | "no input", 32 | "", 33 | "", 34 | }, 35 | } 36 | 37 | for _, test := range tests { 38 | t.Run(test.name, func(t *testing.T) { 39 | f, err := ioutil.TempFile("", test.name) 40 | assert.NoError(t, err) 41 | 42 | f.WriteString(fmt.Sprintf(test.inputFormat, filepath.Base(f.Name()))) 43 | defer func() { 44 | f.Close() 45 | os.Remove(f.Name()) 46 | }() 47 | 48 | v, err := shortDescriptionFrom(f.Name()) 49 | assert.NoError(t, err) 50 | assert.Equal(t, test.expected, v) 51 | }) 52 | } 53 | } 54 | 55 | func TestUsageFrom(t *testing.T) { 56 | var tests = []struct { 57 | name string 58 | input string 59 | checkUsage func(t *testing.T, name string, actual string) 60 | checkArgs func(t *testing.T, v cobra.PositionalArgs) 61 | }{ 62 | { 63 | "no arguments", 64 | "#\n# usage: blah\n#\n", 65 | func(t *testing.T, name string, actual string) { 66 | assert.Equal(t, "blah", actual) 67 | }, 68 | func(t *testing.T, v cobra.PositionalArgs) { 69 | assert.NoError(t, v(&cobra.Command{}, []string{})) 70 | assert.Error(t, v(&cobra.Command{}, []string{"first"})) 71 | assert.Error(t, v(&cobra.Command{}, []string{"first", "second"})) 72 | }, 73 | }, 74 | { 75 | "mandatory argument", 76 | "#\n# usage: blah foo\n#\n", 77 | func(t *testing.T, name string, actual string) { 78 | assert.Equal(t, "blah foo", actual) 79 | }, 80 | func(t *testing.T, v cobra.PositionalArgs) { 81 | assert.Error(t, v(&cobra.Command{}, []string{})) 82 | assert.NoError(t, v(&cobra.Command{}, []string{"first"})) 83 | assert.Error(t, v(&cobra.Command{}, []string{"first", "second"})) 84 | }, 85 | }, 86 | { 87 | "optional argument", 88 | "#\n# usage: blah [foo]\n#\n", 89 | func(t *testing.T, name string, actual string) { 90 | assert.Equal(t, "blah [foo]", actual) 91 | }, 92 | func(t *testing.T, v cobra.PositionalArgs) { 93 | assert.NoError(t, v(&cobra.Command{}, []string{})) 94 | assert.NoError(t, v(&cobra.Command{}, []string{"first"})) 95 | assert.Error(t, v(&cobra.Command{}, []string{"first", "second"})) 96 | }, 97 | }, 98 | { 99 | "mandatory and optional arguments", 100 | "#\n# usage: blah foo [bar]\n#\n", 101 | func(t *testing.T, name string, actual string) { 102 | assert.Equal(t, "blah foo [bar]", actual) 103 | }, 104 | func(t *testing.T, v cobra.PositionalArgs) { 105 | assert.Error(t, v(&cobra.Command{}, []string{})) 106 | assert.NoError(t, v(&cobra.Command{}, []string{"first"})) 107 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second"})) 108 | assert.Error(t, v(&cobra.Command{}, []string{"first", "second", "third"})) 109 | }, 110 | }, 111 | { 112 | "unlimited arguments, mixed", 113 | "#\n# usage: blah foo [bar] ...\n#\n", 114 | func(t *testing.T, name string, actual string) { 115 | assert.Equal(t, "blah foo [bar] ...", actual) 116 | }, 117 | func(t *testing.T, v cobra.PositionalArgs) { 118 | assert.Error(t, v(&cobra.Command{}, []string{})) 119 | assert.NoError(t, v(&cobra.Command{}, []string{"first"})) 120 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second"})) 121 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second", "third"})) 122 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second", "third", "fourth"})) 123 | }, 124 | }, 125 | { 126 | "unlimited arguments, required", 127 | "#\n# usage: blah foo bar ...\n#\n", 128 | func(t *testing.T, name string, actual string) { 129 | assert.Equal(t, "blah foo bar ...", actual) 130 | }, 131 | func(t *testing.T, v cobra.PositionalArgs) { 132 | assert.Error(t, v(&cobra.Command{}, []string{})) 133 | assert.Error(t, v(&cobra.Command{}, []string{"first"})) 134 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second"})) 135 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second", "third"})) 136 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second", "third", "fourth"})) 137 | }, 138 | }, 139 | { 140 | "unlimited arguments, optional", 141 | "#\n# usage: blah [bar] ...\n#\n", 142 | func(t *testing.T, name string, actual string) { 143 | assert.Equal(t, "blah [bar] ...", actual) 144 | }, 145 | func(t *testing.T, v cobra.PositionalArgs) { 146 | assert.NoError(t, v(&cobra.Command{}, []string{})) 147 | assert.NoError(t, v(&cobra.Command{}, []string{"first"})) 148 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second"})) 149 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second", "third"})) 150 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second", "third", "fourth"})) 151 | }, 152 | }, 153 | { 154 | "missing", 155 | "#\n#\n#\n", 156 | func(t *testing.T, name string, actual string) { 157 | assert.Equal(t, name, actual) 158 | }, 159 | func(t *testing.T, v cobra.PositionalArgs) { 160 | assert.NoError(t, v(&cobra.Command{}, []string{})) 161 | assert.NoError(t, v(&cobra.Command{}, []string{"first"})) 162 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second"})) 163 | }, 164 | }, 165 | { 166 | "no input", 167 | "", 168 | func(t *testing.T, name string, actual string) { 169 | assert.Equal(t, name, actual) 170 | }, 171 | func(t *testing.T, v cobra.PositionalArgs) { 172 | assert.NoError(t, v(&cobra.Command{}, []string{})) 173 | assert.NoError(t, v(&cobra.Command{}, []string{"first"})) 174 | assert.NoError(t, v(&cobra.Command{}, []string{"first", "second"})) 175 | }, 176 | }, 177 | } 178 | 179 | for _, test := range tests { 180 | t.Run(test.name, func(t *testing.T) { 181 | f, err := ioutil.TempFile("", test.name) 182 | assert.NoError(t, err) 183 | 184 | f.WriteString(test.input) 185 | defer func() { 186 | _ = f.Close() 187 | _ = os.Remove(f.Name()) 188 | }() 189 | 190 | usage, args, err := usageFrom(f.Name()) 191 | assert.NoError(t, err) 192 | test.checkUsage(t, filepath.Base(f.Name()), usage) 193 | test.checkArgs(t, args) 194 | }) 195 | } 196 | } 197 | 198 | func TestExampleFrom(t *testing.T) { 199 | var tests = []struct { 200 | name string 201 | input string 202 | expected string 203 | }{ 204 | { 205 | "happy path", 206 | "#\n# example: blah\n#\n", 207 | " blah", 208 | }, 209 | { 210 | "missing", 211 | "#\n#\n#\n", 212 | "", 213 | }, 214 | { 215 | "no input", 216 | "", 217 | "", 218 | }, 219 | } 220 | 221 | for _, test := range tests { 222 | t.Run(test.name, func(t *testing.T) { 223 | f, err := ioutil.TempFile("", test.name) 224 | assert.NoError(t, err) 225 | 226 | f.WriteString(test.input) 227 | defer func() { 228 | _ = f.Close() 229 | _ = os.Remove(f.Name()) 230 | }() 231 | 232 | v, err := exampleFrom(f.Name()) 233 | assert.NoError(t, err) 234 | assert.Equal(t, test.expected, v) 235 | }) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /cli/utils.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | /* 4 | * deduplicate a slice of strings, keeping the order of the elements 5 | */ 6 | func deduplicate(input []string) []string { 7 | var output []string 8 | unique := map[string]interface{}{} 9 | for _, i := range input { 10 | unique[i] = new(interface{}) 11 | } 12 | for _, i := range input { 13 | if _, ok := unique[i]; ok { 14 | output = append(output, i) 15 | delete(unique, i) 16 | } 17 | } 18 | return output 19 | } 20 | -------------------------------------------------------------------------------- /cli/utils_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDeduplicate(t *testing.T) { 11 | var tests = []struct { 12 | name string 13 | input []string 14 | expected []string 15 | }{ 16 | { 17 | "simple", 18 | strings.Split("aaaaaa", ""), 19 | []string{"a"}, 20 | }, 21 | { 22 | "two elements", 23 | []string{"a", "b"}, 24 | deduplicate(strings.Split("aaaabb", "")), 25 | }, 26 | { 27 | "two elements repeated", 28 | deduplicate(strings.Split("ababab", "")), 29 | []string{"a", "b"}, 30 | }, 31 | { 32 | "maintains ordering", 33 | deduplicate(strings.Split("acbaabab", "")), 34 | []string{"a", "c", "b"}, 35 | }, 36 | } 37 | 38 | for _, test := range tests { 39 | t.Run(test.name, func(t *testing.T) { 40 | assert.Equal(t, test.expected, deduplicate(test.input)) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cv/sd 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/Sirupsen/logrus v1.0.6 7 | github.com/sirupsen/logrus v1.8.1 // indirect 8 | github.com/spf13/cobra v1.8.0 9 | github.com/stretchr/testify v1.2.2 10 | golang.org/x/crypto v0.21.0 // indirect 11 | gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect 12 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Sirupsen/logrus v1.0.6 h1:HCAGQRk48dRVPA5Y+Yh0qdCSTzPOyU1tBJ7Q9YzotII= 2 | github.com/Sirupsen/logrus v1.0.6/go.mod h1:rmk17hk6i8ZSAJkSDa7nOxamrG+SP4P0mm+DAvExv4U= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 7 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 11 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 12 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 13 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 14 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 15 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 16 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 17 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 18 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 19 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 20 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 21 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 22 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 23 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 24 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 25 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 26 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 27 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 28 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 29 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 30 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 31 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 32 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 33 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 34 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 35 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 36 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 37 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 45 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 46 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 47 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 48 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 49 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 50 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 51 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 52 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 53 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 54 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 55 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 56 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 57 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 58 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 59 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 60 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 61 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 62 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 63 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 64 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 65 | gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo= 66 | gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0= 69 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= 70 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 71 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/cv/sd/cli" 7 | ) 8 | 9 | var ( 10 | // version is set by goreleaser 11 | version string 12 | ) 13 | 14 | func main() { 15 | sd := cli.New(version) 16 | err := sd.Run() 17 | if err != nil { 18 | os.Exit(-1) 19 | } 20 | } 21 | --------------------------------------------------------------------------------