├── go.mod ├── .travis.yml ├── command_handler.go ├── go.sum ├── command_descriptor.go ├── .github └── ISSUE_TEMPLATE │ └── feature_request.md ├── example_argument_test.go ├── LICENSE ├── command_wrapper.go ├── example_command_helper_test.go ├── argument.go ├── argument_test.go ├── CODE_OF_CONDUCT.md ├── command_registry.go ├── command_helper.go ├── README.md ├── command_helper_test.go └── command_registry_test.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yitsushi/go-commander 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 7 | github.com/mitchellh/go-homedir v1.1.0 8 | ) 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | go: tip 4 | 5 | before_install: 6 | - go get github.com/mattn/goveralls 7 | 8 | install: 9 | - go get ./... 10 | 11 | script: 12 | - go test -v ./... -cover -covermode=count -coverprofile=coverage.out 13 | 14 | after_success: 15 | - $GOPATH/bin/goveralls -coverprofile=coverage.out -service=travis-ci 16 | 17 | -------------------------------------------------------------------------------- /command_handler.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | // CommandHandler defines a command. 4 | // If a struct implements all the required function, 5 | // it is acceptable as a CommandHandler for CommandRegistry 6 | type CommandHandler interface { 7 | // Execute function will be executed when the command is called 8 | // opts can be used for logging, parsing flags like '-v' 9 | Execute(opts *CommandHelper) 10 | } 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= 2 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= 3 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 4 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 5 | -------------------------------------------------------------------------------- /command_descriptor.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | // CommandDescriptor describes a command for Help calls 4 | type CommandDescriptor struct { 5 | // Required! name of the command 6 | Name string 7 | // Optional: argument list as a string 8 | // Basic convention: [optional_argument] 9 | Arguments string 10 | // Optional: Short description is used in general help 11 | ShortDescription string 12 | // Optional: Long description is used in command specific help 13 | LongDescription string 14 | // Optional: Examples array is used in command specific help 15 | Examples []string 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /example_argument_test.go: -------------------------------------------------------------------------------- 1 | package commander_test 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | 8 | commander "github.com/yitsushi/go-commander" 9 | ) 10 | 11 | type MyCustomType struct { 12 | ID uint64 13 | Name string 14 | } 15 | 16 | func ExampleRegisterArgumentType_simple() { 17 | commander.RegisterArgumentType("Int32", func(value string) (interface{}, error) { 18 | return strconv.ParseInt(value, 10, 32) 19 | }) 20 | } 21 | 22 | func ExampleRegisterArgumentType_customStruct() { 23 | commander.RegisterArgumentType("MyType", func(value string) (interface{}, error) { 24 | values := strings.Split(value, ":") 25 | 26 | if len(values) < 2 { 27 | return &MyCustomType{}, errors.New("Invalid format! MyType => 'ID:Name'") 28 | } 29 | 30 | id, err := strconv.ParseUint(values[0], 10, 64) 31 | if err != nil { 32 | return &MyCustomType{}, errors.New("Invalid format! MyType => 'ID:Name'") 33 | } 34 | 35 | return &MyCustomType{ 36 | ID: id, 37 | Name: values[1], 38 | }, 39 | nil 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 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 | -------------------------------------------------------------------------------- /command_wrapper.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kardianos/osext" 7 | ) 8 | 9 | // OSExtExecutable returns current executable path 10 | var OSExtExecutable = osext.Executable 11 | 12 | // FmtPrintf is fmt.Printf 13 | var FmtPrintf = fmt.Printf 14 | 15 | // NewCommandFunc is the expected type for CommandRegistry.Register 16 | type NewCommandFunc func(appName string) *CommandWrapper 17 | 18 | // ValidatorFunc can pre-validate the command and it's arguments 19 | // Just throw a panic if something is wrong 20 | type ValidatorFunc func(opts *CommandHelper) 21 | 22 | // CommandWrapper is a general wrapper for a command 23 | // CommandRegistry will know what to do this a struct like this 24 | type CommandWrapper struct { 25 | // Help contains all information about the command 26 | Help *CommandDescriptor 27 | // Handler will be called when the user calls that specific command 28 | Handler CommandHandler 29 | // Validator will be executed before Execute on the Handler 30 | Validator ValidatorFunc 31 | // Arguments is a simple list of possible arguments with type definition 32 | Arguments []*Argument 33 | } 34 | -------------------------------------------------------------------------------- /example_command_helper_test.go: -------------------------------------------------------------------------------- 1 | package commander_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | commander "github.com/yitsushi/go-commander" 8 | ) 9 | 10 | // (c *MyCommand) Execute(opts *commander.CommandHelper) 11 | var opts *commander.CommandHelper 12 | 13 | func init() { 14 | opts = &commander.CommandHelper{} 15 | } 16 | 17 | func ExampleCommandHelper_TypedOpt() { 18 | opts.AttachArgumentList([]*commander.Argument{ 19 | &commander.Argument{ 20 | Name: "list", 21 | Type: "StringArray[]", 22 | }, 23 | }) 24 | opts.Parse([]string{"my-command", "--list=one,two,three"}) 25 | 26 | // list is a StringArray[] 27 | if opts.ErrorForTypedOpt("list") == nil { 28 | log.Println(opts.TypedOpt("list")) 29 | myList := opts.TypedOpt("list").([]string) 30 | if len(myList) > 0 { 31 | fmt.Printf("My list: %v\n", myList) 32 | } 33 | } 34 | 35 | // Never defined, always shoud be an empty string 36 | if opts.TypedOpt("no-key").(string) != "" { 37 | panic("Something went wrong!") 38 | } 39 | 40 | // Output: My list: [one two three] 41 | } 42 | 43 | func ExampleCommandHelper_Arg() { 44 | opts.Parse([]string{"my-command", "plain-argument"}) 45 | 46 | fmt.Println(opts.Arg(0)) 47 | // Output: plain-argument 48 | } 49 | 50 | func ExampleCommandHelper_Flag() { 51 | opts.Parse([]string{"my-command", "plain-argument", "-l", "--no-color"}) 52 | 53 | if opts.Flag("l") { 54 | fmt.Println("-l is defined") 55 | } 56 | 57 | if opts.Flag("no-color") { 58 | fmt.Println("Color mode is disabled") 59 | } 60 | 61 | // Output: -l is defined 62 | // Color mode is disabled 63 | } 64 | -------------------------------------------------------------------------------- /argument.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/mitchellh/go-homedir" 10 | ) 11 | 12 | type argumentTypeFunction func(string) (interface{}, error) 13 | 14 | var argumentTypeList map[string]argumentTypeFunction 15 | 16 | // RegisterArgumentType registers a new argument type 17 | func RegisterArgumentType(name string, f argumentTypeFunction) { 18 | argumentTypeList[name] = f 19 | } 20 | 21 | // Argument represents a single argument 22 | type Argument struct { 23 | Name string 24 | Type string 25 | OriginalValue string 26 | Value interface{} 27 | Error error 28 | FailOnError bool 29 | } 30 | 31 | // SetValue saves the original value to the argument. 32 | // Returns with an error if conversion failed 33 | func (a *Argument) SetValue(original string) error { 34 | a.OriginalValue = original 35 | a.Value, a.Error = argumentTypeList[a.Type](a.OriginalValue) 36 | 37 | return a.Error 38 | } 39 | 40 | func init() { 41 | argumentTypeList = map[string]argumentTypeFunction{} 42 | 43 | RegisterArgumentType("String", func(value string) (interface{}, error) { 44 | return value, nil 45 | }) 46 | 47 | RegisterArgumentType("Int64", func(value string) (interface{}, error) { 48 | return strconv.ParseInt(value, 10, 64) 49 | }) 50 | 51 | RegisterArgumentType("Uint64", func(value string) (interface{}, error) { 52 | return strconv.ParseUint(value, 10, 64) 53 | }) 54 | 55 | RegisterArgumentType("StringArray[]", func(value string) (interface{}, error) { 56 | arr := strings.Split(value, ",") 57 | 58 | return arr, nil 59 | }) 60 | 61 | RegisterArgumentType("FilePath", func(value string) (interface{}, error) { 62 | value, _ = homedir.Expand(value) 63 | _, err := os.Stat(value) 64 | 65 | if os.IsNotExist(err) { 66 | log.Println(err) 67 | value = "" 68 | } 69 | return value, err 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /argument_test.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestArgument_GetValue(t *testing.T) { 9 | type fields struct { 10 | Name string 11 | Type string 12 | OriginalValue string 13 | } 14 | tests := []struct { 15 | name string 16 | fields fields 17 | parameter string 18 | want interface{} 19 | }{ 20 | { 21 | name: "String", 22 | fields: fields{ 23 | Type: "String", 24 | }, 25 | parameter: "testname", 26 | want: "testname", 27 | }, 28 | { 29 | name: "Int64", 30 | fields: fields{ 31 | Type: "Int64", 32 | }, 33 | parameter: "42", 34 | want: int64(42), 35 | }, 36 | { 37 | name: "-Int64", 38 | fields: fields{ 39 | Type: "Int64", 40 | }, 41 | parameter: "-42", 42 | want: int64(-42), 43 | }, 44 | { 45 | name: "Uint64", 46 | fields: fields{ 47 | Type: "Uint64", 48 | }, 49 | parameter: "42", 50 | want: uint64(42), 51 | }, 52 | { 53 | name: "-Uint64", 54 | fields: fields{ 55 | Type: "Uint64", 56 | }, 57 | parameter: "-42", 58 | want: uint64(0), 59 | }, 60 | { 61 | name: "FilePath [exists]", 62 | fields: fields{ 63 | Type: "FilePath", 64 | }, 65 | parameter: "argument_test.go", 66 | want: "argument_test.go", 67 | }, 68 | { 69 | name: "FilePath [not exists]", 70 | fields: fields{ 71 | Type: "FilePath", 72 | }, 73 | parameter: "/ajshdjkashdjashdjasd", 74 | want: "", 75 | }, 76 | { 77 | name: "StringArray[]", 78 | fields: fields{ 79 | Type: "StringArray[]", 80 | }, 81 | parameter: "one,two,three", 82 | want: []string{"one", "two", "three"}, 83 | }, 84 | } 85 | for _, tt := range tests { 86 | t.Run(tt.name, func(t *testing.T) { 87 | a := &Argument{ 88 | Name: tt.fields.Name, 89 | Type: tt.fields.Type, 90 | OriginalValue: tt.fields.OriginalValue, 91 | } 92 | a.SetValue(tt.parameter) 93 | if got := a.Value; !reflect.DeepEqual(got, tt.want) { 94 | t.Errorf("Argument.Value = %v, want %v", got, tt.want) 95 | } 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at yitsushi@protonmail.ch. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /command_registry.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "path" 7 | "strings" 8 | ) 9 | 10 | // CommandRegistry will handle all CLI request 11 | // and find the route to the proper Command 12 | type CommandRegistry struct { 13 | Commands map[string]*CommandWrapper 14 | Helper *CommandHelper 15 | Depth int 16 | 17 | maximumCommandLength int 18 | } 19 | 20 | // Register is a function that adds your command into the registry 21 | func (c *CommandRegistry) Register(f NewCommandFunc) { 22 | wrapper := f(c.executableName()) 23 | name := wrapper.Help.Name 24 | c.Commands[name] = wrapper 25 | commandLength := len(fmt.Sprintf("%s %s", name, wrapper.Help.Arguments)) 26 | if commandLength > c.maximumCommandLength { 27 | c.maximumCommandLength = commandLength 28 | } 29 | } 30 | 31 | // Execute finds the proper command, handle errors from the command and print Help 32 | // if the given command it unknown or print the Command specific help 33 | // if something went wrong or the user asked for it. 34 | func (c *CommandRegistry) Execute() { 35 | name := flag.Arg(c.Depth) 36 | c.Helper = &CommandHelper{} 37 | if command, ok := c.Commands[name]; ok { 38 | defer func() { 39 | if err := recover(); err != nil { 40 | FmtPrintf("[E] %s\n\n", err) 41 | c.CommandHelp(name) 42 | } 43 | }() 44 | 45 | c.Helper.AttachArgumentList(command.Arguments) 46 | c.Helper.Parse(flag.Args()[c.Depth:]) 47 | 48 | if command.Validator != nil { 49 | command.Validator(c.Helper) 50 | } 51 | command.Handler.Execute(c.Helper) 52 | } else { 53 | if (name != "help") && (name != "") { 54 | FmtPrintf("Command not found: %s\n", name) 55 | } 56 | c.Help() 57 | } 58 | } 59 | 60 | // Help lists all available commands to the user 61 | func (c *CommandRegistry) Help() { 62 | if flag.Arg(c.Depth) == "help" && flag.Arg(c.Depth+1) != "" { 63 | c.CommandHelp(flag.Arg(c.Depth + 1)) 64 | return 65 | } 66 | 67 | format := fmt.Sprintf("%%-%ds %%s\n", c.maximumCommandLength) 68 | for name, command := range c.Commands { 69 | FmtPrintf( 70 | format, 71 | fmt.Sprintf("%s %s", name, command.Help.Arguments), 72 | command.Help.ShortDescription, 73 | ) 74 | } 75 | FmtPrintf( 76 | format, 77 | "help [command]", 78 | "Display this help or a command specific help", 79 | ) 80 | } 81 | 82 | // CommandHelp prints more detailed help for a specific Command 83 | func (c *CommandRegistry) CommandHelp(name string) { 84 | if command, ok := c.Commands[name]; ok { 85 | extra := "" 86 | if c.Depth > 0 { 87 | extra = strings.Join(flag.Args()[0:c.Depth], " ") 88 | } 89 | if len(extra) > 0 { 90 | extra += " " 91 | } 92 | FmtPrintf("Usage: %s %s%s %s\n", c.executableName(), extra, name, command.Help.Arguments) 93 | 94 | if command.Help.LongDescription != "" { 95 | FmtPrintf("") 96 | FmtPrintf(command.Help.LongDescription) 97 | } 98 | 99 | for _, arg := range command.Arguments { 100 | extra = " " 101 | if arg.FailOnError { 102 | extra += "" 103 | } else { 104 | extra += "[optional]" 105 | } 106 | FmtPrintf(" --%s=%s%s\n", arg.Name, arg.Type, extra) 107 | } 108 | 109 | if len(command.Help.Examples) > 0 { 110 | FmtPrintf("\nExamples:\n") 111 | for _, line := range command.Help.Examples { 112 | FmtPrintf(" %s %s%s %s\n", c.executableName(), extra, name, line) 113 | } 114 | } 115 | } 116 | } 117 | 118 | // Determine the name of the executable 119 | func (c *CommandRegistry) executableName() string { 120 | filename, _ := OSExtExecutable() 121 | return path.Base(filename) 122 | } 123 | 124 | // NewCommandRegistry is a simple "constructor"-like function 125 | // that initializes Commands map 126 | func NewCommandRegistry() *CommandRegistry { 127 | flag.Parse() 128 | return &CommandRegistry{ 129 | Commands: map[string]*CommandWrapper{}, 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /command_helper.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // CommandHelper is a helper struct 10 | // CommandHandler.Execute will get this as an argument 11 | // and you can access extra functions, farsed flags with this 12 | type CommandHelper struct { 13 | // If -d is defined 14 | DebugMode bool 15 | // If -v is defined 16 | VerboseMode bool 17 | // Boolean opts 18 | Flags map[string]bool 19 | // Other opts passed 20 | Opts map[string]string 21 | // Non-flag arguments 22 | Args []string 23 | 24 | argList []*Argument 25 | } 26 | 27 | // Log is a logger function for debug messages 28 | // it prints a message if DebugeMode is true 29 | func (c *CommandHelper) Log(message string) { 30 | if c.DebugMode { 31 | FmtPrintf("[Debug] %s\n", message) 32 | } 33 | } 34 | 35 | // Arg return with an item from Flags based on the given index 36 | // emtpy string if not exists 37 | func (c *CommandHelper) Arg(index int) string { 38 | if len(c.Args) > index { 39 | return c.Args[index] 40 | } 41 | 42 | return "" 43 | } 44 | 45 | // Flag return with an item from Flags based on the given key 46 | // false if not exists 47 | func (c *CommandHelper) Flag(key string) bool { 48 | if value, ok := c.Flags[key]; ok { 49 | return value 50 | } 51 | 52 | return false 53 | } 54 | 55 | // Opt return with an item from Opts based on the given key 56 | // empty string if not exists 57 | func (c *CommandHelper) Opt(key string) string { 58 | if value, ok := c.Opts[key]; ok { 59 | return value 60 | } 61 | 62 | return "" 63 | } 64 | 65 | // ErrorForTypedOpt returns an error if the given value for 66 | // the key is defined but not valid 67 | func (c *CommandHelper) ErrorForTypedOpt(key string) error { 68 | for _, arg := range c.argList { 69 | if arg.Name != key { 70 | continue 71 | } 72 | 73 | return arg.Error 74 | } 75 | 76 | return errors.New("key not found") 77 | } 78 | 79 | // TypedOpt return with an item from the predifined argument list 80 | // based on the given key empty string if not exists 81 | func (c *CommandHelper) TypedOpt(key string) interface{} { 82 | for _, arg := range c.argList { 83 | if arg.Name != key { 84 | continue 85 | } 86 | 87 | return arg.Value 88 | } 89 | 90 | return "" 91 | } 92 | 93 | // Parse is a helper method that parses all passed arguments 94 | // flags, opts and arguments 95 | func (c *CommandHelper) Parse(flag []string) { 96 | c.Flags = map[string]bool{} 97 | c.Opts = map[string]string{} 98 | 99 | if len(flag) < 2 { 100 | return 101 | } 102 | 103 | arguments := flag[1:] 104 | for _, arg := range arguments { 105 | if len(arg) > 1 && arg[0:2] == "--" { 106 | parts := strings.SplitN(arg[2:], "=", 2) 107 | if len(parts) > 1 { 108 | // has exact value 109 | c.Opts[parts[0]] = parts[1] 110 | } else { 111 | c.Flags[parts[0]] = true 112 | } 113 | continue 114 | } 115 | 116 | if arg[0] == '-' { 117 | for _, o := range []byte(arg[1:]) { 118 | c.Flags[string(o)] = true 119 | } 120 | continue 121 | } 122 | 123 | c.Args = append(c.Args, arg) 124 | } 125 | 126 | if c.Flags["d"] { 127 | c.DebugMode = true 128 | } 129 | 130 | if c.Flags["v"] { 131 | c.VerboseMode = true 132 | } 133 | 134 | for _, arg := range c.argList { 135 | if c.Opt(arg.Name) != "" { 136 | arg.SetValue(c.Opt(arg.Name)) 137 | if arg.Error != nil { 138 | errorMessage := fmt.Sprintf( 139 | "Invalid argument: --%s=%s [%s]", 140 | arg.Name, arg.OriginalValue, arg.Error, 141 | ) 142 | 143 | if arg.FailOnError { 144 | panic(errorMessage) 145 | } 146 | 147 | FmtPrintf("%s\n", errorMessage) 148 | } 149 | } 150 | } 151 | } 152 | 153 | // AttachArgumentList binds an Argument list to CommandHelper 154 | func (c *CommandHelper) AttachArgumentList(argumets []*Argument) { 155 | c.argList = argumets 156 | } 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Documentation](https://godoc.org/github.com/yitsushi/go-commander?status.svg)](http://godoc.org/github.com/yitsushi/go-commander) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/yitsushi/go-commander)](https://goreportcard.com/report/github.com/yitsushi/go-commander) 3 | [![Coverage Status](https://coveralls.io/repos/github/yitsushi/go-commander/badge.svg)](https://coveralls.io/github/yitsushi/go-commander) 4 | [![Build Status](https://travis-ci.org/yitsushi/go-commander.svg?branch=master)](https://travis-ci.org/yitsushi/go-commander) 5 | 6 | This is a simple Go library to manage commands for your CLI tool. 7 | Easy to use and now you can focus on Business Logic instead of building 8 | the command routing. 9 | 10 | ### What this library does for you? 11 | 12 | Manage your separated commands. How? Generates a general help and command 13 | specific helps for your commands. If your command fails somewhere 14 | (`panic` for example), commander will display the error message and 15 | the command specific help to guide your user. 16 | 17 | ### Install 18 | 19 | ```shell 20 | $ go get github.com/yitsushi/go-commander 21 | ``` 22 | 23 | ### Sample output _(from [totp-cli](https://github.com/yitsushi/totp-cli))_ 24 | 25 | ```shell 26 | $ totp-cli help 27 | 28 | change-password Change password 29 | update Check and update totp-cli itself 30 | version Print current version of this application 31 | generate . Generate a specific OTP 32 | add-token [namespace] [account] Add new token 33 | list [namespace] List all available namespaces or accounts under a namespace 34 | delete [.account] Delete an account or a whole namespace 35 | help [command] Display this help or a command specific help 36 | ``` 37 | 38 | ### Usage 39 | 40 | Every single command has to implement `CommandHandler`. 41 | Check [this project](https://github.com/yitsushi/totp-cli) for examples. 42 | 43 | ```go 44 | package main 45 | 46 | // Import the package 47 | import "github.com/yitsushi/go-commander" 48 | 49 | // Your Command 50 | type YourCommand struct { 51 | } 52 | 53 | // Executed only on command call 54 | func (c *YourCommand) Execute(opts *commander.CommandHelper) { 55 | // Command Action 56 | } 57 | 58 | func NewYourCommand(appName string) *commander.CommandWrapper { 59 | return &commander.CommandWrapper{ 60 | Handler: &YourCommand{}, 61 | Help: &commander.CommandDescriptor{ 62 | Name: "your-command", 63 | ShortDescription: "This is my own command", 64 | LongDescription: `This is a very long 65 | description about this command.`, 66 | Arguments: " [optional-argument]", 67 | Examples: []string { 68 | "test.txt", 69 | "test.txt copy", 70 | "test.txt move", 71 | }, 72 | }, 73 | } 74 | } 75 | 76 | // Main Section 77 | func main() { 78 | registry := commander.NewCommandRegistry() 79 | 80 | registry.Register(NewYourCommand) 81 | 82 | registry.Execute() 83 | } 84 | ``` 85 | 86 | Now you have a CLI tool with two commands: `help` and `your-command`. 87 | 88 | ```bash 89 | ❯ go build mytool.go 90 | 91 | ❯ ./mytool 92 | your-command [optional-argument] This is my own command 93 | help [command] Display this help or a command specific help 94 | 95 | ❯ ./mytool help your-command 96 | Usage: mytool your-command [optional-argument] 97 | 98 | This is a very long 99 | description about this command. 100 | 101 | Examples: 102 | mytool your-command test.txt 103 | mytool your-command test.txt copy 104 | mytool your-command test.txt move 105 | ``` 106 | 107 | #### How to use subcommand pattern? 108 | 109 | When you create your main command, just create a new `CommandRegistry` inside 110 | the `Execute` function like you did in your `main()` and change `Depth`. 111 | 112 | ```go 113 | import subcommand "github.com/yitsushi/mypackage/command/something" 114 | 115 | func (c *Something) Execute(opts *commander.CommandHelper) { 116 | registry := commander.NewCommandRegistry() 117 | registry.Depth = 1 118 | registry.Register(subcommand.NewSomethingMySubCommand) 119 | registry.Execute() 120 | } 121 | ``` 122 | 123 | ### PreValidation 124 | 125 | If you want to write a general pre-validation for your command 126 | or just simply keep your validation logic separated: 127 | 128 | ```go 129 | // Or you can define inline if you want 130 | func MyValidator(c *commander.CommandHelper) { 131 | if c.Arg(0) == "" { 132 | panic("File?") 133 | } 134 | 135 | info, err := os.Stat(c.Arg(0)) 136 | if err != nil { 137 | panic("File not found") 138 | } 139 | 140 | if !info.Mode().IsRegular() { 141 | panic("It's not a regular file") 142 | } 143 | 144 | if c.Arg(1) != "" { 145 | if c.Arg(1) != "copy" && c.Arg(1) != "move" { 146 | panic("Invalid operation") 147 | } 148 | } 149 | } 150 | 151 | func NewYourCommand(appName string) *commander.CommandWrapper { 152 | return &commander.CommandWrapper{ 153 | Handler: &YourCommand{}, 154 | Validator: MyValidator 155 | Help: &commander.CommandDescriptor{ 156 | Name: "your-command", 157 | ShortDescription: "This is my own command", 158 | LongDescription: `This is a very long 159 | description about this command.`, 160 | Arguments: " [optional-argument]", 161 | Examples: []string { 162 | "test.txt", 163 | "test.txt copy", 164 | "test.txt move", 165 | }, 166 | }, 167 | } 168 | } 169 | ``` 170 | 171 | 172 | ### Define arguments with type 173 | 174 | ```go 175 | &commander.CommandWrapper{ 176 | Handler: &MyCommand{}, 177 | Arguments: []*commander.Argument{ 178 | &commander.Argument{ 179 | Name: "list", 180 | Type: "StringArray[]", 181 | }, 182 | }, 183 | Help: &commander.CommandDescriptor{ 184 | Name: "my-command", 185 | }, 186 | } 187 | ``` 188 | 189 | In your command: 190 | 191 | ```go 192 | if opts.HasValidTypedOpt("list") == nil { 193 | myList := opts.TypedOpt("list").([]string) 194 | if len(myList) > 0 { 195 | mockPrintf("My list: %v\n", myList) 196 | } 197 | } 198 | ``` 199 | 200 | #### Define own type 201 | 202 | Yes you can ;) 203 | 204 | ```go 205 | // Define your struct (optional) 206 | type MyCustomType struct { 207 | ID uint64 208 | Name string 209 | } 210 | 211 | // register your own type with parsing/validation 212 | commander.RegisterArgumentType("MyType", func(value string) (interface{}, error) { 213 | values := strings.Split(value, ":") 214 | 215 | if len(values) < 2 { 216 | return &MyCustomType{}, errors.New("Invalid format! MyType => 'ID:Name'") 217 | } 218 | 219 | id, err := strconv.ParseUint(values[0], 10, 64) 220 | if err != nil { 221 | return &MyCustomType{}, errors.New("Invalid format! MyType => 'ID:Name'") 222 | } 223 | 224 | return &MyCustomType{ 225 | ID: id, 226 | Name: values[1], 227 | }, 228 | nil 229 | }) 230 | 231 | // Define your command 232 | &commander.CommandWrapper{ 233 | Handler: &MyCommand{}, 234 | Arguments: []*commander.Argument{ 235 | &commander.Argument{ 236 | Name: "owner", 237 | Type: "MyType", 238 | FailOnError: true, // Optional boolean 239 | }, 240 | }, 241 | Help: &commander.CommandDescriptor{ 242 | Name: "my-command", 243 | }, 244 | } 245 | ``` 246 | 247 | In your command: 248 | 249 | ```go 250 | if opts.HasValidTypedOpt("owner") == nil { 251 | owner := opts.TypedOpt("owner").(*MyCustomType) 252 | mockPrintf("OwnerID: %d, Name: %s\n", owner.ID, owner.Name) 253 | } 254 | ``` 255 | -------------------------------------------------------------------------------- /command_helper_test.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestCommandHelper_Opt(t *testing.T) { 9 | type fields struct { 10 | DebugMode bool 11 | VerboseMode bool 12 | Flags map[string]bool 13 | Opts map[string]string 14 | Args []string 15 | } 16 | tests := []struct { 17 | name string 18 | fields fields 19 | key string 20 | want string 21 | }{ 22 | { 23 | name: "Key found", 24 | fields: fields{ 25 | Opts: map[string]string{"file": "something.txt"}, 26 | }, 27 | key: "file", 28 | want: "something.txt", 29 | }, 30 | { 31 | name: "Key not found", 32 | fields: fields{ 33 | Opts: map[string]string{"file": "something.txt"}, 34 | }, 35 | key: "files", 36 | want: "", 37 | }, 38 | } 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | c := &CommandHelper{ 42 | DebugMode: tt.fields.DebugMode, 43 | VerboseMode: tt.fields.VerboseMode, 44 | Flags: tt.fields.Flags, 45 | Opts: tt.fields.Opts, 46 | Args: tt.fields.Args, 47 | } 48 | if got := c.Opt(tt.key); got != tt.want { 49 | t.Errorf("CommandHelper.Opt() = %v, want %v", got, tt.want) 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func TestCommandHelper_Flag(t *testing.T) { 56 | type fields struct { 57 | DebugMode bool 58 | VerboseMode bool 59 | Flags map[string]bool 60 | Opts map[string]string 61 | Args []string 62 | } 63 | tests := []struct { 64 | name string 65 | fields fields 66 | key string 67 | want bool 68 | }{ 69 | { 70 | name: "Key found", 71 | fields: fields{ 72 | Flags: map[string]bool{"force": true}, 73 | }, 74 | key: "force", 75 | want: true, 76 | }, 77 | { 78 | name: "Key not found", 79 | fields: fields{ 80 | Opts: map[string]string{}, 81 | }, 82 | key: "force", 83 | want: false, 84 | }, 85 | } 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | c := &CommandHelper{ 89 | DebugMode: tt.fields.DebugMode, 90 | VerboseMode: tt.fields.VerboseMode, 91 | Flags: tt.fields.Flags, 92 | Opts: tt.fields.Opts, 93 | Args: tt.fields.Args, 94 | } 95 | if got := c.Flag(tt.key); got != tt.want { 96 | t.Errorf("CommandHelper.Flag() = %v, want %v", got, tt.want) 97 | } 98 | }) 99 | } 100 | } 101 | 102 | func TestCommandHelper_Arg(t *testing.T) { 103 | type fields struct { 104 | DebugMode bool 105 | VerboseMode bool 106 | Flags map[string]bool 107 | Opts map[string]string 108 | Args []string 109 | } 110 | tests := []struct { 111 | name string 112 | fields fields 113 | index int 114 | want string 115 | }{ 116 | { 117 | name: "First item", 118 | fields: fields{ 119 | Args: []string{"first"}, 120 | }, 121 | index: 0, 122 | want: "first", 123 | }, 124 | { 125 | name: "Out of index", 126 | fields: fields{ 127 | Args: []string{"first"}, 128 | }, 129 | index: 1, 130 | want: "", 131 | }, 132 | } 133 | for _, tt := range tests { 134 | t.Run(tt.name, func(t *testing.T) { 135 | c := &CommandHelper{ 136 | DebugMode: tt.fields.DebugMode, 137 | VerboseMode: tt.fields.VerboseMode, 138 | Flags: tt.fields.Flags, 139 | Opts: tt.fields.Opts, 140 | Args: tt.fields.Args, 141 | } 142 | if got := c.Arg(tt.index); got != tt.want { 143 | t.Errorf("CommandHelper.Arg() = %v, want %v", got, tt.want) 144 | } 145 | }) 146 | } 147 | } 148 | 149 | func TestCommandHelper_Parse(t *testing.T) { 150 | tests := []struct { 151 | name string 152 | flag []string 153 | test func(*CommandHelper) string 154 | }{ 155 | { 156 | name: "no error without args", 157 | flag: []string{}, 158 | test: func(c *CommandHelper) (errMsg string) { 159 | return 160 | }, 161 | }, 162 | { 163 | name: "simple argument", 164 | flag: []string{"command", "my_param"}, 165 | test: func(c *CommandHelper) (errMsg string) { 166 | value := "my_param" 167 | if c.Arg(0) != value { 168 | return fmt.Sprintf( 169 | "Argument not found. Want(%s) : Got(%s)", 170 | value, 171 | c.Arg(0), 172 | ) 173 | } 174 | return 175 | }, 176 | }, 177 | { 178 | name: "double dash", 179 | flag: []string{"command", "--file=something.txt"}, 180 | test: func(c *CommandHelper) (errMsg string) { 181 | value := "something.txt" 182 | if c.Opt("file") != value { 183 | return fmt.Sprintf( 184 | "Option not found. Want(%s) : Got(%s)", 185 | value, 186 | c.Opt("file"), 187 | ) 188 | } 189 | return 190 | }, 191 | }, 192 | { 193 | name: "double dash flag", 194 | flag: []string{"command", "--force"}, 195 | test: func(c *CommandHelper) (errMsg string) { 196 | if c.Opt("force") != "" { 197 | return fmt.Sprintf( 198 | "Option not found. Want(%s) : Got(%s)", 199 | "", 200 | c.Opt("force"), 201 | ) 202 | } 203 | if !c.Flag("force") { 204 | return "Force Flag is false, but we expect true." 205 | } 206 | return 207 | }, 208 | }, 209 | { 210 | name: "single dash flag", 211 | flag: []string{"command", "-f"}, 212 | test: func(c *CommandHelper) (errMsg string) { 213 | if c.Opt("f") != "" { 214 | return fmt.Sprintf( 215 | "Option not found. Want(%s) : Got(%s)", 216 | "", 217 | c.Opt("f"), 218 | ) 219 | } 220 | if !c.Flag("f") { 221 | return "'f' Flag is false, but we expect true." 222 | } 223 | return 224 | }, 225 | }, 226 | { 227 | name: "all together", 228 | flag: []string{"command", "-f", "--file=something.txt", "simple_arg"}, 229 | test: func(c *CommandHelper) (errMsg string) { 230 | value := "simple_arg" 231 | if c.Arg(0) != value { 232 | return fmt.Sprintf( 233 | "Argument not found. Want(%s) : Got(%s)", 234 | value, 235 | c.Arg(0), 236 | ) 237 | } 238 | 239 | if c.Opt("f") != "" { 240 | return fmt.Sprintf( 241 | "Option not found. Want(%s) : Got(%s)", 242 | "", 243 | c.Opt("f"), 244 | ) 245 | } 246 | if !c.Flag("f") { 247 | return "'f' Flag is false, but we expect true." 248 | } 249 | 250 | value = "something.txt" 251 | if c.Opt("file") != value { 252 | return fmt.Sprintf( 253 | "Option not found. Want(%s) : Got(%s)", 254 | "", 255 | c.Opt("file"), 256 | ) 257 | } 258 | if c.Flag("file") { 259 | return "'f' Flag is true, but we expect false." 260 | } 261 | return 262 | }, 263 | }, 264 | { 265 | name: "enable debug", 266 | flag: []string{"command", "-d"}, 267 | test: func(c *CommandHelper) (errMsg string) { 268 | if c.Opt("d") != "" { 269 | return fmt.Sprintf( 270 | "Option not found. Want(%s) : Got(%s)", 271 | "", 272 | c.Opt("d"), 273 | ) 274 | } 275 | if !c.Flag("d") { 276 | return "'d' Flag is false, but we expect true." 277 | } 278 | if !c.DebugMode { 279 | return "'c.DebugMode' is false, but we expect true." 280 | } 281 | return 282 | }, 283 | }, 284 | { 285 | name: "enable verbose", 286 | flag: []string{"command", "-v"}, 287 | test: func(c *CommandHelper) (errMsg string) { 288 | if c.Opt("v") != "" { 289 | return fmt.Sprintf( 290 | "Option not found. Want(%s) : Got(%s)", 291 | "", 292 | c.Opt("v"), 293 | ) 294 | } 295 | if !c.Flag("v") { 296 | return "'v' Flag is false, but we expect true." 297 | } 298 | if !c.VerboseMode { 299 | return "'c.VerboseMode' is false, but we expect true." 300 | } 301 | return 302 | }, 303 | }, 304 | } 305 | for _, tt := range tests { 306 | t.Run(tt.name, func(t *testing.T) { 307 | c := &CommandHelper{} 308 | c.Parse(tt.flag) 309 | errMsg := tt.test(c) 310 | if errMsg != "" { 311 | t.Errorf("CommandHelper.Parse() => %s", errMsg) 312 | t.Errorf("%v", c) 313 | } 314 | }) 315 | } 316 | } 317 | 318 | func TestCommandHelper_Log(t *testing.T) { 319 | type fields struct { 320 | DebugMode bool 321 | VerboseMode bool 322 | Flags map[string]bool 323 | Opts map[string]string 324 | Args []string 325 | } 326 | tests := []struct { 327 | name string 328 | fields fields 329 | message string 330 | hasOutput bool 331 | }{ 332 | { 333 | name: "Logging with debug mode", 334 | fields: fields{DebugMode: true}, 335 | message: "Test Message", 336 | hasOutput: true, 337 | }, 338 | { 339 | name: "Skip Logging without debug mode", 340 | fields: fields{DebugMode: false}, 341 | message: "Test Message", 342 | hasOutput: false, 343 | }, 344 | } 345 | 346 | var fmtOutput string 347 | FmtPrintf = func(format string, a ...interface{}) (int, error) { 348 | fmtOutput = fmt.Sprintf(format, a...) 349 | return 0, nil 350 | } 351 | for _, tt := range tests { 352 | t.Run(tt.name, func(t *testing.T) { 353 | fmtOutput = "" 354 | c := &CommandHelper{ 355 | DebugMode: tt.fields.DebugMode, 356 | VerboseMode: tt.fields.VerboseMode, 357 | Flags: tt.fields.Flags, 358 | Opts: tt.fields.Opts, 359 | Args: tt.fields.Args, 360 | } 361 | c.Log(tt.message) 362 | if (fmtOutput != "") != tt.hasOutput { 363 | t.Errorf("Logging seems broken :( [%v] output(%s)", tt.hasOutput, fmtOutput) 364 | } 365 | }) 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /command_registry_test.go: -------------------------------------------------------------------------------- 1 | package commander 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | var executeCalled bool 13 | var validatorCalled bool 14 | 15 | type MyCustomType struct { 16 | ID uint64 17 | Name string 18 | } 19 | 20 | // Start: Commands 21 | // Simple command 22 | type MyCommand struct { 23 | } 24 | 25 | func (c *MyCommand) Execute(opts *CommandHelper) { 26 | executeCalled = opts.VerboseMode 27 | 28 | if opts.ErrorForTypedOpt("list") == nil { 29 | myList := opts.TypedOpt("list").([]string) 30 | if len(myList) > 0 { 31 | mockPrintf("My list: %v\n", myList) 32 | } 33 | } 34 | 35 | // Never defined, always shoud be an empty string 36 | if opts.TypedOpt("no-key").(string) != "" { 37 | panic("Something went wrong!") 38 | } 39 | 40 | if opts.ErrorForTypedOpt("owner") == nil { 41 | owner := opts.TypedOpt("owner").(*MyCustomType) 42 | mockPrintf("OwnerID: %d, Name: %s\n", owner.ID, owner.Name) 43 | } 44 | 45 | if opts.Flag("fail-me") { 46 | panic("PANIC!!! PANIC!!! PANIC!!! Calm down, please!") 47 | } 48 | } 49 | 50 | // SubCommand system 51 | type MySubCommand struct { 52 | } 53 | 54 | func (c *MySubCommand) Execute(opts *CommandHelper) { 55 | executeCalled = opts.VerboseMode 56 | } 57 | 58 | type MyMainCommand struct { 59 | } 60 | 61 | func (c *MyMainCommand) Execute(opts *CommandHelper) { 62 | registry := NewCommandRegistry() 63 | registry.Depth = 1 64 | registry.Register(func(appName string) *CommandWrapper { 65 | return &CommandWrapper{ 66 | Handler: &MySubCommand{}, 67 | Help: &CommandDescriptor{ 68 | Name: "my-subcommand", 69 | ShortDescription: "This is my own SubCommand", 70 | Arguments: "", 71 | }, 72 | } 73 | }) 74 | registry.Execute() 75 | } 76 | 77 | // End: Commands 78 | 79 | var mockOutput string 80 | 81 | func mockPrintf(format string, n ...interface{}) (int, error) { 82 | mockOutput += fmt.Sprintf(format, n...) 83 | return 0, nil 84 | } 85 | 86 | func mockEverything() { 87 | OSExtExecutable = func() (string, error) { 88 | return "/some/random/path/my-executable", nil 89 | } 90 | 91 | mockOutput = "" 92 | FmtPrintf = mockPrintf 93 | } 94 | 95 | func myValidatoFunction(c *CommandHelper) { 96 | validatorCalled = true 97 | if !c.Flag("pass-validation") { 98 | panic("Sad panda") 99 | } 100 | } 101 | 102 | func TestCommandRegistry_executableName(t *testing.T) { 103 | tests := []struct { 104 | name string 105 | want string 106 | }{ 107 | {want: "my-executable"}, 108 | } 109 | 110 | mockEverything() 111 | 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | c := NewCommandRegistry() 115 | if got := c.executableName(); got != tt.want { 116 | t.Errorf("CommandRegistry.executableName() = %v, want %v", got, tt.want) 117 | } 118 | }) 119 | } 120 | } 121 | 122 | func TestCommandRegistry(t *testing.T) { 123 | 124 | // register own Type 125 | RegisterArgumentType("MyType", func(value string) (interface{}, error) { 126 | values := strings.Split(value, ":") 127 | 128 | if len(values) < 2 { 129 | return &MyCustomType{}, errors.New("Invalid format! MyType => 'ID:Name'") 130 | } 131 | 132 | id, err := strconv.ParseUint(values[0], 10, 64) 133 | if err != nil { 134 | return &MyCustomType{}, errors.New("Invalid format! MyType => 'ID:Name'") 135 | } 136 | 137 | return &MyCustomType{ 138 | ID: id, 139 | Name: values[1], 140 | }, 141 | nil 142 | }) 143 | 144 | tests := []struct { 145 | name string 146 | cliArgs []string 147 | commands []NewCommandFunc 148 | test func(*CommandRegistry, string) string 149 | }{ 150 | { 151 | name: "No command, print help", 152 | cliArgs: []string{}, 153 | commands: []NewCommandFunc{}, 154 | test: func(r *CommandRegistry, output string) (errMsg string) { 155 | expected := "help [command] Display this help or a command specific help\n" 156 | if output != expected { 157 | return fmt.Sprintf("output(%s), want(%s)", output, expected) 158 | } 159 | return 160 | }, 161 | }, 162 | { 163 | name: "Help command, print help", 164 | cliArgs: []string{"help"}, 165 | commands: []NewCommandFunc{}, 166 | test: func(r *CommandRegistry, output string) (errMsg string) { 167 | expected := "help [command] Display this help or a command specific help\n" 168 | if output != expected { 169 | return fmt.Sprintf("output(%s), want(%s)", output, expected) 170 | } 171 | return 172 | }, 173 | }, 174 | { 175 | name: "No command, invalid command", 176 | cliArgs: []string{"invalid-call"}, 177 | commands: []NewCommandFunc{}, 178 | test: func(r *CommandRegistry, output string) (errMsg string) { 179 | value := "Command not found: invalid-call" 180 | if !strings.Contains(output, value) { 181 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 182 | } 183 | return 184 | }, 185 | }, 186 | { 187 | name: "Register one command, but no command called", 188 | cliArgs: []string{}, 189 | commands: []NewCommandFunc{ 190 | func(appName string) *CommandWrapper { 191 | return &CommandWrapper{ 192 | Handler: &MyCommand{}, 193 | Help: &CommandDescriptor{ 194 | Name: "my-command", 195 | ShortDescription: "This is my own command", 196 | LongDescription: `This is a very long 197 | description about this command.`, 198 | Arguments: " [optional-argument]", 199 | Examples: []string{ 200 | "test.txt", 201 | "test.txt copy", 202 | "test.txt move", 203 | }, 204 | }, 205 | } 206 | }, 207 | }, 208 | test: func(r *CommandRegistry, output string) (errMsg string) { 209 | value := "my-command [optional-argument]" 210 | if !strings.Contains(output, value) { 211 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 212 | } 213 | return 214 | }, 215 | }, 216 | { 217 | name: "Register one command, call help for command", 218 | cliArgs: []string{"help", "my-command"}, 219 | commands: []NewCommandFunc{ 220 | func(appName string) *CommandWrapper { 221 | return &CommandWrapper{ 222 | Handler: &MyCommand{}, 223 | Help: &CommandDescriptor{ 224 | Name: "my-command", 225 | ShortDescription: "This is my own command", 226 | LongDescription: `This is a very long 227 | description about this command.`, 228 | Arguments: " [optional-argument]", 229 | Examples: []string{ 230 | "test.txt", 231 | "test.txt copy", 232 | "test.txt move", 233 | }, 234 | }, 235 | } 236 | }, 237 | }, 238 | test: func(r *CommandRegistry, output string) (errMsg string) { 239 | values := []string{ 240 | "Usage: my-executable my-command [optional-argument]", 241 | "This is a very long\ndescription about this command.", 242 | "my-executable my-command test.txt", 243 | "my-executable my-command test.txt copy", 244 | "my-executable my-command test.txt move", 245 | } 246 | for _, value := range values { 247 | if !strings.Contains(output, value) { 248 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 249 | } 250 | } 251 | return 252 | }, 253 | }, 254 | { 255 | name: "Register one command, call command", 256 | cliArgs: []string{"my-command", "-v"}, 257 | commands: []NewCommandFunc{ 258 | func(appName string) *CommandWrapper { 259 | return &CommandWrapper{ 260 | Handler: &MyCommand{}, 261 | Help: &CommandDescriptor{ 262 | Name: "my-command", 263 | ShortDescription: "This is my own command", 264 | LongDescription: `This is a very long 265 | description about this command.`, 266 | Arguments: " [optional-argument]", 267 | Examples: []string{ 268 | "test.txt", 269 | "test.txt copy", 270 | "test.txt move", 271 | }, 272 | }, 273 | } 274 | }, 275 | }, 276 | test: func(r *CommandRegistry, output string) (errMsg string) { 277 | if !executeCalled { 278 | return "Command should be called with VerboseMode" 279 | } 280 | return 281 | }, 282 | }, 283 | { 284 | name: "Register one command with Argument, call command", 285 | cliArgs: []string{"my-command", "-v", "--list=one,two,three"}, 286 | commands: []NewCommandFunc{ 287 | func(appName string) *CommandWrapper { 288 | return &CommandWrapper{ 289 | Handler: &MyCommand{}, 290 | Arguments: []*Argument{ 291 | &Argument{ 292 | Name: "list", 293 | Type: "StringArray[]", 294 | }, 295 | }, 296 | Help: &CommandDescriptor{ 297 | Name: "my-command", 298 | }, 299 | } 300 | }, 301 | }, 302 | test: func(r *CommandRegistry, output string) (errMsg string) { 303 | if !executeCalled { 304 | return "Command should be called with VerboseMode" 305 | } 306 | 307 | value := "My list: [one two three]" 308 | if !strings.Contains(output, value) { 309 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 310 | } 311 | return 312 | }, 313 | }, 314 | { 315 | name: "Register one command with custom Argument, call command", 316 | cliArgs: []string{"my-command", "-v", "--owner=12:yitsushi"}, 317 | commands: []NewCommandFunc{ 318 | func(appName string) *CommandWrapper { 319 | return &CommandWrapper{ 320 | Handler: &MyCommand{}, 321 | Arguments: []*Argument{ 322 | &Argument{ 323 | Name: "owner", 324 | Type: "MyType", 325 | }, 326 | }, 327 | Help: &CommandDescriptor{ 328 | Name: "my-command", 329 | }, 330 | } 331 | }, 332 | }, 333 | test: func(r *CommandRegistry, output string) (errMsg string) { 334 | if !executeCalled { 335 | return "Command should be called with VerboseMode" 336 | } 337 | 338 | value := "wnerID: 12, Name: yitsushi" 339 | if !strings.Contains(output, value) { 340 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 341 | } 342 | return 343 | }, 344 | }, 345 | { 346 | name: "Register one command with custom Argument, call command invalid", 347 | cliArgs: []string{"my-command", "-v", "--owner=asd:yitsushi"}, 348 | commands: []NewCommandFunc{ 349 | func(appName string) *CommandWrapper { 350 | return &CommandWrapper{ 351 | Handler: &MyCommand{}, 352 | Arguments: []*Argument{ 353 | &Argument{ 354 | Name: "owner", 355 | Type: "MyType", 356 | }, 357 | }, 358 | Help: &CommandDescriptor{ 359 | Name: "my-command", 360 | }, 361 | } 362 | }, 363 | }, 364 | test: func(r *CommandRegistry, output string) (errMsg string) { 365 | if !executeCalled { 366 | return "Command should be called with VerboseMode" 367 | } 368 | 369 | value := "Invalid argument: --owner=asd:yitsushi [Invalid format! MyType => 'ID:Name'" 370 | if !strings.Contains(output, value) { 371 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 372 | } 373 | return 374 | }, 375 | }, 376 | { 377 | name: "Register one command with custom Argument, call command invalid", 378 | cliArgs: []string{"my-command", "-v", "--owner=asd:yitsushi"}, 379 | commands: []NewCommandFunc{ 380 | func(appName string) *CommandWrapper { 381 | return &CommandWrapper{ 382 | Handler: &MyCommand{}, 383 | Arguments: []*Argument{ 384 | &Argument{ 385 | Name: "owner", 386 | Type: "MyType", 387 | FailOnError: true, 388 | }, 389 | &Argument{ 390 | Name: "list", 391 | Type: "StringArray[]", 392 | FailOnError: false, 393 | }, 394 | }, 395 | Help: &CommandDescriptor{ 396 | Name: "my-command", 397 | }, 398 | } 399 | }, 400 | }, 401 | test: func(r *CommandRegistry, output string) (errMsg string) { 402 | if executeCalled { 403 | return "Command should not be called with VerboseMode" 404 | } 405 | 406 | values := []string{ 407 | "[E] Invalid argument: --owner=asd:yitsushi [Invalid format! MyType => 'ID:Name'", 408 | "--owner=MyType ", 409 | "--list=StringArray[] [optional]", 410 | } 411 | for _, value := range values { 412 | if !strings.Contains(output, value) { 413 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 414 | } 415 | } 416 | 417 | return 418 | }, 419 | }, 420 | { 421 | name: "Register one command with [Short] Argument, call command", 422 | cliArgs: []string{"my-command", "-v", "--list=one,two,three"}, 423 | commands: []NewCommandFunc{ 424 | func(appName string) *CommandWrapper { 425 | return &CommandWrapper{ 426 | Handler: &MyCommand{}, 427 | Arguments: []*Argument{ 428 | &Argument{ 429 | Name: "something", 430 | Type: "String", 431 | }, 432 | &Argument{ 433 | Name: "list", 434 | Type: "StringArray[]", 435 | }, 436 | }, 437 | Help: &CommandDescriptor{ 438 | Name: "my-command", 439 | }, 440 | } 441 | }, 442 | }, 443 | test: func(r *CommandRegistry, output string) (errMsg string) { 444 | if !executeCalled { 445 | return "Command should be called with VerboseMode" 446 | } 447 | 448 | value := "My list: [one two three]" 449 | if !strings.Contains(output, value) { 450 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 451 | } 452 | return 453 | }, 454 | }, 455 | { 456 | name: "Main and SubCommand, no arg", 457 | cliArgs: []string{}, 458 | commands: []NewCommandFunc{ 459 | func(appName string) *CommandWrapper { 460 | return &CommandWrapper{ 461 | Handler: &MyCommand{}, 462 | Help: &CommandDescriptor{ 463 | Name: "my-command", 464 | ShortDescription: "This is my own MainCommand", 465 | Arguments: "", 466 | }, 467 | } 468 | }, 469 | }, 470 | test: func(r *CommandRegistry, output string) (errMsg string) { 471 | var value string 472 | 473 | value = "my-command " 474 | if !strings.Contains(output, value) { 475 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 476 | } 477 | 478 | value = "my-subcommand" 479 | if strings.Contains(output, value) { 480 | return fmt.Sprintf("value(%s) found in output(%s)", value, output) 481 | } 482 | return 483 | }, 484 | }, 485 | { 486 | name: "Main and SubCommand, help my-command", 487 | cliArgs: []string{"help", "my-command"}, 488 | commands: []NewCommandFunc{ 489 | func(appName string) *CommandWrapper { 490 | return &CommandWrapper{ 491 | Handler: &MyMainCommand{}, 492 | Help: &CommandDescriptor{ 493 | Name: "my-command", 494 | ShortDescription: "This is my own MainCommand", 495 | Arguments: "", 496 | }, 497 | } 498 | }, 499 | }, 500 | test: func(r *CommandRegistry, output string) (errMsg string) { 501 | var value string 502 | 503 | value = "Usage: my-executable my-command " 504 | if !strings.Contains(output, value) { 505 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 506 | } 507 | 508 | value = "my-subcommand" 509 | if strings.Contains(output, value) { 510 | return fmt.Sprintf("value(%s) found in output(%s)", value, output) 511 | } 512 | return 513 | }, 514 | }, 515 | { 516 | name: "Main and SubCommand, my-command without arg", 517 | cliArgs: []string{"my-command"}, 518 | commands: []NewCommandFunc{ 519 | func(appName string) *CommandWrapper { 520 | return &CommandWrapper{ 521 | Handler: &MyMainCommand{}, 522 | Help: &CommandDescriptor{ 523 | Name: "my-command", 524 | ShortDescription: "This is my own MainCommand", 525 | Arguments: "", 526 | }, 527 | } 528 | }, 529 | }, 530 | test: func(r *CommandRegistry, output string) (errMsg string) { 531 | var value string 532 | 533 | value = "my-subcommand" 534 | if !strings.Contains(output, value) { 535 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 536 | } 537 | 538 | value = "my-command" 539 | if strings.Contains(output, value) { 540 | return fmt.Sprintf("value(%s) found in output(%s)", value, output) 541 | } 542 | return 543 | }, 544 | }, 545 | { 546 | name: "Main and SubCommand, my-command help", 547 | cliArgs: []string{"my-command", "help"}, 548 | commands: []NewCommandFunc{ 549 | func(appName string) *CommandWrapper { 550 | return &CommandWrapper{ 551 | Handler: &MyMainCommand{}, 552 | Help: &CommandDescriptor{ 553 | Name: "my-command", 554 | ShortDescription: "This is my own MainCommand", 555 | Arguments: "", 556 | }, 557 | } 558 | }, 559 | }, 560 | test: func(r *CommandRegistry, output string) (errMsg string) { 561 | var value string 562 | 563 | value = "my-subcommand" 564 | if !strings.Contains(output, value) { 565 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 566 | } 567 | 568 | value = "my-command" 569 | if strings.Contains(output, value) { 570 | return fmt.Sprintf("value(%s) found in output(%s)", value, output) 571 | } 572 | return 573 | }, 574 | }, 575 | { 576 | name: "Main and SubCommand, my-command help my-subcommand", 577 | cliArgs: []string{"my-command", "help", "my-subcommand"}, 578 | commands: []NewCommandFunc{ 579 | func(appName string) *CommandWrapper { 580 | return &CommandWrapper{ 581 | Handler: &MyMainCommand{}, 582 | Help: &CommandDescriptor{ 583 | Name: "my-command", 584 | ShortDescription: "This is my own MainCommand", 585 | Arguments: "", 586 | }, 587 | } 588 | }, 589 | }, 590 | test: func(r *CommandRegistry, output string) (errMsg string) { 591 | var value string 592 | 593 | value = "Usage: my-executable my-command my-subcommand" 594 | if !strings.Contains(output, value) { 595 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 596 | } 597 | return 598 | }, 599 | }, 600 | { 601 | name: "Register one command, and fail it", 602 | cliArgs: []string{"my-command", "-v", "--fail-me"}, 603 | commands: []NewCommandFunc{ 604 | func(appName string) *CommandWrapper { 605 | return &CommandWrapper{ 606 | Handler: &MyCommand{}, 607 | Help: &CommandDescriptor{ 608 | Name: "my-command", 609 | ShortDescription: "This is my own command", 610 | }, 611 | } 612 | }, 613 | }, 614 | test: func(r *CommandRegistry, output string) (errMsg string) { 615 | value := "[E] PANIC!!! PANIC!!! PANIC!!! Calm down, please!" 616 | if !strings.Contains(output, value) { 617 | return fmt.Sprintf("value(%s) not found in output(%s)", value, output) 618 | } 619 | 620 | if !executeCalled { 621 | return "Command should be called with VerboseMode" 622 | } 623 | 624 | return 625 | }, 626 | }, 627 | { 628 | name: "Register one command with validator, call command, but failed on pre-validation", 629 | cliArgs: []string{"my-command", "-v"}, 630 | commands: []NewCommandFunc{ 631 | func(appName string) *CommandWrapper { 632 | return &CommandWrapper{ 633 | Handler: &MyCommand{}, 634 | Help: &CommandDescriptor{ 635 | Name: "my-command", 636 | ShortDescription: "This is my own command", 637 | }, 638 | Validator: myValidatoFunction, 639 | } 640 | }, 641 | }, 642 | test: func(r *CommandRegistry, output string) (errMsg string) { 643 | if executeCalled { 644 | return "Command should not be called" 645 | } 646 | 647 | if !validatorCalled { 648 | return "Command preValidator should be called" 649 | } 650 | return 651 | }, 652 | }, 653 | { 654 | name: "Register one command with validator, call command, but failed on pre-validation", 655 | cliArgs: []string{"my-command", "-v", "--pass-validation"}, 656 | commands: []NewCommandFunc{ 657 | func(appName string) *CommandWrapper { 658 | return &CommandWrapper{ 659 | Handler: &MyCommand{}, 660 | Help: &CommandDescriptor{ 661 | Name: "my-command", 662 | ShortDescription: "This is my own command", 663 | }, 664 | Validator: myValidatoFunction, 665 | } 666 | }, 667 | }, 668 | test: func(r *CommandRegistry, output string) (errMsg string) { 669 | if !executeCalled { 670 | return "Command should be called" 671 | } 672 | 673 | if !validatorCalled { 674 | return "Command preValidator should be called" 675 | } 676 | return 677 | }, 678 | }, 679 | } 680 | oldArgs := os.Args 681 | defer func() { os.Args = oldArgs }() 682 | for _, tt := range tests { 683 | t.Run(tt.name, func(t *testing.T) { 684 | // Pre-boot 685 | os.Args = append([]string{"/some/random/path/my-executable"}, tt.cliArgs...) 686 | executeCalled = false 687 | validatorCalled = false 688 | 689 | // Boot 690 | c := NewCommandRegistry() 691 | for _, command := range tt.commands { 692 | c.Register(command) 693 | } 694 | 695 | mockOutput = "" 696 | c.Execute() 697 | 698 | errMsg := tt.test(c, mockOutput) 699 | if errMsg != "" { 700 | t.Error(errMsg) 701 | } 702 | }) 703 | } 704 | os.Args = oldArgs 705 | } 706 | --------------------------------------------------------------------------------