diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d9535f..ec0471d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit"] + module: ["mailkit", "gonsole"] defaults: run: working-directory: ${{ matrix.module }} @@ -25,7 +25,9 @@ jobs: - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: ${{ matrix.module }}/go.mod - cache-dependency-path: ${{ matrix.module }}/go.sum + cache-dependency-path: | + ${{ matrix.module }}/go.mod + ${{ matrix.module }}/go.sum - run: go test -race -covermode=atomic -coverprofile=cover.out ./... - run: go vet ./... @@ -59,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit"] + module: ["mailkit", "gonsole"] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -67,7 +69,9 @@ jobs: - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: ${{ matrix.module }}/go.mod - cache-dependency-path: ${{ matrix.module }}/go.sum + cache-dependency-path: | + ${{ matrix.module }}/go.mod + ${{ matrix.module }}/go.sum - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.13.2 @@ -79,7 +83,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit"] + module: ["mailkit", "gonsole"] defaults: run: working-directory: ${{ matrix.module }} @@ -90,5 +94,7 @@ jobs: - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: ${{ matrix.module }}/go.mod - cache-dependency-path: ${{ matrix.module }}/go.sum + cache-dependency-path: | + ${{ matrix.module }}/go.mod + ${{ matrix.module }}/go.sum - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... diff --git a/README.md b/README.md index 161f61e..739acbf 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ need and ignore the rest. ## Modules +- [`gonsole`](gonsole/) runs the command line of a Go program built from + core commands, settings and compiled plugins. - [`gottext`](gottext/) reads, writes and syncs gettext catalogs for TypeScript applications, published to npm as `@gopherium/gottext`. - [`mailkit`](mailkit/) renders mail from template files and sends it @@ -18,13 +20,12 @@ need and ignore the rest. ## Design -One repository, one self-contained brick per directory, no shared code -between them. A Go brick carries its own go.mod and a TypeScript brick -its own package.json, each with its own CHANGELOG and lint -configuration, released independently under a path-prefixed tag such -as `mailkit/v0.1.0` or `gottext/v0.4.0`. Bricks depend on published -versions only, never on sibling source, so what you pin is what you -get. +One repository, one self-contained brick per directory. A Go brick +carries its own go.mod and a TypeScript brick its own package.json, +each with its own CHANGELOG and lint configuration, released +independently under a path-prefixed tag such as `mailkit/v0.1.0` or +`gottext/v0.4.0`. Bricks share code only through those published tags, +never through sibling source, so what you pin is what you get. ## Reporting security issues diff --git a/gonsole/.golangci.yml b/gonsole/.golangci.yml new file mode 100644 index 0000000..5059c5e --- /dev/null +++ b/gonsole/.golangci.yml @@ -0,0 +1,49 @@ +version: "2" + +linters: + default: standard + enable: + - cyclop + - depguard + - gocognit + - lll + - misspell + - revive + - unconvert + - unparam + settings: + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + lll: + line-length: 120 + revive: + rules: + - name: blank-imports + disabled: true + - name: exported + depguard: + rules: + engine-purity: + list-mode: strict + files: + - "**/*.go" + allow: + - $gostd + - github.com/gopherium/framework/gonsole + exclusions: + rules: + - path: _test\.go + linters: + - cyclop + - gocognit + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/gopherium/framework diff --git a/gonsole/CHANGELOG.md b/gonsole/CHANGELOG.md new file mode 100644 index 0000000..f4a8481 --- /dev/null +++ b/gonsole/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to the `gonsole` module are documented in this +file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +module follows [Semantic Versioning](https://semver.org/). While at +v0.x, minor releases may contain breaking changes. + +Releases of this module are tagged `gonsole/vX.Y.Z`. + +## [Unreleased] + +### Added + +- `Program`, `Main` and `Run`, running a command line and answering exit code 0, 1 or 2. +- `Command`, `Call` and `Step`, a command, what one run of it receives, and a named schema step. +- `Misuse` and `ErrMisused`, marking an error the program answers with exit 2. +- Command names alone or as `namespace:command`, a help page for each, and a listing of them all. +- `-yes` dry runs for commands that write and `-json` for commands that answer one document. +- `-as` with `Authorize` and `Record`, checking and recording the account that acts. +- `Call.Flags`, the command's own flags the line set, for the audit record. +- The base commands `help`, `list`, `version`, `check`, `serve`, `migrate` and `seed`. +- `Program.Check`, refusing every naming offence in the program and its plugins. +- `Renamed`, keeping an old two word spelling working with a warning. +- `Env` with `Required`, `Duration`, `Count`, `Flag`, `Within`, `Parse` and `Timeouts`. +- `NewServer` and `Serve`, serving HTTP until a signal ends the run. +- `Program.Plugins`, `Call.Plugins`, `Loaded`, `Provider` and `Walk`, for compiled plugins' commands. +- `testkit`, running programs from tests in process and as built binaries. diff --git a/gonsole/actor.go b/gonsole/actor.go new file mode 100644 index 0000000..ce19c17 --- /dev/null +++ b/gonsole/actor.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "fmt" + "runtime/debug" +) + +// perform authorizes the acting account of call, migrates when cmd asks, runs cmd and records the run when it applied. +func (r *runner) perform(ctx context.Context, cmd Command, call Call) error { + if err := r.authorize(ctx, cmd, call); err != nil { + return err + } + if cmd.Migrates && call.Apply { + if err := r.migrate(ctx, call, call.Stderr); err != nil { + return err + } + } + if err := cmd.Run(ctx, call); err != nil { + return err + } + if !call.Apply { + r.warn("dry run, nothing changed, pass -yes to apply") + return nil + } + return r.record(ctx, cmd, call) +} + +// authorize refuses the acting account of call when it lacks the capability cmd names. +func (r *runner) authorize(ctx context.Context, cmd Command, call Call) error { + if cmd.Capability == "" { + return nil + } + return r.program.Authorize(ctx, call, cmd.Capability) +} + +// record stores the applied run of cmd when cmd names a capability, under a context the end of the run cannot cancel. +func (r *runner) record(ctx context.Context, cmd Command, call Call) error { + if cmd.Capability == "" { + return nil + } + return r.program.Record(context.WithoutCancel(ctx), call, cmd.Name) +} + +// panicked is a panic recovered from the run of one command. +type panicked struct { + command string + value any + stack []byte +} + +// Error returns the line naming the command and the panic value. +func (p panicked) Error() string { + return fmt.Sprintf("%s: panic: %v", p.command, p.value) +} + +// crashes returns every panic err holds, in the order its message names them. +func crashes(err error) []panicked { + switch e := err.(type) { + case panicked: + return []panicked{e} + case interface{ Unwrap() []error }: + var all []panicked + for _, inner := range e.Unwrap() { + all = append(all, crashes(inner)...) + } + return all + case interface{ Unwrap() error }: + return crashes(e.Unwrap()) + } + return nil +} + +// recoverRun turns a panic in the run of the command called name into the error err points at. +func recoverRun(name string, err *error) { + if value := recover(); value != nil { + *err = panicked{command: name, value: value, stack: debug.Stack()} + } +} diff --git a/gonsole/actor_test.go b/gonsole/actor_test.go new file mode 100644 index 0000000..f787844 --- /dev/null +++ b/gonsole/actor_test.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// actingAccount is the address every acting account test passes to -as. +const actingAccount = "maria.perez@example.com" + +// hooks are the answers a guarded program's hooks give and the log of every hook and command call. +type hooks struct { + refuse error + fail error + lost error + log []string +} + +// note appends one entry to the log. +func (h *hooks) note(format string, args ...any) { + h.log = append(h.log, fmt.Sprintf(format, args...)) +} + +// guarded returns a program called myapp whose commands need capabilities and whose hooks give the answers h holds. +func guarded(h *hooks) gonsole.Program { + run := func(_ context.Context, call gonsole.Call) error { + h.note("run %s as %s apply=%t", call.Args, call.Actor, call.Apply) + return h.fail + } + return gonsole.Program{ + Name: "myapp", + Commands: []gonsole.Command{ + {Name: "report:revoke", Summary: "revoke one report", Args: []string{"id"}, Writes: true, + Capability: "manage_reports", Run: run}, + {Name: "report:export", Summary: "export every report", Capability: "export_reports", Run: run}, + {Name: "report:list", Summary: "list every report", Run: run}, + }, + Authorize: func(_ context.Context, call gonsole.Call, capability string) error { + h.note("authorize %s for %s %s apply=%t", call.Actor, capability, call.Args, call.Apply) + return h.refuse + }, + Record: func(_ context.Context, call gonsole.Call, command string) error { + h.note("record %s ran %s %s apply=%t", call.Actor, command, call.Args, call.Apply) + return h.lost + }, + } +} + +func TestRunChecksAndRecordsTheActingAccount(t *testing.T) { + t.Parallel() + + authorizedWrite := "authorize " + actingAccount + " for manage_reports [Q3] apply=true" + authorizedDryRun := "authorize " + actingAccount + " for manage_reports [Q3] apply=false" + ranWrite := "run [Q3] as " + actingAccount + " apply=true" + recordedWrite := "record " + actingAccount + " ran report:revoke [Q3] apply=true" + refused := "myapp: " + actingAccount + " lacks manage_reports\n" + cases := []struct { + name string + hooks hooks + args []string + code int + stderr string + log []string + }{ + { + "an applied write", hooks{}, []string{"report:revoke", "-as", actingAccount, "-yes", "Q3"}, + gonsole.ExitDone, "", []string{authorizedWrite, ranWrite, recordedWrite}, + }, + { + "a dry run", hooks{}, []string{"report:revoke", "-as", actingAccount, "Q3"}, + gonsole.ExitDone, dryRunNotice, []string{authorizedDryRun, "run [Q3] as " + actingAccount + " apply=false"}, + }, + { + "a refused write", hooks{refuse: errors.New(actingAccount + " lacks manage_reports")}, + []string{"report:revoke", "-as", actingAccount, "-yes", "Q3"}, + gonsole.ExitFailed, refused, []string{authorizedWrite}, + }, + { + "a refused dry run", hooks{refuse: errors.New(actingAccount + " lacks manage_reports")}, + []string{"report:revoke", "-as", actingAccount, "Q3"}, + gonsole.ExitFailed, refused, []string{authorizedDryRun}, + }, + { + "a write that fails", hooks{fail: errors.New("report store is down")}, + []string{"report:revoke", "-as", actingAccount, "-yes", "Q3"}, + gonsole.ExitFailed, "myapp: report store is down\n", []string{authorizedWrite, ranWrite}, + }, + { + "a write whose record is lost", hooks{lost: errors.New("the record table is missing")}, + []string{"report:revoke", "-as", actingAccount, "-yes", "Q3"}, + gonsole.ExitFailed, "myapp: the record table is missing\n", []string{authorizedWrite, ranWrite, recordedWrite}, + }, + { + "a read that needs a capability", hooks{}, []string{"report:export", "-as", actingAccount}, + gonsole.ExitDone, "", + []string{"authorize " + actingAccount + " for export_reports [] apply=true", + "run [] as " + actingAccount + " apply=true", + "record " + actingAccount + " ran report:export [] apply=true"}, + }, + { + "a command that needs no capability", hooks{}, []string{"report:list"}, + gonsole.ExitDone, "", []string{"run [] as apply=true"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + h := tc.hooks + got := execute(t, guarded(&h), tc.args...) + + if got.code != tc.code { + t.Errorf("code = %d, want %d, stderr %q", got.code, tc.code, got.stderr) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if !slices.Equal(h.log, tc.log) { + t.Errorf("calls = %q, want %q", h.log, tc.log) + } + }) + } +} + +func TestRunHandsTheHooksTheRunContext(t *testing.T) { + t.Parallel() + + type key struct{} + var seen []any + var h hooks + p := guarded(&h) + p.Authorize = func(ctx context.Context, _ gonsole.Call, _ string) error { + seen = append(seen, ctx.Value(key{})) + return nil + } + p.Record = func(ctx context.Context, _ gonsole.Call, _ string) error { + seen = append(seen, ctx.Value(key{})) + return nil + } + ctx := context.WithValue(t.Context(), key{}, "carried") + + code := p.Run(ctx, []string{"report:revoke", "-as", actingAccount, "-yes", "Q3"}, strings.NewReader(""), + io.Discard, io.Discard) + + if code != gonsole.ExitDone { + t.Errorf("code = %d, want %d", code, gonsole.ExitDone) + } + if want := []any{"carried", "carried"}; !slices.Equal(seen, want) { + t.Errorf("hooks saw %v, want %v", seen, want) + } +} + +func TestRunRecordsAnAppliedWriteAfterTheRunIsCancelled(t *testing.T) { + t.Parallel() + + type key struct{} + ctx, cancel := context.WithCancel(context.WithValue(t.Context(), key{}, "run")) + var seen string + var h hooks + p := guarded(&h) + p.Commands[0].Run = func(context.Context, gonsole.Call) error { + cancel() + return nil + } + p.Record = func(ctx context.Context, _ gonsole.Call, _ string) error { + seen = fmt.Sprintf("%v live=%t", ctx.Value(key{}), ctx.Err() == nil) + return ctx.Err() + } + + code := p.Run(ctx, []string{"report:revoke", "-as", actingAccount, "-yes", "Q3"}, strings.NewReader(""), + io.Discard, io.Discard) + + if code != gonsole.ExitDone { + t.Errorf("code = %d, want %d", code, gonsole.ExitDone) + } + if seen != "run live=true" { + t.Errorf("Record saw %q, want the run's values on a live context", seen) + } +} + +func TestRunRefusesACommandThatNeedsAnActingAccountWithoutOne(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + {"no -as", []string{"report:revoke", "-yes", "Q3"}}, + {"an empty -as", []string{"report:revoke", "-as=", "-yes", "Q3"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var h hooks + got := execute(t, guarded(&h), tc.args...) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if want := "myapp: report:revoke wants -as \n"; firstLine(got.stderr) != want { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), want) + } + if len(h.log) != 0 { + t.Errorf("calls = %q, want none", h.log) + } + }) + } +} + +func TestRunRefusesAnActingAccountOnACommandThatNeedsNone(t *testing.T) { + t.Parallel() + + var h hooks + got := execute(t, guarded(&h), "report:list", "-as", actingAccount) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if want := "myapp: report:list: flag provided but not defined: -as\n"; firstLine(got.stderr) != want { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), want) + } +} + +func TestHelpPageListsTheActingAccountFlag(t *testing.T) { + t.Parallel() + + var h hooks + got := execute(t, guarded(&h), "report:revoke", "-h") + + want := `revoke one report + +Usage: + myapp report:revoke [flags] + +Flags: + -as email + email address of the account acting + -yes + apply the change, a dry run without it +` + if got.stdout != want { + t.Errorf("stdout = %q, want %q", got.stdout, want) + } + if len(h.log) != 0 { + t.Errorf("calls = %q, want none for a help page", h.log) + } +} diff --git a/gonsole/base.go b/gonsole/base.go new file mode 100644 index 0000000..e45b756 --- /dev/null +++ b/gonsole/base.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" +) + +// base returns the commands the engine owns in the program. +func (r *runner) base() []Command { + list := func(ctx context.Context, call Call) error { + return r.list(ctx, call.Stdout) + } + commands := []Command{ + {Name: "help", Summary: "print the help of one command", Run: list}, + {Name: "list", Summary: "list every command", Run: list}, + {Name: "version", Summary: "print the version", JSON: true, Run: r.version}, + {Name: "check", Summary: "check every setting, every plugin and every command name", Run: r.check}, + } + if r.program.Serve != nil { + commands = append(commands, Command{Name: "serve", Summary: "run the server", Run: r.program.Serve}) + } + if len(r.program.Migrations) > 0 || r.program.Plugins != nil { + migrate := func(ctx context.Context, call Call) error { + _, err := r.migrateAll(ctx, call, call.Stdout) + return err + } + commands = append(commands, Command{Name: "migrate", Summary: "apply every schema step", Run: migrate}) + } + if r.program.Seed != nil || r.program.Plugins != nil { + commands = append(commands, Command{Name: "seed", Summary: "store the demo data", Writes: true, Run: r.seed}) + } + return commands +} + +// check runs the program's settings check, registers the plugins and answers that everything is valid. +func (r *runner) check(ctx context.Context, call Call) error { + if r.program.Validate != nil { + if err := r.program.Validate(ctx, call); err != nil { + return err + } + } + if err := r.plugged(ctx, call); err != nil { + return err + } + _, err := io.WriteString(call.Stdout, "settings, plugins and command names are valid\n") + return err +} + +// plugged registers the plugins and returns what failed to load, the plugin failures before the offences. +func (r *runner) plugged(ctx context.Context, call Call) error { + loaded, err := call.Plugins(ctx) + if err != nil { + return err + } + return errors.Join(append([]error{loaded.Failed}, r.plugins.audit.offences...)...) +} + +// version prints the program's name and version, as one document with -json. +func (r *runner) version(_ context.Context, call Call) error { + version := cmp.Or(r.program.Version, "(devel)") + if call.JSON { + return call.Encode(struct { + Name string `json:"name"` + Version string `json:"version"` + }{r.program.Name, version}) + } + _, err := fmt.Fprintf(call.Stdout, "%s %s\n", r.program.Name, version) + return err +} + +// seed stores the demo data over a migrated schema, a dry run until -yes. +func (r *runner) seed(ctx context.Context, call Call) error { + if !call.Apply { + _, err := io.WriteString(call.Stdout, "would store the demo data\n") + return err + } + loaded, err := r.migrateAll(ctx, call, call.Stderr) + if err != nil { + return err + } + return r.sow(ctx, call, loaded) +} + +// sow stores the core demo data and then every plugin's, and warns that demo data is for development only. +func (r *runner) sow(ctx context.Context, call Call, loaded Loaded) error { + if r.program.Seed != nil { + if err := r.program.Seed(ctx, call); err != nil { + return err + } + } + if err := optional(ctx, loaded.Seed); err != nil { + return err + } + r.warn("demo data is for development only, never seed a production database") + return nil +} + +// migrate applies the core schema steps under the lock, writing one line per applied step to w. +func (r *runner) migrate(ctx context.Context, call Call, w io.Writer) error { + return r.locked(ctx, call, func(address string) error { + return r.steps(ctx, address, w) + }) +} + +// migrateAll applies the core schema steps and then every plugin's schema under the lock and returns the plugins. +func (r *runner) migrateAll(ctx context.Context, call Call, w io.Writer) (Loaded, error) { + var loaded Loaded + err := r.locked(ctx, call, func(address string) (err error) { + if err = r.steps(ctx, address, w); err != nil { + return err + } + loaded, err = r.migratePlugins(ctx, call, w) + return err + }) + return loaded, err +} + +// locked runs apply over the program's database address while it holds the schema lock. +func (r *runner) locked(ctx context.Context, call Call, apply func(address string) error) (err error) { + address, err := call.DatabaseURL() + if err != nil { + return err + } + release, err := r.lock(ctx, address) + if err != nil { + return err + } + defer func() { err = errors.Join(err, release(context.WithoutCancel(ctx))) }() + return apply(address) +} + +// steps applies the core schema steps to the database at address, writing one line per applied step to w. +func (r *runner) steps(ctx context.Context, address string, w io.Writer) error { + for _, step := range r.program.Migrations { + if err := step.Run(ctx, address); err != nil { + return fmt.Errorf("migrate %s: %w", step.Name, err) + } + if _, err := fmt.Fprintf(w, "migrated %s\n", step.Name); err != nil { + return err + } + } + return nil +} + +// migratePlugins applies every plugin's schema and writes its line to w, nothing in a program without plugins. +func (r *runner) migratePlugins(ctx context.Context, call Call, w io.Writer) (Loaded, error) { + if r.program.Plugins == nil { + return Loaded{}, nil + } + loaded, err := call.Plugins(ctx) + if err != nil { + return loaded, err + } + if loaded.Failed != nil { + return loaded, loaded.Failed + } + if err := optional(ctx, loaded.Migrate); err != nil { + return loaded, fmt.Errorf("migrate plugins: %w", err) + } + _, err = io.WriteString(w, "migrated plugins\n") + return loaded, err +} + +// lock takes the program's schema lock on the database at address and returns its release, a no-op without Lock. +func (r *runner) lock(ctx context.Context, address string) (func(context.Context) error, error) { + if r.program.Lock == nil { + return func(context.Context) error { return nil }, nil + } + release, err := r.program.Lock(ctx, address) + if err != nil { + return nil, fmt.Errorf("lock the schema: %w", err) + } + return func(ctx context.Context) error { + if err := release(ctx); err != nil { + return fmt.Errorf("release the schema lock: %w", err) + } + return nil + }, nil +} diff --git a/gonsole/base_test.go b/gonsole/base_test.go new file mode 100644 index 0000000..c68e72d --- /dev/null +++ b/gonsole/base_test.go @@ -0,0 +1,915 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// databaseAddress is the database address every schema test reads from the program's database setting. +const databaseAddress = "postgres://localhost/myapp" + +// demoNotice is the line a seed of myapp ends with on stderr. +const demoNotice = "myapp: demo data is for development only, never seed a production database\n" + +// schema are the answers a program's schema hooks give and the log of every call they receive. +type schema struct { + lockFails error + releaseFails error + stepFails string + seedFails error + serveFails error + registerFails error + pluginsFailed error + pluginsMigrate error + pluginsSeed error + pluginsBare bool + log []string +} + +// plugins registers plugins whose schema, demo data and release are noted in s, without them when s says bare. +func (s *schema) plugins(_ context.Context, call gonsole.Call) (gonsole.Loaded, error) { + s.note("register describe=%t", call.Describe) + loaded := gonsole.Loaded{Failed: s.pluginsFailed, Release: func(ctx context.Context) error { + s.note("release the plugins live=%t", ctx.Err() == nil) + return nil + }} + if s.pluginsBare { + return loaded, s.registerFails + } + loaded.Migrate = func(context.Context) error { + s.note("migrate the plugins") + return s.pluginsMigrate + } + loaded.Seed = func(context.Context) error { + s.note("seed the plugins") + return s.pluginsSeed + } + return loaded, s.registerFails +} + +// pluggedKeeper returns keeper with plugins, all noted in s. +func pluggedKeeper(s *schema) gonsole.Program { + p := keeper(s) + p.Plugins = s.plugins + return p +} + +// note appends one entry to the log. +func (s *schema) note(format string, args ...any) { + s.log = append(s.log, fmt.Sprintf(format, args...)) +} + +// step returns the schema step called name, which fails when s names it. +func (s *schema) step(name string) gonsole.Step { + return gonsole.Step{Name: name, Run: func(_ context.Context, databaseURL string) error { + s.note("step %s at %s", name, databaseURL) + if s.stepFails == name { + return errors.New("relation already exists") + } + return nil + }} +} + +// keeper returns a program called myapp with a version, a server, two schema steps, a lock and a seed, all noted in s. +func keeper(s *schema) gonsole.Program { + return gonsole.Program{ + Name: "myapp", + Version: "1.4.0", + Env: settings(map[string]string{"MYAPP_PRIMARY_URL": databaseAddress}), + Database: "PRIMARY_URL", + Serve: func(_ context.Context, call gonsole.Call) error { + address, err := call.DatabaseURL() + s.note("serve %s at %s %v", call.Args, address, err) + return s.serveFails + }, + Migrations: []gonsole.Step{s.step("accounts"), s.step("reports")}, + Lock: func(ctx context.Context, databaseURL string) (func(context.Context) error, error) { + s.note("lock %s", databaseURL) + if err := cmp.Or(ctx.Err(), s.lockFails); err != nil { + return nil, err + } + return func(ctx context.Context) error { + s.note("release with the context live=%t", ctx.Err() == nil) + return s.releaseFails + }, nil + }, + Seed: func(_ context.Context, call gonsole.Call) error { + s.note("seed") + if _, err := fmt.Fprintln(call.Stdout, "stored the demo reports"); err != nil { + return err + } + return s.seedFails + }, + } +} + +func TestVersionPrintsTheNameAndTheVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + version string + args []string + stdout string + }{ + {"a released version", "1.4.0", nil, "myapp 1.4.0\n"}, + {"no version", "", nil, "myapp (devel)\n"}, + {"a document", "1.4.0", []string{"-json"}, `{ + "name": "myapp", + "version": "1.4.0" +} +`}, + {"a document without a version", "", []string{"-json"}, `{ + "name": "myapp", + "version": "(devel)" +} +`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := gonsole.Program{Name: "myapp", Title: "Myapp, a report keeper.", Version: tc.version} + + got := execute(t, p, append([]string{"version"}, tc.args...)...) + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + }) + } +} + +func TestListingShowsOnlyTheBaseCommandsTheProgramOffers(t *testing.T) { + t.Parallel() + + var s schema + got := execute(t, keeper(&s), "list") + + want := `myapp Version 1.4.0 + +Usage: + myapp [flags] [arguments] + +` + intro + ` +Available commands: + check check every setting, every plugin and every command name + help print the help of one command + list list every command + migrate apply every schema step + seed store the demo data + serve run the server + version print the version +` + if got.stdout != want { + t.Errorf("stdout = %q, want %q", got.stdout, want) + } +} + +func TestRunHidesEachBaseCommandTheProgramDoesNotOffer(t *testing.T) { + t.Parallel() + + cases := []struct { + command string + leave func(p *gonsole.Program) + }{ + {"serve", func(p *gonsole.Program) { p.Serve = nil }}, + {"migrate", func(p *gonsole.Program) { p.Migrations = nil }}, + {"seed", func(p *gonsole.Program) { p.Seed = nil }}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + t.Parallel() + + var s schema + p := keeper(&s) + tc.leave(&p) + + got := execute(t, p, tc.command) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + want := `myapp: unknown command "` + tc.command + `", run "myapp list" to see every command` + "\n" + if got.stderr != want { + t.Errorf("stderr = %q, want %q", got.stderr, want) + } + }) + } +} + +func TestHelpPagesOfTheBaseCommandsShowOnlyTheirOwnSwitches(t *testing.T) { + t.Parallel() + + cases := []struct { + command string + page string + }{ + {"version", `print the version + +Usage: + myapp version [flags] + +Flags: + -json + answer one JSON document +`}, + {"migrate", `apply every schema step + +Usage: + myapp migrate +`}, + {"seed", `store the demo data + +Usage: + myapp seed [flags] + +Flags: + -yes + apply the change, a dry run without it +`}, + {"serve", `run the server + +Usage: + myapp serve +`}, + {"check", `check every setting, every plugin and every command name + +Usage: + myapp check +`}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + t.Parallel() + + var s schema + got := execute(t, keeper(&s), tc.command, "-h") + + if got.stdout != tc.page { + t.Errorf("stdout = %q, want %q", got.stdout, tc.page) + } + }) + } +} + +func TestBaseCommandsFailWhenTheirAnswerCannotBeWritten(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + {"the version", []string{"version"}}, + {"the version document", []string{"version", "-json"}}, + {"a seed dry run", []string{"seed"}}, + {"the check", []string{"check"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var s schema + var stderr strings.Builder + + code := keeper(&s).Run(t.Context(), tc.args, strings.NewReader(""), closedWriter{}, &stderr) + + if code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", code, gonsole.ExitFailed) + } + if want := "myapp: stdout is closed\n"; stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } + }) + } +} + +func TestSchemaHooksGetTheRunContext(t *testing.T) { + t.Parallel() + + type key struct{} + var seen []string + look := func(hook string, ctx context.Context) { + seen = append(seen, fmt.Sprintf("%s %v live=%t", hook, ctx.Value(key{}), ctx.Err() == nil)) + } + var s schema + p := keeper(&s) + p.Lock = func(ctx context.Context, _ string) (func(context.Context) error, error) { + look("lock", ctx) + return func(ctx context.Context) error { + look("release", ctx) + return nil + }, nil + } + p.Migrations = []gonsole.Step{{Name: "accounts", Run: func(ctx context.Context, _ string) error { + look("step", ctx) + return nil + }}} + p.Seed = func(ctx context.Context, _ gonsole.Call) error { + look("seed", ctx) + return nil + } + p.Plugins = func(ctx context.Context, _ gonsole.Call) (gonsole.Loaded, error) { + look("register", ctx) + return gonsole.Loaded{ + Migrate: func(ctx context.Context) error { + look("migrate the plugins", ctx) + return nil + }, + Seed: func(ctx context.Context) error { + look("seed the plugins", ctx) + return nil + }, + Release: func(ctx context.Context) error { + look("release the plugins", ctx) + return nil + }, + }, nil + } + ctx := context.WithValue(t.Context(), key{}, "run") + + code := p.Run(ctx, []string{"seed", "-yes"}, strings.NewReader(""), io.Discard, io.Discard) + + if code != gonsole.ExitDone { + t.Errorf("code = %d, want %d", code, gonsole.ExitDone) + } + want := []string{ + "lock run live=true", "step run live=true", "register run live=true", "migrate the plugins run live=true", + "release run live=true", "seed run live=true", "seed the plugins run live=true", + "release the plugins run live=true", + } + if !slices.Equal(seen, want) { + t.Errorf("hooks saw %q, want %q", seen, want) + } +} + +func TestMigrateRefusesARunThatEndedBeforeItStarted(t *testing.T) { + t.Parallel() + + var s schema + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var stderr strings.Builder + + code := keeper(&s).Run(ctx, []string{"migrate"}, strings.NewReader(""), io.Discard, &stderr) + + if code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", code, gonsole.ExitFailed) + } + if want := "myapp: lock the schema: context canceled\n"; stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } + if want := []string{"lock " + databaseAddress}; !slices.Equal(s.log, want) { + t.Errorf("calls = %q, want %q", s.log, want) + } +} + +func TestServeRunsTheProgramServer(t *testing.T) { + t.Parallel() + + served := "serve [] at " + databaseAddress + " " + cases := []struct { + name string + args []string + bareServes bool + serveFails error + code int + stderr string + log []string + }{ + {"the serve command", []string{"serve"}, false, nil, gonsole.ExitDone, "", []string{served}}, + {"a commandless run of a program that serves", nil, true, nil, gonsole.ExitDone, "", []string{served}}, + {"a server that fails", []string{"serve"}, false, errors.New("port 8080 is taken"), gonsole.ExitFailed, + "myapp: port 8080 is taken\n", []string{served}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := schema{serveFails: tc.serveFails} + p := keeper(&s) + p.BareServes = tc.bareServes + + got := execute(t, p, tc.args...) + + if got.code != tc.code { + t.Errorf("code = %d, want %d, stderr %q", got.code, tc.code, got.stderr) + } + if got.stdout != "" || got.stderr != tc.stderr { + t.Errorf("stdout, stderr = %q, %q, want %q, %q", got.stdout, got.stderr, "", tc.stderr) + } + if !slices.Equal(s.log, tc.log) { + t.Errorf("calls = %q, want %q", s.log, tc.log) + } + }) + } +} + +func TestMigrateAppliesEveryStepUnderTheLock(t *testing.T) { + t.Parallel() + + locked := "lock " + databaseAddress + accounts := "step accounts at " + databaseAddress + reports := "step reports at " + databaseAddress + released := "release with the context live=true" + cases := []struct { + name string + schema schema + code int + stdout string + stderr string + log []string + }{ + {"every step applied", schema{}, gonsole.ExitDone, "migrated accounts\nmigrated reports\n", "", + []string{locked, accounts, reports, released}}, + {"a step that fails", schema{stepFails: "reports"}, gonsole.ExitFailed, "migrated accounts\n", + "myapp: migrate reports: relation already exists\n", []string{locked, accounts, reports, released}}, + {"a lock that is refused", schema{lockFails: errors.New("the database is read only")}, gonsole.ExitFailed, "", + "myapp: lock the schema: the database is read only\n", []string{locked}}, + {"a release that fails", schema{releaseFails: errors.New("the session ended")}, gonsole.ExitFailed, + "migrated accounts\nmigrated reports\n", "myapp: release the schema lock: the session ended\n", + []string{locked, accounts, reports, released}}, + {"a step and the release that fail", schema{stepFails: "reports", releaseFails: errors.New("the session ended")}, + gonsole.ExitFailed, "migrated accounts\n", + "myapp: migrate reports: relation already exists\nmyapp: release the schema lock: the session ended\n", + []string{locked, accounts, reports, released}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := tc.schema + got := execute(t, keeper(&s), "migrate") + + if got.code != tc.code { + t.Errorf("code = %d, want %d", got.code, tc.code) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if !slices.Equal(s.log, tc.log) { + t.Errorf("calls = %q, want %q", s.log, tc.log) + } + }) + } +} + +func TestMigrateReleasesTheLockWithALiveContextAfterTheRunEnds(t *testing.T) { + t.Parallel() + + var s schema + ctx, cancel := context.WithCancel(t.Context()) + p := keeper(&s) + p.Migrations = []gonsole.Step{{Name: "accounts", Run: func(context.Context, string) error { + cancel() + return nil + }}} + + code := p.Run(ctx, []string{"migrate"}, strings.NewReader(""), io.Discard, io.Discard) + + if code != gonsole.ExitDone { + t.Errorf("code = %d, want %d", code, gonsole.ExitDone) + } + if want := []string{"lock " + databaseAddress, "release with the context live=true"}; !slices.Equal(s.log, want) { + t.Errorf("calls = %q, want %q", s.log, want) + } +} + +func TestMigrateStopsAndReleasesWhenItsAnswerCannotBeWritten(t *testing.T) { + t.Parallel() + + var s schema + var stderr strings.Builder + + code := keeper(&s).Run(t.Context(), []string{"migrate"}, strings.NewReader(""), closedWriter{}, &stderr) + + if code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", code, gonsole.ExitFailed) + } + if want := "myapp: stdout is closed\n"; stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } + want := []string{"lock " + databaseAddress, "step accounts at " + databaseAddress, + "release with the context live=true"} + if !slices.Equal(s.log, want) { + t.Errorf("calls = %q, want %q", s.log, want) + } +} + +func TestMigrateFailsWhenThePluginLineCannotBeWritten(t *testing.T) { + t.Parallel() + + var s schema + p := pluggedKeeper(&s) + p.Migrations = nil + var stderr strings.Builder + + code := p.Run(t.Context(), []string{"migrate"}, strings.NewReader(""), closedWriter{}, &stderr) + + if want := "myapp: stdout is closed\n"; code != gonsole.ExitFailed || stderr.String() != want { + t.Errorf("migrate = %d, %q, want %d, %q", code, stderr.String(), gonsole.ExitFailed, want) + } + want := []string{"lock " + databaseAddress, "register describe=false", "migrate the plugins", + "release with the context live=true", "release the plugins live=true"} + if !slices.Equal(s.log, want) { + t.Errorf("calls = %q, want %q", s.log, want) + } +} + +func TestRunMigratesOnlyTheCoreStepsBeforeACommandThatAsksForThem(t *testing.T) { + t.Parallel() + + var s schema + p := pluggedKeeper(&s) + p.Commands = []gonsole.Command{{ + Name: "createadmin", Summary: "create an account", Migrates: true, + Run: func(_ context.Context, call gonsole.Call) error { + s.note("run apply=%t", call.Apply) + return nil + }, + }} + + got := execute(t, p, "createadmin") + + if want := "migrated accounts\nmigrated reports\n"; got.code != gonsole.ExitDone || got.stderr != want { + t.Errorf("run = %d, %q, want 0, %q", got.code, got.stderr, want) + } + want := []string{"lock " + databaseAddress, "step accounts at " + databaseAddress, + "step reports at " + databaseAddress, "release with the context live=true", "run apply=true"} + if !slices.Equal(s.log, want) { + t.Errorf("calls = %q, want %q", s.log, want) + } +} + +func TestMigrateWithoutALockAppliesEveryStep(t *testing.T) { + t.Parallel() + + var s schema + p := keeper(&s) + p.Lock = nil + + got := execute(t, p, "migrate") + + if got.stdout != "migrated accounts\nmigrated reports\n" { + t.Errorf("stdout = %q, want both steps", got.stdout) + } +} + +func TestMigrateRefusesAMissingDatabaseSetting(t *testing.T) { + t.Parallel() + + var s schema + p := keeper(&s) + p.Env = settings(nil) + + got := execute(t, p, "migrate") + + if got.code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitFailed) + } + if want := "myapp: MYAPP_PRIMARY_URL is required\n"; got.stderr != want { + t.Errorf("stderr = %q, want %q", got.stderr, want) + } + if len(s.log) != 0 { + t.Errorf("calls = %q, want none", s.log) + } +} + +func TestSeedStoresTheDemoDataOnlyWithYes(t *testing.T) { + t.Parallel() + + locked := "lock " + databaseAddress + accounts := "step accounts at " + databaseAddress + reports := "step reports at " + databaseAddress + released := "release with the context live=true" + migrated := "migrated accounts\nmigrated reports\n" + cases := []struct { + name string + schema schema + args []string + code int + stdout string + stderr string + log []string + }{ + {"a dry run", schema{}, nil, gonsole.ExitDone, "would store the demo data\n", dryRunNotice, nil}, + {"an applied seed", schema{}, []string{"-yes"}, gonsole.ExitDone, "stored the demo reports\n", + migrated + demoNotice, []string{locked, accounts, reports, released, "seed"}}, + {"a migration that fails", schema{stepFails: "accounts"}, []string{"-yes"}, gonsole.ExitFailed, "", + "myapp: migrate accounts: relation already exists\n", []string{locked, accounts, released}}, + {"a seed that fails", schema{seedFails: errors.New("the demo reports clash")}, []string{"-yes"}, + gonsole.ExitFailed, "stored the demo reports\n", migrated + "myapp: the demo reports clash\n", + []string{locked, accounts, reports, released, "seed"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := tc.schema + got := execute(t, keeper(&s), append([]string{"seed"}, tc.args...)...) + + if got.code != tc.code { + t.Errorf("code = %d, want %d", got.code, tc.code) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if !slices.Equal(s.log, tc.log) { + t.Errorf("calls = %q, want %q", s.log, tc.log) + } + }) + } +} + +// pluginFailures are the failures of two plugins that did not register. +var pluginFailures = errors.Join(errors.New("plugin billing: no signing key"), errors.New("plugin mail: no relay host")) + +// pluginFailureLines are the lines pluginFailures prints. +const pluginFailureLines = "myapp: plugin billing: no signing key\nmyapp: plugin mail: no relay host\n" + +func TestListingShowsMigrateAndSeedForAProgramWithPlugins(t *testing.T) { + t.Parallel() + + var s schema + p := pluggedKeeper(&s) + p.Migrations, p.Seed = nil, nil + + got := execute(t, p, "list") + + want := `myapp Version 1.4.0 + +Usage: + myapp [flags] [arguments] + +` + intro + ` +Available commands: + check check every setting, every plugin and every command name + help print the help of one command + list list every command + migrate apply every schema step + seed store the demo data + serve run the server + version print the version +` + if got.code != gonsole.ExitDone || got.stdout != want { + t.Errorf("listing = %d, %q, want 0, %q", got.code, got.stdout, want) + } +} + +func TestMigrateAppliesThePluginSchemaAfterTheCoreSteps(t *testing.T) { + t.Parallel() + + locked := "lock " + databaseAddress + accounts := "step accounts at " + databaseAddress + reports := "step reports at " + databaseAddress + registered := "register describe=false" + schemaApplied := "migrate the plugins" + released := "release with the context live=true" + freed := "release the plugins live=true" + core := "migrated accounts\nmigrated reports\n" + cases := []struct { + name string + schema schema + noSteps bool + code int + stdout string + stderr string + log []string + }{ + {"every step and the plugins", schema{}, false, gonsole.ExitDone, core + "migrated plugins\n", "", + []string{locked, accounts, reports, registered, schemaApplied, released, freed}}, + {"plugins without a schema", schema{pluginsBare: true}, false, gonsole.ExitDone, core + "migrated plugins\n", "", + []string{locked, accounts, reports, registered, released, freed}}, + {"plugins without core steps", schema{}, true, gonsole.ExitDone, "migrated plugins\n", "", + []string{locked, registered, schemaApplied, released, freed}}, + {"plugins that failed", schema{pluginsFailed: pluginFailures}, false, gonsole.ExitFailed, core, + pluginFailureLines, []string{locked, accounts, reports, registered, released, freed}}, + {"a registration that fails", schema{registerFails: errors.New("the plugin table is locked")}, false, + gonsole.ExitFailed, core, "myapp: the plugin table is locked\n", + []string{locked, accounts, reports, registered, released, freed}}, + {"a plugin schema that fails", schema{pluginsMigrate: errors.New("relation tenants already exists")}, false, + gonsole.ExitFailed, core, "myapp: migrate plugins: relation tenants already exists\n", + []string{locked, accounts, reports, registered, schemaApplied, released, freed}}, + {"a core step that fails", schema{stepFails: "reports"}, false, gonsole.ExitFailed, "migrated accounts\n", + "myapp: migrate reports: relation already exists\n", []string{locked, accounts, reports, released}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := tc.schema + p := pluggedKeeper(&s) + if tc.noSteps { + p.Migrations = nil + } + + got := execute(t, p, "migrate") + + if got.code != tc.code || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("migrate = %d, %q, %q, want %d, %q, %q", got.code, got.stdout, got.stderr, tc.code, tc.stdout, + tc.stderr) + } + if !slices.Equal(s.log, tc.log) { + t.Errorf("calls = %q, want %q", s.log, tc.log) + } + }) + } +} + +func TestSeedStoresThePluginDemoDataAfterTheCoreDemoData(t *testing.T) { + t.Parallel() + + locked := "lock " + databaseAddress + accounts := "step accounts at " + databaseAddress + reports := "step reports at " + databaseAddress + registered := "register describe=false" + schemaApplied := "migrate the plugins" + released := "release with the context live=true" + sown := "seed the plugins" + freed := "release the plugins live=true" + core := "migrated accounts\nmigrated reports\n" + migrated := core + "migrated plugins\n" + cases := []struct { + name string + schema schema + args []string + noSeed bool + code int + stdout string + stderr string + log []string + }{ + {"a dry run", schema{}, nil, false, gonsole.ExitDone, "would store the demo data\n", dryRunNotice, nil}, + {"an applied seed", schema{}, []string{"-yes"}, false, gonsole.ExitDone, "stored the demo reports\n", + migrated + demoNotice, + []string{locked, accounts, reports, registered, schemaApplied, released, "seed", sown, freed}}, + {"no core demo data", schema{}, []string{"-yes"}, true, gonsole.ExitDone, "", migrated + demoNotice, + []string{locked, accounts, reports, registered, schemaApplied, released, sown, freed}}, + {"plugins without demo data", schema{pluginsBare: true}, []string{"-yes"}, false, gonsole.ExitDone, + "stored the demo reports\n", migrated + demoNotice, + []string{locked, accounts, reports, registered, released, "seed", freed}}, + {"plugins that failed", schema{pluginsFailed: pluginFailures}, []string{"-yes"}, false, gonsole.ExitFailed, "", + core + pluginFailureLines, []string{locked, accounts, reports, registered, released, freed}}, + {"a registration that fails", schema{registerFails: errors.New("the plugin table is locked")}, + []string{"-yes"}, false, gonsole.ExitFailed, "", core + "myapp: the plugin table is locked\n", + []string{locked, accounts, reports, registered, released, freed}}, + {"plugin demo data that fails", schema{pluginsSeed: errors.New("the demo tenant exists")}, []string{"-yes"}, + false, gonsole.ExitFailed, "stored the demo reports\n", migrated + "myapp: the demo tenant exists\n", + []string{locked, accounts, reports, registered, schemaApplied, released, "seed", sown, freed}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := tc.schema + p := pluggedKeeper(&s) + if tc.noSeed { + p.Seed = nil + } + + got := execute(t, p, append([]string{"seed"}, tc.args...)...) + + if got.code != tc.code || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("seed = %d, %q, %q, want %d, %q, %q", got.code, got.stdout, got.stderr, tc.code, tc.stdout, + tc.stderr) + } + if !slices.Equal(s.log, tc.log) { + t.Errorf("calls = %q, want %q", s.log, tc.log) + } + }) + } +} + +func TestRunMigratesBeforeACommandThatAsksForIt(t *testing.T) { + t.Parallel() + + locked := "lock " + databaseAddress + accounts := "step accounts at " + databaseAddress + reports := "step reports at " + databaseAddress + released := "release with the context live=true" + cases := []struct { + name string + schema schema + writes bool + args []string + code int + stderr string + log []string + }{ + {"a command that does not write", schema{}, false, nil, gonsole.ExitDone, + "migrated accounts\nmigrated reports\n", []string{locked, accounts, reports, released, "run apply=true"}}, + {"an applied write", schema{}, true, []string{"-yes"}, gonsole.ExitDone, + "migrated accounts\nmigrated reports\n", []string{locked, accounts, reports, released, "run apply=true"}}, + {"a dry run", schema{}, true, nil, gonsole.ExitDone, dryRunNotice, []string{"run apply=false"}}, + {"a migration that fails", schema{stepFails: "accounts"}, false, nil, gonsole.ExitFailed, + "myapp: migrate accounts: relation already exists\n", []string{locked, accounts, released}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := tc.schema + p := keeper(&s) + p.Commands = []gonsole.Command{{ + Name: "createadmin", Summary: "create an account", Migrates: true, Writes: tc.writes, + Run: func(_ context.Context, call gonsole.Call) error { + s.note("run apply=%t", call.Apply) + return nil + }, + }} + + got := execute(t, p, append([]string{"createadmin"}, tc.args...)...) + + if got.code != tc.code { + t.Errorf("code = %d, want %d", got.code, tc.code) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if !slices.Equal(s.log, tc.log) { + t.Errorf("calls = %q, want %q", s.log, tc.log) + } + }) + } +} + +func TestRunChecksTheActingAccountBeforeItMigrates(t *testing.T) { + t.Parallel() + + var s schema + p := keeper(&s) + p.Commands = []gonsole.Command{{ + Name: "grantrole", Summary: "give a role", Migrates: true, Capability: "manage_users", + Run: func(context.Context, gonsole.Call) error { + s.note("run") + return nil + }, + }} + p.Authorize = func(_ context.Context, call gonsole.Call, capability string) error { + s.note("authorize %s for %s", call.Actor, capability) + return errors.New(call.Actor + " lacks " + capability) + } + p.Record = func(context.Context, gonsole.Call, string) error { return nil } + + got := execute(t, p, "grantrole", "-as", actingAccount) + + if got.code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitFailed) + } + if want := []string{"authorize " + actingAccount + " for manage_users"}; !slices.Equal(s.log, want) { + t.Errorf("calls = %q, want %q", s.log, want) + } +} + +func TestDatabaseURLNeedsACallTheEngineBuilt(t *testing.T) { + t.Parallel() + + _, err := gonsole.Call{Env: settings(map[string]string{"MYAPP_DATABASE_URL": databaseAddress})}.DatabaseURL() + + if want := "gonsole: no database setting in this call"; errorText(err) != want { + t.Errorf("DatabaseURL() error = %q, want %q", errorText(err), want) + } +} + +func TestDatabaseURLReadsTheProgramSetting(t *testing.T) { + t.Parallel() + + var s schema + p := keeper(&s) + p.Commands = []gonsole.Command{{ + Name: "report:list", Summary: "list every report", + Run: func(_ context.Context, call gonsole.Call) error { + address, err := call.DatabaseURL() + if err != nil { + return err + } + _, err = fmt.Fprintln(call.Stdout, address) + return err + }, + }} + + got := execute(t, p, "report:list") + + if got.stdout != databaseAddress+"\n" { + t.Errorf("stdout = %q, want the database address, stderr %q", got.stdout, got.stderr) + } +} diff --git a/gonsole/check.go b/gonsole/check.go new file mode 100644 index 0000000..c1103ed --- /dev/null +++ b/gonsole/check.go @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "errors" + "flag" + "fmt" + "io" + "maps" + "regexp" + "slices" + "strings" +) + +// baseCommands are the names the engine owns as commands and as namespaces in every program. +var baseCommands = []string{"help", "list", "version", "serve", "check", "migrate", "seed"} + +// engineFlags are the names of the flags the engine owns. +var engineFlags = []string{"h", "help", "yes", "json", "as"} + +// namePart matches each part of a command name. +var namePart = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// malformed is the offence of a command name that breaks the grammar. +const malformed = "command name %q is malformed, want lowercase words joined by hyphens and at most one colon" + +// flagsPanicked is the offence of a command whose Flags panics. +const flagsPanicked = "command %q panicked declaring its flags: %v" + +// Check returns every offence against the naming rules in the program and the loaded plugins. +func (p Program) Check(loaded Loaded) error { + a := newAudit(p) + a.core() + for _, group := range loaded.Groups { + a.admit(group) + } + return errors.Join(a.offences...) +} + +// audit collects the offences one Check finds. +type audit struct { + program Program + offences []error + dropped map[string][]error + declared map[string]bool + names map[string]bool + namespaces map[string]bool + reserved map[string]bool +} + +// newAudit returns an audit of p knowing the names and namespaces its commands use. +func newAudit(p Program) *audit { + a := &audit{ + program: p, dropped: map[string][]error{}, declared: map[string]bool{}, names: map[string]bool{}, + namespaces: map[string]bool{}, reserved: map[string]bool{}, + } + for _, cmd := range p.Commands { + if namespace, _, namespaced := strings.Cut(cmd.Name, ":"); namespaced { + a.namespaces[namespace] = true + } else { + a.names[cmd.Name] = true + } + } + for _, namespace := range p.Reserved { + a.reserved[namespace] = true + } + return a +} + +// refuse records one offence. +func (a *audit) refuse(format string, args ...any) { + a.offences = append(a.offences, fmt.Errorf("gonsole: "+format, args...)) +} + +// core refuses the offences of the program's own commands and settings. +func (a *audit) core() { + for _, cmd := range a.program.Commands { + a.owned(cmd) + a.inspect(cmd) + a.once(cmd.Name) + a.guarded(cmd) + a.shadows(cmd) + } + a.renamed() + if a.program.BareServes && a.program.Serve == nil { + a.refuse("BareServes is set without Serve") + } +} + +// admit returns the commands of one plugin's group that break no rule. +func (a *audit) admit(g Group) []Command { + claimed := a.claims(g.Namespace) + var kept []Command + for _, cmd := range g.Commands { + if a.command(g, cmd) && !claimed { + kept = append(kept, cmd) + } + } + return kept +} + +// command refuses the offences of one command of g and reports whether it has none. +func (a *audit) command(g Group, cmd Command) bool { + before := len(a.offences) + a.inspect(cmd) + a.once(cmd.Name) + a.inside(g, cmd) + a.guarded(cmd) + if cmd.Migrates { + a.refuse("plugin command %q asks for the core schema steps", cmd.Name) + } + if len(a.offences) == before { + return true + } + a.dropped[cmd.Name] = append(a.dropped[cmd.Name], a.offences[before:]...) + return false +} + +// owned refuses a program command that takes a base command or an engine namespace. +func (a *audit) owned(cmd Command) { + namespace, _, namespaced := strings.Cut(cmd.Name, ":") + switch { + case !namespaced && slices.Contains(baseCommands, cmd.Name): + a.refuse("command %q is a base command", cmd.Name) + case namespaced && slices.Contains(baseCommands, namespace): + a.refuse("command %q is in the engine namespace %s", cmd.Name, namespace) + } +} + +// inspect refuses a command with a malformed name, a missing or split summary, no run, or flags it cannot declare. +func (a *audit) inspect(cmd Command) { + if !wellFormed(cmd.Name) { + a.refuse(malformed, cmd.Name) + } + switch { + case strings.TrimSpace(cmd.Summary) == "": + a.refuse("command %q has no summary", cmd.Name) + case strings.Contains(cmd.Summary, "\n"): + a.refuse("command %q has a summary of more than one line", cmd.Name) + } + if cmd.Run == nil { + a.refuse("command %q has no run", cmd.Name) + } + a.flags(cmd) +} + +// wellFormed reports whether name is one name part, or two joined by one colon. +func wellFormed(name string) bool { + namespace, command, namespaced := strings.Cut(name, ":") + if !namespaced { + return namePart.MatchString(name) + } + return namePart.MatchString(namespace) && namePart.MatchString(command) +} + +// flags refuses a command whose Flags panics or declares a flag the engine owns. +func (a *audit) flags(cmd Command) { + if cmd.Flags == nil { + return + } + fs := flag.NewFlagSet(cmd.Name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + if value, panicked := declare(cmd, fs); panicked { + a.refuse(flagsPanicked, cmd.Name, value) + return + } + for _, name := range engineFlags { + if fs.Lookup(name) != nil { + a.refuse("command %q declares the engine flag -%s", cmd.Name, name) + } + } +} + +// declare runs cmd's Flags on fs and returns the value of a panic inside it. +func declare(cmd Command, fs *flag.FlagSet) (value any, panicked bool) { + defer func() { + if value = recover(); value != nil { + panicked = true + } + }() + cmd.Flags(fs) + return nil, false +} + +// once refuses a command name declared before. +func (a *audit) once(name string) { + if a.declared[name] { + a.refuse("command %q is declared twice", name) + return + } + a.declared[name] = true +} + +// guarded refuses a command that names a capability the program cannot check or record. +func (a *audit) guarded(cmd Command) { + if cmd.Capability == "" { + return + } + if a.program.Authorize == nil { + a.refuse("command %q names capability %s without Authorize", cmd.Name, cmd.Capability) + } + if a.program.Record == nil { + a.refuse("command %q names capability %s without Record", cmd.Name, cmd.Capability) + } +} + +// shadows refuses a program command without a namespace whose name is also a core or reserved namespace. +func (a *audit) shadows(cmd Command) { + if a.namespaces[cmd.Name] || a.reserved[cmd.Name] { + a.refuse("command %q is also a namespace", cmd.Name) + } +} + +// renamed refuses an old spelling that starts with a base command or points at no core command. +func (a *audit) renamed() { + for _, old := range slices.Sorted(maps.Keys(a.program.Renamed)) { + first, _, _ := strings.Cut(old, " ") + target := a.program.Renamed[old] + if slices.Contains(baseCommands, first) { + a.refuse("old spelling %q starts with the base command %s", old, first) + } + if !a.declared[target] { + a.refuse("old spelling %q points at %q, which is no core command", old, target) + } + } +} + +// claims refuses a plugin whose namespace the engine or core holds and reports whether it did. +func (a *audit) claims(namespace string) bool { + held := a.holder(namespace) + if held != "" { + a.refuse("%s", held) + } + return held != "" +} + +// holder returns the offence of a plugin that takes a namespace the engine or core holds, empty for a free one. +func (a *audit) holder(namespace string) string { + switch { + case slices.Contains(baseCommands, namespace): + return fmt.Sprintf("plugin %s takes the name of the base command %s", namespace, namespace) + case a.names[namespace]: + return fmt.Sprintf("plugin %s takes the name of the core command %s", namespace, namespace) + case a.namespaces[namespace]: + return fmt.Sprintf("plugin %s takes the core namespace %s", namespace, namespace) + case a.reserved[namespace]: + return fmt.Sprintf("plugin %s takes the reserved namespace %s", namespace, namespace) + } + return "" +} + +// inside refuses a plugin command outside the namespace of its group. +func (a *audit) inside(g Group, cmd Command) { + namespace, _, namespaced := strings.Cut(cmd.Name, ":") + if !namespaced || namespace != g.Namespace { + a.refuse("command %q of plugin %s is outside its namespace", cmd.Name, g.Namespace) + } +} diff --git a/gonsole/check_test.go b/gonsole/check_test.go new file mode 100644 index 0000000..9235b45 --- /dev/null +++ b/gonsole/check_test.go @@ -0,0 +1,703 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "errors" + "flag" + "io" + "os" + "slices" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// sound returns a program called myapp that breaks none of the naming rules. +func sound() gonsole.Program { + return gonsole.Program{ + Name: "myapp", + Commands: []gonsole.Command{echo("status"), echo("report:list"), echo("report:create", "title")}, + Renamed: map[string]string{"report new": "report:create"}, + Reserved: []string{"audit"}, + } +} + +// flagged returns cmd declaring one boolean flag called name. +func flagged(cmd gonsole.Command, name string) gonsole.Command { + cmd.Flags = func(fs *flag.FlagSet) { fs.Bool(name, false, "a flag") } + return cmd +} + +// offences returns the lines of err, none when it is nil. +func offences(err error) []string { + if err == nil { + return nil + } + return strings.Split(err.Error(), "\n") +} + +func TestCheckPassesASoundProgram(t *testing.T) { + t.Parallel() + + loaded := gonsole.Loaded{Groups: []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{echo("demo:sync")}}}} + p := sound() + p.Commands = append(p.Commands, echo("report:list-all"), echo("report:q3")) + + if err := p.Check(loaded); err != nil { + t.Errorf("Check() = %v, want nil", err) + } +} + +func TestCheckRefusesTheProgramsOwnOffences(t *testing.T) { + t.Parallel() + + const malformed = "is malformed, want lowercase words joined by hyphens and at most one colon" + cases := []struct { + name string + change func(p *gonsole.Program) + want []string + }{ + {"two commands with one name", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("report:list")) }, + []string{`gonsole: command "report:list" is declared twice`}}, + {"a base command", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("list")) }, + []string{`gonsole: command "list" is a base command`}}, + {"a base command the program does not offer", func(p *gonsole.Program) { + p.Commands = append(p.Commands, echo("serve")) + }, []string{`gonsole: command "serve" is a base command`}}, + {"a command in an engine namespace", func(p *gonsole.Program) { + p.Commands = append(p.Commands, echo("migrate:status")) + }, []string{`gonsole: command "migrate:status" is in the engine namespace migrate`}}, + {"a capital letter", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("Report")) }, + []string{`gonsole: command name "Report" ` + malformed}}, + {"a capital letter after the colon", func(p *gonsole.Program) { + p.Commands = append(p.Commands, echo("report:List")) + }, []string{`gonsole: command name "report:List" ` + malformed}}, + {"two colons", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("report:list:all")) }, + []string{`gonsole: command name "report:list:all" ` + malformed}}, + {"nothing after the colon", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("report:")) }, + []string{`gonsole: command name "report:" ` + malformed}}, + {"nothing before the colon", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo(":list")) }, + []string{`gonsole: command name ":list" ` + malformed}}, + {"a leading digit", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("9report")) }, + []string{`gonsole: command name "9report" ` + malformed}}, + {"an underscore", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("report_list")) }, + []string{`gonsole: command name "report_list" ` + malformed}}, + {"no summary", func(p *gonsole.Program) { p.Commands[1].Summary = "" }, + []string{`gonsole: command "report:list" has no summary`}}, + {"a summary of spaces", func(p *gonsole.Program) { p.Commands[1].Summary = " " }, + []string{`gonsole: command "report:list" has no summary`}}, + {"a summary of two lines", func(p *gonsole.Program) { p.Commands[1].Summary = "list\nevery report" }, + []string{`gonsole: command "report:list" has a summary of more than one line`}}, + {"no run", func(p *gonsole.Program) { p.Commands[1].Run = nil }, + []string{`gonsole: command "report:list" has no run`}}, + {"the flag h", func(p *gonsole.Program) { p.Commands[1] = flagged(p.Commands[1], "h") }, + []string{`gonsole: command "report:list" declares the engine flag -h`}}, + {"the flag help", func(p *gonsole.Program) { p.Commands[1] = flagged(p.Commands[1], "help") }, + []string{`gonsole: command "report:list" declares the engine flag -help`}}, + {"the flag yes", func(p *gonsole.Program) { p.Commands[1] = flagged(p.Commands[1], "yes") }, + []string{`gonsole: command "report:list" declares the engine flag -yes`}}, + {"the flag json", func(p *gonsole.Program) { p.Commands[1] = flagged(p.Commands[1], "json") }, + []string{`gonsole: command "report:list" declares the engine flag -json`}}, + {"the flag as", func(p *gonsole.Program) { p.Commands[1] = flagged(p.Commands[1], "as") }, + []string{`gonsole: command "report:list" declares the engine flag -as`}}, + {"flags that panic", func(p *gonsole.Program) { + p.Commands[1].Flags = func(*flag.FlagSet) { panic("the owner flag is gone") } + }, []string{`gonsole: command "report:list" panicked declaring its flags: the owner flag is gone`}}, + {"flags that declare one flag twice", func(p *gonsole.Program) { + p.Commands[1].Flags = func(fs *flag.FlagSet) { + fs.Bool("all", false, "every report") + fs.Bool("all", false, "every report") + } + }, []string{`gonsole: command "report:list" panicked declaring its flags: report:list flag redefined: all`}}, + {"an old spelling starting with a base command", func(p *gonsole.Program) { + p.Renamed["list all"] = "report:list" + }, []string{`gonsole: old spelling "list all" starts with the base command list`}}, + {"an old spelling pointing at no command", func(p *gonsole.Program) { p.Renamed["report make"] = "report:make" }, + []string{`gonsole: old spelling "report make" points at "report:make", which is no core command`}}, + {"an old spelling pointing at a base command", func(p *gonsole.Program) { p.Renamed["report all"] = "list" }, + []string{`gonsole: old spelling "report all" points at "list", which is no core command`}}, + {"a command named like a namespace", func(p *gonsole.Program) { p.Commands = append(p.Commands, echo("report")) }, + []string{`gonsole: command "report" is also a namespace`}}, + {"a command named like a reserved namespace", func(p *gonsole.Program) { + p.Commands = append(p.Commands, echo("audit")) + }, []string{`gonsole: command "audit" is also a namespace`}}, + {"a bare run that serves without a server", func(p *gonsole.Program) { p.BareServes = true }, + []string{`gonsole: BareServes is set without Serve`}}, + {"a capability without Authorize", func(p *gonsole.Program) { + p.Commands[1].Capability = "export_reports" + p.Record = func(context.Context, gonsole.Call, string) error { return nil } + }, []string{`gonsole: command "report:list" names capability export_reports without Authorize`}}, + {"a capability without Record", func(p *gonsole.Program) { + p.Commands[1].Capability = "export_reports" + p.Authorize = func(context.Context, gonsole.Call, string) error { return nil } + }, []string{`gonsole: command "report:list" names capability export_reports without Record`}}, + {"a capability without Authorize and Record", func(p *gonsole.Program) { + p.Commands[1].Capability = "export_reports" + }, []string{ + `gonsole: command "report:list" names capability export_reports without Authorize`, + `gonsole: command "report:list" names capability export_reports without Record`, + }}, + {"several offences", func(p *gonsole.Program) { + p.Commands = append(p.Commands, echo("list"), echo("Report")) + p.BareServes = true + }, []string{ + `gonsole: command "list" is a base command`, + `gonsole: command name "Report" ` + malformed, + `gonsole: BareServes is set without Serve`, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := sound() + tc.change(&p) + + got := offences(p.Check(gonsole.Loaded{})) + + if strings.Join(got, "\n") != strings.Join(tc.want, "\n") { + t.Errorf("Check() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCheckRefusesEveryBaseCommandAsAProgramCommand(t *testing.T) { + t.Parallel() + + for _, name := range []string{"help", "list", "version", "serve", "check", "migrate", "seed"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + bare, namespaced := sound(), sound() + bare.Commands = append(bare.Commands, echo(name)) + namespaced.Commands = append(namespaced.Commands, echo(name+":status")) + + got := offences(errors.Join(bare.Check(gonsole.Loaded{}), namespaced.Check(gonsole.Loaded{}))) + + want := []string{ + `gonsole: command "` + name + `" is a base command`, + `gonsole: command "` + name + `:status" is in the engine namespace ` + name, + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("Check() = %q, want %q", got, want) + } + }) + } +} + +func TestCheckRefusesOldSpellingsInOrder(t *testing.T) { + t.Parallel() + + p := sound() + p.Renamed["report make"] = "report:make" + p.Renamed["list all"] = "report:list" + p.Renamed["audit show"] = "audit:show" + + got := offences(p.Check(gonsole.Loaded{})) + + want := []string{ + `gonsole: old spelling "audit show" points at "audit:show", which is no core command`, + `gonsole: old spelling "list all" starts with the base command list`, + `gonsole: old spelling "report make" points at "report:make", which is no core command`, + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("Check() = %q, want %q", got, want) + } +} + +func TestCheckRefusesThePluginOffences(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + group gonsole.Group + want []string + }{ + {"a plugin named like a base command", gonsole.Group{Namespace: "list", + Commands: []gonsole.Command{echo("list:all")}}, + []string{`gonsole: plugin list takes the name of the base command list`}}, + {"a plugin named like a base command with a command of its own offence", gonsole.Group{Namespace: "list", + Commands: []gonsole.Command{summarized(echo("list:all"), "")}}, + []string{ + `gonsole: plugin list takes the name of the base command list`, + `gonsole: command "list:all" has no summary`, + }}, + {"a plugin named like a core command", gonsole.Group{Namespace: "status", + Commands: []gonsole.Command{echo("status:all")}}, + []string{`gonsole: plugin status takes the name of the core command status`}}, + {"a plugin named like a core namespace", gonsole.Group{Namespace: "report", + Commands: []gonsole.Command{echo("report:sync")}}, + []string{`gonsole: plugin report takes the core namespace report`}}, + {"a plugin named like a reserved namespace", gonsole.Group{Namespace: "audit", + Commands: []gonsole.Command{echo("audit:sync")}}, + []string{`gonsole: plugin audit takes the reserved namespace audit`}}, + {"a command in another namespace", gonsole.Group{Namespace: "demo", + Commands: []gonsole.Command{echo("other:sync")}}, + []string{`gonsole: command "other:sync" of plugin demo is outside its namespace`}}, + {"a command without a namespace", gonsole.Group{Namespace: "demo", + Commands: []gonsole.Command{echo("sync")}}, + []string{`gonsole: command "sync" of plugin demo is outside its namespace`}}, + {"a command named like its plugin", gonsole.Group{Namespace: "sync", + Commands: []gonsole.Command{echo("sync")}}, + []string{`gonsole: command "sync" of plugin sync is outside its namespace`}}, + {"a command that needs a capability the program cannot check", gonsole.Group{Namespace: "demo", + Commands: []gonsole.Command{{Name: "demo:sync", Summary: "sync the demo", Capability: "manage_demo", + Run: func(context.Context, gonsole.Call) error { return nil }}}}, + []string{ + `gonsole: command "demo:sync" names capability manage_demo without Authorize`, + `gonsole: command "demo:sync" names capability manage_demo without Record`, + }}, + {"two commands with one name", gonsole.Group{Namespace: "demo", + Commands: []gonsole.Command{echo("demo:sync"), echo("demo:sync")}}, + []string{`gonsole: command "demo:sync" is declared twice`}}, + {"a command without a summary", gonsole.Group{Namespace: "demo", + Commands: []gonsole.Command{summarized(echo("demo:sync"), "")}}, + []string{`gonsole: command "demo:sync" has no summary`}}, + {"a command that asks for the core schema steps", gonsole.Group{Namespace: "demo", + Commands: []gonsole.Command{{Name: "demo:sync", Summary: "sync the demo", Migrates: true, + Run: func(context.Context, gonsole.Call) error { return nil }}}}, + []string{`gonsole: plugin command "demo:sync" asks for the core schema steps`}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := offences(sound().Check(gonsole.Loaded{Groups: []gonsole.Group{tc.group}})) + + if strings.Join(got, "\n") != strings.Join(tc.want, "\n") { + t.Errorf("Check() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCheckJoinsEveryOffenceIntoOneError(t *testing.T) { + t.Parallel() + + p := sound() + p.Commands = append(p.Commands, echo("list")) + + err := p.Check(gonsole.Loaded{Groups: []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{echo("sync")}}}}) + + var joined interface{ Unwrap() []error } + if !errors.As(err, &joined) || len(joined.Unwrap()) != 2 { + t.Errorf("Check() = %v, want two joined offences", err) + } +} + +func TestRunRefusesABrokenProgramBeforeAnythingElse(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{{"status"}, {"list"}, {"-h"}, nil} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + p := sound() + p.Commands = append(p.Commands, echo("list")) + + got := execute(t, p, args...) + + if got.code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitFailed) + } + if want := "myapp: gonsole: command \"list\" is a base command\n"; got.stderr != want { + t.Errorf("stderr = %q, want %q", got.stderr, want) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want nothing run", got.stdout) + } + }) + } +} + +func TestCheckCommandReportsTheSettings(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + validate func(context.Context, gonsole.Call) error + code int + stdout string + stderr string + }{ + {"a program without Validate", nil, gonsole.ExitDone, "settings, plugins and command names are valid\n", ""}, + {"settings that pass", func(context.Context, gonsole.Call) error { return nil }, gonsole.ExitDone, + "settings, plugins and command names are valid\n", ""}, + {"settings that fail", func(_ context.Context, call gonsole.Call) error { + _, window := call.Env.Duration("WINDOW", 0) + _, batch := call.Env.Count("BATCH", 1) + return errors.Join(window, batch) + }, gonsole.ExitFailed, "", "myapp: MYAPP_WINDOW: must be a duration like 30s, got \"soon\"\n" + + "myapp: MYAPP_BATCH: must be a whole number, got \"many\"\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := sound() + p.Env = settings(map[string]string{"MYAPP_WINDOW": "soon", "MYAPP_BATCH": "many"}) + p.Validate = tc.validate + + got := execute(t, p, "check") + + if got.code != tc.code { + t.Errorf("code = %d, want %d", got.code, tc.code) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + }) + } +} + +func TestCheckCommandReportsThePlugins(t *testing.T) { + t.Parallel() + + const valid = "settings, plugins and command names are valid\n" + checked := []string{"validate", "register describe=false", "release live=true"} + cases := []struct { + name string + validate error + groups []gonsole.Group + failed error + fail error + code int + stdout string + stderr string + log []string + }{ + {"plugins that load", nil, demoGroups(), nil, nil, gonsole.ExitDone, valid, "", checked}, + {"groups that break the rules", nil, []gonsole.Group{ + {Namespace: "list", Commands: []gonsole.Command{echo("list:all")}}, + }, nil, nil, gonsole.ExitFailed, "", "myapp: gonsole: plugin list takes the name of the base command list\n", + checked}, + {"plugins that failed beside groups that break the rules", nil, []gonsole.Group{ + {Namespace: "list", Commands: []gonsole.Command{echo("list:all")}}, + {Namespace: "demo", Commands: []gonsole.Command{summarized(echo("demo:sync"), ""), echo("demo:list")}}, + }, pluginFailures, nil, gonsole.ExitFailed, "", pluginFailureLines + + "myapp: gonsole: plugin list takes the name of the base command list\n" + + "myapp: gonsole: command \"demo:sync\" has no summary\n", checked}, + {"a registration that fails", nil, demoGroups(), errors.New("plugin mail: no relay host"), + errors.New("the plugin table is locked"), gonsole.ExitFailed, "", "myapp: the plugin table is locked\n", + checked}, + {"settings that fail", errors.New("MYAPP_ADDR: must be a port, got \"web\""), demoGroups(), nil, nil, + gonsole.ExitFailed, "", "myapp: MYAPP_ADDR: must be a port, got \"web\"\n", []string{"validate"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: tc.groups, failed: tc.failed, fail: tc.fail} + p := plugged(r) + p.Validate = func(context.Context, gonsole.Call) error { + r.note("validate") + return tc.validate + } + + got := execute(t, p, "check") + + if got.code != tc.code || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("check = %d, %q, %q, want %d, %q, %q", got.code, got.stdout, got.stderr, tc.code, tc.stdout, + tc.stderr) + } + if !slices.Equal(r.log, tc.log) { + t.Errorf("calls = %q, want %q", r.log, tc.log) + } + }) + } +} + +func TestRunTurnsAPanicIntoAFailure(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + change func(p *gonsole.Program) + line string + }{ + {"a run that panics", func(p *gonsole.Program) { + p.Commands[1].Run = func(context.Context, gonsole.Call) error { panic("the report store vanished") } + }, "myapp: report:list: panic: the report store vanished\n"}, + {"a run that panics with a misuse", func(p *gonsole.Program) { + p.Commands[1].Run = func(context.Context, gonsole.Call) error { + panic(gonsole.Misuse(errors.New("the report store vanished"))) + } + }, "myapp: report:list: panic: the report store vanished\n"}, + {"an account check that panics", func(p *gonsole.Program) { + p.Commands[1].Capability = "export_reports" + p.Authorize = func(context.Context, gonsole.Call, string) error { panic("the role table vanished") } + p.Record = func(context.Context, gonsole.Call, string) error { return nil } + }, "myapp: report:list: panic: the role table vanished\n"}, + {"a record that panics", func(p *gonsole.Program) { + p.Commands[1].Capability = "export_reports" + p.Authorize = func(context.Context, gonsole.Call, string) error { return nil } + p.Record = func(context.Context, gonsole.Call, string) error { panic("the record table vanished") } + }, "myapp: report:list: panic: the record table vanished\n"}, + {"a flag value that panics while it is read", func(p *gonsole.Program) { + p.Commands[1].Flags = func(fs *flag.FlagSet) { fs.Var(fragile{}, "since", "the first day") } + }, "myapp: report:list: panic: the calendar vanished\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := sound() + tc.change(&p) + args := []string{"report:list", "-since", "monday"} + if p.Commands[1].Flags == nil { + args = args[:1] + } + if p.Commands[1].Capability != "" { + args = append(args, "-as", actingAccount) + } + + got := execute(t, p, args...) + + if got.code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitFailed) + } + stack, opened := strings.CutPrefix(got.stderr, tc.line) + if !opened { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), tc.line) + } + if !strings.HasPrefix(stack, "goroutine ") { + t.Errorf("after the panic line = %q, want the raw stack", firstLine(stack)) + } + }) + } +} + +// fragile is a flag value whose every read panics. +type fragile struct{} + +// String returns the empty text of the value. +func (fragile) String() string { + return "" +} + +// Set panics. +func (fragile) Set(string) error { + panic("the calendar vanished") +} + +func TestRunRefusesFlagsThatPanicOnlyWhenTheRunDeclaresThem(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{{"report:list"}, {"report:list", "-h"}, {"help", "report:list"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + declared := 0 + p := sound() + p.Commands[1].Flags = func(*flag.FlagSet) { + declared++ + if declared > 1 { + panic("the owner flag is gone") + } + } + + got := execute(t, p, args...) + + if got.code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitFailed) + } + want := "myapp: gonsole: command \"report:list\" panicked declaring its flags: the owner flag is gone\n" + if got.stderr != want { + t.Errorf("stderr = %q, want %q", got.stderr, want) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want nothing", got.stdout) + } + }) + } +} + +func TestCheckWritesNothingToTheProcessStderr(t *testing.T) { + read, write, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + saved := os.Stderr + os.Stderr = write + p := sound() + p.Commands[1].Flags = func(fs *flag.FlagSet) { + fs.Bool("all", false, "every report") + fs.Bool("all", false, "every report") + } + + _ = p.Check(gonsole.Loaded{}) + + os.Stderr = saved + if err := write.Close(); err != nil { + t.Fatalf("closing the pipe: %v", err) + } + leaked, err := io.ReadAll(read) + if err != nil { + t.Fatalf("reading the pipe: %v", err) + } + if len(leaked) != 0 { + t.Errorf("process stderr = %q, want nothing", leaked) + } +} + +// offending returns a plugin group, demo, holding demo:ok, one command for each offence and demo:twice declared twice. +func offending() gonsole.Group { + idle := echo("demo:idle") + idle.Run = nil + fragile := echo("demo:fragile") + fragile.Flags = func(*flag.FlagSet) { panic("the flag table vanished") } + schema := echo("demo:schema") + schema.Migrates = true + again := echo("demo:twice") + again.Run = func(_ context.Context, call gonsole.Call) error { + _, err := io.WriteString(call.Stdout, "the second demo:twice\n") + return err + } + return gonsole.Group{Namespace: "demo", Commands: []gonsole.Command{ + echo("demo:ok"), echo("demo:Sync"), summarized(echo("demo:quiet"), ""), + summarized(echo("demo:split"), "sync\nthe demo"), idle, flagged(echo("demo:loud"), "yes"), fragile, schema, + echo("other:x"), echo("sync"), echo("demo:twice"), again, summarized(echo("demo:gone"), ""), echo("demo:gone"), + }} +} + +func TestRunDropsThePluginCommandsThatBreakARule(t *testing.T) { + t.Parallel() + + cases := []struct { + args []string + code int + stdout string + stderr string + }{ + {[]string{"demo:ok"}, gonsole.ExitDone, "demo:ok\n", ""}, + {[]string{"demo:twice"}, gonsole.ExitDone, "demo:twice\n", ""}, + {[]string{"demo:Sync"}, gonsole.ExitMisused, "", unknownLine("demo:Sync")}, + {[]string{"demo:quiet"}, gonsole.ExitFailed, "", "myapp: gonsole: command \"demo:quiet\" has no summary\n"}, + {[]string{"demo:quiet", "-h"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"demo:quiet\" has no summary\n"}, + {[]string{"help", "demo:quiet"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"demo:quiet\" has no summary\n"}, + {[]string{"sync"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"sync\" of plugin demo is outside its namespace\n"}, + {[]string{"demo:gone"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"demo:gone\" has no summary\nmyapp: gonsole: command \"demo:gone\" is declared twice\n"}, + {[]string{"demo:split"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"demo:split\" has a summary of more than one line\n"}, + {[]string{"demo:idle"}, gonsole.ExitFailed, "", "myapp: gonsole: command \"demo:idle\" has no run\n"}, + {[]string{"demo:loud"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"demo:loud\" declares the engine flag -yes\n"}, + {[]string{"demo:fragile"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"demo:fragile\" panicked declaring its flags: the flag table vanished\n"}, + {[]string{"demo:schema"}, gonsole.ExitFailed, "", + "myapp: gonsole: plugin command \"demo:schema\" asks for the core schema steps\n"}, + {[]string{"other:x"}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"other:x\" of plugin demo is outside its namespace\n"}, + {[]string{"demo:nope"}, gonsole.ExitMisused, "", + "myapp: unknown command \"demo:nope\", want demo:ok or demo:twice\n"}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + t.Parallel() + + got := execute(t, plugged(®istry{groups: []gonsole.Group{offending()}}), tc.args...) + + if got.code != tc.code || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, %q, want %d, %q, %q", got.code, got.stdout, got.stderr, tc.code, tc.stdout, + tc.stderr) + } + }) + } +} + +func TestRunDropsAPluginCommandTheProgramCannotGuard(t *testing.T) { + t.Parallel() + + cases := []struct { + args []string + code int + stdout string + stderr string + }{ + {[]string{"demo:move", "-as", actingAccount}, gonsole.ExitFailed, "", + "myapp: gonsole: command \"demo:move\" names capability manage_demo without Authorize\n" + + "myapp: gonsole: command \"demo:move\" names capability manage_demo without Record\n"}, + {[]string{"demo:sync", "-yes"}, gonsole.ExitDone, "sync apply=true\n", ""}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + t.Parallel() + + p := plugged(®istry{groups: demoGroups()}) + p.Authorize, p.Record = nil, nil + + got := execute(t, p, tc.args...) + + if got.code != tc.code || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, %q, want %d, %q, %q", got.code, got.stdout, got.stderr, tc.code, tc.stdout, + tc.stderr) + } + }) + } +} + +func TestRunAnswersAGroupLeftWithoutCommandsAsNoGroup(t *testing.T) { + t.Parallel() + + for _, name := range []string{"demo:x", "demo"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{echo("demo:Sync")}}}} + + got := execute(t, plugged(r), name) + + if got.code != gonsole.ExitMisused || got.stderr != unknownLine(name) { + t.Errorf("run = %d, %q, want %d, %q", got.code, got.stderr, gonsole.ExitMisused, unknownLine(name)) + } + }) + } +} + +func TestRunMergesTwoGroupsOfOneNamespace(t *testing.T) { + t.Parallel() + + again := echo("demo:a") + again.Run = func(_ context.Context, call gonsole.Call) error { + _, err := io.WriteString(call.Stdout, "the second demo:a\n") + return err + } + cases := []struct { + args []string + code int + stdout string + stderr string + }{ + {[]string{"demo:a"}, gonsole.ExitDone, "demo:a\n", ""}, + {[]string{"demo:b"}, gonsole.ExitDone, "demo:b\n", ""}, + {[]string{"demo:x"}, gonsole.ExitMisused, "", "myapp: unknown command \"demo:x\", want demo:a or demo:b\n"}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: []gonsole.Group{ + {Namespace: "demo", Commands: []gonsole.Command{echo("demo:a")}}, + {Namespace: "demo", Commands: []gonsole.Command{again, echo("demo:b")}}, + }} + + got := execute(t, plugged(r), tc.args...) + + if got.code != tc.code || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, %q, want %d, %q, %q", got.code, got.stdout, got.stderr, tc.code, tc.stdout, + tc.stderr) + } + }) + } +} diff --git a/gonsole/command.go b/gonsole/command.go new file mode 100644 index 0000000..13d9a1a --- /dev/null +++ b/gonsole/command.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "encoding/json" + "errors" + "flag" + "io" +) + +// Command is one command a program or a plugin offers. +type Command struct { + // Name is the full name, such as status or report:create, in lowercase words joined by hyphens. + Name string + // Summary is the one line the listing prints beside the name. + Summary string + // Args names the positional arguments in order, each one required. + Args []string + // Flags declares the command's own flags, nil for none. + Flags func(fs *flag.FlagSet) + // Writes marks a command that writes to the database. + Writes bool + // JSON marks a command that answers one JSON document. + JSON bool + // Migrates marks a core command the core schema steps run before. + Migrates bool + // Capability names the capability the acting account must hold, empty for none. + Capability string + // Run does the command's work. + Run func(ctx context.Context, call Call) error +} + +// Call is what one run of a command receives. +type Call struct { + // Args holds the positional arguments, one per name in Command.Args. + Args []string + // Flags maps each of the command's own flags the line set to its value, the engine flags left out. + Flags map[string]string + // Stdin is the input a command reads, such as a password. + Stdin io.Reader + // Stdout is where a command writes its answer. + Stdout io.Writer + // Stderr is where a command writes progress and warnings. + Stderr io.Writer + // Env reads the program's settings. + Env Env + // JSON reports whether -json was passed. + JSON bool + // Apply reports whether the run applies its writes. + Apply bool + // Actor is the account the -as flag names. + Actor string + // Describe reports a run that needs only command descriptors. + Describe bool + // database is the name of the setting that holds the database address. + database string + // plugins is the registration of the plugins the run shares. + plugins *memo +} + +// DatabaseURL returns the program's database address, an error naming the setting when it is empty. +func (c Call) DatabaseURL() (string, error) { + if c.database == "" { + return "", errors.New("gonsole: no database setting in this call") + } + return c.Env.Required(c.database) +} + +// Plugins returns the registered plugins' command groups and schema steps. +func (c Call) Plugins(ctx context.Context) (Loaded, error) { + if c.plugins == nil { + return Loaded{}, errors.New("gonsole: no plugins in this call") + } + return c.plugins.answer(ctx, false) +} + +// Step is one named schema step. +type Step struct { + // Name is the step's name in its output line. + Name string + // Run applies the step against the database at databaseURL. + Run func(ctx context.Context, databaseURL string) error +} + +// Encode writes v to Stdout as one indented JSON document. +func (c Call) Encode(v any) error { + encoder := json.NewEncoder(c.Stdout) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + return encoder.Encode(v) +} diff --git a/gonsole/command_test.go b/gonsole/command_test.go new file mode 100644 index 0000000..314b941 --- /dev/null +++ b/gonsole/command_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bytes" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +func TestEncodeWritesOneIndentedDocument(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + err := gonsole.Call{Stdout: &out}.Encode(map[string]any{"title": "Q3 & notes", "applied": true}) + + if err != nil { + t.Fatalf("Encode() error = %v, want nil", err) + } + want := `{ + "applied": true, + "title": "Q3 & notes" +} +` + if out.String() != want { + t.Errorf("document = %q, want %q", out.String(), want) + } +} + +func TestEncodeRefusesAValueJSONCannotHold(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + err := gonsole.Call{Stdout: &out}.Encode(make(chan int)) + + if err == nil { + t.Errorf("Encode() error = nil, want an error") + } + if out.Len() != 0 { + t.Errorf("document = %q, want nothing written", out.String()) + } +} diff --git a/gonsole/doc.go b/gonsole/doc.go new file mode 100644 index 0000000..945345b --- /dev/null +++ b/gonsole/doc.go @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package gonsole runs the command line of a Go program built from core commands, settings and compiled plugins. +package gonsole diff --git a/gonsole/env.go b/gonsole/env.go new file mode 100644 index 0000000..4b32577 --- /dev/null +++ b/gonsole/env.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// Env reads settings under one prefix. +type Env struct { + // Prefix starts every setting name, such as MYAPP_. + Prefix string + // Getenv reads one variable, nil reading every variable as empty. + Getenv func(string) string +} + +// Key returns the full name of the setting called name. +func (e Env) Key(name string) string { + return e.Prefix + name +} + +// Value returns the setting's value with surrounding spaces trimmed, empty when it is unset. +func (e Env) Value(name string) string { + if e.Getenv == nil { + return "" + } + return strings.TrimSpace(e.Getenv(e.Key(name))) +} + +// Required returns the setting's value, an error naming it when it is empty. +func (e Env) Required(name string) (string, error) { + value := e.Value(name) + if value == "" { + return "", fmt.Errorf("%s is required", e.Key(name)) + } + return value, nil +} + +// Duration returns the setting as a duration above zero, the fallback when it is empty. +func (e Env) Duration(name string, fallback time.Duration, bounds ...Bound) (time.Duration, error) { + within := narrow(math.MaxInt64, bounds) + return Parse(e, name, fallback, func(value string) (time.Duration, error) { + read, err := time.ParseDuration(value) + if err != nil { + return 0, complaint("must be a duration like 30s", value) + } + return read, within.judge(int64(read), false, value, durationText) + }) +} + +// Count returns the setting as a whole number above zero, the fallback when it is empty. +func (e Env) Count(name string, fallback int, bounds ...Bound) (int, error) { + within := narrow(math.MaxInt, bounds) + return Parse(e, name, fallback, func(value string) (int, error) { + read, err := strconv.ParseInt(value, 10, 0) + overflowed := errors.Is(err, strconv.ErrRange) + if err != nil && !overflowed { + return 0, complaint("must be a whole number", value) + } + return int(read), within.judge(read, overflowed, value, wholeText) + }) +} + +// Flag returns the setting as true or false, the fallback when it is empty. +func (e Env) Flag(name string, fallback bool) (bool, error) { + return Parse(e, name, fallback, func(value string) (bool, error) { + read, err := strconv.ParseBool(value) + if err != nil { + return false, complaint("must be true or false", value) + } + return read, nil + }) +} + +// Within returns the settings under the prefix followed by more. +func (e Env) Within(more string) Env { + return Env{Prefix: e.Prefix + more, Getenv: e.Getenv} +} + +// Parse returns the setting read by parse, the fallback when it is empty, any error naming the setting. +func Parse[T any](e Env, name string, fallback T, parse func(string) (T, error)) (T, error) { + value := e.Value(name) + if value == "" { + return fallback, nil + } + read, err := parse(value) + if err != nil { + var zero T + return zero, fmt.Errorf("%s: %w", e.Key(name), err) + } + return read, nil +} + +// complaint returns the error saying what a setting must be and the value it holds. +func complaint(must, value string) error { + return fmt.Errorf("%s, got %q", must, value) +} + +// durationText prints a count of nanoseconds as a duration. +func durationText(n int64) string { + return time.Duration(n).String() +} + +// wholeText prints a whole number in base ten. +func wholeText(n int64) string { + return strconv.FormatInt(n, 10) +} + +// limits are the values a Count or Duration setting accepts. +type limits struct { + highest int64 + allowZero bool +} + +// Bound narrows the values a Count or Duration setting accepts. +type Bound func(*limits) + +// AtMost refuses a value above highest. +func AtMost(highest int64) Bound { + return func(l *limits) { l.highest = min(l.highest, highest) } +} + +// AllowZero accepts zero beside the values above it. +func AllowZero() Bound { + return func(l *limits) { l.allowZero = true } +} + +// narrow returns the limits of the values above zero up to top, as bounds change them. +func narrow(top int64, bounds []Bound) limits { + within := limits{highest: top} + for _, bound := range bounds { + bound(&within) + } + return within +} + +// judge refuses a value n that falls outside l or that overflowed past the ceiling. +func (l limits) judge(n int64, overflowed bool, value string, show func(int64) string) error { + switch { + case n < 0 && l.allowZero: + return complaint("must not be negative", value) + case n <= 0 && !l.allowZero: + return complaint("must stand above zero", value) + case n > l.highest || overflowed: + return complaint("must stand at or below "+show(l.highest), value) + } + return nil +} + +// settings returns the program's settings with a reader that is never nil. +func (r *runner) settings() Env { + env := r.program.Env + if env.Getenv == nil { + env.Getenv = func(string) string { return "" } + } + return env +} diff --git a/gonsole/env_test.go b/gonsole/env_test.go new file mode 100644 index 0000000..31de42b --- /dev/null +++ b/gonsole/env_test.go @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "testing" + "time" + + "github.com/gopherium/framework/gonsole" +) + +// settings returns the settings of myapp holding values. +func settings(values map[string]string) gonsole.Env { + return gonsole.Env{Prefix: "MYAPP_", Getenv: func(key string) string { return values[key] }} +} + +// errorText returns the message of err, empty when it is nil. +func errorText(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func TestEnvNamesASettingUnderItsPrefix(t *testing.T) { + t.Parallel() + + env := settings(nil) + + if got := env.Key("WINDOW"); got != "MYAPP_WINDOW" { + t.Errorf("Key() = %q, want MYAPP_WINDOW", got) + } + if got := env.Within("BILLING_").Key("LIMIT"); got != "MYAPP_BILLING_LIMIT" { + t.Errorf("Within().Key() = %q, want MYAPP_BILLING_LIMIT", got) + } +} + +func TestEnvReadsAValueWithoutItsSurroundingSpaces(t *testing.T) { + t.Parallel() + + env := settings(map[string]string{"MYAPP_OWNER": " maria.perez@example.com \n", "MYAPP_BILLING_LIMIT": "10"}) + cases := []struct { + name string + env gonsole.Env + key string + want string + }{ + {"a padded value", env, "OWNER", "maria.perez@example.com"}, + {"an unset value", env, "MISSING", ""}, + {"a value under a longer prefix", env.Within("BILLING_"), "LIMIT", "10"}, + {"a reader that is missing", gonsole.Env{Prefix: "MYAPP_"}, "OWNER", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.env.Value(tc.key); got != tc.want { + t.Errorf("Value() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestEnvRequiresASettingThatHoldsAValue(t *testing.T) { + t.Parallel() + + env := settings(map[string]string{"MYAPP_DATABASE_URL": " postgres://localhost/myapp ", "MYAPP_BLANK": " "}) + cases := []struct { + name string + key string + value string + err string + }{ + {"a set value", "DATABASE_URL", "postgres://localhost/myapp", ""}, + {"an unset value", "MISSING", "", "MYAPP_MISSING is required"}, + {"a value of spaces", "BLANK", "", "MYAPP_BLANK is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := env.Required(tc.key) + + if got != tc.value || errorText(err) != tc.err { + t.Errorf("Required() = %q, %q, want %q, %q", got, errorText(err), tc.value, tc.err) + } + }) + } +} + +func TestEnvReadsADuration(t *testing.T) { + t.Parallel() + + hour := gonsole.AtMost(int64(time.Hour)) + cases := []struct { + name string + value string + bounds []gonsole.Bound + want time.Duration + err string + }{ + {"an unset value", "", nil, 30 * time.Second, ""}, + {"a value of spaces", " ", nil, 30 * time.Second, ""}, + {"a plain value", "45s", nil, 45 * time.Second, ""}, + {"a padded value", " 2m ", nil, 2 * time.Minute, ""}, + {"a word", "soon", nil, 0, `MYAPP_WINDOW: must be a duration like 30s, got "soon"`}, + {"a bare number", "30", nil, 0, `MYAPP_WINDOW: must be a duration like 30s, got "30"`}, + {"zero", "0s", nil, 0, `MYAPP_WINDOW: must stand above zero, got "0s"`}, + {"a negative value", "-5s", nil, 0, `MYAPP_WINDOW: must stand above zero, got "-5s"`}, + {"zero where zero is allowed", "0s", []gonsole.Bound{gonsole.AllowZero()}, 0, ""}, + {"a negative value where zero is allowed", "-5s", []gonsole.Bound{gonsole.AllowZero()}, 0, + `MYAPP_WINDOW: must not be negative, got "-5s"`}, + {"a value at the bound", "1h", []gonsole.Bound{hour}, time.Hour, ""}, + {"a value above the bound", "2h", []gonsole.Bound{hour}, 0, + `MYAPP_WINDOW: must stand at or below 1h0m0s, got "2h"`}, + {"a value above the tighter of two bounds", "50m", []gonsole.Bound{gonsole.AtMost(int64(time.Minute)), hour}, 0, + `MYAPP_WINDOW: must stand at or below 1m0s, got "50m"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := settings(map[string]string{"MYAPP_WINDOW": tc.value}) + + got, err := env.Duration("WINDOW", 30*time.Second, tc.bounds...) + + if got != tc.want || errorText(err) != tc.err { + t.Errorf("Duration() = %v, %q, want %v, %q", got, errorText(err), tc.want, tc.err) + } + }) + } +} + +func TestEnvReadsACount(t *testing.T) { + t.Parallel() + + hundred := gonsole.AtMost(100) + cases := []struct { + name string + value string + bounds []gonsole.Bound + want int + err string + }{ + {"an unset value", "", nil, 25, ""}, + {"a plain value", "7", nil, 7, ""}, + {"a padded value", " 12 ", nil, 12, ""}, + {"a word", "many", nil, 0, `MYAPP_BATCH: must be a whole number, got "many"`}, + {"a fraction", "2.5", nil, 0, `MYAPP_BATCH: must be a whole number, got "2.5"`}, + {"a hexadecimal number", "0x10", nil, 0, `MYAPP_BATCH: must be a whole number, got "0x10"`}, + {"a number with underscores", "1_000", nil, 0, `MYAPP_BATCH: must be a whole number, got "1_000"`}, + {"zero", "0", nil, 0, `MYAPP_BATCH: must stand above zero, got "0"`}, + {"a negative value", "-3", nil, 0, `MYAPP_BATCH: must stand above zero, got "-3"`}, + {"zero where zero is allowed", "0", []gonsole.Bound{gonsole.AllowZero()}, 0, ""}, + {"a negative value where zero is allowed", "-3", []gonsole.Bound{gonsole.AllowZero()}, 0, + `MYAPP_BATCH: must not be negative, got "-3"`}, + {"a value at the bound", "100", []gonsole.Bound{hundred}, 100, ""}, + {"a value above the bound", "101", []gonsole.Bound{hundred}, 0, + `MYAPP_BATCH: must stand at or below 100, got "101"`}, + {"a value too large to hold", "99999999999999999999", nil, 0, + `MYAPP_BATCH: must stand at or below ` + strconv.Itoa(math.MaxInt) + `, got "99999999999999999999"`}, + {"a value above the tighter of two bounds", "5", []gonsole.Bound{gonsole.AtMost(3), gonsole.AtMost(10)}, 0, + `MYAPP_BATCH: must stand at or below 3, got "5"`}, + {"a value above the tighter of two bounds given last", "5", []gonsole.Bound{gonsole.AtMost(10), gonsole.AtMost(3)}, + 0, `MYAPP_BATCH: must stand at or below 3, got "5"`}, + {"a value too small to hold where zero is allowed", "-99999999999999999999", []gonsole.Bound{gonsole.AllowZero()}, + 0, `MYAPP_BATCH: must not be negative, got "-99999999999999999999"`}, + {"a value too large to hold under a bound", "99999999999999999999", []gonsole.Bound{hundred}, 0, + `MYAPP_BATCH: must stand at or below 100, got "99999999999999999999"`}, + {"a value too small to hold", "-99999999999999999999", nil, 0, + `MYAPP_BATCH: must stand above zero, got "-99999999999999999999"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := settings(map[string]string{"MYAPP_BATCH": tc.value}) + + got, err := env.Count("BATCH", 25, tc.bounds...) + + if got != tc.want || errorText(err) != tc.err { + t.Errorf("Count() = %d, %q, want %d, %q", got, errorText(err), tc.want, tc.err) + } + }) + } +} + +func TestEnvReadsACountBeyondThirtyTwoBits(t *testing.T) { + t.Parallel() + + if strconv.IntSize < 64 { + t.Skip("skipping a count only a 64 bit int holds") + } + beyond := int64(3_000_000_000) + env := settings(map[string]string{"MYAPP_BATCH": "3000000000"}) + + got, err := env.Count("BATCH", 25) + + if int64(got) != beyond || err != nil { + t.Errorf("Count() = %d, %v, want %d, nil", got, err, beyond) + } +} + +func TestEnvReadsAFlag(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + value string + fallback bool + want bool + err string + }{ + {"an unset value under a true fallback", "", true, true, ""}, + {"an unset value under a false fallback", "", false, false, ""}, + {"false", "false", true, false, ""}, + {"a padded capital true", " TRUE ", false, true, ""}, + {"a zero", "0", true, false, ""}, + {"a word the reader does not know", "yes", true, false, `MYAPP_VERIFY: must be true or false, got "yes"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := settings(map[string]string{"MYAPP_VERIFY": tc.value}) + + got, err := env.Flag("VERIFY", tc.fallback) + + if got != tc.want || errorText(err) != tc.err { + t.Errorf("Flag() = %t, %q, want %t, %q", got, errorText(err), tc.want, tc.err) + } + }) + } +} + +func TestParseReadsASettingWithTheGivenParser(t *testing.T) { + t.Parallel() + + clock := func(value string) (time.Time, error) { + read, err := time.Parse("15:04", value) + if err != nil { + return time.Time{}, errors.New("must be a clock time like 03:00") + } + return read, nil + } + fallback := time.Date(0, 1, 1, 3, 0, 0, 0, time.UTC) + cases := []struct { + name string + value string + want time.Time + err string + }{ + {"an unset value", "", fallback, ""}, + {"a padded value", " 04:30 ", time.Date(0, 1, 1, 4, 30, 0, 0, time.UTC), ""}, + {"a value the parser refuses", "noon", time.Time{}, "MYAPP_RECONCILE_AT: must be a clock time like 03:00"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := settings(map[string]string{"MYAPP_RECONCILE_AT": tc.value}) + + got, err := gonsole.Parse(env, "RECONCILE_AT", fallback, clock) + + if !got.Equal(tc.want) || errorText(err) != tc.err { + t.Errorf("Parse() = %v, %q, want %v, %q", got, errorText(err), tc.want, tc.err) + } + }) + } +} + +func TestParseKeepsTheParserErrorInItsChain(t *testing.T) { + t.Parallel() + + refused := errors.New("must be a clock time like 03:00") + env := settings(map[string]string{"MYAPP_RECONCILE_AT": "noon"}) + + _, err := gonsole.Parse(env, "RECONCILE_AT", 0, func(string) (int, error) { return 0, refused }) + + if !errors.Is(err, refused) { + t.Errorf("errors.Is(err, refused) = false, want true") + } +} + +func TestRunHandsTheCommandTheProgramSettings(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + env gonsole.Env + want string + }{ + {"a program with settings", settings(map[string]string{"MYAPP_OWNER": "maria.perez@example.com"}), + "maria.perez@example.com\n"}, + {"a program without a reader", gonsole.Env{Prefix: "MYAPP_"}, "\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cmd := echo("report:list") + cmd.Run = func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintln(call.Stdout, call.Env.Getenv(call.Env.Key("OWNER"))) + return err + } + p := single(cmd) + p.Env = tc.env + + got := execute(t, p, "report:list") + + if got.stdout != tc.want { + t.Errorf("stdout = %q, want %q, stderr %q", got.stdout, tc.want, got.stderr) + } + }) + } +} diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go new file mode 100644 index 0000000..6d1a128 --- /dev/null +++ b/gonsole/exec_test.go @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bufio" + "bytes" + "errors" + "io" + "os" + "os/exec" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/internal/exampleapp" +) + +// exampleSwitch is the variable that turns the test binary into the example program. +const exampleSwitch = "GONSOLE_EXEC_EXAMPLE" + +func TestMain(m *testing.M) { + if os.Getenv(exampleSwitch) == "1" { + os.Exit(gonsole.Main(exampleapp.Program(os.Getenv))) + } + os.Exit(m.Run()) +} + +// exampleEnvironment returns the environment of the example program, no inherited MYAPP_ variable and variables added. +func exampleEnvironment(variables ...string) []string { + inherited := slices.DeleteFunc(os.Environ(), func(entry string) bool { return strings.HasPrefix(entry, "MYAPP_") }) + return append(append(inherited, exampleSwitch+"=1"), variables...) +} + +// runExample runs the example program in its own process over args, stdin and extra variables and answers its output. +func runExample(t *testing.T, stdin string, variables []string, args ...string) result { + t.Helper() + cmd := exec.CommandContext(t.Context(), os.Args[0], args...) + cmd.Env = exampleEnvironment(variables...) + cmd.Stdin = strings.NewReader(stdin) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + var exited *exec.ExitError + if err := cmd.Run(); err != nil && !errors.As(err, &exited) { + t.Fatalf("running the example program: %v", err) + } + return result{code: cmd.ProcessState.ExitCode(), stdout: stdout.String(), stderr: stderr.String()} +} + +// importing is the line report:import writes to stderr before it reads its input. +const importing = "reading the reports to import from the input" + +// exampleListing is the listing the example program prints. +const exampleListing = `myapp + +Usage: + myapp [flags] [arguments] + +` + intro + ` +Available commands: + check check every setting, every plugin and every command name + help print the help of one command + list list every command + migrate apply every schema step + seed store the demo data + serve run the server + version print the version + demo plugin + demo:sync sync the demo + report + report:create create a report + report:import import one report per line of the input + report:list list every report + report:revoke revoke one report +` + +func TestExamplePluginsDescribeTheDemoGroup(t *testing.T) { + t.Parallel() + + p := exampleapp.Program(func(string) string { return "" }) + + loaded, err := p.Plugins(t.Context(), gonsole.Call{Describe: true, Env: gonsole.Env{Prefix: "MYAPP_"}}) + + if err != nil || loaded.Failed != nil || !slices.Equal(namespaces(loaded.Groups), []string{"demo"}) { + t.Fatalf("Plugins() = %v failing %v, %v, want demo, nil and nil", namespaces(loaded.Groups), loaded.Failed, err) + } + if err := p.Check(loaded); err != nil { + t.Errorf("Check() = %v, want nil", err) + } +} + +func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + stdin string + variables []string + args []string + code int + stdout string + stderr string + }{ + {"a command that succeeds", "", nil, []string{"report:list"}, gonsole.ExitDone, "quarterly\nyearly\n", ""}, + {"a migration that reads its setting", "", []string{"MYAPP_DATABASE_URL=" + databaseAddress}, + []string{"migrate"}, gonsole.ExitDone, "migrated reports\nmigrated plugins\n", ""}, + {"a migration without its setting", "", nil, []string{"migrate"}, gonsole.ExitFailed, "", + "myapp: MYAPP_DATABASE_URL is required\n"}, + {"a write that reads its input", "sales by region\n", nil, []string{"report:create", "-yes", "Q3"}, + gonsole.ExitDone, "created Q3: sales by region\n", ""}, + {"a dry run of a write", "sales by region\n", nil, []string{"report:create", "Q3"}, gonsole.ExitDone, + "would create Q3: sales by region\n", "myapp: dry run, nothing changed, pass -yes to apply\n"}, + {"a write that reads every line of its input", "quarterly\nyearly\n", nil, []string{"report:import", "-yes"}, + gonsole.ExitDone, "imported 2 reports\n", importing + "\n"}, + {"an import of names made of several words", "Q3 sales\n\nQ4 plan\n", nil, []string{"report:import", "-yes"}, + gonsole.ExitDone, "imported 2 reports\n", importing + "\n"}, + {"a command that answers a document", "", nil, []string{"report:list", "-json"}, gonsole.ExitDone, `{ + "reports": [ + "quarterly", + "yearly" + ] +} +`, ""}, + {"a command that fails", "", nil, []string{"report:revoke", "monthly"}, gonsole.ExitFailed, "", + "myapp: report \"monthly\" does not exist\n"}, + {"a plugin write", "", []string{"MYAPP_DATABASE_URL=" + databaseAddress}, []string{"demo:sync", "-yes"}, + gonsole.ExitDone, "synced the demo\n", ""}, + {"a dry run of a plugin write", "", []string{"MYAPP_DATABASE_URL=" + databaseAddress}, []string{"demo:sync"}, + gonsole.ExitDone, "would sync the demo\n", dryRunNotice}, + {"a plugin write without its setting", "", nil, []string{"demo:sync"}, gonsole.ExitFailed, "", + "myapp: MYAPP_DATABASE_URL is required\n"}, + {"the help of a plugin command without its setting", "", nil, []string{"demo:sync", "-h"}, gonsole.ExitDone, + syncPage, ""}, + {"the listing without the database setting", "", nil, []string{"list"}, gonsole.ExitDone, exampleListing, ""}, + {"a name no plugin command owns", "", []string{"MYAPP_DATABASE_URL=" + databaseAddress}, + []string{"demo:nope"}, gonsole.ExitMisused, "", "myapp: unknown command \"demo:nope\", want demo:sync\n"}, + {"a namespace no plugin owns", "", []string{"MYAPP_DATABASE_URL=" + databaseAddress}, []string{"nope:x"}, + gonsole.ExitMisused, "", "myapp: unknown command \"nope:x\", want a command in demo\n"}, + {"a name no command owns", "", nil, []string{"reprot"}, gonsole.ExitMisused, "", + "myapp: unknown command \"reprot\", run \"myapp list\" to see every command\n"}, + {"a flag no command defines", "", nil, []string{"report:list", "-bogus"}, gonsole.ExitMisused, "", + `myapp: report:list: flag provided but not defined: -bogus + +list every report + +Usage: + myapp report:list [flags] + +Flags: + -json + answer one JSON document +`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := runExample(t, tc.stdin, tc.variables, tc.args...) + + if got.code != tc.code { + t.Errorf("code = %d, want %d, stderr %q", got.code, tc.code, got.stderr) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + }) + } +} + +func TestMainServesUntilASignalEndsTheRun(t *testing.T) { + t.Parallel() + + for _, signal := range []os.Signal{syscall.SIGTERM, os.Interrupt} { + t.Run(signal.String(), func(t *testing.T) { + t.Parallel() + + cmd := exec.CommandContext(t.Context(), os.Args[0], "serve") + cmd.Env = exampleEnvironment("MYAPP_ADDR=127.0.0.1:0") + stderr, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("piping stderr: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("starting the example program: %v", err) + } + lines := bufio.NewScanner(stderr) + address := listeningAddress(t, lines) + + body := get(t, address) + if err := cmd.Process.Signal(signal); err != nil { + t.Fatalf("signalling: %v", err) + } + var rest []string + for lines.Scan() { + rest = append(rest, lines.Text()) + } + err = cmd.Wait() + + if err != nil { + t.Errorf("exit = %v, want 0", err) + } + if body != "quarterly\nyearly\n" { + t.Errorf("body = %q, want the report names", body) + } + if !slices.ContainsFunc(rest, func(line string) bool { return strings.Contains(line, "shutting down") }) { + t.Errorf("stderr after the signal = %q, want a shutting down line", rest) + } + }) + } +} + +func TestMainLetsASecondSignalEndACommandThatIgnoresTheFirst(t *testing.T) { + t.Parallel() + + cmd := exec.CommandContext(t.Context(), os.Args[0], "report:import", "-yes") + cmd.Env = exampleEnvironment() + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatalf("piping stdin: %v", err) + } + defer func() { _ = stdin.Close() }() + stderr, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("piping stderr: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("starting the example program: %v", err) + } + stuck := time.AfterFunc(10*time.Second, func() { _ = cmd.Process.Kill() }) + defer stuck.Stop() + lines := bufio.NewScanner(stderr) + if !lines.Scan() || lines.Text() != importing { + t.Fatalf("stderr opens with %q, want %q before any signal", lines.Text(), importing) + } + exited := make(chan error, 1) + go func() { + _, _ = io.Copy(io.Discard, stderr) + exited <- cmd.Wait() + }() + + _ = cmd.Process.Signal(os.Interrupt) + select { + case err := <-exited: + t.Fatalf("the first signal ended the program with %v, want the run cancelled only", err) + case <-time.After(200 * time.Millisecond): + } + _ = cmd.Process.Signal(os.Interrupt) + + select { + case <-exited: + status, known := cmd.ProcessState.Sys().(syscall.WaitStatus) + if !known || !status.Signaled() { + t.Errorf("state = %v, want the second signal to end the program", cmd.ProcessState) + } + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + t.Errorf("the second signal left the program running") + } +} + +// listeningAddress reads lines until the server says it listens and returns the address it names. +func listeningAddress(t *testing.T, lines *bufio.Scanner) string { + t.Helper() + for lines.Scan() { + if _, address, found := strings.Cut(lines.Text(), "msg=listening addr="); found { + return address + } + } + t.Fatalf("the example program never said it listens") + return "" +} diff --git a/gonsole/go.mod b/gonsole/go.mod new file mode 100644 index 0000000..941d7aa --- /dev/null +++ b/gonsole/go.mod @@ -0,0 +1,3 @@ +module github.com/gopherium/framework/gonsole + +go 1.27.1 diff --git a/gonsole/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go new file mode 100644 index 0000000..ef84946 --- /dev/null +++ b/gonsole/internal/exampleapp/program.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package exampleapp builds the example program the module's tests run as its own process. +package exampleapp + +import ( + "bufio" + "cmp" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "slices" + "strings" + "time" + + "github.com/gopherium/framework/gonsole" +) + +// held returns the names of the reports the example program keeps. +func held() []string { + return []string{"quarterly", "yearly"} +} + +// Program returns the example program, myapp, whose settings getenv reads. +func Program(getenv func(string) string) gonsole.Program { + return gonsole.Program{ + Name: "myapp", + Env: gonsole.Env{Prefix: "MYAPP_", Getenv: getenv}, + Database: "DATABASE_URL", + Serve: serve, + Migrations: []gonsole.Step{{Name: "reports", Run: func(context.Context, string) error { return nil }}}, + Commands: []gonsole.Command{createCommand(), importCommand(), listCommand(), revokeCommand()}, + Plugins: plugins, + } +} + +// plugins registers the demo plugin, reading the database setting unless the run only describes commands. +func plugins(_ context.Context, call gonsole.Call) (gonsole.Loaded, error) { + if !call.Describe { + if _, err := call.DatabaseURL(); err != nil { + return gonsole.Loaded{}, err + } + } + groups, err := gonsole.Walk([]demo{{}}) + return gonsole.Loaded{Groups: groups, Failed: err}, nil +} + +// demo is the example program's one compiled plugin. +type demo struct{} + +// ID returns the plugin's id. +func (demo) ID() string { + return "demo" +} + +// Commands returns demo:sync, which syncs the demo data. +func (demo) Commands() []gonsole.Command { + return []gonsole.Command{{ + Name: "demo:sync", + Summary: "sync the demo", + Writes: true, + Run: func(_ context.Context, call gonsole.Call) error { + verb := "would sync" + if call.Apply { + verb = "synced" + } + _, err := fmt.Fprintf(call.Stdout, "%s the demo\n", verb) + return err + }, + }} +} + +// serve answers every request with the report names until the run ends. +func serve(ctx context.Context, call gonsole.Call) error { + timeouts, err := call.Env.Timeouts(gonsole.Timeouts{ + ReadHeader: 10 * time.Second, Read: 30 * time.Second, Idle: 120 * time.Second, Grace: 10 * time.Second, + }) + if err != nil { + return err + } + srv := gonsole.NewServer(cmp.Or(call.Env.Value("ADDR"), "localhost:8080"), http.HandlerFunc(answer), timeouts) + return gonsole.Serve(ctx, srv, timeouts, nil, slog.New(slog.NewTextHandler(call.Stderr, nil))) +} + +// answer writes the names of the reports. +func answer(w http.ResponseWriter, _ *http.Request) { + for _, name := range held() { + _, _ = fmt.Fprintln(w, name) + } +} + +// createCommand returns report:create, which creates one report described by the first line of its input. +func createCommand() gonsole.Command { + return gonsole.Command{ + Name: "report:create", + Summary: "create a report", + Args: []string{"title"}, + Writes: true, + Run: func(_ context.Context, call gonsole.Call) error { + description, err := bufio.NewReader(call.Stdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + verb := "would create" + if call.Apply { + verb = "created" + } + _, err = fmt.Fprintf(call.Stdout, "%s %s: %s\n", verb, call.Args[0], strings.TrimSpace(description)) + return err + }, + } +} + +// importCommand returns report:import, which imports one report per line of its input after saying it reads it. +func importCommand() gonsole.Command { + return gonsole.Command{ + Name: "report:import", + Summary: "import one report per line of the input", + Writes: true, + Run: func(_ context.Context, call gonsole.Call) error { + if _, err := fmt.Fprintln(call.Stderr, "reading the reports to import from the input"); err != nil { + return err + } + count, err := countNames(call.Stdin) + if err != nil { + return err + } + verb := "would import" + if call.Apply { + verb = "imported" + } + _, err = fmt.Fprintf(call.Stdout, "%s %d reports\n", verb, count) + return err + }, + } +} + +// countNames returns how many lines of input hold a report name. +func countNames(input io.Reader) (int, error) { + count := 0 + lines := bufio.NewScanner(input) + for lines.Scan() { + if strings.TrimSpace(lines.Text()) != "" { + count++ + } + } + return count, lines.Err() +} + +// listCommand returns report:list, which lists every report. +func listCommand() gonsole.Command { + return gonsole.Command{ + Name: "report:list", + Summary: "list every report", + JSON: true, + Run: func(_ context.Context, call gonsole.Call) error { + if call.JSON { + return call.Encode(map[string][]string{"reports": held()}) + } + for _, name := range held() { + if _, err := fmt.Fprintln(call.Stdout, name); err != nil { + return err + } + } + return nil + }, + } +} + +// revokeCommand returns report:revoke, which revokes one report. +func revokeCommand() gonsole.Command { + return gonsole.Command{ + Name: "report:revoke", + Summary: "revoke one report", + Args: []string{"name"}, + Run: func(_ context.Context, call gonsole.Call) error { + name := call.Args[0] + if !slices.Contains(held(), name) { + return fmt.Errorf("report %q does not exist", name) + } + _, err := fmt.Fprintf(call.Stdout, "revoked %s\n", name) + return err + }, + } +} diff --git a/gonsole/parse.go b/gonsole/parse.go new file mode 100644 index 0000000..e0160cb --- /dev/null +++ b/gonsole/parse.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "slices" + "strings" +) + +// asksHelp reports whether args ask for help, with the help command first or a help flag before any double dash. +func asksHelp(args []string) bool { + if len(args) > 0 && args[0] == "help" { + return true + } + for _, arg := range args { + if arg == "--" { + return false + } + if isHelpFlag(arg) { + return true + } + } + return false +} + +// subject returns the arguments of a help run before any double dash, without the help command and the help flags. +func subject(args []string) []string { + if args[0] == "help" { + args = args[1:] + } + var kept []string + for _, arg := range args { + if arg == "--" { + break + } + if !isHelpFlag(arg) { + kept = append(kept, arg) + } + } + return kept +} + +// isHelpFlag reports whether arg is one of the flags that ask for help. +func isHelpFlag(arg string) bool { + switch arg { + case "-h", "-help", "--h", "--help": + return true + } + return false +} + +// switches holds the engine flags one run of a command reads. +type switches struct { + yes bool + json bool + as string +} + +// flagSet returns a fresh flag set holding cmd's flags and the engine flags it offers, which set s. +func flagSet(cmd Command, s *switches) (*flag.FlagSet, error) { + fs := flag.NewFlagSet(cmd.Name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + if cmd.Flags != nil { + if value, panicked := declare(cmd, fs); panicked { + return nil, fmt.Errorf("gonsole: "+flagsPanicked, cmd.Name, value) + } + } + if cmd.Writes { + fs.BoolVar(&s.yes, "yes", false, "apply the change, a dry run without it") + } + if cmd.JSON { + fs.BoolVar(&s.json, "json", false, "answer one JSON document") + } + if cmd.Capability != "" { + fs.StringVar(&s.as, "as", "", "`email` address of the account acting") + } + return fs, nil +} + +// invoke reads args against cmd's flags and arguments and runs it, or prints its help page when they ask for it. +func (r *runner) invoke(ctx context.Context, cmd Command, args []string) (err error) { + defer recoverRun(cmd.Name, &err) + call, err := r.prepare(cmd, args) + if errors.Is(err, flag.ErrHelp) { + _, err = io.WriteString(r.stdout, r.page(r.reached, r.flags)) + return err + } + if err != nil { + return err + } + return r.perform(ctx, cmd, call) +} + +// prepare reads args against cmd's flags and arguments and returns the call that runs it. +func (r *runner) prepare(cmd Command, args []string) (Call, error) { + var s switches + fs, err := flagSet(cmd, &s) + if err != nil { + return Call{}, err + } + r.reached, r.flags = cmd, fs + positional, err := parse(fs, args) + if err != nil { + return Call{}, Misuse(fmt.Errorf("%s: %w", cmd.Name, err)) + } + if err := arity(cmd, positional); err != nil { + return Call{}, err + } + if cmd.Capability != "" && s.as == "" { + return Call{}, Misuse(fmt.Errorf("%s wants -as ", cmd.Name)) + } + return Call{ + Args: positional, Flags: given(fs), Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, Env: r.settings(), + JSON: s.json, Apply: s.yes || !cmd.Writes, Actor: s.as, database: r.program.Database, plugins: r.plugins, + }, nil +} + +// given returns the value of each of the command's own flags the line set on fs, the engine flags left out. +func given(fs *flag.FlagSet) map[string]string { + flags := map[string]string{} + fs.Visit(func(f *flag.Flag) { + if !slices.Contains(engineFlags, f.Name) { + flags[f.Name] = f.Value.String() + } + }) + return flags +} + +// parse sets the flags in args on fs and returns the positional arguments, flags and arguments in any order. +func parse(fs *flag.FlagSet, args []string) ([]string, error) { + var positional []string + for len(args) > 0 { + if err := fs.Parse(args); err != nil { + return nil, err + } + rest := fs.Args() + if terminated(fs, args, rest) { + return append(positional, rest...), nil + } + if len(rest) == 0 { + break + } + positional = append(positional, rest[0]) + args = rest[1:] + } + return positional, nil +} + +// terminated reports whether the arguments fs read from args hold the double dash that ends the flags. +func terminated(fs *flag.FlagSet, args, rest []string) bool { + read := len(args) - len(rest) + for i := 0; i < read; i++ { + if args[i] == "--" { + return true + } + if takesValue(fs, args[i]) { + i++ + } + } + return false +} + +// takesValue reports whether token, a flag fs read, takes the next argument as its value. +func takesValue(fs *flag.FlagSet, token string) bool { + name := strings.TrimPrefix(strings.TrimPrefix(token, "-"), "-") + if strings.Contains(name, "=") { + return false + } + boolean, isBoolean := fs.Lookup(name).Value.(interface{ IsBoolFlag() bool }) + return !isBoolean || !boolean.IsBoolFlag() +} + +// arity refuses positional arguments that do not match the names cmd declares. +func arity(cmd Command, positional []string) error { + switch want := len(cmd.Args); { + case len(positional) < want: + return Misuse(fmt.Errorf("%s wants <%s>", cmd.Name, cmd.Args[len(positional)])) + case len(positional) > want: + return Misuse(fmt.Errorf("%s takes %s, got %d", cmd.Name, arguments(want), len(positional))) + } + return nil +} + +// arguments names a count of arguments in plain English. +func arguments(n int) string { + switch n { + case 0: + return "no arguments" + case 1: + return "1 argument" + } + return fmt.Sprintf("%d arguments", n) +} diff --git a/gonsole/parse_test.go b/gonsole/parse_test.go new file mode 100644 index 0000000..7d6672d --- /dev/null +++ b/gonsole/parse_test.go @@ -0,0 +1,470 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "errors" + "flag" + "fmt" + "reflect" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// filing returns a program whose report:create takes a title, an owner flag and a draft switch and prints what it read. +func filing() gonsole.Program { + var owner string + var draft bool + return single(gonsole.Command{ + Name: "report:create", + Summary: "create a report", + Args: []string{"title"}, + Flags: func(fs *flag.FlagSet) { + fs.StringVar(&owner, "owner", "", "email address of the owner") + fs.BoolVar(&draft, "draft", false, "keep the report as a draft") + }, + Run: func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintf(call.Stdout, "title=%s owner=%s draft=%t\n", call.Args[0], owner, draft) + return err + }, + }) +} + +func TestRunReadsFlagsAndArgumentsInAnyOrder(t *testing.T) { + t.Parallel() + + const owner = "maria.perez@example.com" + cases := []struct { + name string + args []string + stdout string + }{ + {"no flags", []string{"Q3"}, "title=Q3 owner= draft=false\n"}, + {"flags first", []string{"-owner", owner, "-draft", "Q3"}, "title=Q3 owner=" + owner + " draft=true\n"}, + {"the argument first", []string{"Q3", "-owner", owner, "-draft"}, "title=Q3 owner=" + owner + " draft=true\n"}, + {"the argument between flags", []string{"-draft", "Q3", "-owner", owner}, + "title=Q3 owner=" + owner + " draft=true\n"}, + {"a double dash before a dashed argument", []string{"-owner", owner, "--", "-Q3"}, + "title=-Q3 owner=" + owner + " draft=false\n"}, + {"a double dash after a switch", []string{"-draft", "--", "-Q3"}, "title=-Q3 owner= draft=true\n"}, + {"a double dash after a flag with an equals sign", []string{"-owner=" + owner, "--", "-Q3"}, + "title=-Q3 owner=" + owner + " draft=false\n"}, + {"a double dash as the value of a flag", []string{"-owner", "--", "Q3", "-draft"}, "title=Q3 owner=-- draft=true\n"}, + {"a double dash after a value that looks like a flag", []string{"-owner", "-unset", "--", "-Q3"}, + "title=-Q3 owner=-unset draft=false\n"}, + {"a double dash as the value of a double dash flag", []string{"--owner", "--", "Q3", "-draft"}, + "title=Q3 owner=-- draft=true\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, filing(), append([]string{"report:create"}, tc.args...)...) + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + }) + } +} + +func TestRunRefusesAMalformedCommandLine(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stderr string + }{ + {"a missing argument", []string{"report:create"}, "myapp: report:create wants \n"}, + {"a missing argument after a switch", []string{"report:create", "-draft"}, "myapp: report:create wants <title>\n"}, + {"a stray argument", []string{"report:create", "Q3", "Q4"}, "myapp: report:create takes 1 argument, got 2\n"}, + {"a flag after a double dash", []string{"report:create", "--", "Q3", "-draft"}, + "myapp: report:create takes 1 argument, got 2\n"}, + {"flags after a double dash that follows a switch", []string{"report:create", "-draft", "--", "-Q3", "-owner", "x"}, + "myapp: report:create takes 1 argument, got 3\n"}, + {"a flag after a double dash that follows a plain value", []string{"report:create", "-owner", "owner", "--", "Q3", + "-draft"}, "myapp: report:create takes 1 argument, got 2\n"}, + {"a flag after a double dash that follows an unknown flag name as a value", []string{"report:create", "-owner", + "-unset", "--", "-Q3", "-draft"}, "myapp: report:create takes 1 argument, got 2\n"}, + {"a flag after a double dash that follows a flag name as a value", []string{"report:create", "-owner", "-owner", + "--", "Q3", "-draft"}, "myapp: report:create takes 1 argument, got 2\n"}, + {"a flag after a double dash that follows a double dash flag name as a value", []string{"report:create", + "-owner", "--owner", "--", "Q3", "-draft"}, "myapp: report:create takes 1 argument, got 2\n"}, + {"a flag after a double dash that follows a flag with an equals sign", []string{"report:create", + "-owner=x", "--", "Q3", "-draft"}, "myapp: report:create takes 1 argument, got 2\n"}, + {"an unknown flag", []string{"report:create", "-bogus", "Q3"}, + "myapp: report:create: flag provided but not defined: -bogus\n"}, + {"a switch given a value it cannot read", []string{"report:create", "-draft=maybe", "Q3"}, + "myapp: report:create: invalid boolean value \"maybe\" for -draft: parse error\n"}, + {"a flag missing its value", []string{"report:create", "Q3", "-owner"}, + "myapp: report:create: flag needs an argument: -owner\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, filing(), tc.args...) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if firstLine(got.stderr) != tc.stderr { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), tc.stderr) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want the command never run", got.stdout) + } + }) + } +} + +func TestRunCountsArgumentsInPlainEnglish(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + cmd gonsole.Command + args []string + stderr string + }{ + {"none wanted", echo("report:list"), []string{"extra"}, "myapp: report:list takes no arguments, got 1\n"}, + {"two wanted, three given", echo("report:move", "id", "folder"), []string{"a", "b", "c"}, + "myapp: report:move takes 2 arguments, got 3\n"}, + {"two wanted, one given", echo("report:move", "id", "folder"), []string{"a"}, + "myapp: report:move wants <folder>\n"}, + {"two wanted, none given", echo("report:move", "id", "folder"), nil, "myapp: report:move wants <id>\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, single(tc.cmd), append([]string{tc.cmd.Name}, tc.args...)...) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if firstLine(got.stderr) != tc.stderr { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), tc.stderr) + } + }) + } +} + +func TestRunRefusesAFlagOnACommandWithoutFlags(t *testing.T) { + t.Parallel() + + got := execute(t, single(echo("report:list")), "report:list", "-all") + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if want := "myapp: report:list: flag provided but not defined: -all\n"; firstLine(got.stderr) != want { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), want) + } +} + +func TestRunNamesTheFlagSetAfterTheCommand(t *testing.T) { + t.Parallel() + + var named string + cmd := echo("report:list") + cmd.Flags = func(fs *flag.FlagSet) { named = fs.Name() } + + execute(t, single(cmd), "report:list") + + if named != "report:list" { + t.Errorf("flag set name = %q, want report:list", named) + } +} + +// dryRunNotice is the line a dry run of myapp ends with on stderr. +const dryRunNotice = "myapp: dry run, nothing changed, pass -yes to apply\n" + +// drafting returns a program whose report:create writes and prints what it would do or what it did. +func drafting() gonsole.Program { + return single(gonsole.Command{ + Name: "report:create", + Summary: "create a report", + Args: []string{"title"}, + Writes: true, + Run: func(_ context.Context, call gonsole.Call) error { + verb := "would create" + if call.Apply { + verb = "created" + } + _, err := fmt.Fprintf(call.Stdout, "%s %s\n", verb, call.Args[0]) + return err + }, + }) +} + +func TestRunKeepsAWritingCommandADryRunUntilYes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdout string + stderr string + }{ + {"no switch", []string{"Q3"}, "would create Q3\n", dryRunNotice}, + {"the switch before the argument", []string{"-yes", "Q3"}, "created Q3\n", ""}, + {"the switch after the argument", []string{"Q3", "-yes"}, "created Q3\n", ""}, + {"the switch turned off", []string{"-yes=false", "Q3"}, "would create Q3\n", dryRunNotice}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, drafting(), append([]string{"report:create"}, tc.args...)...) + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + }) + } +} + +func TestRunPrintsNoDryRunNoticeAfterAFailure(t *testing.T) { + t.Parallel() + + p := drafting() + p.Commands[0].Run = func(context.Context, gonsole.Call) error { return errors.New("report store is down") } + + got := execute(t, p, "report:create", "Q3") + + if got.code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitFailed) + } + if want := "myapp: report store is down\n"; got.stderr != want { + t.Errorf("stderr = %q, want %q", got.stderr, want) + } +} + +func TestRunAppliesACommandThatDoesNotWrite(t *testing.T) { + t.Parallel() + + cmd := echo("report:list") + cmd.Run = func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintf(call.Stdout, "apply=%t\n", call.Apply) + return err + } + + got := execute(t, single(cmd), "report:list") + + if got.stdout != "apply=true\n" { + t.Errorf("stdout = %q, want apply=true", got.stdout) + } + if got.stderr != "" { + t.Errorf("stderr = %q, want no dry run notice", got.stderr) + } +} + +func TestRunHandsTheCommandTheJSONSwitch(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdout string + }{ + {"without the switch", nil, "json=false\n"}, + {"with the switch", []string{"-json"}, "json=true\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cmd := echo("report:list") + cmd.JSON = true + cmd.Run = func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintf(call.Stdout, "json=%t\n", call.JSON) + return err + } + + got := execute(t, single(cmd), append([]string{"report:list"}, tc.args...)...) + + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q, stderr %q", got.stdout, tc.stdout, got.stderr) + } + }) + } +} + +func TestRunRefusesAnEngineSwitchTheCommandDoesNotOffer(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + program gonsole.Program + args []string + stderr string + }{ + {"yes on a command that does not write", single(echo("report:list")), []string{"report:list", "-yes"}, + "myapp: report:list: flag provided but not defined: -yes\n"}, + {"json on a command without a document", drafting(), []string{"report:create", "-json", "Q3"}, + "myapp: report:create: flag provided but not defined: -json\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, tc.program, tc.args...) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if firstLine(got.stderr) != tc.stderr { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), tc.stderr) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want the command never run", got.stdout) + } + }) + } +} + +func TestRunReadsTheTwoEngineSwitchesApart(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdout string + stderr string + }{ + {"the apply switch alone", []string{"-yes", "Q3"}, "json=false apply=true\n", ""}, + {"the document switch alone", []string{"-json", "Q3"}, "json=true apply=false\n", dryRunNotice}, + {"both switches", []string{"-yes", "-json", "Q3"}, "json=true apply=true\n", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := drafting() + p.Commands[0].JSON = true + p.Commands[0].Run = func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintf(call.Stdout, "json=%t apply=%t\n", call.JSON, call.Apply) + return err + } + + got := execute(t, p, append([]string{"report:create"}, tc.args...)...) + + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + }) + } +} + +func TestRunKeepsTheDryRunDocumentAloneOnStdout(t *testing.T) { + t.Parallel() + + p := drafting() + p.Commands[0].JSON = true + p.Commands[0].Run = func(_ context.Context, call gonsole.Call) error { + return call.Encode(map[string]any{"title": call.Args[0], "applied": call.Apply}) + } + + got := execute(t, p, "report:create", "-json", "Q3") + + want := `{ + "applied": false, + "title": "Q3" +} +` + if got.stdout != want { + t.Errorf("stdout = %q, want %q", got.stdout, want) + } + if got.stderr != dryRunNotice { + t.Errorf("stderr = %q, want %q", got.stderr, dryRunNotice) + } +} + +// flagsSeen are the flags each hook of one run found in its call. +type flagsSeen struct { + authorize map[string]string + run map[string]string + record map[string]string +} + +// flagging returns a program whose report:create writes, answers JSON, acts and notes the flags every hook sees in s. +func flagging(s *flagsSeen) gonsole.Program { + p := single(gonsole.Command{ + Name: "report:create", Summary: "create a report", Args: []string{"title"}, Writes: true, JSON: true, + Capability: "manage_reports", + Flags: func(fs *flag.FlagSet) { + fs.String("owner", "", "email address of the owner") + fs.Bool("draft", false, "keep the report as a draft") + }, + Run: func(_ context.Context, call gonsole.Call) error { + s.run = call.Flags + return nil + }, + }) + p.Authorize = func(_ context.Context, call gonsole.Call, _ string) error { + s.authorize = call.Flags + return nil + } + p.Record = func(_ context.Context, call gonsole.Call, _ string) error { + s.record = call.Flags + return nil + } + return p +} + +func TestCallHoldsTheCommandsOwnFlagsTheLineSet(t *testing.T) { + t.Parallel() + + const owner = "maria.perez@example.com" + cases := []struct { + name string + args []string + want map[string]string + }{ + {"no flag", []string{"Q3"}, map[string]string{}}, + {"a flag and a switch", []string{"-owner", owner, "-draft", "Q3"}, + map[string]string{"owner": owner, "draft": "true"}}, + {"a switch set to its default", []string{"-draft=false", "Q3"}, map[string]string{"draft": "false"}}, + {"a flag given twice", []string{"-owner", "someone@example.com", "-owner", owner, "Q3"}, + map[string]string{"owner": owner}}, + {"a flag after a double dash", []string{"--", "-owner"}, map[string]string{}}, + {"the engine flags beside a flag", []string{"-json", "-owner", owner, "Q3"}, map[string]string{"owner": owner}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var seen flagsSeen + args := append([]string{"report:create", "-yes", "-as", actingAccount}, tc.args...) + + got := execute(t, flagging(&seen), args...) + + if got.code != gonsole.ExitDone { + t.Fatalf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + for hook, flags := range map[string]map[string]string{ + "Authorize": seen.authorize, "Run": seen.run, "Record": seen.record, + } { + if !reflect.DeepEqual(flags, tc.want) { + t.Errorf("%s saw Flags %#v, want %#v", hook, flags, tc.want) + } + } + }) + } +} diff --git a/gonsole/plugins.go b/gonsole/plugins.go new file mode 100644 index 0000000..3a42432 --- /dev/null +++ b/gonsole/plugins.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// Group is the commands one plugin offers under the namespace equal to its id. +type Group struct { + // Namespace is the plugin id every command name in the group starts with. + Namespace string + // Commands are the plugin's commands. + Commands []Command +} + +// Provider is implemented by plugins that offer commands under the namespace equal to their id. +type Provider interface { + // Commands returns the plugin's commands. + Commands() []Command +} + +// Walk returns the command groups of the plugins that implement Provider and an error naming each one that panicked. +func Walk[P interface{ ID() string }](plugins []P) ([]Group, error) { + var groups []Group + var panics []error + for _, plugin := range plugins { + offering, offers := any(plugin).(Provider) + if !offers { + continue + } + id := plugin.ID() + commands, err := provided(id, offering) + if err != nil { + panics = append(panics, err) + } + if len(commands) > 0 { + groups = append(groups, Group{Namespace: id, Commands: commands}) + } + } + return groups, errors.Join(panics...) +} + +// provided returns the commands p offers, a panic inside it as an error naming the plugin id. +func provided(id string, p Provider) (commands []Command, err error) { + defer func() { + if value := recover(); value != nil { + err = fmt.Errorf("plugin %s: commands panicked: %v", id, value) + } + }() + return p.Commands(), nil +} + +// Loaded is what registering the plugins answers. +type Loaded struct { + // Groups are the command groups, one per plugin that offers commands. + Groups []Group + // Migrate applies every plugin's schema in registration order. + Migrate func(ctx context.Context) error + // Seed stores every plugin's demo data in registration order. + Seed func(ctx context.Context) error + // Failed joins the errors of plugins that failed to register or to describe their commands. + Failed error + // Release stops every registered plugin and closes what registering opened. + Release func(ctx context.Context) error +} + +// memo is the one registration of the plugins a run makes. +type memo struct { + register func(ctx context.Context, call Call) (Loaded, error) + call Call + audit *audit + mu sync.Mutex + done bool + loaded Loaded + err error + commands map[string]Command + namespaces map[string][]string +} + +// answer registers the plugins once, in describe mode when describe is set, and returns that registration's answer. +func (m *memo) answer(ctx context.Context, describe bool) (Loaded, error) { + if m.register == nil { + return Loaded{}, nil + } + m.mu.Lock() + defer m.mu.Unlock() + if !m.done { + m.done = true + m.call.Describe = describe + m.loaded, m.err = m.registered(ctx) + if m.err == nil { + m.admit() + } + } + return m.loaded, m.err +} + +// missing returns the lines of what failed to load: the registration error, or the plugin failures and the offences. +func (m *memo) missing() []string { + if m.err != nil { + return lines(m.err) + } + failed := lines(m.loaded.Failed) + for _, offence := range m.audit.offences { + failed = append(failed, lines(offence)...) + } + return failed +} + +// admit indexes the commands of the registered groups that break no rule. +func (m *memo) admit() { + var kept []Command + for _, group := range m.loaded.Groups { + kept = append(kept, m.audit.admit(group)...) + } + m.commands, m.namespaces = index(kept) +} + +// registered returns what the program's Plugins answers, a panic inside it as the error naming the plugins. +func (m *memo) registered(ctx context.Context) (loaded Loaded, err error) { + defer recoverRun("plugins", &err) + return m.register(ctx, m.call) +} + +// release stops the registered plugins under a context the end of the run cannot cancel, nothing when none registered. +func (m *memo) release(ctx context.Context) (err error) { + m.mu.Lock() + defer m.mu.Unlock() + defer recoverRun("plugins", &err) + if err := optional(context.WithoutCancel(ctx), m.loaded.Release); err != nil { + return fmt.Errorf("release the plugins: %w", err) + } + return nil +} diff --git a/gonsole/plugins_test.go b/gonsole/plugins_test.go new file mode 100644 index 0000000..31dc880 --- /dev/null +++ b/gonsole/plugins_test.go @@ -0,0 +1,839 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/gopherium/framework/gonsole" +) + +// compiled is a fake compiled plugin, known by its id. +type compiled interface { + ID() string +} + +// silent is a compiled plugin that offers no commands and counts each read of its id. +type silent struct { + id string + reads *int +} + +// ID returns the plugin's id. +func (s silent) ID() string { + if s.reads != nil { + *s.reads++ + } + return s.id +} + +// nameless is a compiled plugin that offers commands and panics when asked for its id. +type nameless struct { + calls *int +} + +// ID panics. +func (nameless) ID() string { + panic("no id") +} + +// Commands returns one command and counts the call. +func (n nameless) Commands() []gonsole.Command { + *n.calls++ + return []gonsole.Command{echo("nameless:one")} +} + +// provider is a compiled plugin that offers the commands it holds and counts each call of Commands. +type provider struct { + id string + commands []gonsole.Command + calls *int + panics any +} + +// ID returns the plugin's id. +func (p provider) ID() string { + return p.id +} + +// Commands returns the plugin's commands, or panics with the value it holds. +func (p provider) Commands() []gonsole.Command { + if p.calls != nil { + *p.calls++ + } + if p.panics != nil { + panic(p.panics) + } + return p.commands +} + +var _ gonsole.Provider = provider{} + +// namespaces returns the namespace of every group in order. +func namespaces(groups []gonsole.Group) []string { + var held []string + for _, group := range groups { + held = append(held, group.Namespace) + } + return held +} + +// names returns the full names of the commands in every group, one list per group. +func names(groups []gonsole.Group) map[string][]string { + held := map[string][]string{} + for _, group := range groups { + for _, cmd := range group.Commands { + held[group.Namespace] = append(held[group.Namespace], cmd.Name) + } + } + return held +} + +func TestWalkGathersTheCommandsOfEveryProviderInOrder(t *testing.T) { + t.Parallel() + + plugins := []compiled{ + provider{id: "alpha", commands: []gonsole.Command{echo("alpha:one"), echo("alpha:two")}}, + provider{id: "beta", commands: []gonsole.Command{echo("beta:one")}}, + } + + groups, err := gonsole.Walk(plugins) + + if err != nil { + t.Fatalf("Walk() error = %v, want nil", err) + } + if got := namespaces(groups); !slices.Equal(got, []string{"alpha", "beta"}) { + t.Fatalf("namespaces = %v, want alpha then beta", got) + } + want := map[string][]string{"alpha": {"alpha:one", "alpha:two"}, "beta": {"beta:one"}} + if got := names(groups); !reflect.DeepEqual(got, want) { + t.Errorf("commands = %v, want %v", got, want) + } +} + +func TestWalkNamesEachGroupAfterItsPlugin(t *testing.T) { + t.Parallel() + + groups, err := gonsole.Walk([]compiled{provider{id: "demo", commands: []gonsole.Command{echo("other:sync")}}}) + + if err != nil || len(groups) != 1 || groups[0].Namespace != "demo" { + t.Errorf("Walk() = %v, %v, want one group named demo", names(groups), err) + } +} + +func TestWalkAsksEachProviderOnce(t *testing.T) { + t.Parallel() + + var alpha, beta int + plugins := []compiled{ + provider{id: "alpha", commands: []gonsole.Command{echo("alpha:one")}, calls: &alpha}, + provider{id: "beta", commands: []gonsole.Command{echo("beta:one")}, calls: &beta}, + } + + _, _ = gonsole.Walk(plugins) + + if alpha != 1 || beta != 1 { + t.Errorf("calls = %d, %d, want 1 each", alpha, beta) + } +} + +func TestWalkLeavesOutPluginsWithoutCommands(t *testing.T) { + t.Parallel() + + plugins := []compiled{ + silent{id: "quiet"}, + provider{id: "none"}, + provider{id: "empty", commands: []gonsole.Command{}}, + provider{id: "alpha", commands: []gonsole.Command{echo("alpha:one")}}, + } + + groups, err := gonsole.Walk(plugins) + + if got := namespaces(groups); !slices.Equal(got, []string{"alpha"}) || err != nil { + t.Errorf("Walk() = %v, %v, want only alpha and no error", got, err) + } +} + +func TestWalkReadsNoIDOfAPluginThatIsNoProvider(t *testing.T) { + t.Parallel() + + var reads int + + _, _ = gonsole.Walk([]compiled{silent{id: "quiet", reads: &reads}}) + + if reads != 0 { + t.Errorf("ID() read %d times, want 0", reads) + } +} + +func TestWalkLetsAPanickingIDThrough(t *testing.T) { + t.Parallel() + + var calls int + defer func() { + if value := recover(); value != "no id" || calls != 0 { + t.Errorf("recovered %v after %d calls of Commands, want the panic of ID and no call", value, calls) + } + }() + + _, _ = gonsole.Walk([]compiled{nameless{calls: &calls}}) +} + +func TestWalkOfNoPlugins(t *testing.T) { + t.Parallel() + + groups, err := gonsole.Walk[compiled](nil) + + if groups != nil || err != nil { + t.Errorf("Walk() = %v, %v, want nil and nil", groups, err) + } +} + +func TestWalkKeepsTheOtherGroupsWhenAPluginPanics(t *testing.T) { + t.Parallel() + + plugins := []compiled{ + provider{id: "alpha", commands: []gonsole.Command{echo("alpha:one")}}, + provider{id: "broken", panics: "boom"}, + provider{id: "beta", commands: []gonsole.Command{echo("beta:one")}}, + } + + groups, err := gonsole.Walk(plugins) + + if got := namespaces(groups); !slices.Equal(got, []string{"alpha", "beta"}) { + t.Errorf("namespaces = %v, want alpha then beta", got) + } + if want := map[string][]string{"alpha": {"alpha:one"}, "beta": {"beta:one"}}; !reflect.DeepEqual(names(groups), want) { + t.Errorf("groups = %v, want %v", names(groups), want) + } + if errorText(err) != "plugin broken: commands panicked: boom" { + t.Errorf("Walk() error = %q, want the panic named", errorText(err)) + } +} + +func TestWalkJoinsEveryPanic(t *testing.T) { + t.Parallel() + + plugins := []compiled{provider{id: "a", panics: "x"}, provider{id: "b", panics: errors.New("y")}} + + groups, err := gonsole.Walk(plugins) + + if groups != nil { + t.Errorf("groups = %v, want nil", namespaces(groups)) + } + if errorText(err) != "plugin a: commands panicked: x\nplugin b: commands panicked: y" { + t.Errorf("Walk() error = %q, want both panics", errorText(err)) + } + var joined interface{ Unwrap() []error } + if !errors.As(err, &joined) || len(joined.Unwrap()) != 2 { + t.Errorf("Walk() error = %v, want two joined errors", err) + } +} + +// registry is what a program's Plugins answers and the log of every registration and release. +type registry struct { + mu sync.Mutex + groups []gonsole.Group + failed error + fail error + lost error + refuse error + withoutRelease bool + calls []gonsole.Call + log []string +} + +// note appends one entry to the log. +func (r *registry) note(format string, args ...any) { + r.mu.Lock() + defer r.mu.Unlock() + r.log = append(r.log, fmt.Sprintf(format, args...)) +} + +// register answers the groups, the plugin failures and the failure r holds, with a release unless r goes without one. +func (r *registry) register(_ context.Context, call gonsole.Call) (gonsole.Loaded, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, call) + r.log = append(r.log, fmt.Sprintf("register describe=%t", call.Describe)) + loaded := gonsole.Loaded{Groups: r.groups, Failed: r.failed} + if !r.withoutRelease { + loaded.Release = r.release + } + return loaded, r.fail +} + +// release logs whether its context is live and answers the failure r holds. +func (r *registry) release(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + r.log = append(r.log, fmt.Sprintf("release live=%t", ctx.Err() == nil)) + return r.lost +} + +// demoGroups returns one plugin group, demo, whose demo:list answers a document, demo:move acts and demo:sync writes. +func demoGroups() []gonsole.Group { + list := gonsole.Command{Name: "demo:list", Summary: "list the demo", JSON: true, + Run: func(_ context.Context, call gonsole.Call) error { + if call.JSON { + return call.Encode(map[string][]string{"demo": {"synced"}}) + } + _, err := fmt.Fprintln(call.Stdout, "synced") + return err + }} + move := gonsole.Command{Name: "demo:move", Summary: "move the demo", Capability: "manage_demo", + Run: func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintf(call.Stdout, "moved as %s\n", call.Actor) + return err + }} + sync := gonsole.Command{Name: "demo:sync", Summary: "sync the demo", Writes: true, + Run: func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintf(call.Stdout, "sync apply=%t\n", call.Apply) + return err + }} + return []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{list, move, sync}}} +} + +// plugged returns a program called myapp whose plugins r registers, with a status command and report:plugins. +func plugged(r *registry) gonsole.Program { + namespaced := gonsole.Command{ + Name: "report:plugins", + Summary: "print the plugin namespaces", + Run: func(ctx context.Context, call gonsole.Call) error { + loaded, err := call.Plugins(ctx) + if err != nil { + return err + } + _, err = fmt.Fprintln(call.Stdout, namespaces(loaded.Groups)) + return err + }, + } + return gonsole.Program{ + Name: "myapp", + Env: settings(map[string]string{"MYAPP_DATABASE_URL": databaseAddress}), + Database: "DATABASE_URL", + Commands: []gonsole.Command{echo("status"), namespaced}, + Plugins: r.register, + Authorize: func(_ context.Context, call gonsole.Call, capability string) error { + r.note("authorize %s for %s", call.Actor, capability) + return r.refuse + }, + Record: func(_ context.Context, call gonsole.Call, command string) error { + r.note("record %s ran %s", call.Actor, command) + return nil + }, + } +} + +// pluginsPage is the help page of the report:plugins command plugged declares. +const pluginsPage = `print the plugin namespaces + +Usage: + myapp report:plugins +` + +func TestPluginsNeedACallTheEngineBuilt(t *testing.T) { + t.Parallel() + + _, err := gonsole.Call{}.Plugins(t.Context()) + + if want := "gonsole: no plugins in this call"; errorText(err) != want { + t.Errorf("Plugins() error = %q, want %q", errorText(err), want) + } +} + +func TestPluginsOfAProgramWithoutPluginsAreEmpty(t *testing.T) { + t.Parallel() + + var loaded gonsole.Loaded + var err error + cmd := echo("status") + cmd.Run = func(ctx context.Context, call gonsole.Call) error { + loaded, err = call.Plugins(ctx) + return nil + } + + got := execute(t, single(cmd), "status") + + if got.code != gonsole.ExitDone || loaded.Groups != nil || loaded.Release != nil || err != nil { + t.Errorf("code %d, Plugins() = %v with release %t, %v, want 0, no group, no release and nil", + got.code, namespaces(loaded.Groups), loaded.Release != nil, err) + } +} + +func TestRunRegistersThePluginsOnceAndReleasesThemOnce(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fail error + want string + }{ + {"a registration that succeeds", nil, "[demo] <nil>"}, + {"a registration that fails", errors.New("the plugin table is locked"), "[demo] the plugin table is locked"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups(), fail: tc.fail} + var answers []string + ask := func(ctx context.Context, call gonsole.Call) { + for range 2 { + loaded, err := call.Plugins(ctx) + answers = append(answers, fmt.Sprintf("%v %v", namespaces(loaded.Groups), err)) + } + } + p := plugged(r) + p.Commands = append(p.Commands, gonsole.Command{ + Name: "report:sync", Summary: "sync every report", Capability: "manage_reports", + Run: func(ctx context.Context, call gonsole.Call) error { + ask(ctx, call) + return nil + }, + }) + p.Authorize = func(ctx context.Context, call gonsole.Call, _ string) error { + ask(ctx, call) + return nil + } + p.Record = func(context.Context, gonsole.Call, string) error { return nil } + + got := execute(t, p, "report:sync", "-as", actingAccount) + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if want := slices.Repeat([]string{tc.want}, 4); !slices.Equal(answers, want) { + t.Errorf("answers = %q, want %q", answers, want) + } + if want := []string{"register describe=false", "release live=true"}; !slices.Equal(r.log, want) { + t.Errorf("calls = %q, want %q", r.log, want) + } + }) + } +} + +func TestPluginsRegisterOnceForEveryGoroutineOfARun(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups()} + p := plugged(r) + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { _, _ = call.Plugins(ctx) }) + } + wg.Wait() + return nil + } + + got := execute(t, p, "report:plugins") + + want := []string{"register describe=false", "release live=true"} + if got.code != gonsole.ExitDone || !slices.Equal(r.log, want) { + t.Errorf("code %d, calls = %q, want 0 and %q", got.code, r.log, want) + } +} + +func TestPluginsRegisterWithTheStreamsAndSettingsOfTheRun(t *testing.T) { + t.Parallel() + + r := ®istry{} + stdin := strings.NewReader("") + var stdout, stderr bytes.Buffer + + code := plugged(r).Run(t.Context(), []string{"report:plugins"}, stdin, &stdout, &stderr) + + if code != gonsole.ExitDone || len(r.calls) != 1 { + t.Fatalf("code = %d after %d registrations, want 0 after 1, stderr %q", code, len(r.calls), stderr.String()) + } + call := r.calls[0] + if call.Stdin != stdin || call.Stdout != &stdout || call.Stderr != &stderr { + t.Errorf("registration streams = %p %p %p, want the streams of the run", call.Stdin, call.Stdout, call.Stderr) + } + if address, err := call.DatabaseURL(); address != databaseAddress || err != nil { + t.Errorf("DatabaseURL() = %q, %v, want %q", address, err, databaseAddress) + } + if call.Env.Prefix != "MYAPP_" || call.Args != nil || call.JSON || call.Apply || call.Actor != "" || call.Describe { + t.Errorf("registration call = %+v, want the program settings and nothing of the command", call) + } +} + +func TestPluginsHandACoreCommandTheFailuresWithoutAWarning(t *testing.T) { + t.Parallel() + + failed := errors.New("plugin billing: no signing key") + var seen error + p := plugged(®istry{groups: demoGroups(), failed: failed}) + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + loaded, err := call.Plugins(ctx) + seen = loaded.Failed + return err + } + + got := execute(t, p, "report:plugins") + + if got.code != gonsole.ExitDone || got.stderr != "" || !errors.Is(seen, failed) { + t.Errorf("run = %d, %q, Failed %v, want 0, no warning and %v", got.code, got.stderr, seen, failed) + } +} + +func TestRegistrationGetsACallWithoutPlugins(t *testing.T) { + t.Parallel() + + var inner error + p := plugged(®istry{}) + p.Plugins = func(ctx context.Context, call gonsole.Call) (gonsole.Loaded, error) { + _, inner = call.Plugins(ctx) + return gonsole.Loaded{}, nil + } + + got := execute(t, p, "report:plugins") + + if want := "gonsole: no plugins in this call"; got.code != gonsole.ExitDone || errorText(inner) != want { + t.Errorf("code %d, inner Plugins() error = %q, want 0 and %q", got.code, errorText(inner), want) + } +} + +func TestPluginsAnswerTheLoadedAsRegistered(t *testing.T) { + t.Parallel() + + r := ®istry{groups: []gonsole.Group{ + {Namespace: "demo", Commands: []gonsole.Command{echo("demo:Sync"), echo("demo:sync")}}, + {Namespace: "list", Commands: []gonsole.Command{echo("list:all")}}, + }} + var loaded gonsole.Loaded + p := plugged(r) + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + loaded, _ = call.Plugins(ctx) + return nil + } + + execute(t, p, "report:plugins") + + if got := namespaces(loaded.Groups); !slices.Equal(got, []string{"demo", "list"}) { + t.Errorf("namespaces = %v, want demo then list", got) + } + if want := map[string][]string{"demo": {"demo:Sync", "demo:sync"}, "list": {"list:all"}}; !reflect.DeepEqual( + names(loaded.Groups), want) { + t.Errorf("groups = %v, want %v", names(loaded.Groups), want) + } +} + +func TestRunRegistersNoPluginsForARunThatAsksForNone(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"status"}, {"version"}, {"help", "report:plugins"}, {"report:plugins", "-h"}, {"report:nope"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups()} + + execute(t, plugged(r), args...) + + if len(r.log) != 0 { + t.Errorf("calls = %q, want none", r.log) + } + }) + } +} + +func TestRunRefusesABrokenProgramBeforeItRegisters(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{{"report:plugins"}, {"list"}, {"-h"}, {"demo:sync"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups()} + p := plugged(r) + p.Commands = append(p.Commands, echo("list")) + + got := execute(t, p, args...) + + if want := "myapp: gonsole: command \"list\" is a base command\n"; got.code != gonsole.ExitFailed || + got.stderr != want { + t.Errorf("code %d, stderr = %q, want %d and %q", got.code, got.stderr, gonsole.ExitFailed, want) + } + if len(r.log) != 0 { + t.Errorf("calls = %q, want none", r.log) + } + }) + } +} + +func TestRunReleasesThePluginsWithALiveContextAfterTheRunEnds(t *testing.T) { + t.Parallel() + + type key struct{} + var seen string + ctx, cancel := context.WithCancel(context.WithValue(t.Context(), key{}, "run")) + defer cancel() + p := plugged(®istry{}) + p.Plugins = func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + return gonsole.Loaded{Release: func(ctx context.Context) error { + seen = fmt.Sprintf("%v live=%t", ctx.Value(key{}), ctx.Err() == nil) + return nil + }}, nil + } + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + _, err := call.Plugins(ctx) + cancel() + return err + } + + var stderr strings.Builder + code := p.Run(ctx, []string{"report:plugins"}, strings.NewReader(""), &stderr, &stderr) + + if code != gonsole.ExitDone || seen != "run live=true" { + t.Errorf("code %d, release saw %q, want 0 and %q, stderr %q", code, seen, "run live=true", stderr.String()) + } +} + +func TestRunReleasesThePluginsAfterTheDryRunNoticeAndTheRecord(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stderr string + }{ + {"a dry run", []string{"report:sync", "-as", actingAccount}, + "ran\nmyapp: dry run, nothing changed, pass -yes to apply\nreleased\n"}, + {"an applied run", []string{"report:sync", "-yes", "-as", actingAccount}, "ran\nrecorded\nreleased\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := plugged(®istry{}) + p.Plugins = func(_ context.Context, call gonsole.Call) (gonsole.Loaded, error) { + return gonsole.Loaded{Release: func(context.Context) error { + _, err := fmt.Fprintln(call.Stderr, "released") + return err + }}, nil + } + p.Commands = append(p.Commands, gonsole.Command{ + Name: "report:sync", Summary: "sync every report", Writes: true, Capability: "manage_reports", + Run: func(ctx context.Context, call gonsole.Call) error { + if _, err := call.Plugins(ctx); err != nil { + return err + } + _, err := fmt.Fprintln(call.Stderr, "ran") + return err + }, + }) + p.Authorize = func(context.Context, gonsole.Call, string) error { return nil } + p.Record = func(_ context.Context, call gonsole.Call, _ string) error { + _, err := fmt.Fprintln(call.Stderr, "recorded") + return err + } + + got := execute(t, p, tc.args...) + + if got.code != gonsole.ExitDone || got.stderr != tc.stderr { + t.Errorf("code %d, stderr = %q, want 0 and %q", got.code, got.stderr, tc.stderr) + } + }) + } +} + +func TestRunAddsAReleaseFailureToTheAnswer(t *testing.T) { + t.Parallel() + + lost := errors.New("the pool would not close") + const released = "myapp: release the plugins: the pool would not close\n" + cases := []struct { + name string + answer error + lost error + withoutRelease bool + code int + stderr string + }{ + {"a command that succeeds", nil, lost, false, gonsole.ExitFailed, released}, + {"a command that fails", errors.New("the report store vanished"), lost, false, gonsole.ExitFailed, + "myapp: the report store vanished\n" + released}, + {"a command misused", gonsole.Misuse(errors.New("report:plugins wants -since")), lost, false, + gonsole.ExitMisused, "myapp: report:plugins wants -since\n" + released + "\n" + pluginsPage}, + {"a command that answers with help", fmt.Errorf("asked for the page: %w", flag.ErrHelp), lost, false, + gonsole.ExitFailed, released}, + {"a command that fails before a release that succeeds", errors.New("the report store vanished"), nil, + false, gonsole.ExitFailed, "myapp: the report store vanished\n"}, + {"a command that succeeds without a release", nil, nil, true, gonsole.ExitDone, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := plugged(®istry{lost: tc.lost, withoutRelease: tc.withoutRelease}) + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + _, _ = call.Plugins(ctx) + return tc.answer + } + + got := execute(t, p, "report:plugins") + + if got.code != tc.code || got.stderr != tc.stderr { + t.Errorf("code %d, stderr = %q, want %d and %q", got.code, got.stderr, tc.code, tc.stderr) + } + }) + } +} + +func TestRunTurnsAPanicOfThePluginsIntoAFailure(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + plugins func(context.Context, gonsole.Call) (gonsole.Loaded, error) + wraps string + line string + }{ + {"a registration that panics", func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + panic("the plugin table vanished") + }, "", "myapp: plugins: panic: the plugin table vanished\n"}, + {"a registration that panics under a command that wraps it", func(context.Context, gonsole.Call) ( + gonsole.Loaded, error) { + panic("the plugin table vanished") + }, "load the plugins", "myapp: load the plugins: plugins: panic: the plugin table vanished\n"}, + {"a release that panics", func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + return gonsole.Loaded{Release: func(context.Context) error { panic("the pool vanished") }}, nil + }, "", "myapp: plugins: panic: the pool vanished\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := plugged(®istry{}) + p.Plugins = tc.plugins + if tc.wraps != "" { + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + _, err := call.Plugins(ctx) + return fmt.Errorf("%s: %w", tc.wraps, err) + } + } + + got := execute(t, p, "report:plugins") + + if got.code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitFailed) + } + stack, opened := strings.CutPrefix(got.stderr, tc.line) + if !opened { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), tc.line) + } + if !strings.HasPrefix(stack, "goroutine ") { + t.Errorf("after the panic line = %q, want the raw stack", firstLine(stack)) + } + }) + } +} + +func TestRunPrintsTheStackOfEveryPanic(t *testing.T) { + t.Parallel() + + p := plugged(®istry{}) + p.Plugins = func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + return gonsole.Loaded{Release: func(context.Context) error { panic("the pool vanished") }}, nil + } + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + _, _ = call.Plugins(ctx) + panic("the report store vanished") + } + + got := execute(t, p, "report:plugins") + + const lines = "myapp: report:plugins: panic: the report store vanished\nmyapp: plugins: panic: the pool vanished\n" + stacks, opened := strings.CutPrefix(got.stderr, lines) + if got.code != gonsole.ExitFailed || !opened { + t.Fatalf("code %d, stderr opens with %q, want %d and %q", got.code, firstLine(got.stderr), gonsole.ExitFailed, + lines) + } + run, release, split := strings.Cut(stacks, "\ngoroutine ") + if !split || !strings.Contains(run, "(*runner).invoke") || !strings.Contains(release, "(*memo).release") { + t.Errorf("stacks = %q, want the stack of the run and then the stack of the release", stacks) + } +} + +func TestRunWaitsForARegistrationStillRunningBeforeItReleases(t *testing.T) { + t.Parallel() + + entered, proceed := make(chan struct{}), make(chan struct{}) + released := false + p := plugged(®istry{}) + p.Plugins = func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + close(entered) + <-proceed + return gonsole.Loaded{Release: func(context.Context) error { + released = true + return nil + }}, nil + } + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + go func() { _, _ = call.Plugins(ctx) }() + <-entered + time.AfterFunc(50*time.Millisecond, func() { close(proceed) }) + return nil + } + + got := execute(t, p, "report:plugins") + + if got.code != gonsole.ExitDone || !released { + t.Errorf("code %d, released %t, want 0 and the release the registration answered", got.code, released) + } +} + +func TestPluginsRegisterUnderTheContextOfTheFirstCaller(t *testing.T) { + t.Parallel() + + type key struct{} + var seen string + p := plugged(®istry{}) + p.Plugins = func(ctx context.Context, _ gonsole.Call) (gonsole.Loaded, error) { + seen = fmt.Sprintf("%v %v", ctx.Value(key{}), ctx.Err()) + return gonsole.Loaded{}, nil + } + p.Commands[1].Run = func(ctx context.Context, call gonsole.Call) error { + caller, cancel := context.WithCancel(context.WithValue(ctx, key{}, "caller")) + cancel() + _, err := call.Plugins(caller) + return err + } + + got := execute(t, p, "report:plugins") + + if want := "caller context canceled"; got.code != gonsole.ExitDone || seen != want { + t.Errorf("code %d, registration saw %q, want 0 and %q, stderr %q", got.code, seen, want, got.stderr) + } +} + +func TestRegistrationReadsTheSettingsOfAProgramWithoutAReader(t *testing.T) { + t.Parallel() + + owner := "unread" + p := plugged(®istry{}) + p.Env = gonsole.Env{Prefix: "MYAPP_"} + p.Plugins = func(_ context.Context, call gonsole.Call) (gonsole.Loaded, error) { + owner = call.Env.Getenv(call.Env.Key("OWNER")) + return gonsole.Loaded{}, nil + } + + got := execute(t, p, "report:plugins") + + if got.code != gonsole.ExitDone || owner != "" { + t.Errorf("code %d, owner %q, want 0 and empty, stderr %q", got.code, owner, got.stderr) + } +} diff --git a/gonsole/program.go b/gonsole/program.go new file mode 100644 index 0000000..7df18b8 --- /dev/null +++ b/gonsole/program.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "slices" + "syscall" +) + +// ExitDone is the code of a finished command, a help page or a dry run. +const ExitDone = 0 + +// ExitFailed is the code of a command that ran and failed. +const ExitFailed = 1 + +// ExitMisused is the code of a command line the program cannot read. +const ExitMisused = 2 + +// ErrMisused marks an error the program answers with ExitMisused. +var ErrMisused = errors.New("gonsole: misused") + +// Misuse returns err wrapped with ErrMisused. +func Misuse(err error) error { + if err == nil { + return nil + } + return misuse{err: err} +} + +// misuse is an error the program answers with ExitMisused. +type misuse struct { + err error +} + +// Error returns the message of the wrapped error. +func (m misuse) Error() string { + return m.err.Error() +} + +// Unwrap returns ErrMisused and the wrapped error. +func (m misuse) Unwrap() []error { + return []error{ErrMisused, m.err} +} + +// Program is one executable's command line. +type Program struct { + // Name is the executable name, the first word of every usage line and error. + Name string + // Title is the line the listing opens with. + Title string + // Version is the version the version command prints. + Version string + // Footer is the text the listing closes with. + Footer string + // Env reads the program's settings under its prefix. + Env Env + // Database is the name of the setting that holds the database address. + Database string + // Reserved lists namespaces core keeps before any of its commands uses them. + Reserved []string + // Renamed maps an old two word spelling to the full name of the command that replaced it. + Renamed map[string]string + // BareServes reports whether a run that names no command serves instead of printing the listing. + BareServes bool + // Serve runs the server. + Serve func(ctx context.Context, call Call) error + // Validate reads and checks every core setting. + Validate func(ctx context.Context, call Call) error + // Migrations are the core schema steps in the order they apply. + Migrations []Step + // Lock holds the database against concurrent migrations and returns its release. + Lock func(ctx context.Context, databaseURL string) (func(context.Context) error, error) + // Seed stores the core demo data over a migrated schema. + Seed func(ctx context.Context, call Call) error + // Commands are the program's own commands, each named alone or as namespace:command. + Commands []Command + // Plugins registers the compiled plugins and starts none of them. + Plugins func(ctx context.Context, call Call) (Loaded, error) + // Authorize refuses the call's actor when that account lacks capability. + Authorize func(ctx context.Context, call Call, capability string) error + // Record stores one entry naming the actor and the command it applied. + Record func(ctx context.Context, call Call, command string) error +} + +// Main runs p over os.Args[1:] and the standard streams under a context the first SIGINT or SIGTERM ends. +func Main(p Program) int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + context.AfterFunc(ctx, stop) + return p.Run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr) +} + +// Run runs the command args name and returns the exit code. +func (p Program) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { + r := &runner{program: p, stdin: stdin, stdout: stdout, stderr: stderr} + r.commands, r.namespaces = index(slices.Concat(p.Commands, r.base())) + a := newAudit(p) + a.core() + if err := errors.Join(a.offences...); err != nil { + return r.exit(err) + } + r.plugins = &memo{register: p.Plugins, audit: a, call: Call{ + Stdin: stdin, Stdout: stdout, Stderr: stderr, Env: r.settings(), database: p.Database, + }} + return r.exit(r.finish(ctx, r.dispatch(ctx, args))) +} + +// runner is one run of a program over its streams. +type runner struct { + program Program + commands map[string]Command + namespaces map[string][]string + plugins *memo + stdin io.Reader + stdout io.Writer + stderr io.Writer + reached Command + flags *flag.FlagSet +} + +// finish releases the plugins and returns err joined with the release failure, the failure alone after a help answer. +func (r *runner) finish(ctx context.Context, err error) error { + released := r.plugins.release(ctx) + if errors.Is(err, flag.ErrHelp) { + return released + } + return errors.Join(err, released) +} + +// exit prints err and returns the exit code it earns. +func (r *runner) exit(err error) int { + if err == nil || errors.Is(err, flag.ErrHelp) { + return ExitDone + } + for _, line := range lines(err) { + r.warn("%s", line) + } + for _, crash := range crashes(err) { + _, _ = r.stderr.Write(crash.stack) + } + if !errors.Is(err, ErrMisused) { + return ExitFailed + } + if r.flags != nil { + _, _ = io.WriteString(r.stderr, "\n"+r.page(r.reached, r.flags)) + } + return ExitMisused +} + +// warn writes one line to stderr opened by the program name. +func (r *runner) warn(format string, args ...any) { + _, _ = fmt.Fprintf(r.stderr, "%s: %s\n", r.program.Name, fmt.Sprintf(format, args...)) +} diff --git a/gonsole/program_test.go b/gonsole/program_test.go new file mode 100644 index 0000000..8dc68cc --- /dev/null +++ b/gonsole/program_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "io" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// result is what one run of a program answers. +type result struct { + code int + stdout string + stderr string +} + +// execute runs p over args in process and answers its exit code and output. +func execute(t *testing.T, p gonsole.Program, args ...string) result { + t.Helper() + var stdout, stderr bytes.Buffer + code := p.Run(t.Context(), args, strings.NewReader(""), &stdout, &stderr) + return result{code: code, stdout: stdout.String(), stderr: stderr.String()} +} + +// single returns a program called myapp whose only command is cmd. +func single(cmd gonsole.Command) gonsole.Program { + return gonsole.Program{Name: "myapp", Commands: []gonsole.Command{cmd}} +} + +// answering returns a command called report:list whose run answers err. +func answering(err error) gonsole.Command { + return gonsole.Command{ + Name: "report:list", + Summary: "list every report", + Run: func(context.Context, gonsole.Call) error { return err }, + } +} + +func TestExitCodesKeepTheirValues(t *testing.T) { + t.Parallel() + + if gonsole.ExitDone != 0 || gonsole.ExitFailed != 1 || gonsole.ExitMisused != 2 { + t.Errorf("exit codes = %d, %d, %d, want 0, 1, 2", gonsole.ExitDone, gonsole.ExitFailed, gonsole.ExitMisused) + } +} + +func TestMisuseKeepsTheMessageAndMarksTheError(t *testing.T) { + t.Parallel() + + cause := errors.New(`unknown format "pdf"`) + err := gonsole.Misuse(cause) + + if err.Error() != cause.Error() { + t.Errorf("Error() = %q, want %q", err.Error(), cause.Error()) + } + if !errors.Is(err, gonsole.ErrMisused) { + t.Errorf("errors.Is(err, ErrMisused) = false, want true") + } + if !errors.Is(err, cause) { + t.Errorf("errors.Is(err, cause) = false, want true") + } +} + +func TestMisuseOfNilIsNil(t *testing.T) { + t.Parallel() + + if err := gonsole.Misuse(nil); err != nil { + t.Errorf("Misuse(nil) = %v, want nil", err) + } +} + +func TestRunAnswersTheExitCodeTheCommandEarns(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + code int + stderr string + }{ + {"a command that succeeds", nil, gonsole.ExitDone, ""}, + {"a command that fails", errors.New("report store is down"), gonsole.ExitFailed, "myapp: report store is down\n"}, + { + "a command that reports misuse", + gonsole.Misuse(errors.New(`unknown format "pdf"`)), + gonsole.ExitMisused, + `myapp: unknown format "pdf" + +list every report + +Usage: + myapp report:list +`, + }, + {"a command that wraps the help error", fmt.Errorf("report:list: %w", flag.ErrHelp), gonsole.ExitDone, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, single(answering(tc.err)), "report:list") + + if got.code != tc.code { + t.Errorf("code = %d, want %d", got.code, tc.code) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want empty", got.stdout) + } + }) + } +} + +func TestRunHandsTheCommandTheContextAndTheStreams(t *testing.T) { + t.Parallel() + + type key struct{} + ctx := context.WithValue(t.Context(), key{}, "carried") + p := single(gonsole.Command{ + Name: "report:list", + Summary: "list every report", + Run: func(ctx context.Context, call gonsole.Call) error { + if ctx.Value(key{}) != "carried" { + return errors.New("the context lost its value") + } + read, err := io.ReadAll(call.Stdin) + if err != nil { + return err + } + if _, err := fmt.Fprintf(call.Stdout, "read %s\n", read); err != nil { + return err + } + _, err = fmt.Fprintln(call.Stderr, "listing") + return err + }, + }) + var stdout, stderr bytes.Buffer + + code := p.Run(ctx, []string{"report:list"}, strings.NewReader("quarterly"), &stdout, &stderr) + + if code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", code, gonsole.ExitDone, stderr.String()) + } + if stdout.String() != "read quarterly\n" { + t.Errorf("stdout = %q, want the input echoed", stdout.String()) + } + if stderr.String() != "listing\n" { + t.Errorf("stderr = %q, want the progress line", stderr.String()) + } +} diff --git a/gonsole/resolve.go b/gonsole/resolve.go new file mode 100644 index 0000000..76a91c1 --- /dev/null +++ b/gonsole/resolve.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "slices" + "strings" +) + +// index returns the commands by full name and the sorted command names of each namespace. +func index(commands []Command) (map[string]Command, map[string][]string) { + byName := make(map[string]Command, len(commands)) + namespaces := map[string][]string{} + for _, cmd := range commands { + byName[cmd.Name] = cmd + if namespace, _, namespaced := strings.Cut(cmd.Name, ":"); namespaced { + namespaces[namespace] = append(namespaces[namespace], cmd.Name) + } + } + for _, names := range namespaces { + slices.Sort(names) + } + return byName, namespaces +} + +// dispatch runs the command args name, the help they ask for, or the commandless run when they name none. +func (r *runner) dispatch(ctx context.Context, args []string) error { + if asksHelp(args) { + return r.help(ctx, args) + } + if len(args) == 0 { + return r.commandless(ctx) + } + args = r.rename(args) + cmd, err := r.find(ctx, args[0], false) + if err != nil { + return err + } + return r.invoke(ctx, cmd, args[1:]) +} + +// commandless serves when the program serves on a run that names no command, and prints the listing otherwise. +func (r *runner) commandless(ctx context.Context) error { + if r.program.BareServes { + return r.dispatch(ctx, []string{"serve"}) + } + return r.list(ctx, r.stdout) +} + +// help prints the help page of the command args name, or the listing when they name none. +func (r *runner) help(ctx context.Context, args []string) error { + named := r.rename(subject(args)) + if len(named) == 0 { + return r.list(ctx, r.stdout) + } + cmd, err := r.find(ctx, named[0], true) + if err != nil { + return err + } + fs, err := flagSet(cmd, &switches{}) + if err != nil { + return err + } + _, err = io.WriteString(r.stdout, r.page(cmd, fs)) + return err +} + +// rename returns args with an old two word spelling replaced by the name of the command that replaced it. +func (r *runner) rename(args []string) []string { + if len(args) < 2 { + return args + } + old := args[0] + " " + args[1] + name, renamed := r.program.Renamed[old] + if !renamed { + return args + } + r.warn("%q is deprecated, use %q", old, name) + return append([]string{name}, args[2:]...) +} + +// find returns the command called name, registering the plugins when only a plugin may own it. +func (r *runner) find(ctx context.Context, name string, describe bool) (Command, error) { + if cmd, known := r.commands[name]; known { + return cmd, nil + } + namespace, _, namespaced := strings.Cut(name, ":") + if members := r.namespaces[namespace]; len(members) > 0 { + return Command{}, want(name, members) + } + if r.barred(name, namespace) { + return Command{}, r.unknown(name) + } + return r.plugin(ctx, name, describe || !namespaced) +} + +// barred reports whether no plugin may own name, a malformed one or one in a namespace the engine or core holds. +func (r *runner) barred(name, namespace string) bool { + return !wellFormed(name) || r.plugins.audit.holder(namespace) != "" +} + +// plugin returns the plugin command called name, registering the plugins in describe mode when describe is set. +func (r *runner) plugin(ctx context.Context, name string, describe bool) (Command, error) { + loaded, err := r.plugins.answer(ctx, describe) + if cmd, kept := r.plugins.commands[name]; kept { + if !describe { + r.caution(loaded.Failed) + } + return cmd, nil + } + if offences, dropped := r.plugins.audit.dropped[name]; dropped { + return Command{}, errors.Join(offences...) + } + return Command{}, r.stray(name, loaded, err) +} + +// stray returns the error of a name no admitted plugin command owns, given what registering answered. +func (r *runner) stray(name string, loaded Loaded, err error) error { + namespace, _, namespaced := strings.Cut(name, ":") + switch { + case errors.As(err, new(panicked)): + return err + case len(r.plugins.namespaces[namespace]) > 0: + return want(name, r.plugins.namespaces[namespace]) + case !namespaced: + return r.unknown(name) + case err != nil: + return err + case loaded.Failed != nil: + return loaded.Failed + case len(r.plugins.namespaces) > 0: + owners := alternatives(slices.Sorted(maps.Keys(r.plugins.namespaces))) + return Misuse(fmt.Errorf("unknown command %q, want a command in %s", name, owners)) + } + return r.unknown(name) +} + +// caution warns of each line of failed. +func (r *runner) caution(failed error) { + for _, line := range lines(failed) { + r.warn("warning: %s", line) + } +} + +// lines returns the lines of err's message, none when err is nil. +func lines(err error) []string { + if err == nil { + return nil + } + return strings.Split(err.Error(), "\n") +} + +// want returns the misuse of a name no command of a namespace owns, naming the members of that namespace. +func want(name string, members []string) error { + return Misuse(fmt.Errorf("unknown command %q, want %s", name, alternatives(members))) +} + +// unknown returns the misuse of a name no command owns. +func (r *runner) unknown(name string) error { + return Misuse(fmt.Errorf("unknown command %q, run %q to see every command", name, r.program.Name+" list")) +} + +// alternatives joins names as a list read aloud, such as a, b or c. +func alternatives(names []string) string { + if len(names) == 1 { + return names[0] + } + last := len(names) - 1 + return strings.Join(names[:last], ", ") + " or " + names[last] +} diff --git a/gonsole/resolve_test.go b/gonsole/resolve_test.go new file mode 100644 index 0000000..915632d --- /dev/null +++ b/gonsole/resolve_test.go @@ -0,0 +1,634 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// echo returns a command called name taking args that prints its name and the arguments it received. +func echo(name string, args ...string) gonsole.Command { + return gonsole.Command{ + Name: name, + Summary: "print the arguments", + Args: args, + Run: func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintln(call.Stdout, strings.Join(append([]string{name}, call.Args...), " ")) + return err + }, + } +} + +// reports returns a program called myapp with a status command, a report namespace and one old spelling. +func reports() gonsole.Program { + return gonsole.Program{ + Name: "myapp", + Commands: []gonsole.Command{ + echo("report:revoke", "id"), + echo("status"), + echo("report:create", "title"), + echo("report:list"), + }, + Renamed: map[string]string{"report new": "report:create", "report all": "report:list"}, + } +} + +func TestRunRunsTheCommandTheLineNames(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdout string + stderr string + }{ + {"a command without a namespace", []string{"status"}, "status\n", ""}, + {"a command in a namespace", []string{"report:list"}, "report:list\n", ""}, + {"a command in a namespace with its argument", []string{"report:create", "Q3"}, "report:create Q3\n", ""}, + { + "an old two word spelling", + []string{"report", "new", "Q3"}, + "report:create Q3\n", + "myapp: \"report new\" is deprecated, use \"report:create\"\n", + }, + { + "an old two word spelling with nothing after it", + []string{"report", "all"}, + "report:list\n", + "myapp: \"report all\" is deprecated, use \"report:list\"\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, reports(), tc.args...) + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + }) + } +} + +func TestRunRefusesALineThatNamesNoCommand(t *testing.T) { + t.Parallel() + + everyCommand := `run "myapp list" to see every command` + reportCommands := "want report:create, report:list or report:revoke" + cases := []struct { + name string + args []string + stderr string + }{ + {"an unknown command", []string{"reprot"}, `myapp: unknown command "reprot", ` + everyCommand + "\n"}, + {"a flag in place of a command", []string{"-v"}, `myapp: unknown command "-v", ` + everyCommand + "\n"}, + {"an unknown namespace", []string{"audit:list"}, `myapp: unknown command "audit:list", ` + everyCommand + "\n"}, + {"a command used as a namespace", []string{"status:x"}, `myapp: unknown command "status:x", ` + + everyCommand + "\n"}, + {"a namespace alone", []string{"report"}, `myapp: unknown command "report", ` + reportCommands + "\n"}, + {"an unknown command in a namespace", []string{"report:delete"}, `myapp: unknown command "report:delete", ` + + reportCommands + "\n"}, + {"a namespace with nothing after its colon", []string{"report:"}, `myapp: unknown command "report:", ` + + reportCommands + "\n"}, + {"an old spelling with another second word", []string{"report", "old"}, `myapp: unknown command "report", ` + + reportCommands + "\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, reports(), tc.args...) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want empty", got.stdout) + } + }) + } +} + +func TestRunNamesTheAlternativesOfANamespaceInPlainEnglish(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + commands []gonsole.Command + stderr string + }{ + {"one command", []gonsole.Command{echo("report:list")}, "want report:list"}, + {"two commands", []gonsole.Command{echo("report:list"), echo("report:create")}, "want report:create or report:list"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, gonsole.Program{Name: "myapp", Commands: tc.commands}, "report") + + want := `myapp: unknown command "report", ` + tc.stderr + "\n" + if got.stderr != want { + t.Errorf("stderr = %q, want %q", got.stderr, want) + } + }) + } +} + +// unknownLine returns the line of a name no command owns. +func unknownLine(name string) string { + return fmt.Sprintf("myapp: unknown command %q, run \"myapp list\" to see every command\n", name) +} + +// demoLine returns the line of a name in the demo namespace that no demo command owns. +func demoLine(name string) string { + return fmt.Sprintf("myapp: unknown command %q, want demo:list, demo:move or demo:sync\n", name) +} + +// ranPlugins is the log of a run that registered the plugins to run a command. +var ranPlugins = []string{"register describe=false", "release live=true"} + +// describedPlugins is the log of a run that registered the plugins to describe them. +var describedPlugins = []string{"register describe=true", "release live=true"} + +// syncPage is the help page of the demo:sync command demoGroups declares. +const syncPage = `sync the demo + +Usage: + myapp demo:sync [flags] + +Flags: + -yes + apply the change, a dry run without it +` + +// movePage is the help page of the demo:move command demoGroups declares. +const movePage = `move the demo + +Usage: + myapp demo:move [flags] + +Flags: + -as email + email address of the account acting +` + +// tenancyGroup returns a plugin group, tenancy, offering tenancy:list. +func tenancyGroup() gonsole.Group { + return gonsole.Group{Namespace: "tenancy", Commands: []gonsole.Command{echo("tenancy:list")}} +} + +func TestRunRunsThePluginCommandTheLineNames(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdout string + stderr string + log []string + }{ + {"a dry run of a write", []string{"demo:sync"}, "sync apply=false\n", dryRunNotice, ranPlugins}, + {"an applied write", []string{"demo:sync", "-yes"}, "sync apply=true\n", "", ranPlugins}, + {"a read", []string{"demo:list"}, "synced\n", "", ranPlugins}, + {"a read that answers a document", []string{"demo:list", "-json"}, "{\n \"demo\": [\n \"synced\"\n ]\n}\n", + "", ranPlugins}, + {"a command that acts", []string{"demo:move", "-as", actingAccount}, "moved as " + actingAccount + "\n", "", + []string{ + "register describe=false", "authorize " + actingAccount + " for manage_demo", + "record " + actingAccount + " ran demo:move", "release live=true", + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups()} + + got := execute(t, plugged(r), tc.args...) + + if got.code != gonsole.ExitDone || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, %q, want 0, %q, %q", got.code, got.stdout, got.stderr, tc.stdout, tc.stderr) + } + if !slices.Equal(r.log, tc.log) { + t.Errorf("calls = %q, want %q", r.log, tc.log) + } + }) + } +} + +func TestRunChecksTheActingAccountOfAPluginCommand(t *testing.T) { + t.Parallel() + + refused := errors.New("maria.perez@example.com lacks manage_demo") + cases := []struct { + name string + args []string + refuse error + code int + stderr string + log []string + }{ + {"no acting account", []string{"demo:move"}, nil, gonsole.ExitMisused, + "myapp: demo:move wants -as <email>\n\n" + movePage, ranPlugins}, + {"an account that lacks the capability", []string{"demo:move", "-as", actingAccount}, refused, + gonsole.ExitFailed, "myapp: " + refused.Error() + "\n", []string{ + "register describe=false", "authorize " + actingAccount + " for manage_demo", "release live=true", + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups(), refuse: tc.refuse} + + got := execute(t, plugged(r), tc.args...) + + if got.code != tc.code || got.stdout != "" || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, %q, want %d, nothing, %q", got.code, got.stdout, got.stderr, tc.code, tc.stderr) + } + if !slices.Equal(r.log, tc.log) { + t.Errorf("calls = %q, want %q", r.log, tc.log) + } + }) + } +} + +func TestRunNamesTheCommandsOfAPluginNamespace(t *testing.T) { + t.Parallel() + + cases := []struct { + args []string + line string + log []string + }{ + {[]string{"demo:nope"}, demoLine("demo:nope"), ranPlugins}, + {[]string{"demo"}, demoLine("demo"), describedPlugins}, + {[]string{"help", "demo"}, demoLine("demo"), describedPlugins}, + {[]string{"demo", "-h"}, demoLine("demo"), describedPlugins}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups()} + + got := execute(t, plugged(r), tc.args...) + + if got.code != gonsole.ExitMisused || got.stderr != tc.line { + t.Errorf("run = %d, %q, want %d, %q", got.code, got.stderr, gonsole.ExitMisused, tc.line) + } + if !slices.Equal(r.log, tc.log) { + t.Errorf("calls = %q, want %q", r.log, tc.log) + } + }) + } +} + +func TestRunAnswersANamespaceNoPluginOwns(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + groups []gonsole.Group + failed error + fail error + code int + stderr string + }{ + {"plugins in other namespaces", []gonsole.Group{tenancyGroup(), demoGroups()[0]}, nil, nil, + gonsole.ExitMisused, "myapp: unknown command \"audit:list\", want a command in demo or tenancy\n"}, + {"plugins in one other namespace", demoGroups(), nil, nil, gonsole.ExitMisused, + "myapp: unknown command \"audit:list\", want a command in demo\n"}, + {"no plugin command", nil, nil, nil, gonsole.ExitMisused, unknownLine("audit:list")}, + {"plugins that failed", demoGroups(), errors.Join( + errors.New("plugin billing: no signing key"), errors.New("plugin mail: no relay host")), nil, + gonsole.ExitFailed, "myapp: plugin billing: no signing key\nmyapp: plugin mail: no relay host\n"}, + {"a registration that fails", demoGroups(), nil, errors.New("the plugin table is locked"), + gonsole.ExitFailed, "myapp: the plugin table is locked\n"}, + {"a registration that fails beside plugins that failed", demoGroups(), + errors.New("plugin mail: no relay host"), errors.New("the plugin table is locked"), gonsole.ExitFailed, + "myapp: the plugin table is locked\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: tc.groups, failed: tc.failed, fail: tc.fail} + + got := execute(t, plugged(r), "audit:list") + + if got.code != tc.code || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, want %d, %q", got.code, got.stderr, tc.code, tc.stderr) + } + if !slices.Equal(r.log, ranPlugins) { + t.Errorf("calls = %q, want %q", r.log, ranPlugins) + } + }) + } +} + +func TestRunKeepsTheLinesOfNamesNoPluginMayOwn(t *testing.T) { + t.Parallel() + + pluginsCommand := "myapp: unknown command %q, want report:plugins\n" + cases := []struct { + args []string + stderr string + }{ + {[]string{"status:x"}, unknownLine("status:x")}, + {[]string{"migrate:status"}, unknownLine("migrate:status")}, + {[]string{"serve"}, unknownLine("serve")}, + {[]string{"audit:list"}, unknownLine("audit:list")}, + {[]string{"-v"}, unknownLine("-v")}, + {[]string{"Demo:sync"}, unknownLine("Demo:sync")}, + {[]string{"demo:"}, unknownLine("demo:")}, + {[]string{"demo:Sync"}, unknownLine("demo:Sync")}, + {[]string{"demo:sync:all"}, unknownLine("demo:sync:all")}, + {[]string{"demo:Sync", "-h"}, unknownLine("demo:Sync")}, + {[]string{"help", "demo:Sync"}, unknownLine("demo:Sync")}, + {[]string{"report"}, fmt.Sprintf(pluginsCommand, "report")}, + {[]string{"report:delete"}, fmt.Sprintf(pluginsCommand, "report:delete")}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: []gonsole.Group{ + demoGroups()[0], {Namespace: "audit", Commands: []gonsole.Command{echo("audit:list")}}, + }} + p := plugged(r) + p.Reserved = []string{"audit"} + + got := execute(t, p, tc.args...) + + if got.code != gonsole.ExitMisused || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, want %d, %q", got.code, got.stderr, gonsole.ExitMisused, tc.stderr) + } + if len(r.log) != 0 { + t.Errorf("calls = %q, want none", r.log) + } + }) + } +} + +func TestRunDescribesThePluginsForANameWithoutANamespace(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + failed error + fail error + }{ + {"plugins that loaded", nil, nil}, + {"plugins that failed", errors.New("plugin billing: no signing key"), nil}, + {"a registration that fails", nil, errors.New("the plugin table is locked")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups(), failed: tc.failed, fail: tc.fail} + + got := execute(t, plugged(r), "reprot") + + if got.code != gonsole.ExitMisused || got.stderr != unknownLine("reprot") { + t.Errorf("run = %d, %q, want %d, %q", got.code, got.stderr, gonsole.ExitMisused, unknownLine("reprot")) + } + if !slices.Equal(r.log, describedPlugins) { + t.Errorf("calls = %q, want %q", r.log, describedPlugins) + } + }) + } +} + +func TestRunWarnsOfFailedPluginsBeforeAPluginCommand(t *testing.T) { + t.Parallel() + + billing, mail := errors.New("plugin billing: no signing key"), errors.New("plugin mail: no relay host") + warnings := "myapp: warning: plugin billing: no signing key\nmyapp: warning: plugin mail: no relay host\n" + cases := []struct { + name string + args []string + failed error + code int + stderr string + }{ + {"a run", []string{"demo:sync"}, errors.Join(billing, mail), gonsole.ExitDone, warnings + dryRunNotice}, + {"a failure of two lines", []string{"demo:sync"}, + errors.Join(errors.Join(billing, mail), errors.New("plugin chat: no token\nand no webhook")), + gonsole.ExitDone, + warnings + "myapp: warning: plugin chat: no token\nmyapp: warning: and no webhook\n" + dryRunNotice}, + {"a misused run", []string{"demo:sync", "-bogus"}, errors.Join(billing, mail), gonsole.ExitMisused, + warnings + "myapp: demo:sync: flag provided but not defined: -bogus\n\n" + syncPage}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, plugged(®istry{groups: demoGroups(), failed: tc.failed}), tc.args...) + + if got.code != tc.code || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, want %d, %q", got.code, got.stderr, tc.code, tc.stderr) + } + }) + } +} + +func TestHelpOfAPluginCommandDescribesThePlugins(t *testing.T) { + t.Parallel() + + cases := []struct { + args []string + page string + }{ + {[]string{"demo:sync", "-h"}, syncPage}, + {[]string{"help", "demo:sync"}, syncPage}, + {[]string{"help", "demo:move"}, movePage}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups(), failed: errors.New("plugin billing: no signing key")} + p := plugged(r) + p.Migrations = []gonsole.Step{{Name: "reports", Run: func(context.Context, string) error { + r.note("migrate reports") + return nil + }}} + p.Lock = func(context.Context, string) (func(context.Context) error, error) { + r.note("lock") + return func(context.Context) error { return nil }, nil + } + + got := execute(t, p, tc.args...) + + if got.code != gonsole.ExitDone || got.stdout != tc.page || got.stderr != "" { + t.Errorf("help = %d, %q, %q, want 0, %q and no warning", got.code, got.stdout, got.stderr, tc.page) + } + if !slices.Equal(r.log, describedPlugins) { + t.Errorf("calls = %q, want %q", r.log, describedPlugins) + } + }) + } +} + +func TestHelpOfAPluginCommandFailsWhenItsPluginDidNotLoad(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + groups []gonsole.Group + failed error + fail error + stderr string + }{ + {"a registration that fails", demoGroups(), nil, errors.New("the plugin table is locked"), + "myapp: the plugin table is locked\n"}, + {"a plugin that failed", []gonsole.Group{tenancyGroup()}, errors.Join( + errors.New("plugin demo: no signing key"), errors.New("plugin mail: no relay host")), nil, + "myapp: plugin demo: no signing key\nmyapp: plugin mail: no relay host\n"}, + {"a registration that fails beside a plugin that failed", demoGroups(), + errors.New("plugin mail: no relay host"), errors.New("the plugin table is locked"), + "myapp: the plugin table is locked\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: tc.groups, failed: tc.failed, fail: tc.fail} + + got := execute(t, plugged(r), "demo:sync", "-h") + + if got.code != gonsole.ExitFailed || got.stdout != "" || got.stderr != tc.stderr { + t.Errorf("help = %d, %q, %q, want %d, nothing, %q", got.code, got.stdout, got.stderr, + gonsole.ExitFailed, tc.stderr) + } + if !slices.Equal(r.log, describedPlugins) { + t.Errorf("calls = %q, want %q", r.log, describedPlugins) + } + }) + } +} + +func TestRunFailsAPluginCommandWhenTheRegistrationFails(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups(), fail: errors.New("the plugin table is locked")} + + got := execute(t, plugged(r), "demo:sync", "-yes") + + if want := "myapp: the plugin table is locked\n"; got.code != gonsole.ExitFailed || got.stdout != "" || + got.stderr != want { + t.Errorf("run = %d, %q, %q, want %d, nothing, %q", got.code, got.stdout, got.stderr, gonsole.ExitFailed, want) + } + if !slices.Equal(r.log, ranPlugins) { + t.Errorf("calls = %q, want %q", r.log, ranPlugins) + } +} + +func TestRunNeverMigratesForAPluginCommand(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups()} + p := plugged(r) + p.Migrations = []gonsole.Step{{Name: "reports", Run: func(context.Context, string) error { + r.note("migrate reports") + return nil + }}} + p.Lock = func(context.Context, string) (func(context.Context) error, error) { + r.note("lock") + return func(context.Context) error { return nil }, nil + } + + got := execute(t, p, "demo:sync", "-yes") + + if got.code != gonsole.ExitDone || !slices.Equal(r.log, ranPlugins) { + t.Errorf("run = %d, calls = %q, want 0, %q", got.code, r.log, ranPlugins) + } +} + +func TestRunKeepsPluginAndCoreCommandsBesideACollidingGroup(t *testing.T) { + t.Parallel() + + cases := []struct { + args []string + code int + stdout string + stderr string + }{ + {[]string{"demo:sync", "-yes"}, gonsole.ExitDone, "sync apply=true\n", ""}, + {[]string{"status"}, gonsole.ExitDone, "status\n", ""}, + {[]string{"list:all"}, gonsole.ExitMisused, "", unknownLine("list:all")}, + {[]string{"nope:x"}, gonsole.ExitMisused, "", "myapp: unknown command \"nope:x\", want a command in demo\n"}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: []gonsole.Group{ + {Namespace: "list", Commands: []gonsole.Command{echo("list:all")}}, demoGroups()[0], + }} + + got := execute(t, plugged(r), tc.args...) + + if got.code != tc.code || got.stdout != tc.stdout || got.stderr != tc.stderr { + t.Errorf("run = %d, %q, %q, want %d, %q, %q", got.code, got.stdout, got.stderr, tc.code, tc.stdout, + tc.stderr) + } + }) + } +} + +func TestRunAddsAReleaseFailureToAPluginMisuse(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups(), lost: errors.New("the pool would not close")} + + got := execute(t, plugged(r), "demo:nope") + + want := demoLine("demo:nope") + "myapp: release the plugins: the pool would not close\n" + if got.code != gonsole.ExitMisused || got.stderr != want { + t.Errorf("run = %d, %q, want %d, %q", got.code, got.stderr, gonsole.ExitMisused, want) + } +} + +func TestRunTurnsARegistrationPanicIntoAFailureOfAPluginCommand(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{{"demo:sync"}, {"demo:sync", "-h"}, {"reprot"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + p := plugged(®istry{}) + p.Plugins = func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + panic("the plugin table vanished") + } + + got := execute(t, p, args...) + + const line = "myapp: plugins: panic: the plugin table vanished\n" + stack, opened := strings.CutPrefix(got.stderr, line) + if got.code != gonsole.ExitFailed || !opened || !strings.HasPrefix(stack, "goroutine ") { + t.Errorf("run = %d, %q, want %d, %q and the stack", got.code, firstLine(got.stderr), + gonsole.ExitFailed, line) + } + }) + } +} diff --git a/gonsole/serve.go b/gonsole/serve.go new file mode 100644 index 0000000..26d1e8d --- /dev/null +++ b/gonsole/serve.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" +) + +// Timeouts are the HTTP timeouts and the shutdown grace one server runs under. +type Timeouts struct { + // ReadHeader bounds reading one request's headers, the HTTP_READ_HEADER_TIMEOUT setting. + ReadHeader time.Duration + // Read bounds reading one whole request, the HTTP_READ_TIMEOUT setting. + Read time.Duration + // Idle bounds how long a kept alive connection waits for its next request, the HTTP_IDLE_TIMEOUT setting. + Idle time.Duration + // Grace bounds the shutdown of the server and the stop of what it serves, the SHUTDOWN_GRACE setting. + Grace time.Duration +} + +// Timeouts returns the HTTP timeouts and the shutdown grace, each falling back to fallback. +func (e Env) Timeouts(fallback Timeouts) (Timeouts, error) { + var read Timeouts + var failed [4]error + read.ReadHeader, failed[0] = e.Duration("HTTP_READ_HEADER_TIMEOUT", fallback.ReadHeader) + read.Read, failed[1] = e.Duration("HTTP_READ_TIMEOUT", fallback.Read) + read.Idle, failed[2] = e.Duration("HTTP_IDLE_TIMEOUT", fallback.Idle) + read.Grace, failed[3] = e.Duration("SHUTDOWN_GRACE", fallback.Grace) + if err := errors.Join(failed[:]...); err != nil { + return Timeouts{}, err + } + return read, nil +} + +// NewServer returns an HTTP server for handler at addr under the timeouts. +func NewServer(addr string, handler http.Handler, t Timeouts) *http.Server { + return &http.Server{ + Addr: addr, Handler: handler, ReadHeaderTimeout: t.ReadHeader, ReadTimeout: t.Read, IdleTimeout: t.Idle, + } +} + +// Serve serves srv until ctx ends or serving fails, then shuts it down and calls stop within the grace. +func Serve( + ctx context.Context, srv *http.Server, t Timeouts, stop func(context.Context) error, logger *slog.Logger, +) error { + listener, err := net.Listen("tcp", cmp.Or(srv.Addr, ":http")) + if err != nil { + grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), t.Grace) + defer cancel() + return errors.Join(fmt.Errorf("http server: %w", err), optional(grace, stop)) + } + return serveOn(ctx, srv, listener, t, stop, logger) +} + +// serveOn serves srv on listener until ctx ends or serving fails, then shuts it down and calls stop within the grace. +func serveOn( + ctx context.Context, srv *http.Server, listener net.Listener, t Timeouts, stop func(context.Context) error, + logger *slog.Logger, +) error { + logger = cmp.Or(logger, slog.New(slog.DiscardHandler)) + if srv.ErrorLog == nil { + srv.ErrorLog = slog.NewLogLogger(logger.Handler(), slog.LevelError) + } + served := make(chan error, 1) + go func() { served <- srv.Serve(listener) }() + logger.Info("listening", "addr", listener.Addr().String()) + var failed error + select { + case err := <-served: + failed = fmt.Errorf("http server: %w", err) + case <-ctx.Done(): + logger.Info("shutting down") + } + grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), t.Grace) + defer cancel() + shut := srv.Shutdown(grace) + if failed == nil { + <-served + } + return errors.Join(failed, shut, optional(grace, stop)) +} + +// optional calls fn under ctx, nothing when fn is nil. +func optional(ctx context.Context, fn func(context.Context) error) error { + if fn == nil { + return nil + } + return fn(ctx) +} diff --git a/gonsole/serve_internal_test.go b/gonsole/serve_internal_test.go new file mode 100644 index 0000000..b332063 --- /dev/null +++ b/gonsole/serve_internal_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "errors" + "io" + "log/slog" + "net" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" +) + +// brittle is a listener whose accepts after the first fail for good once broken is closed. +type brittle struct { + net.Listener + accepted int + broken chan struct{} +} + +// Accept returns the first connection and a lasting failure after it. +func (b *brittle) Accept() (net.Conn, error) { + b.accepted++ + if b.accepted > 1 { + <-b.broken + return nil, errors.New("the listener broke") + } + return b.Listener.Accept() +} + +func TestServeShutsTheServerDownBeforeItStopsWhenServingFails(t *testing.T) { + t.Parallel() + + inner, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listening: %v", err) + } + var stopped atomic.Bool + var late atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if stopped.Load() { + late.Add(1) + } + _, _ = io.WriteString(w, "quarterly\n") + }) + timeouts := Timeouts{ReadHeader: time.Second, Read: time.Second, Idle: time.Minute, Grace: 5 * time.Second} + srv := NewServer(inner.Addr().String(), handler, timeouts) + stop := func(ctx context.Context) error { + stopped.Store(true) + return ctx.Err() + } + client := &http.Client{Transport: &http.Transport{}} + address := "http://" + inner.Addr().String() + "/" + listener := &brittle{Listener: inner, broken: make(chan struct{})} + var logged strings.Builder + done := make(chan error, 1) + go func() { + done <- serveOn(t.Context(), srv, listener, timeouts, stop, slog.New(slog.NewTextHandler(&logged, nil))) + }() + + fetch(t, client, address) + close(listener.broken) + served := <-done + _, second := client.Get(address) + + if !strings.HasPrefix(errorLine(served), "http server: the listener broke") { + t.Errorf("serveOn() = %v, want the serving failure", served) + } + if second == nil || late.Load() != 0 { + t.Errorf("a request after the failure = %v, handled after stop %d times, want none handled", second, late.Load()) + } + if strings.Contains(logged.String(), "shutting down") { + t.Errorf("log = %q, want no shutting down line after a failure", logged.String()) + } +} + +// fetch fetches address with client and reads the whole answer. +func fetch(t *testing.T, client *http.Client, address string) { + t.Helper() + response, err := client.Get(address) + if err != nil { + t.Fatalf("GET %s: %v", address, err) + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() +} + +// errorLine returns the message of err, empty when it is nil. +func errorLine(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/gonsole/serve_test.go b/gonsole/serve_test.go new file mode 100644 index 0000000..1255247 --- /dev/null +++ b/gonsole/serve_test.go @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bufio" + "context" + "errors" + "io" + "log" + "log/slog" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gopherium/framework/gonsole" +) + +// defaults are the timeouts a program falls back to when its settings are empty. +var defaults = gonsole.Timeouts{ + ReadHeader: 10 * time.Second, Read: 30 * time.Second, Idle: 120 * time.Second, Grace: 15 * time.Second, +} + +func TestEnvReadsTheTimeouts(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + values map[string]string + want gonsole.Timeouts + err string + }{ + {"no settings", nil, defaults, ""}, + {"every setting", map[string]string{ + "MYAPP_HTTP_READ_HEADER_TIMEOUT": "2s", "MYAPP_HTTP_READ_TIMEOUT": "5s", + "MYAPP_HTTP_IDLE_TIMEOUT": "1m", "MYAPP_SHUTDOWN_GRACE": "3s", + }, gonsole.Timeouts{ReadHeader: 2 * time.Second, Read: 5 * time.Second, Idle: time.Minute, + Grace: 3 * time.Second}, ""}, + {"settings that fail", map[string]string{ + "MYAPP_HTTP_READ_HEADER_TIMEOUT": "soon", "MYAPP_HTTP_READ_TIMEOUT": "0s", + "MYAPP_HTTP_IDLE_TIMEOUT": "-1s", "MYAPP_SHUTDOWN_GRACE": "later", + }, gonsole.Timeouts{}, `MYAPP_HTTP_READ_HEADER_TIMEOUT: must be a duration like 30s, got "soon" +MYAPP_HTTP_READ_TIMEOUT: must stand above zero, got "0s" +MYAPP_HTTP_IDLE_TIMEOUT: must stand above zero, got "-1s" +MYAPP_SHUTDOWN_GRACE: must be a duration like 30s, got "later"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := settings(tc.values).Timeouts(defaults) + + if got != tc.want || errorText(err) != tc.err { + t.Errorf("Timeouts() = %+v, %q, want %+v, %q", got, errorText(err), tc.want, tc.err) + } + }) + } +} + +func TestNewServerCarriesTheTimeouts(t *testing.T) { + t.Parallel() + + handler := http.NotFoundHandler() + + srv := gonsole.NewServer("127.0.0.1:8080", handler, defaults) + + if srv.Addr != "127.0.0.1:8080" { + t.Errorf("Addr = %q, want 127.0.0.1:8080", srv.Addr) + } + if srv.ReadHeaderTimeout != defaults.ReadHeader || srv.ReadTimeout != defaults.Read || + srv.IdleTimeout != defaults.Idle { + t.Errorf("timeouts = %v, %v, %v, want %+v", srv.ReadHeaderTimeout, srv.ReadTimeout, srv.IdleTimeout, defaults) + } + if srv.WriteTimeout != 0 { + t.Errorf("WriteTimeout = %v, want none for open streams", srv.WriteTimeout) + } + if srv.Handler == nil { + t.Errorf("Handler = nil, want the handler") + } +} + +// journal is a logger target that keeps every line and hands out the address the server listens on. +type journal struct { + mu sync.Mutex + lines []string + listening chan string +} + +// newJournal returns an empty journal. +func newJournal() *journal { + return &journal{listening: make(chan string, 1)} +} + +// Write keeps one log line and hands out its address when it says the server listens. +func (j *journal) Write(line []byte) (int, error) { + j.mu.Lock() + defer j.mu.Unlock() + text := strings.TrimSpace(string(line)) + j.lines = append(j.lines, text) + if strings.Contains(text, "msg=listening") { + _, address, _ := strings.Cut(text, "addr=") + j.listening <- address + } + return len(line), nil +} + +// said reports whether any kept line holds text. +func (j *journal) said(text string) bool { + j.mu.Lock() + defer j.mu.Unlock() + for _, line := range j.lines { + if strings.Contains(line, text) { + return true + } + } + return false +} + +// logger returns a text logger writing to the journal. +func (j *journal) logger() *slog.Logger { + return slog.New(slog.NewTextHandler(j, nil)) +} + +// stopper records every call to a stop function and the context it received. +type stopper struct { + mu sync.Mutex + calls int + live bool + deadline time.Duration + fails error +} + +// stop records one call and answers the stopper's error. +func (s *stopper) stop(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + s.calls++ + s.live = ctx.Err() == nil + if deadline, bounded := ctx.Deadline(); bounded { + s.deadline = time.Until(deadline) + } + return s.fails +} + +// reportNames answers every request with the report names. +func reportNames() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "quarterly\nyearly\n") + }) +} + +// get fetches the root of the server at address and returns its body. +func get(t *testing.T, address string) string { + t.Helper() + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+address+"/", nil) + if err != nil { + t.Fatalf("building the request: %v", err) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatalf("GET %s: %v", address, err) + } + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("reading the answer: %v", err) + } + return string(body) +} + +// serving starts Serve over handler on a free loopback port and returns the address and the end of the run. +func serving( + t *testing.T, handler http.Handler, grace time.Duration, stop func(context.Context) error, j *journal, +) (string, func() error) { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + timeouts := defaults + timeouts.Grace = grace + srv := gonsole.NewServer("127.0.0.1:0", handler, timeouts) + done := make(chan error, 1) + go func() { done <- gonsole.Serve(ctx, srv, timeouts, stop, j.logger()) }() + select { + case address := <-j.listening: + return address, func() error { + cancel() + return <-done + } + case err := <-done: + cancel() + t.Fatalf("Serve() returned %v before listening", err) + } + return "", nil +} + +func TestServeAnswersUntilTheRunEnds(t *testing.T) { + t.Parallel() + + var s stopper + j := newJournal() + address, end := serving(t, reportNames(), time.Minute, s.stop, j) + + body := get(t, address) + err := end() + + if body != "quarterly\nyearly\n" { + t.Errorf("body = %q, want the report names", body) + } + if err != nil { + t.Errorf("Serve() = %v, want nil", err) + } + if !j.said("msg=\"shutting down\"") { + t.Errorf("log = %q, want a shutting down line", j.lines) + } + if s.calls != 1 || !s.live || s.deadline <= 0 || s.deadline > time.Minute { + t.Errorf("stop calls = %d, live %t, deadline in %v, want one live call within the grace", + s.calls, s.live, s.deadline) + } +} + +func TestServeGivesUpOnARequestThatOutlastsTheGrace(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + defer close(release) + arrived := make(chan struct{}) + slow := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + close(arrived) + <-release + }) + var s stopper + address, end := serving(t, slow, 50*time.Millisecond, s.stop, newJournal()) + go func() { _, _ = http.Get("http://" + address + "/") }() + <-arrived + + err := end() + + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("Serve() = %v, want the grace to run out", err) + } + if s.calls != 1 { + t.Errorf("stop calls = %d, want 1", s.calls) + } +} + +func TestServeStopsOnlyAfterTheServerHasDrained(t *testing.T) { + t.Parallel() + + stopped := make(chan struct{}) + release := make(chan struct{}) + arrived := make(chan struct{}) + var sawStop atomic.Bool + pending := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(arrived) + <-release + select { + case <-stopped: + sawStop.Store(true) + default: + } + _, _ = io.WriteString(w, "quarterly\n") + }) + stop := func(context.Context) error { + close(stopped) + return nil + } + address, end := serving(t, pending, time.Minute, stop, newJournal()) + go func() { _, _ = http.Get("http://" + address + "/") }() + <-arrived + + ended := make(chan error, 1) + go func() { ended <- end() }() + select { + case <-stopped: + case <-time.After(100 * time.Millisecond): + } + close(release) + + if err := <-ended; err != nil { + t.Errorf("Serve() = %v, want nil", err) + } + if sawStop.Load() { + t.Errorf("stop ran while a request was still being served") + } +} + +func TestServeTakesThePortOfHTTPForAnEmptyAddress(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + j := newJournal() + + err := gonsole.Serve(ctx, gonsole.NewServer("", reportNames(), defaults), defaults, nil, j.logger()) + + address := "" + var refused *net.OpError + if errors.As(err, &refused) && refused.Addr != nil { + address = refused.Addr.String() + } + select { + case listened := <-j.listening: + address = listened + default: + } + if _, port, _ := net.SplitHostPort(address); port != "80" { + t.Errorf("Serve() = %v, address %q, want port 80", err, address) + } +} + +func TestServeRunsWithoutALogger(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := gonsole.Serve(ctx, gonsole.NewServer("127.0.0.1:0", reportNames(), defaults), defaults, nil, nil) + + if err != nil { + t.Errorf("Serve() = %v, want nil", err) + } +} + +func TestServeGivesThePortBackBeforeItReturns(t *testing.T) { + for range 20 { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + j := newJournal() + + err := gonsole.Serve(ctx, gonsole.NewServer("127.0.0.1:0", reportNames(), defaults), defaults, nil, j.logger()) + + address := <-j.listening + again, listenErr := net.Listen("tcp", address) + if err != nil || listenErr != nil { + t.Fatalf("Serve() = %v, listening again on %s = %v, want the port free", err, address, listenErr) + } + _ = again.Close() + } +} + +func TestServeStopsWithinTheGraceWhenTheRunEndedBeforeTheListenFailed(t *testing.T) { + t.Parallel() + + taken, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("taking a port: %v", err) + } + defer func() { _ = taken.Close() }() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var s stopper + + _ = gonsole.Serve(ctx, gonsole.NewServer(taken.Addr().String(), reportNames(), defaults), defaults, s.stop, nil) + + if s.calls != 1 || !s.live || s.deadline <= 0 || s.deadline > defaults.Grace { + t.Errorf("stop calls = %d, live %t, deadline in %v, want one live call within the grace", + s.calls, s.live, s.deadline) + } +} + +func TestServeJoinsTheStopError(t *testing.T) { + t.Parallel() + + s := stopper{fails: errors.New("the reports plugin did not stop")} + _, end := serving(t, reportNames(), time.Minute, s.stop, newJournal()) + + err := end() + + if errorText(err) != "the reports plugin did not stop" { + t.Errorf("Serve() = %v, want the stop error", err) + } +} + +func TestServeReportsAnAddressItCannotTake(t *testing.T) { + t.Parallel() + + taken, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("taking a port: %v", err) + } + defer func() { _ = taken.Close() }() + s := stopper{fails: errors.New("the reports plugin did not stop")} + srv := gonsole.NewServer(taken.Addr().String(), reportNames(), defaults) + + err = gonsole.Serve(t.Context(), srv, defaults, s.stop, nil) + + if !strings.HasPrefix(errorText(err), "http server: listen tcp "+taken.Addr().String()) { + t.Errorf("Serve() = %v, want the listen failure", err) + } + if !strings.HasSuffix(errorText(err), "\nthe reports plugin did not stop") { + t.Errorf("Serve() = %v, want the stop error joined", err) + } + if s.calls != 1 || !s.live || s.deadline <= 0 || s.deadline > defaults.Grace { + t.Errorf("stop calls = %d, live %t, deadline in %v, want one live call within the grace", + s.calls, s.live, s.deadline) + } +} + +func TestServeRunsWithoutAStop(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + j := newJournal() + srv := gonsole.NewServer("127.0.0.1:0", reportNames(), defaults) + done := make(chan error, 1) + go func() { done <- gonsole.Serve(ctx, srv, defaults, nil, j.logger()) }() + <-j.listening + + cancel() + + if err := <-done; err != nil { + t.Errorf("Serve() = %v, want nil", err) + } +} + +func TestServeLogsTheServerErrorsThroughTheLogger(t *testing.T) { + t.Parallel() + + crashing := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("the report store vanished") }) + var s stopper + j := newJournal() + address, end := serving(t, crashing, time.Minute, s.stop, j) + + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("dialling %s: %v", address, err) + } + _, _ = io.WriteString(connection, "GET / HTTP/1.1\r\nHost: reports\r\n\r\n") + _, _ = bufio.NewReader(connection).ReadString('\n') + _ = connection.Close() + _ = end() + + if !j.said(`level=ERROR msg="http: panic serving`) || !j.said("the report store vanished") { + t.Errorf("log = %q, want the server's own panic line at error level", j.lines) + } +} + +func TestServeKeepsTheErrorLogTheServerAlreadyHas(t *testing.T) { + t.Parallel() + + var own strings.Builder + var mu sync.Mutex + crashing := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("the report store vanished") }) + ctx, cancel := context.WithCancel(t.Context()) + j := newJournal() + srv := gonsole.NewServer("127.0.0.1:0", crashing, defaults) + srv.ErrorLog = log.New(writerFunc(func(line []byte) (int, error) { + mu.Lock() + defer mu.Unlock() + return own.Write(line) + }), "", 0) + done := make(chan error, 1) + go func() { done <- gonsole.Serve(ctx, srv, defaults, nil, j.logger()) }() + address := <-j.listening + + connection, err := net.Dial("tcp", address) + if err != nil { + t.Fatalf("dialling %s: %v", address, err) + } + _, _ = io.WriteString(connection, "GET / HTTP/1.1\r\nHost: reports\r\n\r\n") + _, _ = bufio.NewReader(connection).ReadString('\n') + _ = connection.Close() + cancel() + <-done + + mu.Lock() + defer mu.Unlock() + if !strings.Contains(own.String(), "panic serving") || j.said("panic serving") { + t.Errorf("own log = %q, journal %q, want the panic line in the server's own log only", own.String(), j.lines) + } +} + +// writerFunc is a function that writes. +type writerFunc func([]byte) (int, error) + +// Write calls the function. +func (f writerFunc) Write(p []byte) (int, error) { + return f(p) +} diff --git a/gonsole/testkit/testkit.go b/gonsole/testkit/testkit.go new file mode 100644 index 0000000..06a6e04 --- /dev/null +++ b/gonsole/testkit/testkit.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package testkit runs programs built on gonsole from tests, in process and as built binaries. +package testkit + +import ( + "bufio" + "io" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// Result is what one in process run answers. +type Result struct { + // Code is the exit code the run returned. + Code int + // Stdout is everything the run wrote to standard output. + Stdout string + // Stderr is everything the run wrote to standard error. + Stderr string +} + +// Run runs p with args in process, feeding stdin, and answers its exit code and output. +func Run(t testing.TB, p gonsole.Program, stdin string, args ...string) Result { + t.Helper() + var stdout, stderr strings.Builder + code := p.Run(t.Context(), args, strings.NewReader(stdin), &stdout, &stderr) + return Result{Code: code, Stdout: stdout.String(), Stderr: stderr.String()} +} + +// Getenv returns a getenv that reads only values. +func Getenv(values map[string]string) func(string) string { + return func(key string) string { + return values[key] + } +} + +// CoverBinary returns the cover built binary called name and its environment, skipping outside a cover run. +func CoverBinary(t testing.TB, prefix, name string) (string, []string) { + t.Helper() + bindir, coverdir := os.Getenv(prefix+"COVER_BINDIR"), os.Getenv(prefix+"COVER_GOCOVERDIR") + if bindir == "" || coverdir == "" { + t.Skip("skipping binary test: run via make cover") + } + env := slices.DeleteFunc(os.Environ(), func(entry string) bool { + return strings.HasPrefix(entry, prefix) || strings.HasPrefix(entry, "GOCOVERDIR=") + }) + return filepath.Join(bindir, name), append(env, "GOCOVERDIR="+coverdir) +} + +// FreeAddr returns a loopback address whose port nothing listens on. +func FreeAddr(t testing.TB) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("testkit: reserve a port: %v", err) + } + address := listener.Addr().String() + _ = listener.Close() + return address +} + +// WaitForListening blocks until the server logs on stderr that it is listening. +func WaitForListening(t testing.TB, stderr io.Reader) { + t.Helper() + lines := bufio.NewScanner(stderr) + for lines.Scan() { + if strings.Contains(lines.Text(), "listening") { + go func() { _, _ = io.Copy(io.Discard, stderr) }() + return + } + } + t.Fatal("server never reported listening") +} diff --git a/gonsole/testkit/testkit_test.go b/gonsole/testkit/testkit_test.go new file mode 100644 index 0000000..4f3f0f9 --- /dev/null +++ b/gonsole/testkit/testkit_test.go @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testkit_test + +import ( + "context" + "fmt" + "io" + "net" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/internal/exampleapp" + "github.com/gopherium/framework/gonsole/testkit" +) + +// databaseAddress is the database address the example program reads in these tests. +const databaseAddress = "postgres://localhost/myapp" + +// halt is the value a stopper panics with to stop the helper it runs. +type halt struct{} + +// stopper is a test that records why a helper skipped or failed it, and stops the helper. +type stopper struct { + testing.TB + skipped string + failed string +} + +// Skip records why the helper skipped the test and stops it. +func (s *stopper) Skip(args ...any) { + s.skipped = fmt.Sprint(args...) + panic(halt{}) +} + +// Skipf records why the helper skipped the test and stops it. +func (s *stopper) Skipf(format string, args ...any) { + s.skipped = fmt.Sprintf(format, args...) + panic(halt{}) +} + +// SkipNow stops the helper without a reason. +func (s *stopper) SkipNow() { + panic(halt{}) +} + +// Fatal records why the helper failed the test and stops it. +func (s *stopper) Fatal(args ...any) { + s.failed = fmt.Sprint(args...) + panic(halt{}) +} + +// stopped runs helper and reports whether a stopper stopped it. +func stopped(helper func()) (halted bool) { + defer func() { _, halted = recover().(halt) }() + helper() + return false +} + +func TestRunAnswersTheExitCodeAndTheOutput(t *testing.T) { + t.Parallel() + + const dryRun = "myapp: dry run, nothing changed, pass -yes to apply\n" + cases := []struct { + name string + stdin string + args []string + want testkit.Result + }{ + {"a write that reads its input", "sales by region\n", []string{"report:create", "-yes", "Q3"}, + testkit.Result{Code: gonsole.ExitDone, Stdout: "created Q3: sales by region\n"}}, + {"a dry run", "sales by region\n", []string{"report:create", "Q3"}, + testkit.Result{Code: gonsole.ExitDone, Stdout: "would create Q3: sales by region\n", Stderr: dryRun}}, + {"a command that fails", "", []string{"report:revoke", "monthly"}, + testkit.Result{Code: gonsole.ExitFailed, Stderr: "myapp: report \"monthly\" does not exist\n"}}, + {"a command that reads a setting", "", []string{"demo:sync"}, + testkit.Result{Code: gonsole.ExitDone, Stdout: "would sync the demo\n", Stderr: dryRun}}, + {"a misused line", "", []string{"reprot"}, testkit.Result{Code: gonsole.ExitMisused, + Stderr: "myapp: unknown command \"reprot\", run \"myapp list\" to see every command\n"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := exampleapp.Program(testkit.Getenv(map[string]string{"MYAPP_DATABASE_URL": databaseAddress})) + + got := testkit.Run(t, p, tc.stdin, tc.args...) + + if got != tc.want { + t.Errorf("Run() = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestRunRunsUnderTheContextOfTheTest(t *testing.T) { + t.Parallel() + + var done <-chan struct{} + p := gonsole.Program{Name: "myapp", Commands: []gonsole.Command{{ + Name: "status", Summary: "print the status", + Run: func(ctx context.Context, _ gonsole.Call) error { + done = ctx.Done() + return nil + }, + }}} + + testkit.Run(t, p, "", "status") + + if done == nil { + t.Error("the command ran under a context that never ends, want the test's context") + } +} + +func TestGetenvReadsOnlyTheValuesItHolds(t *testing.T) { + t.Setenv("MYAPP_OWNER", "maria.perez@example.com") + + getenv := testkit.Getenv(map[string]string{"MYAPP_DATABASE_URL": databaseAddress}) + + if got := getenv("MYAPP_DATABASE_URL"); got != databaseAddress { + t.Errorf("getenv(MYAPP_DATABASE_URL) = %q, want %q", got, databaseAddress) + } + if got := getenv("MYAPP_OWNER"); got != "" { + t.Errorf("getenv(MYAPP_OWNER) = %q, want empty, never the process environment", got) + } +} + +func TestCoverBinaryAnswersTheBinaryAndItsEnvironment(t *testing.T) { + bindir, coverdir := t.TempDir(), t.TempDir() + t.Setenv("MYAPP_COVER_BINDIR", bindir) + t.Setenv("MYAPP_COVER_GOCOVERDIR", coverdir) + t.Setenv("MYAPP_DATABASE_URL", databaseAddress) + t.Setenv("GOCOVERDIR", filepath.Join(bindir, "elsewhere")) + t.Setenv("KEPT", "yes") + t.Setenv("KEPT_MYAPP_", "yes") + s := &stopper{TB: t} + var binary string + var env []string + + if stopped(func() { binary, env = testkit.CoverBinary(s, "MYAPP_", "myapp") }) { + t.Fatalf("CoverBinary skipped with %q, want the binary", s.skipped) + } + + if want := filepath.Join(bindir, "myapp"); binary != want { + t.Errorf("binary = %q, want %q", binary, want) + } + for _, kept := range []string{"KEPT=yes", "KEPT_MYAPP_=yes"} { + if !slices.Contains(env, kept) { + t.Errorf("env holds no %s, want every variable whose name does not start with the prefix", kept) + } + } + covers := slices.DeleteFunc(slices.Clone(env), func(entry string) bool { + return !strings.HasPrefix(entry, "MYAPP_") && !strings.HasPrefix(entry, "GOCOVERDIR=") + }) + if want := []string{"GOCOVERDIR=" + coverdir}; !slices.Equal(covers, want) { + t.Errorf("prefixed and cover entries = %q, want only %q", covers, want) + } + if env[len(env)-1] != "GOCOVERDIR="+coverdir { + t.Errorf("last entry = %q, want the cover folder", env[len(env)-1]) + } +} + +func TestCoverBinarySkipsOutsideACoverRun(t *testing.T) { + cases := []struct { + name string + bindir string + coverdir string + }{ + {"no binary folder", "", "/cover"}, + {"no cover folder", "/bin", ""}, + {"neither folder", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MYAPP_COVER_BINDIR", tc.bindir) + t.Setenv("MYAPP_COVER_GOCOVERDIR", tc.coverdir) + s := &stopper{TB: t} + + halted := stopped(func() { testkit.CoverBinary(s, "MYAPP_", "myapp") }) + + if want := "skipping binary test: run via make cover"; !halted || s.skipped != want { + t.Errorf("CoverBinary stopped %t, skipped %q, want a skip %q", halted, s.skipped, want) + } + }) + } +} + +func TestFreeAddrAnswersALoopbackAddressNothingListensOn(t *testing.T) { + t.Parallel() + + address := testkit.FreeAddr(t) + + host, _, err := net.SplitHostPort(address) + if err != nil || host != "127.0.0.1" { + t.Fatalf("FreeAddr() = %q, want a 127.0.0.1 address", address) + } + listener, err := net.Listen("tcp", address) + if err != nil { + t.Fatalf("listening on %s: %v, want a free port", address, err) + } + defer func() { _ = listener.Close() }() + if listener.Addr().String() != address { + t.Errorf("listening on %s bound %s, want the very address", address, listener.Addr()) + } +} + +func TestWaitForListeningReturnsOnceTheServerListensAndDrainsTheRest(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + log string + }{ + {"a text log", "level=INFO msg=starting\nlevel=INFO msg=listening addr=127.0.0.1:8080\n"}, + {"a JSON log", `{"level":"INFO","msg":"starting"}` + "\n" + `{"level":"INFO","msg":"listening"}` + "\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + read, write := io.Pipe() + defer func() { _ = write.Close() }() + go func() { _, _ = io.WriteString(write, tc.log) }() + + testkit.WaitForListening(t, read) + + written := make(chan error, 1) + go func() { + _, err := io.WriteString(write, "level=INFO msg=\"shutting down\"\n") + written <- err + }() + select { + case err := <-written: + if err != nil { + t.Errorf("writing a later line: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("a later line blocked, want the rest of stderr drained") + } + }) + } +} + +// bindFailure is the stderr line of a program whose server could not take its address. +const bindFailure = "myapp: http server: listen tcp 127.0.0.1:8080: bind: address already in use\n" + +func TestWaitForListeningFailsWhenTheServerNeverListens(t *testing.T) { + t.Parallel() + + s := &stopper{TB: t} + + halted := stopped(func() { + testkit.WaitForListening(s, strings.NewReader(bindFailure)) + }) + + if want := "server never reported listening"; !halted || s.failed != want { + t.Errorf("WaitForListening stopped %t, failed %q, want a failure %q", halted, s.failed, want) + } +} diff --git a/gonsole/text.go b/gonsole/text.go new file mode 100644 index 0000000..cf524ed --- /dev/null +++ b/gonsole/text.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "cmp" + "context" + "errors" + "flag" + "fmt" + "io" + "maps" + "slices" + "strings" +) + +// list writes the listing to w. +func (r *runner) list(ctx context.Context, w io.Writer) error { + s, err := r.shelf(ctx) + if err != nil { + return err + } + _, err = io.WriteString(w, r.listing(s)) + return err +} + +// listing returns the list of every command s holds. +func (r *runner) listing(s shelf) string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n\nUsage:\n %s <command> [flags] [arguments]\n\n", r.heading(), r.program.Name) + b.WriteString("Every command answers -h. A command that offers -json answers one JSON document. ") + b.WriteString("A command that offers -yes is a dry run until -yes.\n\nAvailable commands:\n") + width := s.width() + for _, name := range s.bare() { + fmt.Fprintf(&b, " %-*s%s\n", width, name, s.commands[name].Summary) + } + for _, namespace := range slices.Sorted(maps.Keys(s.namespaces)) { + s.section(&b, namespace, width) + } + if len(s.missing) > 0 { + b.WriteString("\nNot loaded:\n") + for _, line := range s.missing { + fmt.Fprintf(&b, " %s\n", line) + } + } + if r.program.Footer != "" { + fmt.Fprintf(&b, "\n%s\n", r.program.Footer) + } + return b.String() +} + +// shelf is what the listing prints: the commands, each namespace's members, the plugin namespaces and the failures. +type shelf struct { + commands map[string]Command + namespaces map[string][]string + plugins map[string]bool + missing []string +} + +// shelf returns the core commands and the plugin commands registered to describe them, with what failed to load. +func (r *runner) shelf(ctx context.Context) (shelf, error) { + if _, err := r.plugins.answer(ctx, true); errors.As(err, new(panicked)) { + return shelf{}, err + } + s := shelf{ + commands: maps.Clone(r.commands), namespaces: maps.Clone(r.namespaces), plugins: map[string]bool{}, + missing: r.plugins.missing(), + } + maps.Copy(s.commands, r.plugins.commands) + for namespace, members := range r.plugins.namespaces { + s.namespaces[namespace] = members + s.plugins[namespace] = true + } + return s, nil +} + +// section writes the line of one namespace and the lines of its commands, the name column width wide. +func (s shelf) section(b *strings.Builder, namespace string, width int) { + if s.plugins[namespace] { + fmt.Fprintf(b, " %-*splugin\n", width+1, namespace) + } else { + fmt.Fprintf(b, " %s\n", namespace) + } + for _, name := range s.namespaces[namespace] { + fmt.Fprintf(b, " %-*s%s\n", width, name, s.commands[name].Summary) + } +} + +// heading returns the line the listing opens with. +func (r *runner) heading() string { + title := cmp.Or(r.program.Title, r.program.Name) + if r.program.Version == "" { + return title + } + return title + " Version " + r.program.Version +} + +// bare returns the sorted names of the commands outside every namespace. +func (s shelf) bare() []string { + var names []string + for name := range s.commands { + if !strings.Contains(name, ":") { + names = append(names, name) + } + } + slices.Sort(names) + return names +} + +// width returns the width of the listing's name column, the longest name and two spaces. +func (s shelf) width() int { + longest := 0 + for name := range s.commands { + longest = max(longest, len(name)) + } + return longest + 2 +} + +// page returns the help page of cmd, whose flags fs holds. +func (r *runner) page(cmd Command, fs *flag.FlagSet) string { + flagged := false + fs.VisitAll(func(*flag.Flag) { flagged = true }) + var b strings.Builder + fmt.Fprintf(&b, "%s\n\nUsage:\n %s %s", cmd.Summary, r.program.Name, cmd.Name) + if flagged { + b.WriteString(" [flags]") + } + for _, arg := range cmd.Args { + fmt.Fprintf(&b, " <%s>", arg) + } + b.WriteString("\n") + if flagged { + b.WriteString("\nFlags:\n") + fs.SetOutput(&b) + fs.PrintDefaults() + } + return b.String() +} diff --git a/gonsole/text_test.go b/gonsole/text_test.go new file mode 100644 index 0000000..d43d967 --- /dev/null +++ b/gonsole/text_test.go @@ -0,0 +1,647 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bytes" + "context" + "errors" + "flag" + "slices" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" +) + +// summarized returns cmd with summary as its summary. +func summarized(cmd gonsole.Command, summary string) gonsole.Command { + cmd.Summary = summary + return cmd +} + +// catalog returns a program called myapp with a title, a version, a footer, two namespaces and an old spelling. +func catalog() gonsole.Program { + return gonsole.Program{ + Name: "myapp", + Title: "Myapp, a report keeper.", + Version: "1.4.0", + Footer: "Read the guide at https://example.com/myapp.", + Commands: []gonsole.Command{ + summarized(echo("status"), "print the store status"), + summarized(echo("report:revoke", "id"), "revoke one report"), + filing().Commands[0], + summarized(echo("report:list"), "list every report"), + summarized(echo("audit:export"), "export the audit trail"), + }, + Renamed: map[string]string{"report new": "report:create"}, + } +} + +// intro is the paragraph every listing prints before its commands. +const intro = "Every command answers -h. A command that offers -json answers one JSON document. " + + "A command that offers -yes is a dry run until -yes.\n" + +// catalogListing is the listing catalog prints. +const catalogListing = `Myapp, a report keeper. Version 1.4.0 + +Usage: + myapp <command> [flags] [arguments] + +` + intro + ` +Available commands: + check check every setting, every plugin and every command name + help print the help of one command + list list every command + status print the store status + version print the version + audit + audit:export export the audit trail + report + report:create create a report + report:list list every report + report:revoke revoke one report + +Read the guide at https://example.com/myapp. +` + +// createPage is the help page of the report:create command filing declares. +const createPage = `create a report + +Usage: + myapp report:create [flags] <title> + +Flags: + -draft + keep the report as a draft + -owner string + email address of the owner +` + +// revokePage is the help page of the report:revoke command catalog declares. +const revokePage = `revoke one report + +Usage: + myapp report:revoke <id> +` + +// listPage is the help page of the list base command. +const listPage = `list every command + +Usage: + myapp list +` + +// helpPage is the help page of the help base command. +const helpPage = `print the help of one command + +Usage: + myapp help +` + +// firstLine returns the first line of s with its newline. +func firstLine(s string) string { + line, _, _ := strings.Cut(s, "\n") + return line + "\n" +} + +func TestListingShowsEveryCommandUnderItsNamespace(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + {"no command", nil}, + {"the list command", []string{"list"}}, + {"the help command", []string{"help"}}, + {"a short help flag", []string{"-h"}}, + {"a long help flag", []string{"--help"}}, + {"the help command with a help flag", []string{"help", "-h"}}, + {"a help flag before a double dash and a name", []string{"-h", "--", "report:create"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, catalog(), tc.args...) + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if got.stdout != catalogListing { + t.Errorf("stdout = %q, want %q", got.stdout, catalogListing) + } + if got.stderr != "" { + t.Errorf("stderr = %q, want empty", got.stderr) + } + }) + } +} + +func TestListingLeavesOutWhatTheProgramDoesNotSet(t *testing.T) { + t.Parallel() + + status := summarized(echo("status"), "print the store status") + cases := []struct { + name string + program gonsole.Program + want string + }{ + { + "a title without a version or a footer", + gonsole.Program{Name: "myapp", Title: "Myapp, a report keeper.", Commands: []gonsole.Command{status}}, + `Myapp, a report keeper. + +Usage: + myapp <command> [flags] [arguments] + +` + intro + ` +Available commands: + check check every setting, every plugin and every command name + help print the help of one command + list list every command + status print the store status + version print the version +`, + }, + { + "a version without a title", + gonsole.Program{Name: "myapp", Version: "1.4.0", Commands: []gonsole.Command{status}}, + `myapp Version 1.4.0 + +Usage: + myapp <command> [flags] [arguments] + +` + intro + ` +Available commands: + check check every setting, every plugin and every command name + help print the help of one command + list list every command + status print the store status + version print the version +`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, tc.program, "list") + + if got.stdout != tc.want { + t.Errorf("stdout = %q, want %q", got.stdout, tc.want) + } + }) + } +} + +func TestHelpPrintsThePageOfTheNamedCommand(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdout string + stderr string + }{ + {"the help command", []string{"help", "report:create"}, createPage, ""}, + {"a short help flag", []string{"report:create", "-h"}, createPage, ""}, + {"a long help flag with one dash", []string{"report:create", "-help"}, createPage, ""}, + {"a short help flag with two dashes", []string{"report:create", "--h"}, createPage, ""}, + {"a long help flag", []string{"report:create", "--help"}, createPage, ""}, + {"a help flag before the name", []string{"-h", "report:create"}, createPage, ""}, + {"a help flag after arguments and flags", []string{"report:create", "Q3", "-owner", "x", "-h"}, createPage, ""}, + {"a command without flags", []string{"report:revoke", "-h"}, revokePage, ""}, + {"the list command", []string{"list", "-h"}, listPage, ""}, + {"the help command itself", []string{"help", "help"}, helpPage, ""}, + {"an old spelling", []string{"report", "new", "-h"}, createPage, + "myapp: \"report new\" is deprecated, use \"report:create\"\n"}, + {"the help command before an old spelling", []string{"help", "report", "new"}, createPage, + "myapp: \"report new\" is deprecated, use \"report:create\"\n"}, + {"a short help flag with an equals sign", []string{"report:create", "-h=false", "Q3"}, createPage, ""}, + {"a long help flag with an equals sign", []string{"report:create", "--help=1", "Q3"}, createPage, ""}, + {"a help flag after a double dash read as a value", []string{"report:create", "-owner", "--", "-h"}, + createPage, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, catalog(), tc.args...) + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if got.stdout != tc.stdout { + t.Errorf("stdout = %q, want %q", got.stdout, tc.stdout) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + }) + } +} + +func TestHelpRefusesANameNoCommandOwns(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stderr string + }{ + {"the help command", []string{"help", "reprot"}, + `myapp: unknown command "reprot", run "myapp list" to see every command` + "\n"}, + {"a help flag", []string{"reprot", "-h"}, + `myapp: unknown command "reprot", run "myapp list" to see every command` + "\n"}, + {"a namespace", []string{"help", "report"}, + `myapp: unknown command "report", want report:create, report:list or report:revoke` + "\n"}, + {"a flag before a help flag", []string{"-v", "-h"}, + `myapp: unknown command "-v", run "myapp list" to see every command` + "\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, catalog(), tc.args...) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want empty", got.stdout) + } + }) + } +} + +func TestHelpFlagAfterADoubleDashIsAnArgument(t *testing.T) { + t.Parallel() + + got := execute(t, catalog(), "report:create", "--", "-h") + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if want := "title=-h owner= draft=false\n"; got.stdout != want { + t.Errorf("stdout = %q, want %q", got.stdout, want) + } +} + +func TestHelpCountsOnlyAsTheFirstArgument(t *testing.T) { + t.Parallel() + + got := execute(t, catalog(), "report:create", "help") + + if got.code != gonsole.ExitDone { + t.Errorf("code = %d, want %d, stderr %q", got.code, gonsole.ExitDone, got.stderr) + } + if want := "title=help owner= draft=false\n"; got.stdout != want { + t.Errorf("stdout = %q, want %q", got.stdout, want) + } +} + +func TestHelpPageListsTheEngineSwitchesTheCommandOffers(t *testing.T) { + t.Parallel() + + create := gonsole.Command{ + Name: "report:create", + Summary: "create a report", + Args: []string{"title"}, + Writes: true, + JSON: true, + Run: func(context.Context, gonsole.Call) error { return nil }, + } + + got := execute(t, single(create), "report:create", "-h") + + want := `create a report + +Usage: + myapp report:create [flags] <title> + +Flags: + -json + answer one JSON document + -yes + apply the change, a dry run without it +` + if got.stdout != want { + t.Errorf("stdout = %q, want %q", got.stdout, want) + } +} + +func TestHelpPageNamesEveryArgument(t *testing.T) { + t.Parallel() + + move := summarized(echo("report:move", "id", "folder"), "move a report into a folder") + + got := execute(t, single(move), "report:move", "-h") + + want := `move a report into a folder + +Usage: + myapp report:move <id> <folder> +` + if got.stdout != want { + t.Errorf("stdout = %q, want %q", got.stdout, want) + } +} + +// closedWriter is a writer whose every write fails. +type closedWriter struct{} + +// Write returns an error for every write. +func (closedWriter) Write([]byte) (int, error) { + return 0, errors.New("stdout is closed") +} + +func TestRunFailsWhenTheAnswerCannotBeWritten(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + {"the listing of no command", nil}, + {"the listing of the list command", []string{"list"}}, + {"the listing of a help flag", []string{"-h"}}, + {"a help page", []string{"report:create", "-h"}}, + {"a help page the flag package asks for", []string{"report:create", "-h=true"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + code := catalog().Run(t.Context(), tc.args, strings.NewReader(""), closedWriter{}, &stderr) + + if code != gonsole.ExitFailed { + t.Errorf("code = %d, want %d", code, gonsole.ExitFailed) + } + if want := "myapp: stdout is closed\n"; stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } + }) + } +} + +func TestMisuseOfAKnownCommandEndsWithItsHelpPage(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stderr string + }{ + {"a missing argument", []string{"report:create"}, "myapp: report:create wants <title>\n\n" + createPage}, + {"an unknown flag", []string{"report:create", "-bogus", "Q3"}, + "myapp: report:create: flag provided but not defined: -bogus\n\n" + createPage}, + {"a stray argument to a base command", []string{"list", "extra"}, + "myapp: list takes no arguments, got 1\n\n" + listPage}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := execute(t, catalog(), tc.args...) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + if got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", got.stderr, tc.stderr) + } + if got.stdout != "" { + t.Errorf("stdout = %q, want empty", got.stdout) + } + }) + } +} + +// pluggedHeading is the opening of every listing plugged prints, down to its bare commands. +const pluggedHeading = `myapp + +Usage: + myapp <command> [flags] [arguments] + +` + intro + ` +Available commands: +` + +// pluggedListing is the listing plugged prints with the demo plugin loaded. +const pluggedListing = pluggedHeading + ` check check every setting, every plugin and every command name + help print the help of one command + list list every command + migrate apply every schema step + seed store the demo data + status print the arguments + version print the version + demo plugin + demo:list list the demo + demo:move move the demo + demo:sync sync the demo + report + report:plugins print the plugin namespaces +` + +// pluggedFooter is the footer the Not loaded tests give plugged. +const pluggedFooter = "Read the guide at https://example.com/myapp." + +func TestListingShowsTheNamespaceOfEveryPlugin(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{nil, {"list"}, {"help"}, {"-h"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + r := ®istry{groups: demoGroups()} + + got := execute(t, plugged(r), args...) + + if got.code != gonsole.ExitDone || got.stdout != pluggedListing || got.stderr != "" { + t.Errorf("listing = %d, %q, %q, want 0, %q, nothing", got.code, got.stdout, got.stderr, pluggedListing) + } + if !slices.Equal(r.log, describedPlugins) { + t.Errorf("calls = %q, want %q", r.log, describedPlugins) + } + }) + } +} + +func TestListingWidensTheNameColumnOnlyForALoadedPluginCommand(t *testing.T) { + t.Parallel() + + r := ®istry{groups: []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{ + echo("demo:synchronize-everything"), summarized(echo("demo:a-dropped-command-with-the-longest-name"), ""), + }}}} + + got := execute(t, plugged(r), "list") + + want := pluggedHeading + ` check check every setting, every plugin and every command name + help print the help of one command + list list every command + migrate apply every schema step + seed store the demo data + status print the arguments + version print the version + demo plugin + demo:synchronize-everything print the arguments + report + report:plugins print the plugin namespaces + +Not loaded: + gonsole: command "demo:a-dropped-command-with-the-longest-name" has no summary +` + if got.code != gonsole.ExitDone || got.stdout != want { + t.Errorf("listing = %d, %q, want 0, %q", got.code, got.stdout, want) + } +} + +func TestListingShowsWhatFailedToLoadBeforeTheFooter(t *testing.T) { + t.Parallel() + + commands := ` check check every setting, every plugin and every command name + help print the help of one command + list list every command + migrate apply every schema step + seed store the demo data + status print the arguments + version print the version +` + cases := []struct { + name string + groups []gonsole.Group + failed error + fail error + want string + }{ + {"a registration that fails", demoGroups(), errors.New("plugin mail: no relay host"), + errors.New("the plugin table is locked\nby another run"), commands + ` report + report:plugins print the plugin namespaces + +Not loaded: + the plugin table is locked + by another run +`}, + {"plugins that failed beside groups that break the rules", []gonsole.Group{ + {Namespace: "list", Commands: []gonsole.Command{echo("list:all")}}, + {Namespace: "demo", Commands: []gonsole.Command{echo("demo:sync")}}, + {Namespace: "tenancy", Commands: []gonsole.Command{echo("report:plugins")}}, + }, errors.Join(errors.New("plugin billing: no signing key"), errors.New("plugin mail: no relay host")), nil, + commands + ` demo plugin + demo:sync print the arguments + report + report:plugins print the plugin namespaces + +Not loaded: + plugin billing: no signing key + plugin mail: no relay host + gonsole: plugin list takes the name of the base command list + gonsole: command "report:plugins" is declared twice + gonsole: command "report:plugins" of plugin tenancy is outside its namespace +`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := plugged(®istry{groups: tc.groups, failed: tc.failed, fail: tc.fail}) + p.Footer = pluggedFooter + + got := execute(t, p, "list") + + want := pluggedHeading + tc.want + "\n" + pluggedFooter + "\n" + if got.code != gonsole.ExitDone || got.stdout != want || got.stderr != "" { + t.Errorf("listing = %d, %q, %q, want 0, %q, nothing", got.code, got.stdout, got.stderr, want) + } + }) + } +} + +func TestListingShowsTheSameOffencesAsCheck(t *testing.T) { + t.Parallel() + + groups := []gonsole.Group{offending(), {Namespace: "tenancy", Commands: []gonsole.Command{echo("report:plugins")}}} + p := plugged(®istry{groups: groups}) + + got := execute(t, p, "list") + + _, block, found := strings.Cut(got.stdout, "\nNot loaded:\n") + var want strings.Builder + for _, offence := range offences(p.Check(gonsole.Loaded{Groups: groups})) { + want.WriteString(" " + offence + "\n") + } + if !found || block != want.String() { + t.Errorf("Not loaded = %q, want %q", block, want.String()) + } +} + +func TestListingIndentsEveryLineOfAnOffence(t *testing.T) { + t.Parallel() + + fragile := echo("demo:fragile") + fragile.Flags = func(*flag.FlagSet) { panic("the flag table\nvanished") } + p := plugged(®istry{groups: []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{fragile}}}}) + + got := execute(t, p, "list") + + _, block, found := strings.Cut(got.stdout, "\nNot loaded:\n") + want := " gonsole: command \"demo:fragile\" panicked declaring its flags: the flag table\n vanished\n" + if !found || block != want { + t.Errorf("Not loaded = %q, want %q", block, want) + } +} + +func TestListingRegistersThePluginsUnderTheRunContext(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{nil, {"list"}, {"help"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + type key struct{} + var seen any + p := plugged(®istry{}) + p.Plugins = func(ctx context.Context, _ gonsole.Call) (gonsole.Loaded, error) { + seen = ctx.Value(key{}) + return gonsole.Loaded{}, nil + } + + code := p.Run(context.WithValue(t.Context(), key{}, "run"), args, strings.NewReader(""), &bytes.Buffer{}, + &bytes.Buffer{}) + + if code != gonsole.ExitDone || seen != "run" { + t.Errorf("code %d, registration saw %v, want 0 and the run context", code, seen) + } + }) + } +} + +func TestListingFailsWhenTheRegistrationPanics(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{nil, {"list"}, {"help"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + p := plugged(®istry{}) + p.Plugins = func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + panic("the plugin table vanished") + } + + got := execute(t, p, args...) + + const line = "myapp: plugins: panic: the plugin table vanished\n" + stack, opened := strings.CutPrefix(got.stderr, line) + if got.code != gonsole.ExitFailed || got.stdout != "" || !opened || !strings.HasPrefix(stack, "goroutine ") { + t.Errorf("listing = %d, %q, %q, want %d, nothing, %q and the stack", got.code, got.stdout, + firstLine(got.stderr), gonsole.ExitFailed, line) + } + }) + } +} diff --git a/gottext/test/errors.test.ts b/gottext/test/errors.test.ts index 5826e60..939962c 100644 --- a/gottext/test/errors.test.ts +++ b/gottext/test/errors.test.ts @@ -7,7 +7,7 @@ import { errorText } from '../src/index.js' const TEMPLATES = { first_out_of_range: 'Ask for between %(min)d and %(max)d at a time.', name_taken: 'That name is already taken.', - locale_unknown: 'AlphOne does not speak %(wanted)s yet.', + locale_unknown: 'This site does not speak %(wanted)s yet.', } const FALLBACK = 'Something went wrong. Try again.'