├── go.mod ├── .gitignore ├── integrations ├── go.sum ├── go.mod └── spf13-pflag_test.go ├── tools ├── go.mod ├── tools.go └── go.sum ├── example_minimal_test.go ├── error_test.go ├── error.go ├── example_singlecommand_test.go ├── LICENSE ├── .github └── workflows │ └── main.yml ├── flags_test.go ├── example_multicommand_test.go ├── Makefile ├── flags.go ├── README.md ├── lieut.go └── lieut_test.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Rican7/lieut 2 | 3 | go 1.20 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Tools binaries 2 | /tools/bin 3 | 4 | # Code coverage files 5 | coverage.out 6 | -------------------------------------------------------------------------------- /integrations/go.sum: -------------------------------------------------------------------------------- 1 | github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace h1:9PNP1jnUjRhfmGMlkXHjYPishpcw4jpSt/V/xYY3FMA= 2 | github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 3 | -------------------------------------------------------------------------------- /integrations/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Rican7/lieut/integrations 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/Rican7/lieut v0.2.0 7 | github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace 8 | ) 9 | 10 | replace github.com/Rican7/lieut => ../ 11 | -------------------------------------------------------------------------------- /tools/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Rican7/lieut/tools 2 | 3 | go 1.20 4 | 5 | require ( 6 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 7 | honnef.co/go/tools v0.4.7 8 | mvdan.cc/gofumpt v0.6.0 9 | ) 10 | 11 | require ( 12 | github.com/BurntSushi/toml v1.3.2 // indirect 13 | github.com/google/go-cmp v0.6.0 // indirect 14 | golang.org/x/exp/typeparams v0.0.0-20240222234643-814bf88cf225 // indirect 15 | golang.org/x/mod v0.16.0 // indirect 16 | golang.org/x/sync v0.6.0 // indirect 17 | golang.org/x/tools v0.19.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Trevor N. Suarez (Rican7) 2 | 3 | //go:build tools 4 | 5 | // Package tools provides tools for development. 6 | // 7 | // It follows the pattern set-forth in the wiki: 8 | // - https://go.dev/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 9 | // - https://github.com/go-modules-by-example/index/tree/4ea90b07f9/010_tools 10 | package tools 11 | 12 | import ( 13 | // Tools for development 14 | _ "golang.org/x/lint/golint" 15 | _ "honnef.co/go/tools/cmd/staticcheck" 16 | _ "mvdan.cc/gofumpt" 17 | ) 18 | -------------------------------------------------------------------------------- /example_minimal_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Trevor N. Suarez (Rican7) 2 | 3 | package lieut_test 4 | 5 | import ( 6 | "context" 7 | "flag" 8 | "fmt" 9 | "os" 10 | 11 | "github.com/Rican7/lieut" 12 | ) 13 | 14 | func Example_minimal() { 15 | do := func(ctx context.Context, arguments []string) error { 16 | _, err := fmt.Println(arguments) 17 | 18 | return err 19 | } 20 | 21 | app := lieut.NewSingleCommandApp( 22 | lieut.AppInfo{Name: "example"}, 23 | do, 24 | flag.CommandLine, 25 | os.Stdout, 26 | os.Stderr, 27 | ) 28 | 29 | exitCode := app.Run(context.Background(), os.Args[1:]) 30 | 31 | os.Exit(exitCode) 32 | } 33 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package lieut 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestErrWithStatusCode(t *testing.T) { 9 | errMsg := "test error" 10 | errCode := 107 11 | 12 | err := errors.New(errMsg) 13 | 14 | statusCodeErr := ErrWithStatusCode(err, errCode) 15 | if statusCodeErr == nil { 16 | t.Fatal("ErrWithStatusCode returned nil") 17 | } 18 | 19 | if gotMsg := statusCodeErr.Error(); gotMsg != errMsg { 20 | t.Errorf("err.Error() returned %q, wanted %q", gotMsg, errMsg) 21 | } 22 | 23 | if gotCode := statusCodeErr.StatusCode(); gotCode != errCode { 24 | t.Errorf("err.Error() returned %v, wanted %v", gotCode, errCode) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package lieut 2 | 3 | // StatusCodeError represents an error that reports an associated status code. 4 | type StatusCodeError interface { 5 | error 6 | 7 | // StatusCode returns the status code of the error, which can be used by an 8 | // app's execution error to know which status code to return. 9 | StatusCode() int 10 | } 11 | 12 | type statusCodeError struct { 13 | error 14 | 15 | statusCode int 16 | } 17 | 18 | // ErrWithStatusCode takes an error and a status code and returns a type that 19 | // satisfies StatusCodeError. 20 | func ErrWithStatusCode(err error, statusCode int) StatusCodeError { 21 | return &statusCodeError{error: err, statusCode: statusCode} 22 | } 23 | 24 | // StatusCode returns the status code of the error. 25 | func (e *statusCodeError) StatusCode() int { 26 | return e.statusCode 27 | } 28 | -------------------------------------------------------------------------------- /example_singlecommand_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Trevor N. Suarez (Rican7) 2 | 3 | package lieut_test 4 | 5 | import ( 6 | "context" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "github.com/Rican7/lieut" 13 | ) 14 | 15 | var singleAppInfo = lieut.AppInfo{ 16 | Name: "sayhello", 17 | Summary: "An example CLI app to say hello to the given names", 18 | Usage: "[option]... [names]...", 19 | Version: "v0.1-alpha", 20 | } 21 | 22 | func Example_singleCommand() { 23 | flagSet := flag.NewFlagSet(singleAppInfo.Name, flag.ExitOnError) 24 | 25 | app := lieut.NewSingleCommandApp( 26 | singleAppInfo, 27 | sayHello, 28 | flagSet, 29 | os.Stdout, 30 | os.Stderr, 31 | ) 32 | 33 | exitCode := app.Run(context.Background(), os.Args[1:]) 34 | 35 | os.Exit(exitCode) 36 | } 37 | 38 | func sayHello(ctx context.Context, arguments []string) error { 39 | names := strings.Join(arguments, ", ") 40 | hello := fmt.Sprintf("Hello %s!", names) 41 | 42 | _, err := fmt.Println(hello) 43 | 44 | return err 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2023 Trevor N. Suarez (Rican7) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 18 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | tags: ['v*'] 7 | pull_request: 8 | branches: ['*'] 9 | 10 | env: 11 | GO_TEST_COVERAGE_FILE_NAME: coverage.out 12 | 13 | jobs: 14 | 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | go: ['1.20.x', '1.21.x', '1.22.x'] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | 25 | - name: Setup Go 26 | uses: actions/setup-go@v3 27 | with: 28 | go-version: ${{ matrix.go }} 29 | cache: true 30 | cache-dependency-path: '**/go.sum' # Main module and tools submodule 31 | 32 | - name: Download dependencies 33 | run: make install-deps install-deps-dev 34 | 35 | - name: Lint 36 | run: make lint 37 | 38 | - name: Vet 39 | run: make vet 40 | 41 | - name: Test 42 | run: make test-with-coverage-profile 43 | 44 | - name: Test Integrations 45 | run: make test-integrations 46 | 47 | - name: Send code coverage to coveralls 48 | if: ${{ matrix.go == '1.22.x' }} 49 | env: 50 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | run: | 52 | go install github.com/mattn/goveralls@latest 53 | goveralls -coverprofile="$GO_TEST_COVERAGE_FILE_NAME" -service=github 54 | -------------------------------------------------------------------------------- /integrations/spf13-pflag_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | 8 | "github.com/Rican7/lieut" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | var testAppInfo = lieut.AppInfo{ 13 | Name: "test", 14 | Summary: "A test", 15 | Usage: "testing", 16 | Version: "vTest", 17 | } 18 | 19 | var testNoOpExecutor = func(ctx context.Context, arguments []string) error { 20 | return nil 21 | } 22 | 23 | func TestPFlag_WorkWithSingleCommandApps(t *testing.T) { 24 | flagSet := pflag.NewFlagSet("test", pflag.ExitOnError) 25 | out := io.Discard 26 | 27 | app := lieut.NewSingleCommandApp(testAppInfo, testNoOpExecutor, flagSet, out, out) 28 | 29 | if app == nil { 30 | t.Fatal("NewSingleCommandApp returned nil") 31 | } 32 | 33 | // Execute the many methods and make sure they don't panic 34 | app.PrintVersion() 35 | app.PrintUsage() 36 | app.PrintHelp() 37 | app.PrintUsageError(nil) 38 | app.Run(context.TODO(), nil) 39 | } 40 | 41 | func TestPFlag__WorkWithMultiCommandApps(t *testing.T) { 42 | flagSet := pflag.NewFlagSet("test", pflag.ExitOnError) 43 | commandFlagSet := pflag.NewFlagSet("foo", pflag.ExitOnError) 44 | out := io.Discard 45 | 46 | app := lieut.NewMultiCommandApp(testAppInfo, flagSet, out, out) 47 | if app == nil { 48 | t.Fatal("NewMultiCommandApp returned nil") 49 | } 50 | 51 | err := app.SetCommand(lieut.CommandInfo{Name: "foo"}, nil, commandFlagSet) 52 | if err != nil { 53 | t.Fatalf("SetCommand returned error: %v", err) 54 | } 55 | 56 | // Execute the many methods and make sure they don't panic 57 | app.PrintVersion() 58 | app.PrintUsage("") 59 | app.PrintUsage("foo") 60 | app.PrintHelp("") 61 | app.PrintHelp("foo") 62 | app.PrintUsageError("", nil) 63 | app.PrintUsageError("foo", nil) 64 | app.Run(context.TODO(), nil) 65 | } 66 | -------------------------------------------------------------------------------- /flags_test.go: -------------------------------------------------------------------------------- 1 | package lieut 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | ) 8 | 9 | type bogusFlags string 10 | 11 | func (b *bogusFlags) Parse(arguments []string) error { 12 | return nil 13 | } 14 | 15 | func (b *bogusFlags) Args() []string { 16 | return []string{} 17 | } 18 | 19 | func (b *bogusFlags) PrintDefaults() { 20 | } 21 | 22 | func (b *bogusFlags) Output() io.Writer { 23 | return nil 24 | } 25 | 26 | func (b *bogusFlags) SetOutput(output io.Writer) { 27 | } 28 | 29 | // Provide method that's used by "pflag" libraries (like github.com/spf13/pflag) 30 | // to validate that it's at least called in the interface checks. 31 | func (b *bogusFlags) BoolVarP(p *bool, name string, shorthand string, value bool, usage string) { 32 | } 33 | 34 | func TestBogusFlags_WorkWithSingleCommandApps(t *testing.T) { 35 | flagSet := bogusFlags("global") 36 | out := io.Discard 37 | 38 | app := NewSingleCommandApp(testAppInfo, testNoOpExecutor, &flagSet, out, out) 39 | 40 | if app == nil { 41 | t.Fatal("NewSingleCommandApp returned nil") 42 | } 43 | 44 | // Execute the many methods and make sure they don't panic 45 | app.PrintVersion() 46 | app.PrintUsage() 47 | app.PrintHelp() 48 | app.PrintUsageError(nil) 49 | app.Run(context.TODO(), nil) 50 | } 51 | 52 | func TestBogusFlags_WorkWithMultiCommandApps(t *testing.T) { 53 | flagSet := bogusFlags("global") 54 | commandFlagSet := bogusFlags("foo") 55 | out := io.Discard 56 | 57 | app := NewMultiCommandApp(testAppInfo, &flagSet, out, out) 58 | if app == nil { 59 | t.Fatal("NewMultiCommandApp returned nil") 60 | } 61 | 62 | err := app.SetCommand(CommandInfo{Name: "foo"}, nil, &commandFlagSet) 63 | if err != nil { 64 | t.Fatalf("SetCommand returned error: %v", err) 65 | } 66 | 67 | // Execute the many methods and make sure they don't panic 68 | app.PrintVersion() 69 | app.PrintUsage("") 70 | app.PrintUsage("foo") 71 | app.PrintHelp("") 72 | app.PrintHelp("foo") 73 | app.PrintUsageError("", nil) 74 | app.PrintUsageError("foo", nil) 75 | app.Run(context.TODO(), nil) 76 | } 77 | -------------------------------------------------------------------------------- /example_multicommand_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Trevor N. Suarez (Rican7) 2 | 3 | package lieut_test 4 | 5 | import ( 6 | "context" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "time" 11 | 12 | "github.com/Rican7/lieut" 13 | ) 14 | 15 | var multiAppInfo = lieut.AppInfo{ 16 | Name: "now", 17 | Summary: "An example CLI app to report the date and time", 18 | Usage: "... [options]...", 19 | Version: "v1.0.4", 20 | } 21 | 22 | var ( 23 | timeZone = "UTC" 24 | includeSeconds = false 25 | includeYear = false 26 | ) 27 | 28 | var location *time.Location 29 | 30 | func Example_multiCommand() { 31 | globalFlags := flag.NewFlagSet(multiAppInfo.Name, flag.ExitOnError) 32 | globalFlags.StringVar(&timeZone, "timezone", timeZone, "the timezone to report in") 33 | 34 | app := lieut.NewMultiCommandApp( 35 | multiAppInfo, 36 | globalFlags, 37 | os.Stdout, 38 | os.Stderr, 39 | ) 40 | 41 | timeFlags := flag.NewFlagSet("time", flag.ExitOnError) 42 | timeFlags.BoolVar(&includeSeconds, "seconds", includeSeconds, "to include seconds") 43 | 44 | dateFlags := flag.NewFlagSet("date", flag.ExitOnError) 45 | dateFlags.BoolVar(&includeYear, "year", includeYear, "to include year") 46 | 47 | app.SetCommand(lieut.CommandInfo{Name: "time", Summary: "Show the time", Usage: "[options]"}, printTime, timeFlags) 48 | app.SetCommand(lieut.CommandInfo{Name: "date", Summary: "Show the date", Usage: "[options]"}, printDate, dateFlags) 49 | 50 | app.OnInit(validateGlobals) 51 | 52 | exitCode := app.Run(context.Background(), os.Args[1:]) 53 | 54 | os.Exit(exitCode) 55 | } 56 | 57 | func validateGlobals() error { 58 | var err error 59 | 60 | location, err = time.LoadLocation(timeZone) 61 | 62 | return err 63 | } 64 | 65 | func printTime(ctx context.Context, arguments []string) error { 66 | format := "15:04" 67 | 68 | if includeSeconds { 69 | format += ":05" 70 | } 71 | 72 | _, err := fmt.Println(time.Now().Format(format)) 73 | 74 | return err 75 | } 76 | 77 | func printDate(ctx context.Context, arguments []string) error { 78 | format := "01-02" 79 | 80 | if includeYear { 81 | format += "-2006" 82 | } 83 | 84 | _, err := fmt.Println(time.Now().Format(format)) 85 | 86 | return err 87 | } 88 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Define directories 2 | ROOT_DIR ?= ${CURDIR} 3 | TOOLS_DIR ?= ${ROOT_DIR}/tools 4 | INTEGRATIONS_DIR ?= ${ROOT_DIR}/integrations 5 | 6 | # Set a local GOBIN, since the default value can't be trusted 7 | # (See https://github.com/golang/go/issues/23439) 8 | export GOBIN ?= ${TOOLS_DIR}/bin 9 | 10 | # Build flags 11 | GO_CLEAN_FLAGS ?= -i -r -x ${GO_BUILD_FLAGS} 12 | 13 | # Tool flags 14 | GOFUMPT_FLAGS ?= 15 | GOLINT_MIN_CONFIDENCE ?= 0.3 16 | 17 | # Set the mode for code-coverage 18 | GO_TEST_COVERAGE_MODE ?= count 19 | GO_TEST_COVERAGE_FILE_NAME ?= coverage.out 20 | 21 | 22 | all: install-deps build 23 | 24 | clean: 25 | go clean ${GO_CLEAN_FLAGS} ./... 26 | 27 | build: install-deps 28 | go build ${GO_BUILD_FLAGS} 29 | 30 | install-deps: 31 | go mod download 32 | 33 | ${TOOLS_DIR} tools install-deps-dev: 34 | cd ${TOOLS_DIR} && go install \ 35 | golang.org/x/lint/golint \ 36 | honnef.co/go/tools/cmd/staticcheck \ 37 | mvdan.cc/gofumpt 38 | 39 | update-deps: 40 | go get ./... 41 | 42 | test: 43 | go test -v ./... 44 | 45 | test-with-coverage: 46 | go test -cover -covermode ${GO_TEST_COVERAGE_MODE} ./... 47 | 48 | test-with-coverage-formatted: 49 | go test -cover -covermode ${GO_TEST_COVERAGE_MODE} ./... | column -t | sort -r 50 | 51 | test-with-coverage-profile: 52 | go test -covermode ${GO_TEST_COVERAGE_MODE} -coverprofile ${GO_TEST_COVERAGE_FILE_NAME} ./... 53 | 54 | ${INTEGRATIONS_DIR} test-integrations: 55 | cd ${INTEGRATIONS_DIR} && go test -v ./... 56 | 57 | test-all: test test-integrations 58 | 59 | format-lint: 60 | $(info ${GOBIN}/gofumpt -l ${GOFUMPT_FLAGS} .) 61 | @errors=$$(${GOBIN}/gofumpt -l ${GOFUMPT_FLAGS} .); if [ "$${errors}" != "" ]; then echo "Format lint failed on:\n$${errors}\n"; exit 1; fi 62 | 63 | style-lint: install-deps-dev 64 | ${GOBIN}/golint -min_confidence=${GOLINT_MIN_CONFIDENCE} -set_exit_status ./... 65 | ${GOBIN}/staticcheck ./... 66 | 67 | lint: install-deps-dev format-lint style-lint 68 | 69 | vet: 70 | go vet ./... 71 | 72 | format-fix: 73 | ${GOBIN}/gofumpt -w ${GOFUMPT_FLAGS} . 74 | 75 | fix: install-deps-dev format-fix 76 | go fix ./... 77 | 78 | 79 | .PHONY: all clean build install-deps tools install-deps-dev update-deps test test-with-coverage test-with-coverage-formatted test-with-coverage-profile test-integrations test-all format-lint style-lint lint vet format-fix fix 80 | -------------------------------------------------------------------------------- /tools/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 4 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 5 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 6 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 7 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 8 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 9 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 10 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 11 | golang.org/x/exp/typeparams v0.0.0-20240222234643-814bf88cf225 h1:BzKNaIRXh1bD+1557OcFIHlpYBiVbK4zEyn8zBHi1SE= 12 | golang.org/x/exp/typeparams v0.0.0-20240222234643-814bf88cf225/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 13 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 14 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 15 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 16 | golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 17 | golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 18 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 19 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 20 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 21 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 22 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 23 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 24 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 26 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 27 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 28 | golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= 29 | golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= 30 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 31 | honnef.co/go/tools v0.4.7 h1:9MDAWxMoSnB6QoSqiVr7P5mtkT9pOc1kSxchzPCnqJs= 32 | honnef.co/go/tools v0.4.7/go.mod h1:+rnGS1THNh8zMwnd2oVOTL9QF6vmfyG6ZXBULae2uc0= 33 | mvdan.cc/gofumpt v0.6.0 h1:G3QvahNDmpD+Aek/bNOLrFR2XC6ZAdo62dZu65gmwGo= 34 | mvdan.cc/gofumpt v0.6.0/go.mod h1:4L0wf+kgIPZtcCWXynNS2e6bhmj73umwnuXSZarixzA= 35 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Trevor N. Suarez (Rican7) 2 | 3 | package lieut 4 | 5 | import ( 6 | "bytes" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "reflect" 11 | ) 12 | 13 | // Flags defines an interface for command flags. 14 | type Flags interface { 15 | Parse(arguments []string) error 16 | Args() []string 17 | PrintDefaults() 18 | Output() io.Writer 19 | SetOutput(output io.Writer) 20 | } 21 | 22 | type boolFlagger interface { 23 | BoolVar(p *bool, name string, value bool, usage string) 24 | } 25 | 26 | type boolShortFlagger interface { 27 | BoolVarP(p *bool, name string, shorthand string, value bool, usage string) 28 | } 29 | 30 | type lookupVarFlagger interface { 31 | Lookup(name string) *flag.Flag 32 | Var(value flag.Value, name string, usage string) 33 | } 34 | 35 | type visitAllFlagger interface { 36 | VisitAll(fn func(*flag.Flag)) 37 | } 38 | 39 | type flagSet struct { 40 | Flags 41 | 42 | requestedHelp bool 43 | requestedVersion bool 44 | } 45 | 46 | func createDefaultFlags(name string) *flag.FlagSet { 47 | return flag.NewFlagSet(name, flag.ContinueOnError) 48 | } 49 | 50 | // Parse wraps the inner flag Parse method, making sure that the error output is 51 | // discarded/silenced. 52 | func (f *flagSet) Parse(arguments []string) error { 53 | originalOut := f.Output() 54 | 55 | f.SetOutput(io.Discard) 56 | defer f.SetOutput(originalOut) 57 | 58 | return f.Flags.Parse(arguments) 59 | } 60 | 61 | func (a *MultiCommandApp) isUniqueFlagSet(flags Flags) bool { 62 | // NOTE: We have to use `reflect.DeepEqual`, because the interface values 63 | // could be non-comparable and could panic at runtime. 64 | if reflect.DeepEqual(flags, a.flags.Flags) { 65 | return false 66 | } 67 | 68 | for _, command := range a.commands { 69 | if reflect.DeepEqual(flags, command.flags.Flags) { 70 | return false 71 | } 72 | } 73 | 74 | return true 75 | } 76 | 77 | func (a *app) setupFlagSet(flagSet *flagSet) { 78 | flagSet.SetOutput(a.errOut) 79 | 80 | helpDescription := "Display the help message" 81 | 82 | switch flags := flagSet.Flags.(type) { 83 | case boolShortFlagger: 84 | flags.BoolVarP(&flagSet.requestedHelp, "help", "h", flagSet.requestedHelp, helpDescription) 85 | case boolFlagger: 86 | flags.BoolVar(&flagSet.requestedHelp, "help", flagSet.requestedHelp, helpDescription) 87 | } 88 | 89 | if flags, ok := flagSet.Flags.(boolFlagger); ok { 90 | flags.BoolVar(&flagSet.requestedVersion, "version", flagSet.requestedVersion, "Display the application version") 91 | } 92 | 93 | // If the passed flags are not the app's global/shared flags 94 | // 95 | // NOTE: We have to use `reflect.DeepEqual`, because the interface values 96 | // could be non-comparable and could panic at runtime. 97 | if !reflect.DeepEqual(a.flags.Flags, flagSet.Flags) { 98 | globalFlags, globalFlagsOk := a.flags.Flags.(visitAllFlagger) 99 | flags, flagsOk := flagSet.Flags.(lookupVarFlagger) 100 | 101 | if globalFlagsOk && flagsOk { 102 | // Loop through the globals and merge them into the specifics 103 | globalFlags.VisitAll(func(flag *flag.Flag) { 104 | // Don't override any existing flags (which causes panics...) 105 | if existing := flags.Lookup(flag.Name); existing == nil { 106 | flags.Var(flag.Value, flag.Name, flag.Usage) 107 | } 108 | }) 109 | } 110 | } 111 | } 112 | 113 | // printFlagDefaults wraps the writing of flag default values 114 | func (a *app) printFlagDefaults(flags Flags) { 115 | var buffer bytes.Buffer 116 | originalOut := flags.Output() 117 | 118 | flags.SetOutput(&buffer) 119 | flags.PrintDefaults() 120 | 121 | if buffer.Len() > 0 { 122 | // Only write a header if the printing of defaults actually wrote bytes 123 | fmt.Fprintf(originalOut, "\nOptions:\n\n") 124 | 125 | // Write the buffered flag output 126 | buffer.WriteTo(originalOut) 127 | } 128 | 129 | // Restore the original output 130 | flags.SetOutput(originalOut) 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lieut 2 | 3 | [![Build Status](https://github.com/Rican7/lieut/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/Rican7/lieut/actions/workflows/main.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/Rican7/lieut/badge.svg)](https://coveralls.io/github/Rican7/lieut) 5 | [![Go Report Card](https://goreportcard.com/badge/Rican7/lieut)](http://goreportcard.com/report/Rican7/lieut) 6 | [![Go Reference](https://pkg.go.dev/badge/github.com/Rican7/lieut.svg)](https://pkg.go.dev/github.com/Rican7/lieut) 7 | [![Latest Stable Version](https://img.shields.io/github/release/Rican7/lieut.svg?style=flat)](https://github.com/Rican7/lieut/releases) 8 | 9 | _Lieut, short for lieutenant, or "second-in-command" to a commander._ 10 | 11 | An opinionated, feature-limited, no external dependency, "micro-framework" for building command line applications in Go. 12 | 13 | #### But why though? 14 | 15 | In general, I personally don't like using frameworks... especially macro frameworks, and especially in Go. 16 | 17 | I prefer using Go's extensive standard library, and when that doesn't quite provide what I'm looking for, I look towards smaller libraries that carry few (if any) other external dependencies and that integrate well with the standard library and its interfaces. 18 | 19 | That being said, [the `flag` package in the standard library](https://pkg.go.dev/flag) leaves a lot to be desired, and unfortunately acts far less like a library and much more like a framework (it's library code [that calls `os.Exit()`](https://github.com/golang/go/blob/go1.21.0/src/flag/flag.go#L1168-L1171)... 😕😖). It defines a lot of higher-level application behaviors than typically expected, and those can be a little surprising. 20 | 21 | **The goal of this project** is to get some of those quirks out of the way (or at least work WITH them in ways that reduce the surprises in behavior) and reduce the typical "setup" code that a command line application needs, all while working with the standard library, to expand upon it's capabilities rather than locking your application into a tree of external/third-party dependencies. 22 | 23 | _"Wait, what? Why not just use 'x' or 'y'?"_ [Don't worry, I've got you covered.](#wait-what-why-not-just-use-x-or-y) 24 | 25 | 26 | ## Project Status 27 | 28 | This project is currently in "pre-release". The API may change. 29 | Use a tagged version or vendor this dependency if you plan on using it. 30 | 31 | 32 | ## Features 33 | 34 | - Relies solely on the standard library. 35 | - Sub-command applications (`app command`, `app othercommand`). 36 | - Automatic handling of typical error paths. 37 | - Standardized output handling of application (and command) usage, description, help, and version.. 38 | - Help flag (`--help`) handling, with actual user-facing notice (it shows up as a flag in the options list), rather than just handling it silently.. 39 | - Version flag (`--version`) handling with a standardized output. 40 | - Global and sub-command flags with automatic merging. 41 | - Built-in signal handling (interrupt) with context cancellation. 42 | - Smart defaults, so there's less to configure. 43 | 44 | 45 | ## Example 46 | 47 | ```go 48 | package main 49 | 50 | import ( 51 | "context" 52 | "flag" 53 | "fmt" 54 | "os" 55 | 56 | "github.com/Rican7/lieut" 57 | ) 58 | 59 | func main() { 60 | do := func(ctx context.Context, arguments []string) error { 61 | _, err := fmt.Println(arguments) 62 | 63 | return err 64 | } 65 | 66 | app := lieut.NewSingleCommandApp( 67 | lieut.AppInfo{Name: "example"}, 68 | do, 69 | flag.CommandLine, 70 | os.Stdout, 71 | os.Stderr, 72 | ) 73 | 74 | exitCode := app.Run(context.Background(), os.Args[1:]) 75 | 76 | os.Exit(exitCode) 77 | } 78 | ``` 79 | 80 | For more examples, see [the documentation](https://pkg.go.dev/github.com/Rican7/lieut). 81 | 82 | 83 | ## How can I use this with another flag package? 84 | 85 | ### pflag 86 | 87 | If you want to use the `github.com/spf13/pflag` package (or one of its many forks), you'll just need to "merge" the global and sub-command flag-sets yourself... Like this: 88 | 89 | ```go 90 | globalFlags := flag.NewFlagSet("app", flag.ContinueOnError) 91 | globalFlags.String("someglobalflag", "example", "an example global flag") 92 | 93 | subCommandFlags := flag.NewFlagSet("subcommand", flag.ContinueOnError) 94 | subCommandFlags.AddFlagSet(globalFlags) // Merge the globals into this command's flags 95 | ``` 96 | 97 | ### Other 98 | 99 | Honestly, I haven't tried, but I'd imagine you just handle and parse the flags yourself before running the lieut app. 100 | 101 | 102 | ## Wait, what? Why not just use "x" or "y"? 103 | 104 | If you're reading this, you're probably thinking of an alternative solution or wondering why someone would choose this library over another. Well, I'm not here to convince you, but if you're curious, read on. 105 | 106 | I've tried using many of the popular libraries ([cobra](https://github.com/spf13/cobra), [kingpin](https://github.com/alecthomas/kingpin), [urfave/cli](https://github.com/urfave/cli), etc), and they all suffer from one of the following problems: 107 | 108 | - They're large in scope and attempt to solve too many problems, generically. 109 | - They use external/third-party dependencies. 110 | - They don't work well directly with the standard library. 111 | - They've been abandoned (practically, if not directly). 112 | - (This would be less of a concern if it weren't for the fact that they're LARGE in scope, so it's harder to just own yourself if the source rots). 113 | 114 | If none of that matters to you, then that's fine, but they were enough of a concern for me to spend a few days extracting this package's behaviors from another project and making it reusable. 🙂 115 | -------------------------------------------------------------------------------- /lieut.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Trevor N. Suarez (Rican7) 2 | 3 | // Package lieut provides mechanisms to standardize command line app execution. 4 | // 5 | // Lieut, short for lieutenant, or "second-in-command" to a commander. 6 | // 7 | // An opinionated, feature-limited, no external dependency, "micro-framework" 8 | // for building command line applications in Go. 9 | package lieut 10 | 11 | import ( 12 | "context" 13 | "errors" 14 | "fmt" 15 | "io" 16 | "os" 17 | "os/signal" 18 | "path/filepath" 19 | "runtime" 20 | "strings" 21 | ) 22 | 23 | const ( 24 | // DefaultCommandUsage defines the default usage string for commands. 25 | DefaultCommandUsage = "[arguments ...]" 26 | 27 | // DefaultParentCommandUsage defines the default usage string for commands 28 | // that have sub-commands. 29 | DefaultParentCommandUsage = " [arguments ...]" 30 | ) 31 | 32 | // Executor is a functional interface that defines an executable command. 33 | // 34 | // It takes a context and arguments, and returns an error (if any occurred). 35 | type Executor func(ctx context.Context, arguments []string) error 36 | 37 | // CommandInfo describes information about a command. 38 | type CommandInfo struct { 39 | Name string 40 | Summary string 41 | Usage string 42 | } 43 | 44 | type command struct { 45 | info CommandInfo 46 | Executor 47 | 48 | flags *flagSet // Flags for each command 49 | } 50 | 51 | // AppInfo describes information about an app. 52 | type AppInfo struct { 53 | Name string 54 | Summary string 55 | Usage string 56 | Version string 57 | } 58 | 59 | // app is a runnable application configuration. 60 | type app struct { 61 | info AppInfo 62 | 63 | flags *flagSet // Flags for the entire app (global) 64 | 65 | out io.Writer 66 | errOut io.Writer 67 | 68 | init func() error 69 | } 70 | 71 | // SingleCommandApp is a runnable application that only has one command. 72 | type SingleCommandApp struct { 73 | app 74 | 75 | exec Executor 76 | } 77 | 78 | // MultiCommandApp is a runnable application that has many commands. 79 | type MultiCommandApp struct { 80 | app 81 | 82 | commands map[string]command 83 | } 84 | 85 | // NewSingleCommandApp returns an initialized SingleCommandApp. 86 | // 87 | // The provided flags should have ContinueOnError ErrorHandling, or else flag 88 | // parsing errors won't properly be displayed/handled. 89 | func NewSingleCommandApp(info AppInfo, exec Executor, flags Flags, out io.Writer, errOut io.Writer) *SingleCommandApp { 90 | if info.Name == "" { 91 | info.Name = inferAppName() 92 | } 93 | 94 | if info.Usage == "" { 95 | info.Usage = DefaultCommandUsage 96 | } 97 | 98 | if flags == nil { 99 | flags = createDefaultFlags(info.Name) 100 | } 101 | 102 | flagSet := &flagSet{Flags: flags} 103 | 104 | app := &SingleCommandApp{ 105 | app: app{ 106 | info: info, 107 | 108 | flags: flagSet, 109 | 110 | out: out, 111 | errOut: errOut, 112 | }, 113 | 114 | exec: exec, 115 | } 116 | 117 | app.setupFlagSet(app.flags) 118 | 119 | return app 120 | } 121 | 122 | // NewMultiCommandApp returns an initialized MultiCommandApp. 123 | // 124 | // The provided flags are global/shared among the app's commands. 125 | // 126 | // The provided flags should have ContinueOnError ErrorHandling, or else flag 127 | // parsing errors won't properly be displayed/handled. 128 | func NewMultiCommandApp(info AppInfo, flags Flags, out io.Writer, errOut io.Writer) *MultiCommandApp { 129 | if info.Name == "" { 130 | info.Name = inferAppName() 131 | } 132 | 133 | if info.Usage == "" { 134 | info.Usage = DefaultParentCommandUsage 135 | } 136 | 137 | if flags == nil { 138 | flags = createDefaultFlags(info.Name) 139 | } 140 | 141 | flagSet := &flagSet{Flags: flags} 142 | 143 | app := &MultiCommandApp{ 144 | app: app{ 145 | info: info, 146 | 147 | flags: flagSet, 148 | 149 | out: out, 150 | errOut: errOut, 151 | }, 152 | 153 | commands: make(map[string]command), 154 | } 155 | 156 | app.setupFlagSet(app.flags) 157 | 158 | return app 159 | } 160 | 161 | // SetCommand sets a command for the given info, executor, and flags. 162 | // 163 | // It returns an error if the provided flags have already been used for another 164 | // command (or for the globals). 165 | // 166 | // The provided flags should have ContinueOnError ErrorHandling, or else flag 167 | // parsing errors won't properly be displayed/handled. 168 | func (a *MultiCommandApp) SetCommand(info CommandInfo, exec Executor, flags Flags) error { 169 | if info.Usage == "" { 170 | info.Usage = DefaultCommandUsage 171 | } 172 | 173 | if !a.isUniqueFlagSet(flags) { 174 | return errors.New("provided flags are duplicate") 175 | } 176 | 177 | if flags == nil { 178 | flags = createDefaultFlags(info.Name) 179 | } 180 | 181 | flagSet := &flagSet{Flags: flags} 182 | 183 | a.setupFlagSet(flagSet) 184 | 185 | a.commands[info.Name] = command{info: info, Executor: exec, flags: flagSet} 186 | 187 | return nil 188 | } 189 | 190 | // CommandNames returns the names of the set commands. 191 | func (a *MultiCommandApp) CommandNames() []string { 192 | names := make([]string, 0, len(a.commands)) 193 | 194 | for name := range a.commands { 195 | names = append(names, name) 196 | } 197 | 198 | return names 199 | } 200 | 201 | // Run takes a context and arguments, runs the expected command, and returns an 202 | // exit code. 203 | // 204 | // If the init function or command Executor returns a StatusCodeError, then the 205 | // returned exit code will match that of the value returned by 206 | // StatusCodeError.StatusCode(). 207 | func (a *SingleCommandApp) Run(ctx context.Context, arguments []string) int { 208 | if len(arguments) == 0 { 209 | arguments = os.Args[1:] 210 | } 211 | 212 | if err := a.flags.Parse(arguments); err != nil { 213 | a.PrintUsageError(err) 214 | return 2 215 | } 216 | 217 | if intercepted := a.intercept(a.flags); intercepted { 218 | return 0 219 | } 220 | 221 | if err := a.initialize(); err != nil { 222 | return a.handleError(err) 223 | } 224 | 225 | return a.execute(ctx, a.exec, a.flags.Args()) 226 | } 227 | 228 | // Run takes a context and arguments, runs the expected command, and returns an 229 | // exit code. 230 | // 231 | // If the init function or command Executor returns a StatusCodeError, then the 232 | // returned exit code will match that of the value returned by 233 | // StatusCodeError.StatusCode(). 234 | func (a *MultiCommandApp) Run(ctx context.Context, arguments []string) int { 235 | if len(arguments) == 0 { 236 | arguments = os.Args[1:] 237 | } 238 | 239 | if len(a.commands) == 0 || len(arguments) == 0 { 240 | a.PrintHelp("") 241 | return 2 242 | } 243 | 244 | flags := a.flags 245 | commandName := arguments[0] 246 | 247 | cmd, hasCommand := a.commands[commandName] 248 | if !hasCommand && commandName[0] != '-' { 249 | return a.printUnknownCommand(commandName) 250 | } 251 | 252 | if hasCommand { 253 | flags = cmd.flags 254 | arguments = arguments[1:] 255 | } 256 | 257 | if err := flags.Parse(arguments); err != nil { 258 | a.PrintUsageError(commandName, err) 259 | return 2 260 | } 261 | 262 | if intercepted := a.intercept(flags, commandName); intercepted { 263 | return 0 264 | } 265 | 266 | if !hasCommand { 267 | return a.printUnknownCommand(commandName) 268 | } 269 | 270 | if err := a.initialize(); err != nil { 271 | return a.handleError(err) 272 | } 273 | 274 | return a.execute(ctx, cmd.Executor, cmd.flags.Args()) 275 | } 276 | 277 | // OnInit takes an init function that is then called after initialization and 278 | // before execution of a command. 279 | func (a *app) OnInit(init func() error) { 280 | a.init = init 281 | } 282 | 283 | // PrintVersion prints the version to the app's standard output. 284 | func (a *app) PrintVersion() { 285 | a.printVersion(false) 286 | } 287 | 288 | // PrintHelp prints the help info to the app's error output. 289 | // 290 | // It's exposed so it can be called or assigned to a flag set's usage function. 291 | func (a *SingleCommandApp) PrintHelp() { 292 | a.printFullUsage() 293 | fmt.Fprintln(a.errOut) 294 | a.printVersion(true) 295 | } 296 | 297 | // PrintHelp prints the help info to the app's error output. 298 | // 299 | // It's exposed so it can be called or assigned to a flag set's usage function. 300 | func (a *MultiCommandApp) PrintHelp(commandName string) { 301 | a.printFullUsage(commandName) 302 | fmt.Fprintln(a.errOut) 303 | a.printVersion(true) 304 | } 305 | 306 | // PrintUsage prints the usage to the app's error output. 307 | func (a *app) PrintUsage() { 308 | fmt.Fprintf(a.errOut, "Usage: %s %s\n", a.info.Name, a.info.Usage) 309 | } 310 | 311 | // PrintUsage prints the usage to the app's error output. 312 | func (a *MultiCommandApp) PrintUsage(commandName string) { 313 | name := a.fullCommandName(commandName) 314 | command, hasCommand := a.commands[commandName] 315 | 316 | if !hasCommand { 317 | a.app.PrintUsage() 318 | return 319 | } 320 | 321 | fmt.Fprintf(a.errOut, "Usage: %s %s\n", name, command.info.Usage) 322 | } 323 | 324 | // PrintUsageError prints a standardized usage error to the app's error output. 325 | func (a *SingleCommandApp) PrintUsageError(err error) { 326 | if err != nil && a.printError(err) { 327 | // Print a spacer line if an error was printed 328 | fmt.Fprintln(a.errOut) 329 | } 330 | 331 | a.PrintUsage() 332 | 333 | fmt.Fprintf(a.errOut, "\nRun '%s --help' for usage.\n", a.info.Name) 334 | } 335 | 336 | // PrintUsageError prints a standardized usage error to the app's error output. 337 | func (a *MultiCommandApp) PrintUsageError(commandName string, err error) { 338 | name := a.fullCommandName(commandName) 339 | 340 | if err != nil && a.printError(err) { 341 | // Print a spacer line if an error was printed 342 | fmt.Fprintln(a.errOut) 343 | } 344 | 345 | a.PrintUsage(commandName) 346 | 347 | fmt.Fprintf(a.errOut, "\nRun '%s --help' for usage.\n", name) 348 | } 349 | 350 | func (a *app) intercept(flagSet *flagSet) bool { 351 | if flagSet.requestedVersion { 352 | a.PrintVersion() 353 | return true 354 | } 355 | 356 | return false 357 | } 358 | 359 | func (a *SingleCommandApp) intercept(flagSet *flagSet) bool { 360 | if flagSet.requestedHelp { 361 | a.PrintHelp() 362 | return true 363 | } 364 | 365 | return a.app.intercept(flagSet) 366 | } 367 | 368 | func (a *MultiCommandApp) intercept(flagSet *flagSet, commandName string) bool { 369 | if flagSet.requestedHelp { 370 | a.PrintHelp(commandName) 371 | return true 372 | } 373 | 374 | return a.app.intercept(flagSet) 375 | } 376 | 377 | func (a *app) initialize() error { 378 | if a.init == nil { 379 | return nil 380 | } 381 | 382 | return a.init() 383 | } 384 | 385 | func (a *app) execute(ctx context.Context, exec Executor, arguments []string) int { 386 | ctx, stop := signal.NotifyContext(ctx, os.Interrupt) 387 | defer stop() 388 | 389 | err := exec(ctx, arguments) 390 | if err != nil && !errors.Is(err, context.Canceled) { 391 | return a.handleError(err) 392 | } 393 | 394 | return 0 395 | } 396 | 397 | // printError takes an error, prints it with formatting, and then returns 398 | // whether or not any actual error message was printed. 399 | func (a *app) printError(err error) bool { 400 | msg := err.Error() 401 | 402 | if msg == "" { 403 | // Return false to denote that no error was printed 404 | return false 405 | } 406 | 407 | fmt.Fprintf(a.errOut, "Error: %s\n", msg) 408 | 409 | return true 410 | } 411 | 412 | func (a *app) handleError(err error) int { 413 | a.printError(err) 414 | 415 | var statusErr StatusCodeError 416 | if errors.As(err, &statusErr) { 417 | return statusErr.StatusCode() 418 | } 419 | 420 | return 1 421 | } 422 | 423 | func (a *MultiCommandApp) fullCommandName(commandName string) string { 424 | name := a.info.Name 425 | command, hasCommand := a.commands[commandName] 426 | 427 | if hasCommand { 428 | name = fmt.Sprintf("%s %s", name, command.info.Name) 429 | } 430 | 431 | return name 432 | } 433 | 434 | func (a *app) printVersion(toErr bool) { 435 | out := a.out 436 | if toErr { 437 | out = a.errOut 438 | } 439 | 440 | identifier := a.info.Name 441 | if a.info.Version != "" { 442 | identifier = fmt.Sprintf("%s %s", identifier, a.info.Version) 443 | } 444 | 445 | fmt.Fprintf(out, "%s (%s/%s)\n", identifier, runtime.GOOS, runtime.GOARCH) 446 | } 447 | 448 | func (a *MultiCommandApp) printUnknownCommand(commandName string) int { 449 | a.PrintUsageError("", fmt.Errorf("unknown command '%s'", commandName)) 450 | 451 | return 1 452 | } 453 | 454 | func (a *SingleCommandApp) printFullUsage() { 455 | a.PrintUsage() 456 | 457 | if a.info.Summary != "" { 458 | fmt.Fprintln(a.errOut) 459 | fmt.Fprintln(a.errOut, a.info.Summary) 460 | } 461 | 462 | a.printFlagDefaults(a.flags) 463 | } 464 | 465 | func (a *MultiCommandApp) printFullUsage(commandName string) { 466 | command, hasCommand := a.commands[commandName] 467 | 468 | switch { 469 | case hasCommand: 470 | a.PrintUsage(commandName) 471 | 472 | if command.info.Summary != "" { 473 | fmt.Fprintln(a.errOut) 474 | fmt.Fprintln(a.errOut, command.info.Summary) 475 | } 476 | 477 | a.printFlagDefaults(command.flags) 478 | default: 479 | a.PrintUsage("") 480 | 481 | if a.info.Summary != "" { 482 | fmt.Fprintln(a.errOut) 483 | fmt.Fprintln(a.errOut, a.info.Summary) 484 | } 485 | 486 | fmt.Fprintf(a.errOut, "\nCommands:\n\n") 487 | 488 | for _, command := range a.commands { 489 | fmt.Fprintf(a.errOut, "\t%s\t%s\n", command.info.Name, command.info.Summary) 490 | } 491 | 492 | a.printFlagDefaults(a.flags) 493 | } 494 | } 495 | 496 | func inferAppName() string { 497 | basename := filepath.Base(os.Args[0]) 498 | extension := filepath.Ext(basename) 499 | 500 | return strings.TrimSuffix(basename, extension) 501 | } 502 | -------------------------------------------------------------------------------- /lieut_test.go: -------------------------------------------------------------------------------- 1 | package lieut 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "os" 11 | "runtime" 12 | "sort" 13 | "testing" 14 | ) 15 | 16 | var testAppInfo = AppInfo{ 17 | Name: "test", 18 | Summary: "A test", 19 | Usage: "testing", 20 | Version: "vTest", 21 | } 22 | 23 | var testNoOpExecutor = func(ctx context.Context, arguments []string) error { 24 | return nil 25 | } 26 | 27 | func TestMain(m *testing.M) { 28 | originalOSArgs := os.Args[:] 29 | defer func() { 30 | // Put back the original args... to not mess with global state 31 | os.Args = originalOSArgs 32 | }() 33 | 34 | // Parse the flags, as the testing package needs them 35 | flag.Parse() 36 | 37 | // Set the args to just the executable name 38 | // (removing passed flags to the test executable) 39 | os.Args = os.Args[0:1] 40 | 41 | os.Exit(m.Run()) 42 | } 43 | 44 | func TestNewSingleCommandApp(t *testing.T) { 45 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 46 | 47 | app := NewSingleCommandApp(testAppInfo, testNoOpExecutor, flagSet, os.Stdout, os.Stderr) 48 | 49 | if app == nil { 50 | t.Error("NewSingleCommandApp returned nil") 51 | } 52 | } 53 | 54 | func TestNewSingleCommandApp_ZeroValues(t *testing.T) { 55 | app := NewSingleCommandApp(AppInfo{}, nil, nil, nil, nil) 56 | 57 | if app == nil { 58 | t.Fatal("NewSingleCommandApp returned nil") 59 | } 60 | 61 | if inferredName := inferAppName(); app.info.Name != inferredName { 62 | t.Errorf("NewSingleCommandApp with no given name gave %q name, wanted %q", app.info.Name, inferredName) 63 | } 64 | 65 | if app.info.Usage != DefaultCommandUsage { 66 | t.Errorf("NewSingleCommandApp with no given usage gave %q usage, wanted %q", app.info.Usage, DefaultCommandUsage) 67 | } 68 | 69 | if app.flags.Flags == nil { 70 | t.Errorf("NewSingleCommandApp with no given flags had nil flags") 71 | } 72 | } 73 | 74 | func TestNewMultiCommandApp(t *testing.T) { 75 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 76 | 77 | app := NewMultiCommandApp(testAppInfo, flagSet, os.Stdout, os.Stderr) 78 | 79 | if app == nil { 80 | t.Error("NewMultiCommandApp returned nil") 81 | } 82 | } 83 | 84 | func TestNewMultiCommandApp_ZeroValues(t *testing.T) { 85 | app := NewMultiCommandApp(AppInfo{}, nil, nil, nil) 86 | 87 | if app == nil { 88 | t.Fatal("NewMultiCommandApp returned nil") 89 | } 90 | 91 | if inferredName := inferAppName(); app.info.Name != inferredName { 92 | t.Errorf("NewMultiCommandApp with no given name gave %q name, wanted %q", app.info.Name, inferredName) 93 | } 94 | 95 | if app.info.Usage != DefaultParentCommandUsage { 96 | t.Errorf("NewMultiCommandApp with no given usage gave %q usage, wanted %q", app.info.Usage, DefaultParentCommandUsage) 97 | } 98 | 99 | if app.flags.Flags == nil { 100 | t.Errorf("NewMultiCommandApp with no given flags had nil flags") 101 | } 102 | } 103 | 104 | func TestMultiCommandApp_SetCommand(t *testing.T) { 105 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 106 | 107 | app := NewMultiCommandApp(testAppInfo, flagSet, os.Stdout, os.Stderr) 108 | 109 | includedCommandFlagSet := flag.NewFlagSet("included", flag.ExitOnError) 110 | err := app.SetCommand(CommandInfo{Name: "included"}, nil, includedCommandFlagSet) 111 | if err != nil { 112 | t.Fatalf("SetCommand returned error: %v", err) 113 | } 114 | 115 | for testName, testData := range map[string]struct { 116 | info CommandInfo 117 | exec Executor 118 | flags Flags 119 | wantErr bool 120 | }{ 121 | "all": { 122 | info: CommandInfo{ 123 | Name: "test", 124 | Summary: "testing", 125 | Usage: "test testing test", 126 | }, 127 | exec: testNoOpExecutor, 128 | flags: flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError), 129 | }, 130 | "only info": { 131 | info: CommandInfo{ 132 | Name: "test", 133 | Summary: "testing", 134 | Usage: "test testing test", 135 | }, 136 | }, 137 | "only exec": { 138 | exec: testNoOpExecutor, 139 | }, 140 | "only flags": { 141 | flags: flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError), 142 | }, 143 | "zero values": {}, 144 | "duplicate flags from globals": { 145 | flags: flagSet, 146 | wantErr: true, 147 | }, 148 | "duplicate flags from other command": { 149 | flags: includedCommandFlagSet, 150 | wantErr: true, 151 | }, 152 | } { 153 | t.Run(testName, func(t *testing.T) { 154 | err := app.SetCommand(testData.info, testData.exec, testData.flags) 155 | if err != nil && !testData.wantErr { 156 | t.Errorf("SetCommand returned error: %v", err) 157 | } 158 | }) 159 | } 160 | } 161 | 162 | func TestMultiCommandApp_CommandNames(t *testing.T) { 163 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 164 | 165 | app := NewMultiCommandApp(testAppInfo, flagSet, os.Stdout, os.Stderr) 166 | 167 | if names := app.CommandNames(); len(names) > 0 { 168 | t.Errorf("CommandNames returned a non-empty slice %v", names) 169 | } 170 | 171 | app.SetCommand(CommandInfo{Name: "foo"}, nil, nil) 172 | app.SetCommand(CommandInfo{Name: "bar"}, nil, nil) 173 | 174 | names := app.CommandNames() 175 | sort.Strings(names) 176 | 177 | if names[0] != "bar" && names[1] != "foo" { 178 | t.Errorf("CommandNames returned an unexpected slice %v", names) 179 | } 180 | } 181 | 182 | func TestSingleCommandApp_PrintVersion(t *testing.T) { 183 | for testName, testData := range map[string]struct { 184 | version string 185 | want string 186 | }{ 187 | "specified": { 188 | version: "vTest", 189 | want: fmt.Sprintf("%s vTest (%s/%s)\n", testAppInfo.Name, runtime.GOOS, runtime.GOARCH), 190 | }, 191 | "no version string": { 192 | version: "", 193 | want: fmt.Sprintf("%s (%s/%s)\n", testAppInfo.Name, runtime.GOOS, runtime.GOARCH), 194 | }, 195 | } { 196 | t.Run(testName, func(t *testing.T) { 197 | appInfo := testAppInfo 198 | appInfo.Version = testData.version 199 | var buf bytes.Buffer 200 | 201 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 202 | app := NewSingleCommandApp(appInfo, testNoOpExecutor, flagSet, &buf, &buf) 203 | 204 | app.PrintVersion() 205 | 206 | got := buf.String() 207 | 208 | if got != testData.want { 209 | t.Errorf("app.PrintVersion gave %q, want %q", got, testData.want) 210 | } 211 | }) 212 | } 213 | } 214 | 215 | func TestMultiCommandApp_PrintVersion(t *testing.T) { 216 | for testName, testData := range map[string]struct { 217 | version string 218 | want string 219 | }{ 220 | "specified": { 221 | version: "vTest", 222 | want: fmt.Sprintf("%s vTest (%s/%s)\n", testAppInfo.Name, runtime.GOOS, runtime.GOARCH), 223 | }, 224 | "no version string": { 225 | version: "", 226 | want: fmt.Sprintf("%s (%s/%s)\n", testAppInfo.Name, runtime.GOOS, runtime.GOARCH), 227 | }, 228 | } { 229 | t.Run(testName, func(t *testing.T) { 230 | appInfo := testAppInfo 231 | appInfo.Version = testData.version 232 | var buf bytes.Buffer 233 | 234 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 235 | app := NewMultiCommandApp(appInfo, flagSet, &buf, &buf) 236 | 237 | app.PrintVersion() 238 | 239 | got := buf.String() 240 | 241 | if got != testData.want { 242 | t.Errorf("app.PrintVersion gave %q, want %q", got, testData.want) 243 | } 244 | }) 245 | } 246 | } 247 | 248 | func TestSingleCommandApp_PrintHelp(t *testing.T) { 249 | wantFormat := `Usage: test testing 250 | 251 | A test 252 | 253 | Options: 254 | 255 | -help 256 | Display the help message 257 | -testflag string 258 | A test flag (default "testval") 259 | -version 260 | Display the application version 261 | 262 | test vTest (%s/%s) 263 | ` 264 | want := fmt.Sprintf(wantFormat, runtime.GOOS, runtime.GOARCH) 265 | 266 | var buf bytes.Buffer 267 | 268 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 269 | flagSet.String("testflag", "testval", "A test flag") 270 | 271 | app := NewSingleCommandApp(testAppInfo, testNoOpExecutor, flagSet, &buf, &buf) 272 | 273 | app.PrintHelp() 274 | 275 | got := buf.String() 276 | 277 | if got != want { 278 | t.Errorf("app.PrintHelp gave %q, want %q", got, want) 279 | } 280 | } 281 | 282 | func TestMultiCommandApp_PrintHelp(t *testing.T) { 283 | wantFormat := `Usage: test testing 284 | 285 | A test 286 | 287 | Commands: 288 | 289 | testcommand A test command 290 | 291 | Options: 292 | 293 | -help 294 | Display the help message 295 | -testflag string 296 | A test flag (default "testval") 297 | -version 298 | Display the application version 299 | 300 | test vTest (%s/%s) 301 | ` 302 | want := fmt.Sprintf(wantFormat, runtime.GOOS, runtime.GOARCH) 303 | 304 | var buf bytes.Buffer 305 | 306 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 307 | flagSet.String("testflag", "testval", "A test flag") 308 | 309 | app := NewMultiCommandApp(testAppInfo, flagSet, &buf, &buf) 310 | 311 | testCommandInfo := CommandInfo{ 312 | Name: "testcommand", 313 | Summary: "A test command", 314 | Usage: "args here...", 315 | } 316 | 317 | commandFlagSet := flag.NewFlagSet(testCommandInfo.Name, flag.ExitOnError) 318 | commandFlagSet.Int("testcommandflag", 5, "A test command flag") 319 | 320 | err := app.SetCommand(testCommandInfo, testNoOpExecutor, commandFlagSet) 321 | if err != nil { 322 | t.Fatalf("SetCommand returned error: %v", err) 323 | } 324 | 325 | app.PrintHelp("") 326 | 327 | got := buf.String() 328 | 329 | if got != want { 330 | t.Errorf("app.PrintHelp gave %q, want %q", got, want) 331 | } 332 | } 333 | 334 | func TestMultiCommandApp_PrintHelp_Command(t *testing.T) { 335 | wantFormat := `Usage: test testcommand args here... 336 | 337 | A test command 338 | 339 | Options: 340 | 341 | -help 342 | Display the help message 343 | -testcommandflag int 344 | A test command flag (default 5) 345 | -testflag string 346 | A test flag (default "testval") 347 | -version 348 | Display the application version 349 | 350 | test vTest (%s/%s) 351 | ` 352 | want := fmt.Sprintf(wantFormat, runtime.GOOS, runtime.GOARCH) 353 | 354 | var buf bytes.Buffer 355 | 356 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 357 | flagSet.String("testflag", "testval", "A test flag") 358 | 359 | app := NewMultiCommandApp(testAppInfo, flagSet, &buf, &buf) 360 | 361 | testCommandInfo := CommandInfo{ 362 | Name: "testcommand", 363 | Summary: "A test command", 364 | Usage: "args here...", 365 | } 366 | 367 | commandFlagSet := flag.NewFlagSet(testCommandInfo.Name, flag.ExitOnError) 368 | commandFlagSet.Int("testcommandflag", 5, "A test command flag") 369 | 370 | err := app.SetCommand(testCommandInfo, testNoOpExecutor, commandFlagSet) 371 | if err != nil { 372 | t.Fatalf("SetCommand returned error: %v", err) 373 | } 374 | 375 | app.PrintHelp("testcommand") 376 | 377 | got := buf.String() 378 | 379 | if got != want { 380 | t.Errorf("app.PrintHelp gave %q, want %q", got, want) 381 | } 382 | } 383 | 384 | func TestSingleCommandApp_PrintUsage(t *testing.T) { 385 | for testName, testData := range map[string]struct { 386 | usage string 387 | want string 388 | }{ 389 | "specified": { 390 | usage: "testing [test]", 391 | want: fmt.Sprintf("Usage: %s testing [test]\n", testAppInfo.Name), 392 | }, 393 | "no usage string": { 394 | usage: "", 395 | want: fmt.Sprintf("Usage: %s %s\n", testAppInfo.Name, DefaultCommandUsage), 396 | }, 397 | } { 398 | t.Run(testName, func(t *testing.T) { 399 | appInfo := testAppInfo 400 | appInfo.Usage = testData.usage 401 | var buf bytes.Buffer 402 | 403 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 404 | app := NewSingleCommandApp(appInfo, testNoOpExecutor, flagSet, &buf, &buf) 405 | 406 | app.PrintUsage() 407 | 408 | got := buf.String() 409 | 410 | if got != testData.want { 411 | t.Errorf("app.PrintUsage gave %q, want %q", got, testData.want) 412 | } 413 | }) 414 | } 415 | } 416 | 417 | func TestMultiCommandApp_PrintUsage(t *testing.T) { 418 | testCommandInfo := CommandInfo{ 419 | Name: "test", 420 | Summary: "testing", 421 | } 422 | 423 | for testName, testData := range map[string]struct { 424 | appUsage string 425 | commandUsage string 426 | forCommand string 427 | want string 428 | }{ 429 | "specified app and command usage, for command": { 430 | appUsage: "testing [test]", 431 | commandUsage: "test [opts]", 432 | forCommand: testCommandInfo.Name, 433 | want: fmt.Sprintf("Usage: %s %s test [opts]\n", testAppInfo.Name, testCommandInfo.Name), 434 | }, 435 | "specified app usage, no command usage, for command": { 436 | appUsage: "testing [test]", 437 | commandUsage: "", 438 | forCommand: testCommandInfo.Name, 439 | want: fmt.Sprintf("Usage: %s %s %s\n", testAppInfo.Name, testCommandInfo.Name, DefaultCommandUsage), 440 | }, 441 | "no app usage, specified command usage, for command": { 442 | appUsage: "", 443 | commandUsage: "test [opts]", 444 | forCommand: testCommandInfo.Name, 445 | want: fmt.Sprintf("Usage: %s %s test [opts]\n", testAppInfo.Name, testCommandInfo.Name), 446 | }, 447 | "no app or command usage, for command": { 448 | appUsage: "", 449 | commandUsage: "", 450 | forCommand: testCommandInfo.Name, 451 | want: fmt.Sprintf("Usage: %s %s %s\n", testAppInfo.Name, testCommandInfo.Name, DefaultCommandUsage), 452 | }, 453 | "specified app and command usage, no command": { 454 | appUsage: "testing [test]", 455 | commandUsage: "test [opts]", 456 | forCommand: "", 457 | want: fmt.Sprintf("Usage: %s testing [test]\n", testAppInfo.Name), 458 | }, 459 | "specified app usage, no command usage, no command": { 460 | appUsage: "testing [test]", 461 | commandUsage: "", 462 | forCommand: "", 463 | want: fmt.Sprintf("Usage: %s testing [test]\n", testAppInfo.Name), 464 | }, 465 | "no app usage, specified command usage, no command": { 466 | appUsage: "", 467 | commandUsage: "test [opts]", 468 | forCommand: "", 469 | want: fmt.Sprintf("Usage: %s %s\n", testAppInfo.Name, DefaultParentCommandUsage), 470 | }, 471 | "no app or command usage, no command": { 472 | appUsage: "", 473 | commandUsage: "", 474 | forCommand: "", 475 | want: fmt.Sprintf("Usage: %s %s\n", testAppInfo.Name, DefaultParentCommandUsage), 476 | }, 477 | } { 478 | t.Run(testName, func(t *testing.T) { 479 | appInfo := testAppInfo 480 | appInfo.Usage = testData.appUsage 481 | var buf bytes.Buffer 482 | 483 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 484 | app := NewMultiCommandApp(appInfo, flagSet, &buf, &buf) 485 | 486 | testCommandInfo.Usage = testData.commandUsage 487 | commandFlagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 488 | err := app.SetCommand(testCommandInfo, testNoOpExecutor, commandFlagSet) 489 | if err != nil { 490 | t.Errorf("SetCommand returned error: %v", err) 491 | } 492 | 493 | app.PrintUsage(testData.forCommand) 494 | 495 | got := buf.String() 496 | 497 | if got != testData.want { 498 | t.Errorf("app.PrintUsage gave %q, want %q", got, testData.want) 499 | } 500 | }) 501 | } 502 | } 503 | 504 | func TestSingleCommandApp_PrintUsageError(t *testing.T) { 505 | var buf bytes.Buffer 506 | 507 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 508 | app := NewSingleCommandApp(testAppInfo, testNoOpExecutor, flagSet, &buf, &buf) 509 | 510 | usageErr := errors.New("test usage error") 511 | want := `Error: test usage error 512 | 513 | Usage: test testing 514 | 515 | Run 'test --help' for usage. 516 | ` 517 | 518 | app.PrintUsageError(usageErr) 519 | 520 | got := buf.String() 521 | 522 | if got != want { 523 | t.Errorf("app.PrintUsageError gave %q, want %q", got, want) 524 | } 525 | } 526 | 527 | func TestMultiCommandApp_PrintUsageError(t *testing.T) { 528 | var buf bytes.Buffer 529 | 530 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 531 | app := NewMultiCommandApp(testAppInfo, flagSet, &buf, &buf) 532 | 533 | usageErr := errors.New("test usage error") 534 | want := `Error: test usage error 535 | 536 | Usage: test testing 537 | 538 | Run 'test --help' for usage. 539 | ` 540 | 541 | app.PrintUsageError("", usageErr) 542 | 543 | got := buf.String() 544 | 545 | if got != want { 546 | t.Errorf("app.PrintUsageError gave %q, want %q", got, want) 547 | } 548 | } 549 | 550 | func TestMultiCommandApp_PrintUsageError_Command(t *testing.T) { 551 | var buf bytes.Buffer 552 | 553 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 554 | app := NewMultiCommandApp(testAppInfo, flagSet, &buf, &buf) 555 | 556 | testCommandInfo := CommandInfo{Name: "testcommand"} 557 | app.SetCommand(testCommandInfo, nil, nil) 558 | 559 | usageErr := errors.New("test usage error") 560 | want := `Error: test usage error 561 | 562 | Usage: test testcommand [arguments ...] 563 | 564 | Run 'test testcommand --help' for usage. 565 | ` 566 | 567 | app.PrintUsageError(testCommandInfo.Name, usageErr) 568 | 569 | got := buf.String() 570 | 571 | if got != want { 572 | t.Errorf("app.PrintUsageError gave %q, want %q", got, want) 573 | } 574 | } 575 | 576 | func TestSingleCommandApp_Run(t *testing.T) { 577 | var executorCapture struct { 578 | ctx context.Context 579 | arguments []string 580 | } 581 | 582 | executor := func(ctx context.Context, arguments []string) error { 583 | executorCapture.ctx = ctx 584 | executorCapture.arguments = arguments 585 | 586 | return nil 587 | } 588 | 589 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 590 | out := io.Discard 591 | 592 | app := NewSingleCommandApp(testAppInfo, executor, flagSet, out, out) 593 | 594 | ctxTestKey := struct{ k string }{k: "test-key-for-testing"} 595 | ctxTestVal := "test context val" 596 | ctx := context.WithValue(context.TODO(), ctxTestKey, ctxTestVal) 597 | args := []string{"testarg1", "testarg2"} 598 | wantedExitCode := 0 599 | 600 | initRan := false 601 | initFn := func() error { 602 | initRan = true 603 | return nil 604 | } 605 | 606 | app.OnInit(initFn) 607 | 608 | exitCode := app.Run(ctx, args) 609 | 610 | if exitCode != wantedExitCode { 611 | t.Errorf("app.Run gave %v, wanted %v", exitCode, wantedExitCode) 612 | } 613 | 614 | if !initRan { 615 | t.Error("app.Run didn't run init function") 616 | } 617 | 618 | if executorCapture.ctx.Value(ctxTestKey) != ctxTestVal { 619 | t.Errorf("app.Run executor gave %q, wanted %q", executorCapture.ctx.Value(ctxTestKey), ctxTestVal) 620 | } 621 | 622 | if executorCapture.arguments[0] != args[0] && executorCapture.arguments[1] != args[1] { 623 | t.Errorf("app.Run executor gave %q, wanted %q", executorCapture.arguments, args) 624 | } 625 | } 626 | 627 | func TestSingleCommandApp_Run_EmptyArgsProvided(t *testing.T) { 628 | var capturedArgs []string 629 | 630 | executor := func(ctx context.Context, arguments []string) error { 631 | capturedArgs = arguments 632 | return nil 633 | } 634 | 635 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 636 | out := io.Discard 637 | 638 | app := NewSingleCommandApp(testAppInfo, executor, flagSet, out, out) 639 | 640 | originalOSArgs := os.Args[:] 641 | defer func() { 642 | // Put back the original args... to not mess with global state 643 | os.Args = originalOSArgs 644 | }() 645 | 646 | os.Args = []string{testAppInfo.Name, "arg"} 647 | expectedArgs := os.Args[1:] 648 | 649 | if exitCode := app.Run(context.TODO(), nil); exitCode != 0 { 650 | t.Errorf("app.Run gave non-zero exit code %v", exitCode) 651 | } 652 | 653 | if capturedArgs[0] != expectedArgs[0] { 654 | t.Errorf("app.Run executor gave args %q, wanted %q", capturedArgs, expectedArgs) 655 | } 656 | } 657 | 658 | func TestSingleCommandApp_Run_AltPaths(t *testing.T) { 659 | for testName, testData := range map[string]struct { 660 | exec Executor 661 | init func() error 662 | flags Flags 663 | 664 | args []string 665 | 666 | wantedExitCode int 667 | wantedOut string 668 | wantedErrOut string 669 | }{ 670 | "version requested": { 671 | args: []string{"--version"}, 672 | 673 | wantedExitCode: 0, 674 | wantedOut: fmt.Sprintf("test vTest (%s/%s)\n", runtime.GOOS, runtime.GOARCH), 675 | wantedErrOut: "", 676 | }, 677 | "help requested": { 678 | args: []string{"--help"}, 679 | 680 | wantedExitCode: 0, 681 | wantedOut: "", 682 | wantedErrOut: fmt.Sprintf(`Usage: test testing 683 | 684 | A test 685 | 686 | Options: 687 | 688 | -help 689 | Display the help message 690 | -version 691 | Display the application version 692 | 693 | test vTest (%s/%s) 694 | `, runtime.GOOS, runtime.GOARCH), 695 | }, 696 | "args contain non-defined flags": { 697 | flags: flag.NewFlagSet("test", flag.ContinueOnError), 698 | args: []string{"--non-existent-flag=val"}, 699 | 700 | wantedExitCode: 2, 701 | wantedOut: "", 702 | wantedErrOut: `Error: flag provided but not defined: -non-existent-flag 703 | 704 | Usage: test testing 705 | 706 | Run 'test --help' for usage. 707 | `, 708 | }, 709 | "initialize returns error": { 710 | init: func() error { 711 | return errors.New("test init error") 712 | }, 713 | 714 | args: []string{"test"}, 715 | 716 | wantedExitCode: 1, 717 | wantedOut: "", 718 | wantedErrOut: "Error: test init error\n", 719 | }, 720 | "initialize returns status code error": { 721 | init: func() error { 722 | return ErrWithStatusCode(errors.New("test init error"), 101) 723 | }, 724 | 725 | args: []string{"test"}, 726 | 727 | wantedExitCode: 101, 728 | wantedOut: "", 729 | wantedErrOut: "Error: test init error\n", 730 | }, 731 | "initialize returns status code empty error": { 732 | init: func() error { 733 | return ErrWithStatusCode(errors.New(""), 101) 734 | }, 735 | 736 | args: []string{"test"}, 737 | 738 | wantedExitCode: 101, 739 | wantedOut: "", 740 | wantedErrOut: "", 741 | }, 742 | "execute returns error": { 743 | exec: func(ctx context.Context, arguments []string) error { 744 | return errors.New("test exec error") 745 | }, 746 | 747 | args: []string{"test"}, 748 | 749 | wantedExitCode: 1, 750 | wantedOut: "", 751 | wantedErrOut: "Error: test exec error\n", 752 | }, 753 | "execute returns status code error": { 754 | exec: func(ctx context.Context, arguments []string) error { 755 | return ErrWithStatusCode(errors.New("test exec error"), 217) 756 | }, 757 | 758 | args: []string{"test"}, 759 | 760 | wantedExitCode: 217, 761 | wantedOut: "", 762 | wantedErrOut: "Error: test exec error\n", 763 | }, 764 | "execute returns status code empty error": { 765 | exec: func(ctx context.Context, arguments []string) error { 766 | return ErrWithStatusCode(errors.New(""), 217) 767 | }, 768 | 769 | args: []string{"test"}, 770 | 771 | wantedExitCode: 217, 772 | wantedOut: "", 773 | wantedErrOut: "", 774 | }, 775 | } { 776 | t.Run(testName, func(t *testing.T) { 777 | var out, errOut bytes.Buffer 778 | 779 | app := NewSingleCommandApp(testAppInfo, testData.exec, testData.flags, &out, &errOut) 780 | 781 | if testData.init != nil { 782 | app.OnInit(testData.init) 783 | } 784 | 785 | exitCode := app.Run(context.TODO(), testData.args) 786 | 787 | if exitCode != testData.wantedExitCode { 788 | t.Errorf("app.Run gave %v, wanted %v", exitCode, testData.wantedExitCode) 789 | } 790 | 791 | if out.String() != testData.wantedOut { 792 | t.Errorf("app.Run gave out %q, wanted %q", out.String(), testData.wantedOut) 793 | } 794 | 795 | if errOut.String() != testData.wantedErrOut { 796 | t.Errorf("app.Run gave errOut %q, wanted %q", errOut.String(), testData.wantedErrOut) 797 | } 798 | }) 799 | } 800 | } 801 | 802 | func TestMultiCommandApp_Run(t *testing.T) { 803 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 804 | out := io.Discard 805 | 806 | app := NewMultiCommandApp(testAppInfo, flagSet, out, out) 807 | 808 | testCommandInfo := CommandInfo{Name: "testcommand"} 809 | 810 | var executorCapture struct { 811 | ctx context.Context 812 | arguments []string 813 | } 814 | executor := func(ctx context.Context, arguments []string) error { 815 | executorCapture.ctx = ctx 816 | executorCapture.arguments = arguments 817 | 818 | return nil 819 | } 820 | commandFlagSet := flag.NewFlagSet(testCommandInfo.Name, flag.ExitOnError) 821 | 822 | app.SetCommand(testCommandInfo, executor, commandFlagSet) 823 | 824 | ctxTestKey := struct{ k string }{k: "test-key-for-testing"} 825 | ctxTestVal := "test context val" 826 | ctx := context.WithValue(context.TODO(), ctxTestKey, ctxTestVal) 827 | args := []string{testCommandInfo.Name, "testarg1", "testarg2"} 828 | wantedExitCode := 0 829 | 830 | initRan := false 831 | initFn := func() error { 832 | initRan = true 833 | return nil 834 | } 835 | 836 | app.OnInit(initFn) 837 | 838 | exitCode := app.Run(ctx, args) 839 | 840 | if exitCode != wantedExitCode { 841 | t.Errorf("app.Run gave %v, wanted %v", exitCode, wantedExitCode) 842 | } 843 | 844 | if !initRan { 845 | t.Error("app.Run didn't run init function") 846 | } 847 | 848 | if executorCapture.ctx.Value(ctxTestKey) != ctxTestVal { 849 | t.Errorf("app.Run executor gave %q, wanted %q", executorCapture.ctx.Value(ctxTestKey), ctxTestVal) 850 | } 851 | 852 | if executorCapture.arguments[0] != args[1] && executorCapture.arguments[1] != args[2] { 853 | t.Errorf("app.Run executor gave %q, wanted %q", executorCapture.arguments, args) 854 | } 855 | } 856 | 857 | func TestMultiCommandApp_Run_EmptyArgsProvided(t *testing.T) { 858 | flagSet := flag.NewFlagSet(testAppInfo.Name, flag.ExitOnError) 859 | out := io.Discard 860 | 861 | app := NewMultiCommandApp(testAppInfo, flagSet, out, out) 862 | 863 | testCommandInfo := CommandInfo{Name: "testcommand"} 864 | 865 | var capturedArgs []string 866 | executor := func(ctx context.Context, arguments []string) error { 867 | capturedArgs = arguments 868 | return nil 869 | } 870 | 871 | commandFlagSet := flag.NewFlagSet(testCommandInfo.Name, flag.ExitOnError) 872 | 873 | app.SetCommand(testCommandInfo, executor, commandFlagSet) 874 | 875 | originalOSArgs := os.Args[:] 876 | defer func() { 877 | // Put back the original args... to not mess with global state 878 | os.Args = originalOSArgs 879 | }() 880 | 881 | os.Args = []string{testAppInfo.Name, testCommandInfo.Name, "arg"} 882 | expectedArgs := os.Args[2:] 883 | 884 | if exitCode := app.Run(context.TODO(), nil); exitCode != 0 { 885 | t.Errorf("app.Run gave non-zero exit code %v", exitCode) 886 | } 887 | 888 | if capturedArgs[0] != expectedArgs[0] { 889 | t.Errorf("app.Run executor gave args %q, wanted %q", capturedArgs, expectedArgs) 890 | } 891 | } 892 | 893 | func TestMultiCommandApp_Run_AltPaths(t *testing.T) { 894 | testCommandInfo := CommandInfo{ 895 | Name: "testcommand", 896 | Summary: "A test command", 897 | Usage: "args here...", 898 | } 899 | 900 | for testName, testData := range map[string]struct { 901 | flags Flags 902 | exec Executor 903 | commandFlags Flags 904 | init func() error 905 | 906 | args []string 907 | 908 | wantedExitCode int 909 | wantedOut string 910 | wantedErrOut string 911 | }{ 912 | "version requested": { 913 | args: []string{"--version"}, 914 | 915 | wantedExitCode: 0, 916 | wantedOut: fmt.Sprintf("test vTest (%s/%s)\n", runtime.GOOS, runtime.GOARCH), 917 | wantedErrOut: "", 918 | }, 919 | "help requested": { 920 | args: []string{"--help"}, 921 | 922 | wantedExitCode: 0, 923 | wantedOut: "", 924 | wantedErrOut: fmt.Sprintf(`Usage: test testing 925 | 926 | A test 927 | 928 | Commands: 929 | 930 | testcommand A test command 931 | 932 | Options: 933 | 934 | -help 935 | Display the help message 936 | -version 937 | Display the application version 938 | 939 | test vTest (%s/%s) 940 | `, runtime.GOOS, runtime.GOARCH), 941 | }, 942 | "command help requested": { 943 | args: []string{testCommandInfo.Name, "--help"}, 944 | 945 | wantedExitCode: 0, 946 | wantedOut: "", 947 | wantedErrOut: fmt.Sprintf(`Usage: test testcommand args here... 948 | 949 | A test command 950 | 951 | Options: 952 | 953 | -help 954 | Display the help message 955 | -version 956 | Display the application version 957 | 958 | test vTest (%s/%s) 959 | `, runtime.GOOS, runtime.GOARCH), 960 | }, 961 | "empty args": { 962 | args: []string{}, 963 | 964 | wantedExitCode: 2, 965 | wantedOut: "", 966 | wantedErrOut: fmt.Sprintf(`Usage: test testing 967 | 968 | A test 969 | 970 | Commands: 971 | 972 | testcommand A test command 973 | 974 | Options: 975 | 976 | -help 977 | Display the help message 978 | -version 979 | Display the application version 980 | 981 | test vTest (%s/%s) 982 | `, runtime.GOOS, runtime.GOARCH), 983 | }, 984 | "args contain non-defined flags": { 985 | flags: flag.NewFlagSet("test", flag.ContinueOnError), 986 | args: []string{"--non-existent-flag=val"}, 987 | 988 | wantedExitCode: 2, 989 | wantedOut: "", 990 | wantedErrOut: `Error: flag provided but not defined: -non-existent-flag 991 | 992 | Usage: test testing 993 | 994 | Run 'test --help' for usage. 995 | `, 996 | }, 997 | "initialize returns error": { 998 | init: func() error { 999 | return errors.New("test init error") 1000 | }, 1001 | 1002 | args: []string{testCommandInfo.Name}, 1003 | 1004 | wantedExitCode: 1, 1005 | wantedOut: "", 1006 | wantedErrOut: "Error: test init error\n", 1007 | }, 1008 | "initialize returns status code error": { 1009 | init: func() error { 1010 | return ErrWithStatusCode(errors.New("test init error"), 101) 1011 | }, 1012 | 1013 | args: []string{testCommandInfo.Name}, 1014 | 1015 | wantedExitCode: 101, 1016 | wantedOut: "", 1017 | wantedErrOut: "Error: test init error\n", 1018 | }, 1019 | "execute returns error": { 1020 | exec: func(ctx context.Context, arguments []string) error { 1021 | return errors.New("test exec error") 1022 | }, 1023 | 1024 | args: []string{testCommandInfo.Name}, 1025 | 1026 | wantedExitCode: 1, 1027 | wantedOut: "", 1028 | wantedErrOut: "Error: test exec error\n", 1029 | }, 1030 | "execute returns status code error": { 1031 | exec: func(ctx context.Context, arguments []string) error { 1032 | return ErrWithStatusCode(errors.New("test exec error"), 217) 1033 | }, 1034 | 1035 | args: []string{testCommandInfo.Name}, 1036 | 1037 | wantedExitCode: 217, 1038 | wantedOut: "", 1039 | wantedErrOut: "Error: test exec error\n", 1040 | }, 1041 | "unknown command": { 1042 | args: []string{"thiscommanddoesnotexist"}, 1043 | 1044 | wantedExitCode: 1, 1045 | wantedOut: "", 1046 | wantedErrOut: "Error: unknown command 'thiscommanddoesnotexist'\n\nUsage: test testing\n\nRun 'test --help' for usage.\n", 1047 | }, 1048 | "unknown command is known flag": { 1049 | flags: func() Flags { 1050 | flags := flag.NewFlagSet("test", flag.ContinueOnError) 1051 | 1052 | flags.Bool("testflag", false, "some test flag") 1053 | 1054 | return flags 1055 | }(), 1056 | args: []string{"--testflag"}, 1057 | 1058 | wantedExitCode: 1, 1059 | wantedOut: "", 1060 | wantedErrOut: "Error: unknown command '--testflag'\n\nUsage: test testing\n\nRun 'test --help' for usage.\n", 1061 | }, 1062 | } { 1063 | t.Run(testName, func(t *testing.T) { 1064 | var out, errOut bytes.Buffer 1065 | 1066 | app := NewMultiCommandApp(testAppInfo, testData.flags, &out, &errOut) 1067 | 1068 | app.SetCommand(testCommandInfo, testData.exec, testData.commandFlags) 1069 | 1070 | if testData.init != nil { 1071 | app.OnInit(testData.init) 1072 | } 1073 | 1074 | exitCode := app.Run(context.TODO(), testData.args) 1075 | 1076 | if exitCode != testData.wantedExitCode { 1077 | t.Errorf("app.Run gave %v, wanted %v", exitCode, testData.wantedExitCode) 1078 | } 1079 | 1080 | if out.String() != testData.wantedOut { 1081 | t.Errorf("app.Run gave out %q, wanted %q", out.String(), testData.wantedOut) 1082 | } 1083 | 1084 | if errOut.String() != testData.wantedErrOut { 1085 | t.Errorf("app.Run gave errOut %q, wanted %q", errOut.String(), testData.wantedErrOut) 1086 | } 1087 | }) 1088 | } 1089 | } 1090 | --------------------------------------------------------------------------------