diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b178334 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: test (go ${{ matrix.go }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go: ['1.21', '1.22', '1.23', '1.24', '1.25', 'stable'] + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + check-latest: true + + - name: go vet + run: go vet ./... + + - name: staticcheck + uses: dominikh/staticcheck-action@v1 + with: + version: latest + install-go: false + + - name: go test + run: go test -race -count=1 ./... diff --git a/.github/workflows/vulns.yml b/.github/workflows/vulns.yml new file mode 100644 index 0000000..04b2947 --- /dev/null +++ b/.github/workflows/vulns.yml @@ -0,0 +1,25 @@ +name: vulns + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + check-latest: true + + - name: Run govulncheck + uses: golang/govulncheck-action@v1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..47dbdf6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Roscoe Skeens + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 86b4125..4a8ba06 100644 --- a/README.md +++ b/README.md @@ -1 +1,263 @@ -# tidycli \ No newline at end of file +

+ + + + tidycli + +

+ +[![CI](https://github.com/RSkeens/tidycli/actions/workflows/tests.yml/badge.svg)](https://github.com/RSkeens/tidycli/actions/workflows/tests.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/RSkeens/tidycli.svg)](https://pkg.go.dev/github.com/RSkeens/tidycli) ![GitHub License](https://img.shields.io/github/license/RSkeens/tidycli) [![Go Report Card](https://goreportcard.com/badge/github.com/RSkeens/tidycli)](https://goreportcard.com/report/github.com/RSkeens/tidycli) + +`tidycli` is a tiny and dependency free router for go clis that need subcommands +without adopting a rich framework. It provides cli handling without ceremony. + +As a positional subcommand router, there is no global state or opinionated +bootstrapping. It does all this in under 200 lines of executable code with +zero dependencies while getting out the way of stdlib `flag`. + +Prettier than stdlib, simpler than [urfave/cli](https://github.com/urfave/cli) +or [kong](https://github.com/alecthomas/kong) and less framework heavy +than [Cobra](https://github.com/spf13/cobra). `tidycli` focuses on excellent +defaults, clean help management, a tiny api surface and fast setup. + +Perfectly structured: + +- Positional subcommands. +- Stdlib `flag` compatibility. +- Zero dependencies. +- Explicit application wiring. +- Command functions that receive a context. +- Tiny API that is easy to read and vendor. + +Describe your cli as a tree of commands mapped by arg keyword. Built in per +command minimum arg enforcement and usage strings surfaced as errors. +It never dictates how your app should bootstrap, configure logging or parse +global flags. App state is supplied to commands by closing over it from +the caller package. + +## Install + +``` +go get github.com/rskeens/tidycli +``` + +## Smallest App + +The smallest useful `tidycli` app is one `Sub` map with one `Fn`: + +```go +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + + "github.com/rskeens/tidycli" +) + +var errHello = errors.New("usage: hello ") + +func main() { + flag.Parse() + + if err := tidycli.Run(context.Background(), tidycli.Sub{ + "hello": {Help: errHello, Min: 1, Fn: func(_ context.Context, args []string) error { + fmt.Println("hello,", args[0]) + + return nil + }}, + }); err != nil { + log.Fatal(err) + } +} +``` + +``` +$ go run . hello world +hello, world + +$ go run . hello +usage: hello +``` + +That is the entire api surface for a single level cli! A `Sub` keyed by arg, +`Help` error, `Min` count and `Fn`. Everything else in this readme is +about scaling that pattern up to nested commands and multi package layouts. + +## Comparison + +`tidycli` exists because alternatives are either too limited (stdlib `flag` +has no subcommand routing) or too opinionated (frameworks that own `main`, +the flag parser or help output). How it compares: + +| Feature | `tidycli` | stdlib `flag` | [`urfave/cli`][urfave] | [`kong`][kong] | [`cobra`][cobra] | +| --- | --- | --- | --- | --- | --- | +| Positional subcommand routing | ✅ | ❌ | ✅ | ✅ | ✅ | +| Works with stdlib `flag` | native | native | own parser | own parser | via [`pflag`][pflag] | +| Reflection / struct tags required | ❌ | ❌ | ❌ | ✅ | ❌ | +| Runtime deps | 0 | — | 0 | 0 | 4 | +| Implementation size | <200 LOC (`cli.go` + `errs.go`) | — | ~4,900 LOC across 42 files | ~5,500 LOC across 31 files | ~4,900 LOC across 19 files | +| Role | router | parser | framework | declarative parser | framework | + +Dependency counts are from current default branch `go.mod` `require` block, +excluding modules imported only from `_test.go` files. `tidycli`'s own `go.mod` +has an empty `require` block. + +Implementation size figures are measured with [`gocloc`](https://github.com/hhatto/gocloc) +over non test `.go` files (counting code lines only, excluding blanks and comments). +Intended as ballpark rather than exact totals. + +[urfave]: https://github.com/urfave/cli +[kong]: https://github.com/alecthomas/kong +[cobra]: https://github.com/spf13/cobra +[pflag]: https://github.com/spf13/pflag + +## Project Layout + +Example app at [`example/`](./example) is laid out using one package per command: + +``` +example/ +├── cmd/hello-world/ +│ ├── cli.go // cmds arg:cmd layout +│ └── main.go // entry +└── pkg/cli/ + ├── help/help.go // central usage errors + ├── hello/ + │ ├── cmd.go // hello.Cmd() returns *tidycli.Cmd + │ └── hello.go // hello.Do leaf + └── greet/ + ├── cmd.go // greet.Cmd() with Sub for hi / bye + └── greet.go // greet.Hi / greet.Bye leaves +``` + +`cmd/hello-world/cli.go` arg:command map: + +```go +// cmd/hello-world/cli.go +package main + +import ( + "github.com/rskeens/tidycli" + "github.com/rskeens/tidycli/example/pkg/cli/greet" + "github.com/rskeens/tidycli/example/pkg/cli/hello" +) + +// cmds stores arg:cmd layout. +var cmds = tidycli.Sub{ + "hello": hello.Cmd(), + "greet": greet.Cmd(), +} +``` + +`cmd/hello-world/main.go` wires `flag.Parse` to `tidycli.Run`: + +```go +// cmd/hello-world/main.go +package main + +import ( + "context" + "flag" + "log" + + "github.com/rskeens/tidycli" +) + +func main() { + flag.Parse() + + if err := tidycli.Run(context.Background(), cmds); err != nil { + log.Fatal(err) + } +} +``` + +Each command package exposes a `Cmd()` constructor which returns `*tidycli.Cmd` +alongside leaf functions: + +```go +// pkg/cli/hello/cmd.go +package hello + +import ( + "fmt" + + "github.com/rskeens/tidycli" + "github.com/rskeens/tidycli/example/pkg/cli/help" +) + +// Cmd returns the cmd. +func Cmd() *tidycli.Cmd { + return &tidycli.Cmd{ + Help: help.ErrHello, + Min: 1, + Fn: Do, + } +} + +// pkg/cli/hello/hello.go +func Do(_ context.Context, args []string) error { + fmt.Println("hello,", args[0]) + + return nil +} +``` + +Parents with subcommands keep `Sub` map inline in the same `Cmd()` constructor. + +Run: + +``` +go run ./example/cmd/hello-world hello world +go run ./example/cmd/hello-world greet hi alice +go run ./example/cmd/hello-world +``` + +## Concepts + +### Types + +| Type | Description | +|---------|-------------| +| `Cmd` | Single command node. | +| `Sub` | `map[string]*Cmd` describing one level of the command tree. | +| `Fn` | `func(ctx context.Context, args []string) error` called once routing to resolve leaf. Context passed from `Run` so commands can honour cancel and deadlines. To give a command access to app state, close over it when constructing `Fn`. | + +### `Cmd` Fields + +| Field | Description | +|---------|-------------| +| `Help` | Usage string returned as error when args are missing or command is non leaf. | +| `Min` | Minimum number of trailing args. | +| `Sub` | Command. | +| `Flags` | When non nil, `*flag.FlagSet` parsed against args once command resolves. | +| `Fn` | Invoked func. | + +### Routing and Execution + +| Call | Description | +|--------------------------|-------------| +| `Run(ctx, sub)` | Calls `sub.Route()` then `Fn` with `ctx` and trailing args. | + +### Invalid Returns + +| Result | Condition | +|-------------------|-----------| +| `ErrArgNone` | No args given. Returns without writing to stderr, caller decides how (or whether) to render usage. | +| `ErrArgInvalid` | Arg does not match (wrapped with unknown keyword). | +| Command's `Help` | Non leaf or fewer than `Min` args given. | + +`sub.Print()` writes usage to `os.Stdout`. `sub.Fprint(w)` writes to arbitrary +`io.Writer` (tests, redirection to stderr, etc). + +## Helps + +The [`help`](./help) subpackage offers small types (`Help`, `Helps`) for +apps which want to manage usage strings as a uniform collection. +Their use is optional. + +Hopefully this software can somehow bring a bit of peace to this troubled world. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e5279f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +Security is considered of greater importance than any other aspect of my software. Any reported vulnerability, +regardless of severity is escalated to immediate priority. + +## Reporting a vulnerability + +Please send the report to `web@rskeens.com` + +We kindly request that you please **do not create a GitHub Issue** to report a security vulnerability. This policy +is out of respect for users of the affected version. + +While not required, a Proof of Concept or other instructions which detail how to reproduce would be immensely appreciated. + +You can expect a response within 24 hours. Feedback for security ideas or features is welcome. + +Full recognition will be given should the report be valid. \ No newline at end of file diff --git a/assets/logo-dark.png b/assets/logo-dark.png new file mode 100644 index 0000000..f1f3833 Binary files /dev/null and b/assets/logo-dark.png differ diff --git a/assets/logo-light.png b/assets/logo-light.png new file mode 100644 index 0000000..cc08513 Binary files /dev/null and b/assets/logo-light.png differ diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..11b3771 --- /dev/null +++ b/cli.go @@ -0,0 +1,344 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +// Package tidycli is a tiny and dependency free router for clis that need +// subcommands without adopting a framework. A minimal positional subcommand +// router rather than bloat. +// +// tidycli routes positional subcommands in under a couple hundred lines of +// code and stays out of the way of the standard library's [flag] package. +// +// Prettier than stdlib, simpler than urfave/cli or kong and less heavy than +// Cobra. A core focus on simplicity, excellent defaults, clean help output, +// tiny api surface and fast setup. +// +// A cmd is described as a tree of [Cmd] values keyed by arg in a [Sub]. +// Each leaf cmd is invoked through a [Fn], which receives [context.Context] +// and trailing arg slice. Every value in a [Sub] must be non nil [*Cmd]. +// +// Typical use: +// +// import ( +// "context" +// "errors" +// "flag" +// "fmt" +// "log" +// +// "github.com/rskeens/tidycli" +// ) +// +// cmds := tidycli.Sub{ +// "hello": { +// Help: errors.New("hello NAME"), +// Min: 1, +// Fn: func(ctx context.Context, args []string) error { +// fmt.Println("hello,", args[0]) +// return nil +// }, +// }, +// } +// +// flag.Parse() +// if err := tidycli.Run(context.Background(), cmds); err != nil { +// log.Fatal(err) +// } +// +// Per cmd flags are supported by attaching [flag.FlagSet] to a leaf [Cmd] +// via [Cmd.Flags]. That is parsed against trailing args before invoking +// [Cmd.Fn]. [Cmd.Flags] on non leaf commands are ignored. +// +// tidycli does not bootstrap, configure logging or parse global flags. +// Callers wire those concerns to fit their needs. +package tidycli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "sort" +) + +// Fn is cmd func. Optional [context.Context] for cancel and deadline. +// Args follows cmd keyword per cmd flag parse. +type Fn func(ctx context.Context, args []string) error + +// Cmd represents single cmd in the cmd tree. +// +// Help is returned as err if cmd is invoked with fewer than min args or when +// cmd is non leaf (cmd with sub holds at least one entry) and descent stops +// there because no further args. Unknown subcmd at returns [ErrArgInvalid] +// instead of Help. The err is intentional, it lets callers propagate usage +// string. +// +// Sub, when non empty, makes this cmd a parent. Routing descends it when next +// arg matches key. Nil sub and non nil but empty sub are both treated as a +// leaf. Empty Sub{} on leaf cmd does not turn into parent. +// +// Fn, Min, and Flags only effect leaf cmds. If Sub is non empty then routing +// either descends into child or returns err (Help when descent stops at cmd, +// [ErrArgInvalid] when the next arg does not match child key), so parent's Fn +// is never invoked, its Min is never checked and the Flags are never parsed. +// Setting Fn, Min, or Flags alongside a non empty Sub is silently ignored, +// attach them to leaf instead. +// +// Flags, when non nil and set on leaf are parsed against trailing args when +// routing has resolved cmd. Remaining positional args are passed to Fn and +// counted against Min. Cmd vals are not mutated by routing. Flags is owned by +// the caller and may carry parsed vals after Run / Route returns. +// +// Flags must use [flag.ContinueOnError]. Routing relies on Parse returning +// errs to convert to Help. [Sub.RouteArgs] re applies [flag.ContinueOnError] +// via [flag.FlagSet.Init] before parsing, then restores prior error +// handling after Parse. FlagSet with [flag.ExitOnError] or +// [flag.PanicOnError] are downgraded for the duration of Parse rather than +// allowed to terminate proc. +// +// Fn invoked for leaf cmds after routing succeeds and arg count satisfies Min. +type Cmd struct { + Help error + Min int + Sub Sub + Flags *flag.FlagSet + Fn Fn +} + +// Sub represents arg keyword to cmd. Vals may not be nil. Routing treats nil +// [*Cmd] as [ErrCmdNil] without panic. +type Sub map[string]*Cmd + +// Run routes parsed args by stdlib [flag] package and on success invokes resolved +// cmd [Cmd.Fn] with ctx and trailing args. +// +// Thin wrapper around [RunArgs] that supplies [flag.Args], use [RunArgs] when +// integrating with alternative flag parser, embedded shell or test harness for +// custom arg slice. +// +// [Cmd] with nil [Cmd.Fn] returns [Cmd.Help] err without panic wrapped with [ErrCmdNoFn]. +func Run(ctx context.Context, sub Sub) error { + return RunArgs(ctx, sub, flag.Args()) +} + +// RunArgs is [Run] entry for given arg slice instead of [flag.Args]. +// Identical routing and [Cmd.Fn] invocation as [Run]. +func RunArgs(ctx context.Context, sub Sub, args []string) error { + cmd, rest, err := sub.RouteArgs(args) + if err != nil { + return err + } + + if cmd.Fn == nil { + return errors.Join(ErrCmdNoFn, help(cmd)) + } + + return cmd.Fn(ctx, rest) +} + +// Route resolves [flag.Args] against sub and returns leaf cmd with trailing +// positional args. Thin wrapper around [Sub.RouteArgs]. +func (sub Sub) Route() (*Cmd, []string, error) { + return sub.RouteArgs(flag.Args()) +} + +// RouteArgs resolves args against sub and returns leaf cmd with trailing +// positional arguments. Returns [Cmd.Help] err when cmd is non leaf with no further +// args to consume or when fewer than [Cmd.Min] trailing args. Returns [ErrArgNone] +// if args is empty and [ErrArgInvalid] when arg at any depth does not match +// cmd. If cmd has non nil [Cmd.Flags] then RouteArgs resets each flag to +// [flag.Flag.DefValue] and parses against trailing args. +// +// Reset relies on clean Value.Set(DefValue), which is reliable for stdlib +// [flag] scalar flag types but may not fully reset custom [flag.Value] whose +// Set accumulates state. Such flags should construct a new [flag.FlagSet] +// per invocation rather than rely on reset. Reset is skipped for any flag +// whose current [flag.Value.String] already equals [flag.Flag.DefValue], so +// custom Values that reject their own DefValue do not fail on the first +// invocation before user input is parsed. If [flag.Value.Set] returns err +// during reset then RouteArgs returns [ErrFlagReset] rather than silently +// leaking stale state. +// +// A [flag.ErrHelp] result from the user supplying -h or --help is reported +// as cmd [Cmd.Help] err. Other errs from [flag.FlagSet.Parse] are wrapped with +// [ErrFlagParse]. +// +// Before parsing, RouteArgs re applies [flag.ContinueOnError] to FlagSet via +// [flag.FlagSet.Init]: a FlagSet constructed with [flag.ExitOnError] or +// [flag.PanicOnError] would otherwise terminate the process from inside Parse +// on parse err or on -h / --help, bypassing RouteArgs's err reporting. The +// prior error handling is captured and restored after Parse so RouteArgs +// does not leave a silent side effect on the caller-owned FlagSet. +// +// RouteArgs does not mutate [Cmd] other than vals stored inside [Cmd.Flags] +// (caller owned) and does not read from global [flag.CommandLine]. Safe for +// alongside other flag parsers, embedded shells and parallel tests that don't +// share [Cmd.Flags] across goroutines. Performs no i/o of its own so callers +// are responsible for rendering errs (including [ErrArgNone]). +// +// Malformed cmd trees report as errs without panic. Nil [*Cmd] in sub or +// descendant [Sub] returns [ErrCmdNil] and routed cmd with [Cmd.Help] as nil when +// [Cmd.Help] would be returned returns [ErrCmdNoHelp]. +func (sub Sub) RouteArgs(args []string) (*Cmd, []string, error) { + if len(args) == 0 { + return nil, nil, ErrArgNone + } + + cmd, ok := sub[args[0]] + if !ok { + return nil, nil, fmt.Errorf("%w: %v", ErrArgInvalid, args[0]) + } + + if cmd == nil { + return nil, nil, fmt.Errorf("%w: %v", ErrCmdNil, args[0]) + } + + rest := args[1:] + + for len(cmd.Sub) > 0 && len(rest) > 0 { + next, ok := cmd.Sub[rest[0]] + if !ok { + // Distinguish typo from -h or empty descent. + return nil, nil, fmt.Errorf("%w: %v", ErrArgInvalid, rest[0]) + } + + if next == nil { + return nil, nil, fmt.Errorf("%w: %v", ErrCmdNil, rest[0]) + } + + cmd = next + rest = rest[1:] + } + + if len(cmd.Sub) > 0 { + return nil, nil, help(cmd) + } + + if cmd.Flags != nil { + // Reset each registered flag to default. Flag set on prev invocation + // does not silently leak into next one if reused. Round trip through + // Value.Set is expected to succeed for scalar flag types provided by + // stdlib. Custom flag.Value with Set rejects DefValue or accumulates + // state can still fail as [ErrFlagReset]. + // + // Skip flags if current Value already equals DefValue. On first + // invocation no prior parse has dirty state so reset is noop for well + // behaved Values, and custom flag.Value whose Set rejects its DefValue + // would fail before user input parse. + var ( + errReset error + resetName string + ) + + cmd.Flags.VisitAll(func(f *flag.Flag) { + if errReset != nil { + return + } + + if f.Value.String() == f.DefValue { + return + } + + if err := f.Value.Set(f.DefValue); err != nil { + errReset = err + resetName = f.Name + } + }) + + if errReset != nil { + return nil, nil, fmt.Errorf("%w: %s: %w", ErrFlagReset, resetName, errReset) + } + + // flag.FlagSet.Parse writes to fs.Output() (stderr by default) on parse + // err and -h / --help, emitting "Usage of :" plus flag defaults. + // RouteArgs has no own i/o. Err rendering to callers so silence FlagSet + // for Parse then restore writer. + // + // Restore is deferred for panic from custom flag.Value.Set. + prevOut := cmd.Flags.Output() + cmd.Flags.SetOutput(io.Discard) + defer cmd.Flags.SetOutput(prevOut) + + // Also capture prior error handling and restore it. + prevErrHandling := cmd.Flags.ErrorHandling() + cmd.Flags.Init(cmd.Flags.Name(), flag.ContinueOnError) + defer cmd.Flags.Init(cmd.Flags.Name(), prevErrHandling) + + if err := cmd.Flags.Parse(rest); err != nil { + // usage request, not a parse failure. Return cmd [Cmd.Help]. + if errors.Is(err, flag.ErrHelp) { + return nil, nil, help(cmd) + } + + return nil, nil, fmt.Errorf("%w: %w", ErrFlagParse, err) + } + + rest = cmd.Flags.Args() + } + + if len(rest) < cmd.Min { + return nil, nil, help(cmd) + } + + return cmd, rest, nil +} + +// help returns cmd.Help. Falls back to [ErrCmdNoHelp] if no [Cmd.Help]. Used wherever +// routing would return nil err in place of usage string. +func help(cmd *Cmd) error { + if cmd.Help == nil { + return ErrCmdNoHelp + } + + return cmd.Help +} + +// Print writes cmd keywords and Helps to stdout alphabetically. Thin +// wrapper around [Sub.Fprint] targets [os.Stdout]. Redirect of usage view +// should call [Sub.Fprint]. Any logo or trailing footer should print around it. +func (sub Sub) Print() error { + return sub.Fprint(os.Stdout) +} + +// Fprint writes cmd keywords and Helps to wr alphabetically. Each entry +// renders keyword on one line followed by Help on the next then separated +// from following entry by blank line. +// +// Nil [*Cmd] sub returns [ErrCmdNil] without panic. [Cmd] with nil [Cmd.Help] is +// rendered with placeholder line so keyword appears in usage view. +func (sub Sub) Fprint(wr io.Writer) error { + args := make([]string, 0, len(sub)) + + for arg := range sub { + args = append(args, arg) + } + + sort.Strings(args) + + for idx, arg := range args { + var ( + cmd = sub[arg] + sep = "" + ) + + if cmd == nil { + return fmt.Errorf("%w: %v", ErrCmdNil, arg) + } + + if idx > 0 { + sep = "\n" + } + + desc := any(cmd.Help) + if cmd.Help == nil { + desc = "(no help)" + } + + if _, err := fmt.Fprintf(wr, "%s%s\n%v\n", sep, arg, desc); err != nil { + return err + } + } + + return nil +} diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..9183022 --- /dev/null +++ b/cli_test.go @@ -0,0 +1,1028 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package tidycli + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "os" + "reflect" + "strings" + "testing" +) + +// input represents inputs. +type input struct { + err error + fn Fn +} + +// want represents wants. +type want struct { + err error + fn Fn + help error +} + +// errWriter represents [io.Writer] that always fails. Used for +// err return of [Sub.Fprint]. +type errWriter struct{} + +// resetRejVal represents [flag.Value] that accepts non-empty Set +// values and stores them, but returns err on Set with empty string +// (the DefValue for fs.Var registered with this type). Used to drive +// reset err path in [Sub.RouteArgs] after a prior parse has moved +// the flag value away from its default. +type resetRejVal struct { + err error + val string +} + +// panicVal represents [flag.Value] to panic. Used for deferred +// [flag.FlagSet.SetOutput] restore in [Sub.RouteArgs]. +type panicVal struct{} + +var mock = input{ + err: fmt.Errorf("mock"), + fn: Fn(nil), +} + +func (rej *resetRejVal) String() string { + if rej == nil { + return "" + } + + return rej.val +} + +func (rej *resetRejVal) Set(val string) error { + if val == "" { + return rej.err + } + + rej.val = val + + return nil +} + +func (panicVal) String() string { + return "" +} + +func (panicVal) Set(string) error { + panic("panic") +} + +// Write is the [errWriter] writer. +func (errWriter) Write([]byte) (int, error) { + return 0, mock.err +} + +// resetFlags replaces global [flag.CommandLine] and [os.Args] for +// tests without leaking state to other tests. Restores prev vals. +func resetFlags(t *testing.T, args []string) { + t.Helper() + + var ( + prevArgs = os.Args + prevFlag = flag.CommandLine + ) + + os.Args = append([]string{t.Name()}, args...) + + flag.CommandLine = flag.NewFlagSet(t.Name(), flag.ContinueOnError) + + t.Cleanup(func() { + os.Args = prevArgs + flag.CommandLine = prevFlag + }) + + flag.Parse() +} + +// newCmd is constructor for route table tests. +func newCmd(min int, sub Sub) *Cmd { + return &Cmd{ + Help: mock.err, + Min: min, + Sub: sub, + Fn: mock.fn, + } +} + +func TestRouteArgs(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + sub Sub + want *want + }{ + "none": { + args: nil, + + sub: Sub{}, + + want: &want{ + err: ErrArgNone, + }, + }, + + "single": { + args: []string{"mock"}, + + sub: Sub{ + "mock": { + Help: mock.err, + Fn: mock.fn, + }, + }, + + want: &want{ + err: nil, + fn: mock.fn, + help: mock.err, + }, + }, + + "single-under": { + args: []string{"mock"}, + + sub: Sub{ + "mock": newCmd( + 1, + + Sub{}, + ), + }, + + want: &want{ + err: mock.err, + fn: mock.fn, + help: mock.err, + }, + }, + + "nested": { + args: []string{"mock-a", "mock-b"}, + + sub: Sub{ + "mock-a": newCmd( + 1, + + Sub{ + "mock-b": { + Help: mock.err, + Min: 0, + Fn: mock.fn, + }, + }, + ), + }, + + want: &want{ + err: nil, + fn: mock.fn, + help: mock.err, + }, + }, + + "nested-under": { + args: []string{"mock-a", "mock-b"}, + + sub: Sub{ + "mock-a": newCmd( + 1, + + Sub{ + "mock-b": { + Help: mock.err, + Min: 1, + Fn: mock.fn, + }, + }, + ), + }, + + want: &want{ + err: mock.err, + fn: mock.fn, + help: mock.err, + }, + }, + } + + for name, test := range tests { + name, test := name, test + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, _, err := test.sub.RouteArgs(test.args) + if !errors.Is(err, test.want.err) { + t.Errorf("unexpected route error %v, want %v", err, test.want.err) + } + + if got == nil { + return + } + + if fmt.Sprintf("%v", got.Fn) != fmt.Sprintf("%v", test.want.fn) { + t.Errorf("unexpected route fn %v, want %v", got.Fn, test.want.fn) + } + + if !errors.Is(got.Help, test.want.help) { + t.Errorf("unexpected route help %v, want %v", got.Help, test.want.help) + } + }) + } +} + +func TestRouteArgsErrInvalid(t *testing.T) { + t.Parallel() + + input := Sub{ + "actions": newCmd( + 1, + + Sub{ + "get": { + Help: mock.err, + Min: 1, + Fn: mock.fn, + }, + }, + ), + } + + if _, _, got := input.RouteArgs([]string{t.Name()}); !errors.Is(got, ErrArgInvalid) { + t.Errorf("unexpected route error %v, want %v", got, ErrArgInvalid) + } +} + +func TestRunArgs(t *testing.T) { + t.Parallel() + + var ( + called bool + + input = Sub{ + "install": { + Help: mock.err, + + Fn: func(context.Context, []string) error { + called = true + return nil + }, + }, + } + + got = RunArgs(context.Background(), input, []string{"install"}) + ) + + if got != nil { + t.Errorf("run error %v", got) + } + + if !called { + t.Errorf("expected fn invoke") + } +} + +func TestRunArgsRouteErr(t *testing.T) { + t.Parallel() + + if got := RunArgs(context.Background(), Sub{}, nil); !errors.Is(got, ErrArgNone) { + t.Errorf("unexpected run error %v, want %v", got, ErrArgNone) + } +} + +func TestPrint(t *testing.T) { + input := Sub{ + "1": { + Help: errors.New("desc-1"), + }, + + "2": { + Help: errors.New("desc-2"), + }, + } + + if err := input.Print(); err != nil { + t.Errorf("print error %v", err) + } +} + +func TestFprint(t *testing.T) { + t.Parallel() + + var ( + input = Sub{ + "2": { + Help: errors.New("desc-2"), + }, + + "1": { + Help: errors.New("desc-1"), + }, + } + + buf bytes.Buffer + + want = "1\ndesc-1\n\n2\ndesc-2\n" + ) + + if err := input.Fprint(&buf); err != nil { + t.Fatalf("fprint error %v", err) + } + + if got := buf.String(); got != want { + t.Errorf("unexpected fprint output\ngot: %q\nwant: %q", got, want) + } +} + +// TestRouteArgsEmptySubLeaf asserts non nil empty Sub does not turn +// leaf cmd into non leaf. Fn must be reachable and Min checked with +// trailing args. +func TestRouteArgsEmptySubLeaf(t *testing.T) { + t.Parallel() + + var ( + called bool + + input = Sub{ + "mock": { + Help: mock.err, + Sub: Sub{}, + Fn: func(context.Context, []string) error { + called = true + return nil + }, + }, + } + ) + + if err := RunArgs(context.Background(), input, []string{"mock"}); err != nil { + t.Fatalf("unexpected run error %v", err) + } + + if !called { + t.Errorf("expected fn invoke") + } +} + +// TestRouteArgsEmptyArgs asserts empty arg return [ErrArgNone] without i/o. +func TestRouteArgsEmptyArgs(t *testing.T) { + t.Parallel() + + input := Sub{ + "mock": {Help: mock.err}, + } + + if _, _, err := input.RouteArgs(nil); !errors.Is(err, ErrArgNone) { + t.Errorf("unexpected route error %v, want %v", err, ErrArgNone) + } +} + +// TestRunArgsNilFn asserts Run does not panic on bad leaf with nil +// Fn and instead return [ErrCmdNoFn]. +func TestRunArgsNilFn(t *testing.T) { + t.Parallel() + + var ( + input = Sub{ + "broken": { + Help: mock.err, + Fn: nil, + }, + } + + got = RunArgs(context.Background(), input, []string{"broken"}) + ) + + if !errors.Is(got, ErrCmdNoFn) { + t.Errorf("unexpected run error %v, want %v", got, ErrCmdNoFn) + } +} + +// TestRouteArgsFlags asserts per cmd flags are parsed and removed +// from trailing args before returned to caller. +func TestRouteArgsFlags(t *testing.T) { + t.Parallel() + + var ( + flags = flag.NewFlagSet(t.Name(), flag.ContinueOnError) + verbose = flags.Bool("v", false, "verbose") + + input = Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + ) + + got, args, err := input.RouteArgs([]string{t.Name(), "-v", "a"}) + if err != nil { + t.Fatalf("route error %v", err) + } + + if got == nil { + t.Fatal("expected cmd, got nil") + } + + if !*verbose { + t.Errorf("expected -v to be parsed, verbose=%v", *verbose) + } + + if len(args) != 1 || args[0] != "a" { + t.Errorf("unexpected trailing args %v, want [a]", args) + } +} + +// TestRouteArgsNoMutation asserts that routing does not mutate cmd +// vals across successive RouteArgs calls. +func TestRouteArgsNoMutation(t *testing.T) { + t.Parallel() + + var ( + leaf = &Cmd{ + Help: mock.err, + Min: 1, + Sub: Sub{}, + Fn: mock.fn, + } + + sub = Sub{ + "leaf": leaf, + } + + snap = *leaf + ) + + if _, _, err := sub.RouteArgs([]string{"leaf", "a"}); err != nil { + t.Fatalf("unexpected route error %v", err) + } + + if leaf.Min != snap.Min || + !errors.Is(leaf.Help, snap.Help) { + t.Errorf("cmd changed: got %+v, want %+v", *leaf, snap) + } + + if reflect.ValueOf(leaf.Sub).Pointer() != reflect.ValueOf(snap.Sub).Pointer() { + t.Errorf("sub changed: got %v, want %v", leaf.Sub, snap.Sub) + } +} + +// TestRouteArgsSubMiss asserts that descent stops when next arg is +// unknown. Token return wrapped [ErrArgInvalid] so callers can +// distinguish typo from usage req. +func TestRouteArgsSubMiss(t *testing.T) { + t.Parallel() + + input := Sub{ + "mock-a": newCmd( + 0, + + Sub{ + "mock-b": { + Help: mock.err, + Fn: mock.fn, + }, + }, + ), + } + + _, _, err := input.RouteArgs([]string{"mock-a", "mock-c"}) + + if !errors.Is(err, ErrArgInvalid) { + t.Errorf("unexpected route error %v, want %v", err, ErrArgInvalid) + } + + if err == nil || !strings.Contains(err.Error(), "mock-c") { + t.Errorf("expected error to mention offending token %q, got %v", "mock-c", err) + } +} + +// TestRouteArgsFlagsErr asserts err from [flag.FlagSet.Parse] is +// wrapped with [ErrFlagParse]. +func TestRouteArgsFlagsErr(t *testing.T) { + t.Parallel() + + flags := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + flags.SetOutput(&bytes.Buffer{}) + flags.Bool("v", false, "verbose") + + input := Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + + _, _, got := input.RouteArgs([]string{t.Name(), "-no"}) + + if got == nil { + t.Fatalf("unexpected flag parse success") + } + + if !errors.Is(got, ErrFlagParse) { + t.Errorf("unexpected route error %v, want %v", got, ErrFlagParse) + } +} + +// TestRouteArgsFlagsResetErr asserts err returned by custom [flag.Value] +// Set pre parse reset returns [ErrFlagReset] wrapped with flag name and +// underlying Set err. +func TestRouteArgsFlagsResetErr(t *testing.T) { + t.Parallel() + + var ( + flags = flag.NewFlagSet(t.Name(), flag.ContinueOnError) + rej = &resetRejVal{err: mock.err} + ) + + flags.SetOutput(&bytes.Buffer{}) + flags.Var(rej, "tag", "tag") + flags.String("zone", "", "zone") + + input := Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + + // Dirty flag successful first parse so subsequent route attempts + // to reset back to DefValue. + if _, _, err := input.RouteArgs([]string{t.Name(), "-tag=x"}); err != nil { + t.Fatalf("unexpected first route error: %v", err) + } + + _, _, got := input.RouteArgs([]string{t.Name(), "a"}) + if got == nil { + t.Fatalf("unexpected flag reset success") + } + + if !errors.Is(got, ErrFlagReset) { + t.Errorf("unexpected route error %v, want %v", got, ErrFlagReset) + } + + if !errors.Is(got, mock.err) { + t.Errorf("expected underlying Set error to be wrapped, got %v", got) + } + + if !strings.Contains(got.Error(), "tag") { + t.Errorf("expected error to mention flag name %q, got %v", "tag", got) + } +} + +// TestRouteArgsFlagsReset asserts flag vals left over from prev +// RouteArgs are reset to defaults before next Parse. +func TestRouteArgsFlagsReset(t *testing.T) { + t.Parallel() + + var ( + flags = flag.NewFlagSet(t.Name(), flag.ContinueOnError) + verbose = flags.Bool("v", false, "verbose") + name = flags.String("name", "default", "name") + + input = Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + ) + + if _, _, got := input.RouteArgs([]string{t.Name(), "-v", "-name=alt", "a"}); got != nil { + t.Fatalf("unexpected route error %v", got) + } + + if !*verbose || *name != "alt" { + t.Fatalf("expected set flags, got verbose=%v name=%q", *verbose, *name) + } + + if _, _, got := input.RouteArgs([]string{t.Name(), "a"}); got != nil { + t.Fatalf("unexpected route error %v", got) + } + + if *verbose { + t.Errorf("expected -v to reset to false, got true") + } + + if *name != "default" { + t.Errorf("expected -name to reset to %q, got %q", "default", *name) + } +} + +// TestRouteArgsFlagsHelp asserts -h / --help on per cmd +// FlagSet returns cmd Help err instead of [flag.ErrHelp]. +func TestRouteArgsFlagsHelp(t *testing.T) { + t.Parallel() + + flags := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + flags.SetOutput(&bytes.Buffer{}) + flags.Bool("v", false, "verbose") + + input := Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + + _, _, got := input.RouteArgs([]string{t.Name(), "-h"}) + + if !errors.Is(got, mock.err) { + t.Errorf("unexpected route error %v, want %v", got, mock.err) + } + + if errors.Is(got, ErrFlagParse) { + t.Errorf("flag.ErrHelp should not be wrapped with ErrFlagParse, got %v", got) + } +} + +// TestRouteArgsFlagsHelpNoHelp asserts -h on cmd with nil Help +// returns [ErrCmdNoHelp] instead of [flag.ErrHelp]. +func TestRouteArgsFlagsHelpNoHelp(t *testing.T) { + t.Parallel() + + flags := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + flags.SetOutput(&bytes.Buffer{}) + flags.Bool("v", false, "verbose") + + input := Sub{ + t.Name(): { + Flags: flags, + Fn: mock.fn, + }, + } + + if _, _, got := input.RouteArgs([]string{t.Name(), "-h"}); !errors.Is(got, ErrCmdNoHelp) { + t.Errorf("unexpected route error %v, want %v", got, ErrCmdNoHelp) + } +} + +// TestRouteArgsFlagsSilent asserts RouteArgs performs no i/o when +// [flag.FlagSet.Parse] would otherwise emit usage to fs.Output(). +func TestRouteArgsFlagsSilent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + }{ + { + "parse error", []string{t.Name(), "-err"}, + }, + + { + "help flag", []string{t.Name(), "-h"}, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var ( + flags = flag.NewFlagSet(t.Name(), flag.ContinueOnError) + out = &bytes.Buffer{} + ) + + flags.SetOutput(out) + flags.Bool("v", false, "verbose") + + input := Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + + if _, _, err := input.RouteArgs(test.args); err == nil { + t.Fatalf("unexpected route success") + } + + if out.Len() != 0 { + t.Errorf("wrote %q output", out.String()) + } + + if flags.Output() != out { + t.Errorf("left flagset output as %v, want caller's writer restored", flags.Output()) + } + }) + } +} + +// TestRouteArgsFlagsExitOnErrorDowngrade asserts FlagSet with +// [flag.ExitOnError] is downgraded to [flag.ContinueOnError] before +// Parse, so parse err or -h / --help returns routing err instead of +// terminate proc, and the prior error handling is restored after +// Parse so the caller-owned FlagSet is not silently mutated. +func TestRouteArgsFlagsExitOnErrorDowngrade(t *testing.T) { + t.Parallel() + + flags := flag.NewFlagSet(t.Name(), flag.ExitOnError) + flags.SetOutput(&bytes.Buffer{}) + flags.Bool("v", false, "verbose") + + input := Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + + if _, _, got := input.RouteArgs([]string{t.Name(), "-nope"}); !errors.Is(got, ErrFlagParse) { + t.Errorf("unexpected route error %v, want %v", got, ErrFlagParse) + } + + if eh := flags.ErrorHandling(); eh != flag.ExitOnError { + t.Errorf("flagset ErrorHandling = %v, want %v restored after parse err", eh, flag.ExitOnError) + } + + if _, _, got := input.RouteArgs([]string{t.Name(), "-h"}); !errors.Is(got, mock.err) { + t.Errorf("unexpected route error %v, want %v", got, mock.err) + } + + if eh := flags.ErrorHandling(); eh != flag.ExitOnError { + t.Errorf("flagset ErrorHandling = %v, want %v restored after help", eh, flag.ExitOnError) + } +} + +// TestRouteArgsFlagsResetSkipDefault asserts that the pre parse reset +// is skipped for any flag whose Value already equals DefValue, so a +// custom flag.Value whose Set rejects its own DefValue does not fail +// on the first invocation before user input is parsed. +func TestRouteArgsFlagsResetSkipDefault(t *testing.T) { + t.Parallel() + + var ( + flags = flag.NewFlagSet(t.Name(), flag.ContinueOnError) + rej = &resetRejVal{err: mock.err} + ) + + flags.SetOutput(&bytes.Buffer{}) + flags.Var(rej, "tag", "tag") + + input := Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + + if _, _, err := input.RouteArgs([]string{t.Name(), "-tag=x"}); err != nil { + t.Fatalf("unexpected first route error: %v", err) + } +} + +// TestRouteArgsFlagsRestoreOnPanic asserts that caller FlagSet +// output writer is restored even if Parse panics, so to not +// silently pin FlagSet output to [io.Discard]. +func TestRouteArgsFlagsRestoreOnPanic(t *testing.T) { + t.Parallel() + + var ( + out = &bytes.Buffer{} + flags = flag.NewFlagSet(t.Name(), flag.ContinueOnError) + ) + + flags.SetOutput(out) + flags.Var(panicVal{}, "panic", "panics on Set") + + input := Sub{ + t.Name(): { + Help: mock.err, + Flags: flags, + Fn: mock.fn, + }, + } + + defer func() { + if ok := recover(); ok == nil { + t.Fatalf("expected panic") + } + + if flags.Output() != out { + t.Errorf("router left flagset output %v after panic, want writer restored", flags.Output()) + } + }() + + _, _, _ = input.RouteArgs([]string{t.Name(), "-panic=x"}) +} + +// TestFprintErr asserts write err propagates to caller. +func TestFprintErr(t *testing.T) { + t.Parallel() + + input := Sub{ + t.Name(): { + Help: mock.err, + }, + } + + if got := input.Fprint(errWriter{}); !errors.Is(got, mock.err) { + t.Errorf("unexpected fprint error %v, want %v", got, mock.err) + } +} + +// TestRouteArgsNilCmd asserts nil [*Cmd] in sub return [ErrCmdNil] +// instead of panic. +func TestRouteArgsNilCmd(t *testing.T) { + t.Parallel() + + input := Sub{t.Name(): nil} + + if _, _, got := input.RouteArgs([]string{t.Name()}); !errors.Is(got, ErrCmdNil) { + t.Errorf("unexpected route error %v, want %v", got, ErrCmdNil) + } +} + +// TestRouteArgsNilSubCmd asserts nil [*Cmd] during descent into +// parent Sub return [ErrCmdNil] instead of panic. +func TestRouteArgsNilSubCmd(t *testing.T) { + t.Parallel() + + input := Sub{ + "parent": newCmd( + 0, + + Sub{ + "child": nil, + }, + ), + } + + if _, _, got := input.RouteArgs([]string{"parent", "child"}); !errors.Is(got, ErrCmdNil) { + t.Errorf("unexpected route error %v, want %v", got, ErrCmdNil) + } +} + +// TestRouteArgsNilHelpNonLeaf asserts non leaf with nil Help +// returns [ErrCmdNoHelp] instead of nil. +func TestRouteArgsNilHelpNonLeaf(t *testing.T) { + t.Parallel() + + input := Sub{ + "parent": { + Sub: Sub{ + "child": { + Help: mock.err, + Fn: mock.fn, + }, + }, + }, + } + + if _, _, got := input.RouteArgs([]string{"parent"}); !errors.Is(got, ErrCmdNoHelp) { + t.Errorf("unexpected route error %v, want %v", got, ErrCmdNoHelp) + } +} + +// TestRouteArgsNilHelpUnderMin asserts leaf with nil Help with +// too few args return [ErrCmdNoHelp] instead of nil. +func TestRouteArgsNilHelpUnderMin(t *testing.T) { + t.Parallel() + + input := Sub{ + t.Name(): { + Min: 1, + Fn: mock.fn, + }, + } + + if _, _, got := input.RouteArgs([]string{t.Name()}); !errors.Is(got, ErrCmdNoHelp) { + t.Errorf("unexpected route error %v, want %v", got, ErrCmdNoHelp) + } +} + +// TestRunArgsNilFnNilHelp asserts leaf with both Fn and Help +// nil return [ErrCmdNoFn] joined with [ErrCmdNoHelp] instead of panic. +func TestRunArgsNilFnNilHelp(t *testing.T) { + t.Parallel() + + var ( + input = Sub{t.Name(): {}} + + got = RunArgs( + context.Background(), + input, + []string{t.Name()}, + ) + ) + + if !errors.Is(got, ErrCmdNoFn) { + t.Errorf("unexpected run error %v, want %v", got, ErrCmdNoFn) + } + + if !errors.Is(got, ErrCmdNoHelp) { + t.Errorf("unexpected run error %v, want %v", got, ErrCmdNoHelp) + } +} + +// TestFprintNilCmd asserts nil [*Cmd] in sub causes Fprint to +// return [ErrCmdNil] instead of panic. +func TestFprintNilCmd(t *testing.T) { + t.Parallel() + + input := Sub{t.Name(): nil} + + if err := input.Fprint(&bytes.Buffer{}); !errors.Is(err, ErrCmdNil) { + t.Errorf("unexpected fprint error %v, want %v", err, ErrCmdNil) + } +} + +// TestFprintNilHelp asserts cmd with nil Help shows placeholder +// instead of "". +func TestFprintNilHelp(t *testing.T) { + t.Parallel() + + var ( + input = Sub{ + "mock": {}, + } + + buf bytes.Buffer + want = "mock\n(no help)\n" + ) + + if err := input.Fprint(&buf); err != nil { + t.Fatalf("fprint error %v", err) + } + + if got := buf.String(); got != want { + t.Errorf("unexpected fprint output\ngot: %q\nwant: %q", got, want) + } +} + +// TestRun asserts [Run] dispatches to [RunArgs] using left over +// [flag.Parse] args. +func TestRun(t *testing.T) { + resetFlags(t, []string{"install"}) + + var ( + called bool + + input = Sub{ + "install": { + Help: mock.err, + + Fn: func(context.Context, []string) error { + called = true + return nil + }, + }, + } + ) + + if err := Run(context.Background(), input); err != nil { + t.Errorf("run error %v", err) + } + + if !called { + t.Errorf("expected fn invoke") + } +} + +// TestRoute asserts [Sub.Route] resolves [flag.Args] to matched +// leaf cmd. +func TestRoute(t *testing.T) { + resetFlags(t, []string{t.Name()}) + + input := Sub{ + t.Name(): { + Help: mock.err, + Fn: mock.fn, + }, + } + + got, _, err := input.Route() + if err != nil { + t.Fatalf("route error %v", err) + } + + if got == nil { + t.Fatal("expected resolved cmd, got nil") + } + + if !errors.Is(got.Help, mock.err) { + t.Errorf("unexpected route help %v, want %v", got.Help, mock.err) + } +} diff --git a/errs.go b/errs.go new file mode 100644 index 0000000..cd7be8a --- /dev/null +++ b/errs.go @@ -0,0 +1,39 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package tidycli + +import "errors" + +var ( + // ErrArgNone means no specified args. + ErrArgNone = errors.New("tidycli: no args specified") + + // ErrArgInvalid means arg did not match command. Returned for + // unknown top level arg and any unknown token while descending + // sub command [Sub]. Wraps with offending token. + ErrArgInvalid = errors.New("tidycli: invalid arg specified") + + // ErrFlagParse means [flag.FlagSet.Parse] returned err while + // parsing [Cmd.Flags] against trailing args. Err from flag pkg is + // wrapped to distinguish parse failure from routing failure or [Fn] + // failure with [errors.Is]. + ErrFlagParse = errors.New("tidycli: flag parse failed") + + // ErrFlagReset means [flag.Value] returned err from [flag.Value.Set] when + // [Sub.RouteArgs] tried to restore it to default before parsing. + // Wrapped with offending flag name and the underlying [flag.Value.Set] error + // to distinguish reset failure from parse failure with [errors.Is]. + ErrFlagReset = errors.New("tidycli: flag reset failed") + + // ErrCmdNoFn means resolved leaf command [Cmd.Fn] is nil. + // Wraps with [Cmd.Help] to surface usage. + ErrCmdNoFn = errors.New("tidycli: command has no func") + + // ErrCmdNoHelp means routing wanted to return [Cmd.Help] but [Cmd.Help] was nil. + ErrCmdNoHelp = errors.New("tidycli: command has no help") + + // ErrCmdNil means [Sub] held nil [*Cmd] for key routing / rendering. + // [Sub] vals must not be nil. + ErrCmdNil = errors.New("tidycli: nil command") +) diff --git a/example/cmd/hello-world/cli.go b/example/cmd/hello-world/cli.go new file mode 100644 index 0000000..1206822 --- /dev/null +++ b/example/cmd/hello-world/cli.go @@ -0,0 +1,16 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package main + +import ( + "github.com/rskeens/tidycli" + "github.com/rskeens/tidycli/example/pkg/cli/greet" + "github.com/rskeens/tidycli/example/pkg/cli/hello" +) + +// cmds stores arg:cmd layout. +var cmds = tidycli.Sub{ + "hello": hello.Cmd(), + "greet": greet.Cmd(), +} diff --git a/example/cmd/hello-world/main.go b/example/cmd/hello-world/main.go new file mode 100644 index 0000000..e7c3fb4 --- /dev/null +++ b/example/cmd/hello-world/main.go @@ -0,0 +1,26 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +// Command hello-world demonstrates a minimal tidycli app. +// One package per command. +// +// go run ./example/cmd/hello-world hello world +// go run ./example/cmd/hello-world greet hi alice +// go run ./example/cmd/hello-world # prints usage +package main + +import ( + "context" + "flag" + "log" + + "github.com/rskeens/tidycli" +) + +func main() { + flag.Parse() + + if err := tidycli.Run(context.Background(), cmds); err != nil { + log.Fatal(err) + } +} diff --git a/example/cmd/hello-world/main_test.go b/example/cmd/hello-world/main_test.go new file mode 100644 index 0000000..6dbaf1f --- /dev/null +++ b/example/cmd/hello-world/main_test.go @@ -0,0 +1,47 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package main + +import ( + "flag" + "os" + "os/exec" + "testing" +) + +// TestMainHello drives main() with a valid command to assert that +// argument parsing and dispatch wire up correctly. +func TestMainHello(t *testing.T) { + prevArgs := os.Args + prevFlag := flag.CommandLine + + os.Args = []string{"hello-world", "hello", "world"} + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + + t.Cleanup(func() { + os.Args = prevArgs + flag.CommandLine = prevFlag + }) + + main() +} + +// TestMainErr re-invokes the test binary as a subprocess so the +// log.Fatal branch in main() can be exercised without terminating the +// test runner itself. +func TestMainErr(t *testing.T) { + if os.Getenv("TIDYCLI_TEST_MAIN_ERR") == "1" { + os.Args = []string{"hello-world"} + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + main() + return + } + + cmd := exec.Command(os.Args[0], "-test.run=TestMainErr") + cmd.Env = append(os.Environ(), "TIDYCLI_TEST_MAIN_ERR=1") + + if err := cmd.Run(); err == nil { + t.Errorf("expected subprocess to exit non-zero, got nil") + } +} diff --git a/example/pkg/cli/greet/cmd.go b/example/pkg/cli/greet/cmd.go new file mode 100644 index 0000000..56358b1 --- /dev/null +++ b/example/pkg/cli/greet/cmd.go @@ -0,0 +1,32 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +// Package greet implements "greet" parent command and "hi" / "bye" leaves. +package greet + +import ( + "github.com/rskeens/tidycli" + "github.com/rskeens/tidycli/example/pkg/cli/help" +) + +// Cmd returns cmd. +func Cmd() *tidycli.Cmd { + return &tidycli.Cmd{ + Help: help.ErrGreet, + Min: 1, + + Sub: tidycli.Sub{ + "hi": { + Help: help.ErrGreet, + Min: 1, + Fn: Hi, + }, + + "bye": { + Help: help.ErrGreet, + Min: 1, + Fn: Bye, + }, + }, + } +} diff --git a/example/pkg/cli/greet/cmd_test.go b/example/pkg/cli/greet/cmd_test.go new file mode 100644 index 0000000..2d71b71 --- /dev/null +++ b/example/pkg/cli/greet/cmd_test.go @@ -0,0 +1,14 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package greet + +import "testing" + +func TestCmd(t *testing.T) { + t.Parallel() + + if Cmd() == nil { + t.Error("expected non nil cmd") + } +} diff --git a/example/pkg/cli/greet/greet.go b/example/pkg/cli/greet/greet.go new file mode 100644 index 0000000..4ed5b92 --- /dev/null +++ b/example/pkg/cli/greet/greet.go @@ -0,0 +1,25 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package greet + +import ( + "context" + "fmt" +) + +// Hi prints "hi" greeting for given name. +// hello-world greet hi NAME +func Hi(_ context.Context, args []string) error { + _, err := fmt.Println("hi,", args[0]) + + return err +} + +// Bye prints "bye" farewell for given name. +// hello-world greet bye NAME +func Bye(_ context.Context, args []string) error { + _, err := fmt.Println("bye,", args[0]) + + return err +} diff --git a/example/pkg/cli/greet/greet_test.go b/example/pkg/cli/greet/greet_test.go new file mode 100644 index 0000000..0c03c51 --- /dev/null +++ b/example/pkg/cli/greet/greet_test.go @@ -0,0 +1,25 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package greet + +import ( + "context" + "testing" +) + +func TestHi(t *testing.T) { + t.Parallel() + + if err := Hi(context.Background(), []string{"alice"}); err != nil { + t.Errorf("hi error %v", err) + } +} + +func TestBye(t *testing.T) { + t.Parallel() + + if err := Bye(context.Background(), []string{"alice"}); err != nil { + t.Errorf("bye error %v", err) + } +} diff --git a/example/pkg/cli/hello/cmd.go b/example/pkg/cli/hello/cmd.go new file mode 100644 index 0000000..785a03e --- /dev/null +++ b/example/pkg/cli/hello/cmd.go @@ -0,0 +1,19 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +// Package hello implements "hello" leaf command. +package hello + +import ( + "github.com/rskeens/tidycli" + "github.com/rskeens/tidycli/example/pkg/cli/help" +) + +// Cmd returns cmd. +func Cmd() *tidycli.Cmd { + return &tidycli.Cmd{ + Help: help.ErrHello, + Min: 1, + Fn: Do, + } +} diff --git a/example/pkg/cli/hello/cmd_test.go b/example/pkg/cli/hello/cmd_test.go new file mode 100644 index 0000000..f375d2f --- /dev/null +++ b/example/pkg/cli/hello/cmd_test.go @@ -0,0 +1,14 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package hello + +import "testing" + +func TestCmd(t *testing.T) { + t.Parallel() + + if Cmd() == nil { + t.Error("expected non nil cmd") + } +} diff --git a/example/pkg/cli/hello/hello.go b/example/pkg/cli/hello/hello.go new file mode 100644 index 0000000..c1aeb70 --- /dev/null +++ b/example/pkg/cli/hello/hello.go @@ -0,0 +1,17 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package hello + +import ( + "context" + "fmt" +) + +// Do prints greeting for given name. +// hello-world hello NAME +func Do(_ context.Context, args []string) error { + _, err := fmt.Println("hello,", args[0]) + + return err +} diff --git a/example/pkg/cli/hello/hello_test.go b/example/pkg/cli/hello/hello_test.go new file mode 100644 index 0000000..5c5bd45 --- /dev/null +++ b/example/pkg/cli/hello/hello_test.go @@ -0,0 +1,17 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package hello + +import ( + "context" + "testing" +) + +func TestDo(t *testing.T) { + t.Parallel() + + if err := Do(context.Background(), []string{"world"}); err != nil { + t.Errorf("do error %v", err) + } +} diff --git a/example/pkg/cli/help/help.go b/example/pkg/cli/help/help.go new file mode 100644 index 0000000..af96c84 --- /dev/null +++ b/example/pkg/cli/help/help.go @@ -0,0 +1,19 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +// Package help centralises example apps's per command usage messages. +// +// Stored as exported errs lets each command package import only what it needs. +// Handed to [tidycli.Cmd.Help] without restating usage string at call site. +package help + +import "errors" + +// https://developers.google.com/style/code-syntax +var ( + // ErrHello means hello help. + ErrHello = errors.New("hello-world hello NAME") + + // ErrGreet means greet help. + ErrGreet = errors.New("hello-world greet { hi | bye } NAME") +) diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..8efa90f --- /dev/null +++ b/example_test.go @@ -0,0 +1,58 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +package tidycli_test + +import ( + "context" + "errors" + "fmt" + + "github.com/rskeens/tidycli" +) + +// Example demonstrates min cmd tree routed through [tidycli.RunArgs]. +// "hello" cmd requires one positional arg and prints greeting. +func Example() { + cmds := tidycli.Sub{ + "hello": { + Help: errors.New("hello NAME"), + Min: 1, + + Fn: func(_ context.Context, args []string) error { + fmt.Println("hello,", args[0]) + + return nil + }, + }, + } + + if err := tidycli.RunArgs(context.Background(), cmds, []string{"hello", "world"}); err != nil { + fmt.Println("error:", err) + } + + // Output: hello, world +} + +// ExampleRunArgs demonstrates [tidycli.RunArgs]. Returns [tidycli.ErrArgInvalid] +// for unknown cmd keyword without proc exit. +func ExampleRunArgs() { + cmds := tidycli.Sub{ + "echo": { + Help: errors.New("echo TEXT"), + Min: 1, + + Fn: func(_ context.Context, args []string) error { + fmt.Println(args[0]) + + return nil + }, + }, + } + + err := tidycli.RunArgs(context.Background(), cmds, []string{"no"}) + + fmt.Println(errors.Is(err, tidycli.ErrArgInvalid)) + + // Output: true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..995828c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/rskeens/tidycli + +go 1.21 diff --git a/help/help.go b/help/help.go new file mode 100644 index 0000000..7e33cf4 --- /dev/null +++ b/help/help.go @@ -0,0 +1,19 @@ +// © Roscoe Skeens +// SPDX-License-Identifier: MIT + +// Package help provides helper types for usage messages. +// +// Convenience for apps needing uniform usage strings. +package help + +// Help describes a command's arg and usage. +// +// Desc is stored as err returned from [tidycli.Cmd.Help]. +// Propagates through normal err paths. +type Help struct { + Arg string + Desc error +} + +// Helps is collection of [Help] entries. +type Helps []Help