From 0c69414e17152f64c8bc9652f018ce82d3bd1e93 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Wed, 23 Sep 2026 14:41:59 +0200 Subject: [PATCH 01/24] chore(gonsole): scaffold the module --- gonsole/.golangci.yml | 49 +++++++++++++++++++++++++++++++++++++++++++ gonsole/CHANGELOG.md | 11 ++++++++++ gonsole/doc.go | 4 ++++ gonsole/go.mod | 3 +++ 4 files changed, 67 insertions(+) create mode 100644 gonsole/.golangci.yml create mode 100644 gonsole/CHANGELOG.md create mode 100644 gonsole/doc.go create mode 100644 gonsole/go.mod 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..65d2d94 --- /dev/null +++ b/gonsole/CHANGELOG.md @@ -0,0 +1,11 @@ +# 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] 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/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 From f4cf1a2aeabbaba7c5ec961fe7bca1cd25a3ba9d Mon Sep 17 00:00:00 2001 From: SirLouen Date: Wed, 23 Sep 2026 14:42:00 +0200 Subject: [PATCH 02/24] feat(gonsole): read a command line and run the command it names --- gonsole/command.go | 35 +++++ gonsole/exec_test.go | 81 +++++++++++ gonsole/internal/exampleapp/program.go | 79 +++++++++++ gonsole/parse.go | 94 +++++++++++++ gonsole/parse_test.go | 182 +++++++++++++++++++++++++ gonsole/program.go | 99 ++++++++++++++ gonsole/program_test.go | 154 +++++++++++++++++++++ gonsole/resolve.go | 79 +++++++++++ gonsole/resolve_test.go | 151 ++++++++++++++++++++ 9 files changed, 954 insertions(+) create mode 100644 gonsole/command.go create mode 100644 gonsole/exec_test.go create mode 100644 gonsole/internal/exampleapp/program.go create mode 100644 gonsole/parse.go create mode 100644 gonsole/parse_test.go create mode 100644 gonsole/program.go create mode 100644 gonsole/program_test.go create mode 100644 gonsole/resolve.go create mode 100644 gonsole/resolve_test.go diff --git a/gonsole/command.go b/gonsole/command.go new file mode 100644 index 0000000..86d5690 --- /dev/null +++ b/gonsole/command.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "flag" + "io" +) + +// Command is one command a program or a plugin offers. +type Command struct { + // Name is the full name, a bare word or namespace:word 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) + // 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 + // 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 +} diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go new file mode 100644 index 0000000..8498371 --- /dev/null +++ b/gonsole/exec_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bytes" + "errors" + "os" + "os/exec" + "strings" + "testing" + + "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.Exit(m.Run()) +} + +// runExample runs the example program in its own process over args and stdin and answers its exit code and output. +func runExample(t *testing.T, stdin string, args ...string) result { + t.Helper() + cmd := exec.CommandContext(t.Context(), os.Args[0], args...) + cmd.Env = append(os.Environ(), exampleSwitch+"=1") + 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()} +} + +func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + stdin string + args []string + code int + stdout string + stderr string + }{ + {"a command that succeeds", "", []string{"report:list"}, gonsole.ExitDone, "quarterly\nyearly\n", ""}, + {"a command that reads its input", "sales by region\n", []string{"report:create", "Q3"}, gonsole.ExitDone, + "created Q3: sales by region\n", ""}, + {"a command that fails", "", []string{"report:revoke", "monthly"}, gonsole.ExitFailed, "", + "myapp: report \"monthly\" does not exist\n"}, + {"a word no command owns", "", []string{"reprot"}, gonsole.ExitMisused, "", + "myapp: unknown command \"reprot\", run \"myapp list\" to see every command\n"}, + {"a flag no command defines", "", []string{"report:list", "-bogus"}, gonsole.ExitMisused, "", + "myapp: report:list: flag provided but not defined: -bogus\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := runExample(t, tc.stdin, 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) + } + }) + } +} diff --git a/gonsole/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go new file mode 100644 index 0000000..e3de5e9 --- /dev/null +++ b/gonsole/internal/exampleapp/program.go @@ -0,0 +1,79 @@ +// 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" + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + + "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, with its report commands. +func Program() gonsole.Program { + return gonsole.Program{ + Name: "myapp", + Commands: []gonsole.Command{createCommand(), listCommand(), revokeCommand()}, + } +} + +// 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"}, + 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 + } + _, err = fmt.Fprintf(call.Stdout, "created %s: %s\n", call.Args[0], strings.TrimSpace(description)) + return err + }, + } +} + +// listCommand returns report:list, which lists every report. +func listCommand() gonsole.Command { + return gonsole.Command{ + Name: "report:list", + Summary: "list every report", + Run: func(_ context.Context, call gonsole.Call) error { + 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..903938e --- /dev/null +++ b/gonsole/parse.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "flag" + "fmt" + "io" + "strings" +) + +// invoke reads args against cmd's flags and arguments and runs it. +func (r *runner) invoke(ctx context.Context, cmd Command, args []string) error { + fs := flag.NewFlagSet(cmd.Name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + if cmd.Flags != nil { + cmd.Flags(fs) + } + positional, err := parse(fs, args) + if err != nil { + return Misuse(fmt.Errorf("%s: %w", cmd.Name, err)) + } + if err := arity(cmd, positional); err != nil { + return err + } + return cmd.Run(ctx, Call{Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr}) +} + +// 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..fd68a63 --- /dev/null +++ b/gonsole/parse_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "flag" + "fmt" + "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 got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", 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 got.stderr != tc.stderr { + t.Errorf("stderr = %q, want %q", 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"; got.stderr != want { + t.Errorf("stderr = %q, want %q", 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) + } +} diff --git a/gonsole/program.go b/gonsole/program.go new file mode 100644 index 0000000..32265ed --- /dev/null +++ b/gonsole/program.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" +) + +// 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 + // Renamed maps an old two word spelling to the full name of the command that replaced it. + Renamed map[string]string + // Commands are the program's own commands, each a bare word or namespace:word. + Commands []Command +} + +// Main runs p over the process arguments and the standard streams and returns the exit code. +func Main(p Program) int { + return p.Run(context.Background(), 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 { + commands, namespaces := index(p.Commands) + r := &runner{ + program: p, commands: commands, namespaces: namespaces, + stdin: stdin, stdout: stdout, stderr: stderr, + } + return r.exit(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 + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +// 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 + } + r.warn("%v", err) + if errors.Is(err, ErrMisused) { + return ExitMisused + } + return ExitFailed +} + +// 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..e4cded2 --- /dev/null +++ b/gonsole/program_test.go @@ -0,0 +1,154 @@ +// 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\"\n", + }, + {"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..d0b4c6f --- /dev/null +++ b/gonsole/resolve.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "fmt" + "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. +func (r *runner) dispatch(ctx context.Context, args []string) error { + word, rest := head(r.rename(args)) + cmd, err := r.find(word) + if err != nil { + return err + } + return r.invoke(ctx, cmd, rest) +} + +// head splits args into the first word and the rest. +func head(args []string) (string, []string) { + if len(args) == 0 { + return "", nil + } + return args[0], args[1:] +} + +// 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 word. +func (r *runner) find(word string) (Command, error) { + if cmd, known := r.commands[word]; known { + return cmd, nil + } + namespace, _, _ := strings.Cut(word, ":") + if names := r.namespaces[namespace]; len(names) > 0 { + return Command{}, Misuse(fmt.Errorf("unknown command %q, want %s", word, alternatives(names))) + } + return Command{}, Misuse(fmt.Errorf("unknown command %q, run %q to see every command", word, 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..c9afebb --- /dev/null +++ b/gonsole/resolve_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "fmt" + "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 word, 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 bare word", []string{"status"}, "status\n", ""}, + {"a namespaced word", []string{"report:list"}, "report:list\n", ""}, + {"a namespaced word 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 + }{ + {"no word", nil, `myapp: unknown command "", ` + everyCommand + "\n"}, + {"an unknown bare word", []string{"reprot"}, `myapp: unknown command "reprot", ` + everyCommand + "\n"}, + {"a flag as the first word", []string{"-v"}, `myapp: unknown command "-v", ` + everyCommand + "\n"}, + {"an unknown namespace", []string{"audit:list"}, `myapp: unknown command "audit:list", ` + everyCommand + "\n"}, + {"a bare word 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"}, + {"a wrong word in a namespace", []string{"report:delete"}, `myapp: unknown command "report:delete", ` + + reportCommands + "\n"}, + {"an empty word in a namespace", []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) + } + }) + } +} From 8b7a79554ba27e8c42181c1fe5f9aeb5a988488e Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Wed, 23 Sep 2026 15:42:05 +0200 Subject: [PATCH 03/24] feat(gonsole): print the command list and a help page per command --- gonsole/base.go | 20 +++ gonsole/exec_test.go | 8 +- gonsole/parse.go | 58 +++++- gonsole/parse_test.go | 12 +- gonsole/program.go | 25 ++- gonsole/program_test.go | 8 +- gonsole/resolve.go | 33 +++- gonsole/resolve_test.go | 11 +- gonsole/text.go | 84 +++++++++ gonsole/text_test.go | 384 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 615 insertions(+), 28 deletions(-) create mode 100644 gonsole/base.go create mode 100644 gonsole/text.go create mode 100644 gonsole/text_test.go diff --git a/gonsole/base.go b/gonsole/base.go new file mode 100644 index 0000000..51f37b3 --- /dev/null +++ b/gonsole/base.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "context" + "io" +) + +// base returns the commands the engine owns in every program. +func (r *runner) base() []Command { + list := func(_ context.Context, call Call) error { + _, err := io.WriteString(call.Stdout, r.listing()) + return err + } + return []Command{ + {Name: "help", Summary: "print the help of one command", Run: list}, + {Name: "list", Summary: "list every command", Run: list}, + } +} diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 8498371..e237579 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -59,7 +59,13 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { {"a word no command owns", "", []string{"reprot"}, gonsole.ExitMisused, "", "myapp: unknown command \"reprot\", run \"myapp list\" to see every command\n"}, {"a flag no command defines", "", []string{"report:list", "-bogus"}, gonsole.ExitMisused, "", - "myapp: report:list: flag provided but not defined: -bogus\n"}, + `myapp: report:list: flag provided but not defined: -bogus + +list every report + +Usage: + myapp report:list +`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/gonsole/parse.go b/gonsole/parse.go index 903938e..72323bb 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -4,20 +4,74 @@ package gonsole import ( "context" + "errors" "flag" "fmt" "io" "strings" ) -// invoke reads args against cmd's flags and arguments and runs it. -func (r *runner) invoke(ctx context.Context, cmd Command, args []string) error { +// asksHelp reports whether args ask for help, with the help word 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 words of a help run before any double dash, without the help word and the help flags. +func subject(args []string) []string { + if args[0] == "help" { + args = args[1:] + } + var words []string + for _, arg := range args { + if arg == "--" { + break + } + if !isHelpFlag(arg) { + words = append(words, arg) + } + } + return words +} + +// 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 +} + +// flagSet returns a fresh flag set holding cmd's flags. +func flagSet(cmd Command) *flag.FlagSet { fs := flag.NewFlagSet(cmd.Name, flag.ContinueOnError) fs.SetOutput(io.Discard) if cmd.Flags != nil { cmd.Flags(fs) } + return fs +} + +// invoke reads args against cmd's flags and arguments and runs it. +func (r *runner) invoke(ctx context.Context, cmd Command, args []string) error { + fs := flagSet(cmd) + r.reached, r.flags = cmd, fs positional, err := parse(fs, args) + if errors.Is(err, flag.ErrHelp) { + _, err = io.WriteString(r.stdout, r.page(cmd, fs)) + return err + } if err != nil { return Misuse(fmt.Errorf("%s: %w", cmd.Name, err)) } diff --git a/gonsole/parse_test.go b/gonsole/parse_test.go index fd68a63..1064e3c 100644 --- a/gonsole/parse_test.go +++ b/gonsole/parse_test.go @@ -112,8 +112,8 @@ func TestRunRefusesAMalformedCommandLine(t *testing.T) { 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 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) @@ -147,8 +147,8 @@ func TestRunCountsArgumentsInPlainEnglish(t *testing.T) { 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 firstLine(got.stderr) != tc.stderr { + t.Errorf("stderr opens with %q, want %q", firstLine(got.stderr), tc.stderr) } }) } @@ -162,8 +162,8 @@ func TestRunRefusesAFlagOnACommandWithoutFlags(t *testing.T) { 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"; got.stderr != want { - t.Errorf("stderr = %q, want %q", got.stderr, want) + 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) } } diff --git a/gonsole/program.go b/gonsole/program.go index 32265ed..956784b 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "slices" ) // ExitDone is the code of a finished command, a help page or a dry run. @@ -50,6 +51,12 @@ func (m misuse) Unwrap() []error { 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 program's version. + Version string + // Footer is the text the listing closes with. + Footer string // Renamed maps an old two word spelling to the full name of the command that replaced it. Renamed map[string]string // Commands are the program's own commands, each a bare word or namespace:word. @@ -63,11 +70,8 @@ func Main(p Program) int { // 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 { - commands, namespaces := index(p.Commands) - r := &runner{ - program: p, commands: commands, namespaces: namespaces, - stdin: stdin, stdout: stdout, stderr: stderr, - } + r := &runner{program: p, stdin: stdin, stdout: stdout, stderr: stderr} + r.commands, r.namespaces = index(slices.Concat(p.Commands, r.base())) return r.exit(r.dispatch(ctx, args)) } @@ -79,6 +83,8 @@ type runner struct { stdin io.Reader stdout io.Writer stderr io.Writer + reached Command + flags *flag.FlagSet } // exit prints err and returns the exit code it earns. @@ -87,10 +93,13 @@ func (r *runner) exit(err error) int { return ExitDone } r.warn("%v", err) - if errors.Is(err, ErrMisused) { - return ExitMisused + if !errors.Is(err, ErrMisused) { + return ExitFailed + } + if r.flags != nil { + _, _ = io.WriteString(r.stderr, "\n"+r.page(r.reached, r.flags)) } - return ExitFailed + return ExitMisused } // warn writes one line to stderr opened by the program name. diff --git a/gonsole/program_test.go b/gonsole/program_test.go index e4cded2..8dc68cc 100644 --- a/gonsole/program_test.go +++ b/gonsole/program_test.go @@ -92,7 +92,13 @@ func TestRunAnswersTheExitCodeTheCommandEarns(t *testing.T) { "a command that reports misuse", gonsole.Misuse(errors.New(`unknown format "pdf"`)), gonsole.ExitMisused, - "myapp: unknown format \"pdf\"\n", + `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, ""}, } diff --git a/gonsole/resolve.go b/gonsole/resolve.go index d0b4c6f..1200d76 100644 --- a/gonsole/resolve.go +++ b/gonsole/resolve.go @@ -5,6 +5,7 @@ package gonsole import ( "context" "fmt" + "io" "slices" "strings" ) @@ -25,22 +26,36 @@ func index(commands []Command) (map[string]Command, map[string][]string) { return byName, namespaces } -// dispatch runs the command args name. +// dispatch runs the command args name, the help they ask for, or the listing when they name none. func (r *runner) dispatch(ctx context.Context, args []string) error { - word, rest := head(r.rename(args)) - cmd, err := r.find(word) + if asksHelp(args) { + return r.help(args) + } + if len(args) == 0 { + _, err := io.WriteString(r.stdout, r.listing()) + return err + } + args = r.rename(args) + cmd, err := r.find(args[0]) if err != nil { return err } - return r.invoke(ctx, cmd, rest) + return r.invoke(ctx, cmd, args[1:]) } -// head splits args into the first word and the rest. -func head(args []string) (string, []string) { - if len(args) == 0 { - return "", nil +// help prints the help page of the command args name, or the listing when they name none. +func (r *runner) help(args []string) error { + words := r.rename(subject(args)) + if len(words) == 0 { + _, err := io.WriteString(r.stdout, r.listing()) + return err + } + cmd, err := r.find(words[0]) + if err != nil { + return err } - return args[0], args[1:] + _, err = io.WriteString(r.stdout, r.page(cmd, flagSet(cmd))) + return err } // rename returns args with an old two word spelling replaced by the name of the command that replaced it. diff --git a/gonsole/resolve_test.go b/gonsole/resolve_test.go index c9afebb..b557839 100644 --- a/gonsole/resolve_test.go +++ b/gonsole/resolve_test.go @@ -92,7 +92,6 @@ func TestRunRefusesALineThatNamesNoCommand(t *testing.T) { args []string stderr string }{ - {"no word", nil, `myapp: unknown command "", ` + everyCommand + "\n"}, {"an unknown bare word", []string{"reprot"}, `myapp: unknown command "reprot", ` + everyCommand + "\n"}, {"a flag as the first word", []string{"-v"}, `myapp: unknown command "-v", ` + everyCommand + "\n"}, {"an unknown namespace", []string{"audit:list"}, `myapp: unknown command "audit:list", ` + everyCommand + "\n"}, @@ -125,6 +124,16 @@ func TestRunRefusesALineThatNamesNoCommand(t *testing.T) { } } +func TestRunKeepsTheBaseWordsForTheEngine(t *testing.T) { + t.Parallel() + + got := execute(t, single(echo("list")), "list") + + if !strings.HasPrefix(got.stdout, "myapp\n\nUsage:\n") { + t.Errorf("stdout = %q, want the listing", got.stdout) + } +} + func TestRunNamesTheAlternativesOfANamespaceInPlainEnglish(t *testing.T) { t.Parallel() diff --git a/gonsole/text.go b/gonsole/text.go new file mode 100644 index 0000000..5f19895 --- /dev/null +++ b/gonsole/text.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import ( + "cmp" + "flag" + "fmt" + "maps" + "slices" + "strings" +) + +// listing returns the list of every command the run knows. +func (r *runner) listing() 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.\n\nAvailable commands:\n") + width := r.width() + for _, name := range r.bare() { + fmt.Fprintf(&b, " %-*s%s\n", width, name, r.commands[name].Summary) + } + for _, namespace := range slices.Sorted(maps.Keys(r.namespaces)) { + fmt.Fprintf(&b, " %s\n", namespace) + for _, name := range r.namespaces[namespace] { + fmt.Fprintf(&b, " %-*s%s\n", width, name, r.commands[name].Summary) + } + } + if r.program.Footer != "" { + fmt.Fprintf(&b, "\n%s\n", r.program.Footer) + } + return b.String() +} + +// 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 (r *runner) bare() []string { + var names []string + for name := range r.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 (r *runner) width() int { + longest := 0 + for name := range r.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..e3e4338 --- /dev/null +++ b/gonsole/text_test.go @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "bytes" + "errors" + "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"}, + } +} + +// catalogListing is the listing catalog prints. +const catalogListing = `Myapp, a report keeper. Version 1.4.0 + +Usage: + myapp <command> [flags] [arguments] + +Every command answers -h. + +Available commands: + help print the help of one command + list list every command + status print the store status + 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 word. +const listPage = `list every command + +Usage: + myapp list +` + +// helpPage is the help page of the help base word. +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 word", nil}, + {"the list word", []string{"list"}}, + {"the help word", []string{"help"}}, + {"a short help flag", []string{"-h"}}, + {"a long help flag", []string{"--help"}}, + {"the help word 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] + +Every command answers -h. + +Available commands: + help print the help of one command + list list every command + status print the store status +`, + }, + { + "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] + +Every command answers -h. + +Available commands: + help print the help of one command + list list every command + status print the store status +`, + }, + } + 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 word", []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 word", []string{"list", "-h"}, listPage, ""}, + {"the help word itself", []string{"help", "help"}, helpPage, ""}, + {"an old spelling", []string{"report", "new", "-h"}, createPage, + "myapp: \"report new\" is deprecated, use \"report:create\"\n"}, + {"the help word 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 word", []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 TestHelpWordCountsOnlyAsTheFirstWord(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 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 word", nil}, + {"the listing of the list word", []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 word", []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) + } + }) + } +} From 3701b960b1b283f0812582bfc22c7f442183ca11 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Wed, 23 Sep 2026 15:55:23 +0200 Subject: [PATCH 04/24] feat(gonsole): keep writes a dry run until -yes and answer -json --- gonsole/command.go | 17 ++ gonsole/command_test.go | 45 ++++++ gonsole/exec_test.go | 17 +- gonsole/internal/exampleapp/program.go | 11 +- gonsole/parse.go | 29 +++- gonsole/parse_test.go | 214 +++++++++++++++++++++++++ gonsole/resolve.go | 2 +- gonsole/text.go | 3 +- gonsole/text_test.go | 44 ++++- 9 files changed, 367 insertions(+), 15 deletions(-) create mode 100644 gonsole/command_test.go diff --git a/gonsole/command.go b/gonsole/command.go index 86d5690..521bef3 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -4,6 +4,7 @@ package gonsole import ( "context" + "encoding/json" "flag" "io" ) @@ -18,6 +19,10 @@ type Command struct { 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 // Run does the command's work. Run func(ctx context.Context, call Call) error } @@ -32,4 +37,16 @@ type Call struct { Stdout io.Writer // Stderr is where a command writes progress and warnings. Stderr io.Writer + // JSON reports whether -json was passed. + JSON bool + // Apply reports whether the run applies its writes. + Apply bool +} + +// 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 <draft> & notes", "applied": true}) + + if err != nil { + t.Fatalf("Encode() error = %v, want nil", err) + } + want := `{ + "applied": true, + "title": "Q3 <draft> & 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/exec_test.go b/gonsole/exec_test.go index e237579..cb62e73 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -52,8 +52,17 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { stderr string }{ {"a command that succeeds", "", []string{"report:list"}, gonsole.ExitDone, "quarterly\nyearly\n", ""}, - {"a command that reads its input", "sales by region\n", []string{"report:create", "Q3"}, gonsole.ExitDone, + {"a write that reads its input", "sales by region\n", []string{"report:create", "-yes", "Q3"}, gonsole.ExitDone, "created Q3: sales by region\n", ""}, + {"a dry run of a write", "sales by region\n", []string{"report:create", "Q3"}, gonsole.ExitDone, + "would create Q3: sales by region\n", "myapp: dry run, nothing changed, pass -yes to apply\n"}, + {"a command that answers a document", "", []string{"report:list", "-json"}, gonsole.ExitDone, `{ + "reports": [ + "quarterly", + "yearly" + ] +} +`, ""}, {"a command that fails", "", []string{"report:revoke", "monthly"}, gonsole.ExitFailed, "", "myapp: report \"monthly\" does not exist\n"}, {"a word no command owns", "", []string{"reprot"}, gonsole.ExitMisused, "", @@ -64,7 +73,11 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { list every report Usage: - myapp report:list + myapp report:list [flags] + +Flags: + -json + answer one JSON document `}, } for _, tc := range cases { diff --git a/gonsole/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go index e3de5e9..c57bfa8 100644 --- a/gonsole/internal/exampleapp/program.go +++ b/gonsole/internal/exampleapp/program.go @@ -34,12 +34,17 @@ func createCommand() 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 } - _, err = fmt.Fprintf(call.Stdout, "created %s: %s\n", call.Args[0], strings.TrimSpace(description)) + 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 }, } @@ -50,7 +55,11 @@ 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 diff --git a/gonsole/parse.go b/gonsole/parse.go index 72323bb..6eb6b2d 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -53,19 +53,32 @@ func isHelpFlag(arg string) bool { return false } -// flagSet returns a fresh flag set holding cmd's flags. -func flagSet(cmd Command) *flag.FlagSet { +// switches holds the engine flags one run of a command reads. +type switches struct { + yes bool + json bool +} + +// 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 { fs := flag.NewFlagSet(cmd.Name, flag.ContinueOnError) fs.SetOutput(io.Discard) if cmd.Flags != nil { cmd.Flags(fs) } + 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") + } return fs } // invoke reads args against cmd's flags and arguments and runs it. func (r *runner) invoke(ctx context.Context, cmd Command, args []string) error { - fs := flagSet(cmd) + var s switches + fs := flagSet(cmd, &s) r.reached, r.flags = cmd, fs positional, err := parse(fs, args) if errors.Is(err, flag.ErrHelp) { @@ -78,7 +91,15 @@ func (r *runner) invoke(ctx context.Context, cmd Command, args []string) error { if err := arity(cmd, positional); err != nil { return err } - return cmd.Run(ctx, Call{Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr}) + call := Call{ + Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, + JSON: s.json, Apply: s.yes || !cmd.Writes, + } + if err := cmd.Run(ctx, call); err != nil || call.Apply { + return err + } + r.warn("dry run, nothing changed, pass -yes to apply") + return nil } // parse sets the flags in args on fs and returns the positional arguments, flags and arguments in any order. diff --git a/gonsole/parse_test.go b/gonsole/parse_test.go index 1064e3c..c6c10f5 100644 --- a/gonsole/parse_test.go +++ b/gonsole/parse_test.go @@ -4,6 +4,7 @@ package gonsole_test import ( "context" + "errors" "flag" "fmt" "testing" @@ -180,3 +181,216 @@ func TestRunNamesTheFlagSetAfterTheCommand(t *testing.T) { 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) + } +} diff --git a/gonsole/resolve.go b/gonsole/resolve.go index 1200d76..d61389d 100644 --- a/gonsole/resolve.go +++ b/gonsole/resolve.go @@ -54,7 +54,7 @@ func (r *runner) help(args []string) error { if err != nil { return err } - _, err = io.WriteString(r.stdout, r.page(cmd, flagSet(cmd))) + _, err = io.WriteString(r.stdout, r.page(cmd, flagSet(cmd, &switches{}))) return err } diff --git a/gonsole/text.go b/gonsole/text.go index 5f19895..4aa4235 100644 --- a/gonsole/text.go +++ b/gonsole/text.go @@ -15,7 +15,8 @@ import ( func (r *runner) listing() 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.\n\nAvailable commands:\n") + 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 := r.width() for _, name := range r.bare() { fmt.Fprintf(&b, " %-*s%s\n", width, name, r.commands[name].Summary) diff --git a/gonsole/text_test.go b/gonsole/text_test.go index e3e4338..7767acc 100644 --- a/gonsole/text_test.go +++ b/gonsole/text_test.go @@ -4,6 +4,7 @@ package gonsole_test import ( "bytes" + "context" "errors" "strings" "testing" @@ -35,14 +36,17 @@ func catalog() gonsole.Program { } } +// 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] -Every command answers -h. - +` + intro + ` Available commands: help print the help of one command list list every command @@ -148,8 +152,7 @@ func TestListingLeavesOutWhatTheProgramDoesNotSet(t *testing.T) { Usage: myapp <command> [flags] [arguments] -Every command answers -h. - +` + intro + ` Available commands: help print the help of one command list list every command @@ -164,8 +167,7 @@ Available commands: Usage: myapp <command> [flags] [arguments] -Every command answers -h. - +` + intro + ` Available commands: help print the help of one command list list every command @@ -295,6 +297,36 @@ func TestHelpWordCountsOnlyAsTheFirstWord(t *testing.T) { } } +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() From 702f88b6159a396dcbee1b5dc4c2fc6b89a58324 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Wed, 23 Sep 2026 16:17:14 +0200 Subject: [PATCH 05/24] feat(gonsole): check and record the account a write acts as --- gonsole/actor.go | 25 +++++ gonsole/actor_test.go | 229 ++++++++++++++++++++++++++++++++++++++++++ gonsole/command.go | 4 + gonsole/parse.go | 41 +++++--- gonsole/program.go | 4 + 5 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 gonsole/actor.go create mode 100644 gonsole/actor_test.go diff --git a/gonsole/actor.go b/gonsole/actor.go new file mode 100644 index 0000000..d59e1fa --- /dev/null +++ b/gonsole/actor.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +import "context" + +// perform authorizes the acting account of call, runs cmd with it and records the run when it applied. +func (r *runner) perform(ctx context.Context, cmd Command, call Call) error { + if cmd.Capability != "" { + if err := r.program.Authorize(ctx, call, cmd.Capability); 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 + } + if cmd.Capability == "" { + return nil + } + return r.program.Record(ctx, call, cmd.Name) +} diff --git a/gonsole/actor_test.go b/gonsole/actor_test.go new file mode 100644 index 0000000..d7a1747 --- /dev/null +++ b/gonsole/actor_test.go @@ -0,0 +1,229 @@ +// 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 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 <email>\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] <id> + +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/command.go b/gonsole/command.go index 521bef3..6eeeb79 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -23,6 +23,8 @@ type Command struct { Writes bool // JSON marks a command that answers one JSON document. JSON 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 } @@ -41,6 +43,8 @@ type Call struct { JSON bool // Apply reports whether the run applies its writes. Apply bool + // Actor is the account the -as flag names. + Actor string } // Encode writes v to Stdout as one indented JSON document. diff --git a/gonsole/parse.go b/gonsole/parse.go index 6eb6b2d..56790b2 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -57,6 +57,7 @@ func isHelpFlag(arg string) bool { 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. @@ -72,34 +73,44 @@ func flagSet(cmd Command, s *switches) *flag.FlagSet { 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 } -// invoke reads args against cmd's flags and arguments and runs it. +// 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) error { + 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 := flagSet(cmd, &s) r.reached, r.flags = cmd, fs positional, err := parse(fs, args) - if errors.Is(err, flag.ErrHelp) { - _, err = io.WriteString(r.stdout, r.page(cmd, fs)) - return err - } if err != nil { - return Misuse(fmt.Errorf("%s: %w", cmd.Name, err)) + return Call{}, Misuse(fmt.Errorf("%s: %w", cmd.Name, err)) } if err := arity(cmd, positional); err != nil { - return err + return Call{}, err } - call := Call{ - Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, - JSON: s.json, Apply: s.yes || !cmd.Writes, + if cmd.Capability != "" && s.as == "" { + return Call{}, Misuse(fmt.Errorf("%s wants -as <email>", cmd.Name)) } - if err := cmd.Run(ctx, call); err != nil || call.Apply { - return err - } - r.warn("dry run, nothing changed, pass -yes to apply") - return nil + return Call{ + Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, + JSON: s.json, Apply: s.yes || !cmd.Writes, Actor: s.as, + }, nil } // parse sets the flags in args on fs and returns the positional arguments, flags and arguments in any order. diff --git a/gonsole/program.go b/gonsole/program.go index 956784b..4bc6eda 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -61,6 +61,10 @@ type Program struct { Renamed map[string]string // Commands are the program's own commands, each a bare word or namespace:word. Commands []Command + // 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 the process arguments and the standard streams and returns the exit code. From 34c7199666725ca6bf6dd68ee07be9d9a40285bc Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Wed, 23 Sep 2026 17:42:21 +0200 Subject: [PATCH 06/24] feat(gonsole): read settings under one prefix with bounds and plain errors --- gonsole/command.go | 2 + gonsole/env.go | 162 ++++++++++++++++++++++ gonsole/env_test.go | 321 ++++++++++++++++++++++++++++++++++++++++++++ gonsole/parse.go | 2 +- gonsole/program.go | 2 + 5 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 gonsole/env.go create mode 100644 gonsole/env_test.go diff --git a/gonsole/command.go b/gonsole/command.go index 6eeeb79..ccb8732 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -39,6 +39,8 @@ type Call struct { 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. 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/parse.go b/gonsole/parse.go index 56790b2..c98ec03 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -108,7 +108,7 @@ func (r *runner) prepare(cmd Command, args []string) (Call, error) { return Call{}, Misuse(fmt.Errorf("%s wants -as <email>", cmd.Name)) } return Call{ - Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, + Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, Env: r.settings(), JSON: s.json, Apply: s.yes || !cmd.Writes, Actor: s.as, }, nil } diff --git a/gonsole/program.go b/gonsole/program.go index 4bc6eda..011f379 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -57,6 +57,8 @@ type Program struct { Version string // Footer is the text the listing closes with. Footer string + // Env reads the program's settings under its prefix. + Env Env // Renamed maps an old two word spelling to the full name of the command that replaced it. Renamed map[string]string // Commands are the program's own commands, each a bare word or namespace:word. From 959d2d2ad0599fac7eb7ee070c7b9965942272ad Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Wed, 23 Sep 2026 18:50:42 +0200 Subject: [PATCH 07/24] feat(gonsole): add the version, serve, migrate and seed words --- gonsole/actor.go | 22 +- gonsole/base.go | 87 +++- gonsole/base_test.go | 650 +++++++++++++++++++++++++ gonsole/command.go | 21 + gonsole/exec_test.go | 45 +- gonsole/internal/exampleapp/program.go | 11 +- gonsole/parse.go | 2 +- gonsole/program.go | 19 +- gonsole/resolve.go | 14 +- gonsole/text_test.go | 15 +- 10 files changed, 846 insertions(+), 40 deletions(-) create mode 100644 gonsole/base_test.go diff --git a/gonsole/actor.go b/gonsole/actor.go index d59e1fa..90a0670 100644 --- a/gonsole/actor.go +++ b/gonsole/actor.go @@ -4,10 +4,13 @@ package gonsole import "context" -// perform authorizes the acting account of call, runs cmd with it and records the run when it applied. +// 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 cmd.Capability != "" { - if err := r.program.Authorize(ctx, call, cmd.Capability); err != nil { + 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 } } @@ -18,6 +21,19 @@ func (r *runner) perform(ctx context.Context, cmd Command, call Call) error { 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. +func (r *runner) record(ctx context.Context, cmd Command, call Call) error { if cmd.Capability == "" { return nil } diff --git a/gonsole/base.go b/gonsole/base.go index 51f37b3..1495aec 100644 --- a/gonsole/base.go +++ b/gonsole/base.go @@ -3,18 +3,101 @@ package gonsole import ( + "cmp" "context" + "errors" + "fmt" "io" ) -// base returns the commands the engine owns in every program. +// base returns the commands the engine owns in the program. func (r *runner) base() []Command { list := func(_ context.Context, call Call) error { _, err := io.WriteString(call.Stdout, r.listing()) return err } - return []Command{ + 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}, } + 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 { + migrate := func(ctx context.Context, call Call) error { return r.migrate(ctx, call, call.Stdout) } + commands = append(commands, Command{Name: "migrate", Summary: "apply every schema step", Run: migrate}) + } + if r.program.Seed != nil { + commands = append(commands, Command{Name: "seed", Summary: "store the demo data", Writes: true, Run: r.seed}) + } + return commands +} + +// 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 + } + if err := r.migrate(ctx, call, call.Stderr); err != nil { + return err + } + if err := r.program.Seed(ctx, call); 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) (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))) }() + 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 +} + +// 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..5eaf66b --- /dev/null +++ b/gonsole/base_test.go @@ -0,0 +1,650 @@ +// 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 + log []string +} + +// 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 TestListingShowsOnlyTheBaseWordsTheProgramOffers(t *testing.T) { + t.Parallel() + + var s schema + got := execute(t, keeper(&s), "list") + + want := `myapp Version 1.4.0 + +Usage: + myapp <command> [flags] [arguments] + +` + intro + ` +Available commands: + 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 TestRunHidesEachBaseWordTheProgramDoesNotOffer(t *testing.T) { + t.Parallel() + + cases := []struct { + word 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.word, func(t *testing.T) { + t.Parallel() + + var s schema + p := keeper(&s) + tc.leave(&p) + + got := execute(t, p, tc.word) + + if got.code != gonsole.ExitMisused { + t.Errorf("code = %d, want %d", got.code, gonsole.ExitMisused) + } + want := `myapp: unknown command "` + tc.word + `", run "myapp list" to see every command` + "\n" + if got.stderr != want { + t.Errorf("stderr = %q, want %q", got.stderr, want) + } + }) + } +} + +func TestHelpPagesOfTheBaseWordsShowOnlyTheirOwnSwitches(t *testing.T) { + t.Parallel() + + cases := []struct { + word 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 +`}, + } + for _, tc := range cases { + t.Run(tc.word, func(t *testing.T) { + t.Parallel() + + var s schema + got := execute(t, keeper(&s), tc.word, "-h") + + if got.stdout != tc.page { + t.Errorf("stdout = %q, want %q", got.stdout, tc.page) + } + }) + } +} + +func TestBaseWordsFailWhenTheirAnswerCannotBeWritten(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"}}, + } + 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 + } + 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", "release run live=true", "seed 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 + " <nil>" + cases := []struct { + name string + args []string + bareServes bool + serveFails error + code int + stderr string + log []string + }{ + {"the serve word", []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 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) + } + }) + } +} + +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/command.go b/gonsole/command.go index ccb8732..bb2a882 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -5,6 +5,7 @@ package gonsole import ( "context" "encoding/json" + "errors" "flag" "io" ) @@ -23,6 +24,8 @@ type Command struct { 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. @@ -47,6 +50,24 @@ type Call struct { Apply bool // Actor is the account the -as flag names. Actor string + // database is the name of the setting that holds the database address. + database string +} + +// 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) +} + +// Step is one named schema step. +type Step struct { + // Name is the word the step's output line names it by. + 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. diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index cb62e73..35b8652 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "os/exec" + "slices" "strings" "testing" @@ -19,16 +20,17 @@ const exampleSwitch = "GONSOLE_EXEC_EXAMPLE" func TestMain(m *testing.M) { if os.Getenv(exampleSwitch) == "1" { - os.Exit(gonsole.Main(exampleapp.Program())) + os.Exit(gonsole.Main(exampleapp.Program(os.Getenv))) } os.Exit(m.Run()) } -// runExample runs the example program in its own process over args and stdin and answers its exit code and output. -func runExample(t *testing.T, stdin string, args ...string) result { +// 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() + inherited := slices.DeleteFunc(os.Environ(), func(entry string) bool { return strings.HasPrefix(entry, "MYAPP_") }) cmd := exec.CommandContext(t.Context(), os.Args[0], args...) - cmd.Env = append(os.Environ(), exampleSwitch+"=1") + cmd.Env = append(append(inherited, exampleSwitch+"=1"), variables...) cmd.Stdin = strings.NewReader(stdin) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -44,30 +46,35 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { t.Parallel() cases := []struct { - name string - stdin string - args []string - code int - stdout string - stderr string + name string + stdin string + variables []string + args []string + code int + stdout string + stderr string }{ - {"a command that succeeds", "", []string{"report:list"}, gonsole.ExitDone, "quarterly\nyearly\n", ""}, - {"a write that reads its input", "sales by region\n", []string{"report:create", "-yes", "Q3"}, gonsole.ExitDone, - "created Q3: sales by region\n", ""}, - {"a dry run of a write", "sales by region\n", []string{"report:create", "Q3"}, gonsole.ExitDone, + {"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\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 command that answers a document", "", []string{"report:list", "-json"}, gonsole.ExitDone, `{ + {"a command that answers a document", "", nil, []string{"report:list", "-json"}, gonsole.ExitDone, `{ "reports": [ "quarterly", "yearly" ] } `, ""}, - {"a command that fails", "", []string{"report:revoke", "monthly"}, gonsole.ExitFailed, "", + {"a command that fails", "", nil, []string{"report:revoke", "monthly"}, gonsole.ExitFailed, "", "myapp: report \"monthly\" does not exist\n"}, - {"a word no command owns", "", []string{"reprot"}, gonsole.ExitMisused, "", + {"a word 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", "", []string{"report:list", "-bogus"}, gonsole.ExitMisused, "", + {"a flag no command defines", "", nil, []string{"report:list", "-bogus"}, gonsole.ExitMisused, "", `myapp: report:list: flag provided but not defined: -bogus list every report @@ -84,7 +91,7 @@ Flags: t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := runExample(t, tc.stdin, tc.args...) + 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) diff --git a/gonsole/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go index c57bfa8..66b42a2 100644 --- a/gonsole/internal/exampleapp/program.go +++ b/gonsole/internal/exampleapp/program.go @@ -20,11 +20,14 @@ func held() []string { return []string{"quarterly", "yearly"} } -// Program returns the example program, myapp, with its report commands. -func Program() gonsole.Program { +// Program returns the example program, myapp, whose settings getenv reads. +func Program(getenv func(string) string) gonsole.Program { return gonsole.Program{ - Name: "myapp", - Commands: []gonsole.Command{createCommand(), listCommand(), revokeCommand()}, + Name: "myapp", + Env: gonsole.Env{Prefix: "MYAPP_", Getenv: getenv}, + Database: "DATABASE_URL", + Migrations: []gonsole.Step{{Name: "reports", Run: func(context.Context, string) error { return nil }}}, + Commands: []gonsole.Command{createCommand(), listCommand(), revokeCommand()}, } } diff --git a/gonsole/parse.go b/gonsole/parse.go index c98ec03..472a725 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -109,7 +109,7 @@ func (r *runner) prepare(cmd Command, args []string) (Call, error) { } return Call{ Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, Env: r.settings(), - JSON: s.json, Apply: s.yes || !cmd.Writes, Actor: s.as, + JSON: s.json, Apply: s.yes || !cmd.Writes, Actor: s.as, database: r.program.Database, }, nil } diff --git a/gonsole/program.go b/gonsole/program.go index 011f379..bf9e15f 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -10,6 +10,7 @@ import ( "io" "os" "slices" + "strings" ) // ExitDone is the code of a finished command, a help page or a dry run. @@ -53,14 +54,26 @@ type Program struct { Name string // Title is the line the listing opens with. Title string - // Version is the program's version. + // 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 // 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 with no word serves instead of printing the listing. + BareServes bool + // Serve runs the server. + Serve 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 a bare word or namespace:word. Commands []Command // Authorize refuses the call's actor when that account lacks capability. @@ -98,7 +111,9 @@ func (r *runner) exit(err error) int { if err == nil || errors.Is(err, flag.ErrHelp) { return ExitDone } - r.warn("%v", err) + for line := range strings.SplitSeq(err.Error(), "\n") { + r.warn("%s", line) + } if !errors.Is(err, ErrMisused) { return ExitFailed } diff --git a/gonsole/resolve.go b/gonsole/resolve.go index d61389d..a366aab 100644 --- a/gonsole/resolve.go +++ b/gonsole/resolve.go @@ -26,14 +26,13 @@ func index(commands []Command) (map[string]Command, map[string][]string) { return byName, namespaces } -// dispatch runs the command args name, the help they ask for, or the listing when they name none. +// 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(args) } if len(args) == 0 { - _, err := io.WriteString(r.stdout, r.listing()) - return err + return r.commandless(ctx) } args = r.rename(args) cmd, err := r.find(args[0]) @@ -43,6 +42,15 @@ func (r *runner) dispatch(ctx context.Context, args []string) error { 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"}) + } + _, err := io.WriteString(r.stdout, r.listing()) + return err +} + // help prints the help page of the command args name, or the listing when they name none. func (r *runner) help(args []string) error { words := r.rename(subject(args)) diff --git a/gonsole/text_test.go b/gonsole/text_test.go index 7767acc..6f71459 100644 --- a/gonsole/text_test.go +++ b/gonsole/text_test.go @@ -51,6 +51,7 @@ Available commands: 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 @@ -154,9 +155,10 @@ Usage: ` + intro + ` Available commands: - help print the help of one command - list list every command - status print the store status + help print the help of one command + list list every command + status print the store status + version print the version `, }, { @@ -169,9 +171,10 @@ Usage: ` + intro + ` Available commands: - help print the help of one command - list list every command - status print the store status + help print the help of one command + list list every command + status print the store status + version print the version `, }, } From 18379d51378ea0e42caf17100caf2d734644dd7b Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Wed, 23 Sep 2026 19:19:25 +0200 Subject: [PATCH 08/24] refactor(gonsole): say command instead of word in names and docblocks --- gonsole/base_test.go | 28 ++++++++++++++-------------- gonsole/command.go | 4 ++-- gonsole/exec_test.go | 2 +- gonsole/parse.go | 10 +++++----- gonsole/program.go | 4 ++-- gonsole/resolve.go | 20 ++++++++++---------- gonsole/resolve_test.go | 20 ++++++++++---------- gonsole/text_test.go | 30 +++++++++++++++--------------- 8 files changed, 59 insertions(+), 59 deletions(-) diff --git a/gonsole/base_test.go b/gonsole/base_test.go index 5eaf66b..8bfc922 100644 --- a/gonsole/base_test.go +++ b/gonsole/base_test.go @@ -120,7 +120,7 @@ func TestVersionPrintsTheNameAndTheVersion(t *testing.T) { } } -func TestListingShowsOnlyTheBaseWordsTheProgramOffers(t *testing.T) { +func TestListingShowsOnlyTheBaseCommandsTheProgramOffers(t *testing.T) { t.Parallel() var s schema @@ -145,31 +145,31 @@ Available commands: } } -func TestRunHidesEachBaseWordTheProgramDoesNotOffer(t *testing.T) { +func TestRunHidesEachBaseCommandTheProgramDoesNotOffer(t *testing.T) { t.Parallel() cases := []struct { - word string - leave func(p *gonsole.Program) + 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.word, func(t *testing.T) { + t.Run(tc.command, func(t *testing.T) { t.Parallel() var s schema p := keeper(&s) tc.leave(&p) - got := execute(t, p, tc.word) + 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.word + `", run "myapp list" to see every command` + "\n" + 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) } @@ -177,12 +177,12 @@ func TestRunHidesEachBaseWordTheProgramDoesNotOffer(t *testing.T) { } } -func TestHelpPagesOfTheBaseWordsShowOnlyTheirOwnSwitches(t *testing.T) { +func TestHelpPagesOfTheBaseCommandsShowOnlyTheirOwnSwitches(t *testing.T) { t.Parallel() cases := []struct { - word string - page string + command string + page string }{ {"version", `print the version @@ -214,11 +214,11 @@ Usage: `}, } for _, tc := range cases { - t.Run(tc.word, func(t *testing.T) { + t.Run(tc.command, func(t *testing.T) { t.Parallel() var s schema - got := execute(t, keeper(&s), tc.word, "-h") + got := execute(t, keeper(&s), tc.command, "-h") if got.stdout != tc.page { t.Errorf("stdout = %q, want %q", got.stdout, tc.page) @@ -227,7 +227,7 @@ Usage: } } -func TestBaseWordsFailWhenTheirAnswerCannotBeWritten(t *testing.T) { +func TestBaseCommandsFailWhenTheirAnswerCannotBeWritten(t *testing.T) { t.Parallel() cases := []struct { @@ -329,7 +329,7 @@ func TestServeRunsTheProgramServer(t *testing.T) { stderr string log []string }{ - {"the serve word", []string{"serve"}, false, nil, gonsole.ExitDone, "", []string{served}}, + {"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}}, diff --git a/gonsole/command.go b/gonsole/command.go index bb2a882..b3d2fcd 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -12,7 +12,7 @@ import ( // Command is one command a program or a plugin offers. type Command struct { - // Name is the full name, a bare word or namespace:word in lowercase words joined by hyphens. + // 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 @@ -64,7 +64,7 @@ func (c Call) DatabaseURL() (string, error) { // Step is one named schema step. type Step struct { - // Name is the word the step's output line names it by. + // 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 diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 35b8652..4bba517 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -72,7 +72,7 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { `, ""}, {"a command that fails", "", nil, []string{"report:revoke", "monthly"}, gonsole.ExitFailed, "", "myapp: report \"monthly\" does not exist\n"}, - {"a word no command owns", "", nil, []string{"reprot"}, gonsole.ExitMisused, "", + {"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 diff --git a/gonsole/parse.go b/gonsole/parse.go index 472a725..30ea49c 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -11,7 +11,7 @@ import ( "strings" ) -// asksHelp reports whether args ask for help, with the help word first or a help flag before any double dash. +// 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 @@ -27,21 +27,21 @@ func asksHelp(args []string) bool { return false } -// subject returns the words of a help run before any double dash, without the help word and the help flags. +// 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 words []string + var kept []string for _, arg := range args { if arg == "--" { break } if !isHelpFlag(arg) { - words = append(words, arg) + kept = append(kept, arg) } } - return words + return kept } // isHelpFlag reports whether arg is one of the flags that ask for help. diff --git a/gonsole/program.go b/gonsole/program.go index bf9e15f..3ff3f62 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -64,7 +64,7 @@ type Program struct { Database 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 with no word serves instead of printing the listing. + // 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 @@ -74,7 +74,7 @@ type Program struct { 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 a bare word or namespace:word. + // Commands are the program's own commands, each named alone or as namespace:command. Commands []Command // Authorize refuses the call's actor when that account lacks capability. Authorize func(ctx context.Context, call Call, capability string) error diff --git a/gonsole/resolve.go b/gonsole/resolve.go index a366aab..d4f84da 100644 --- a/gonsole/resolve.go +++ b/gonsole/resolve.go @@ -53,12 +53,12 @@ func (r *runner) commandless(ctx context.Context) error { // help prints the help page of the command args name, or the listing when they name none. func (r *runner) help(args []string) error { - words := r.rename(subject(args)) - if len(words) == 0 { + named := r.rename(subject(args)) + if len(named) == 0 { _, err := io.WriteString(r.stdout, r.listing()) return err } - cmd, err := r.find(words[0]) + cmd, err := r.find(named[0]) if err != nil { return err } @@ -80,16 +80,16 @@ func (r *runner) rename(args []string) []string { return append([]string{name}, args[2:]...) } -// find returns the command called word. -func (r *runner) find(word string) (Command, error) { - if cmd, known := r.commands[word]; known { +// find returns the command called name. +func (r *runner) find(name string) (Command, error) { + if cmd, known := r.commands[name]; known { return cmd, nil } - namespace, _, _ := strings.Cut(word, ":") - if names := r.namespaces[namespace]; len(names) > 0 { - return Command{}, Misuse(fmt.Errorf("unknown command %q, want %s", word, alternatives(names))) + namespace, _, _ := strings.Cut(name, ":") + if members := r.namespaces[namespace]; len(members) > 0 { + return Command{}, Misuse(fmt.Errorf("unknown command %q, want %s", name, alternatives(members))) } - return Command{}, Misuse(fmt.Errorf("unknown command %q, run %q to see every command", word, r.program.Name+" list")) + return Command{}, 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. diff --git a/gonsole/resolve_test.go b/gonsole/resolve_test.go index b557839..07e29ef 100644 --- a/gonsole/resolve_test.go +++ b/gonsole/resolve_test.go @@ -24,7 +24,7 @@ func echo(name string, args ...string) gonsole.Command { } } -// reports returns a program called myapp with a status word, a report namespace and one old spelling. +// 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", @@ -47,9 +47,9 @@ func TestRunRunsTheCommandTheLineNames(t *testing.T) { stdout string stderr string }{ - {"a bare word", []string{"status"}, "status\n", ""}, - {"a namespaced word", []string{"report:list"}, "report:list\n", ""}, - {"a namespaced word with its argument", []string{"report:create", "Q3"}, "report:create Q3\n", ""}, + {"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"}, @@ -92,15 +92,15 @@ func TestRunRefusesALineThatNamesNoCommand(t *testing.T) { args []string stderr string }{ - {"an unknown bare word", []string{"reprot"}, `myapp: unknown command "reprot", ` + everyCommand + "\n"}, - {"a flag as the first word", []string{"-v"}, `myapp: unknown command "-v", ` + everyCommand + "\n"}, + {"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 bare word used as a namespace", []string{"status:x"}, `myapp: unknown command "status:x", ` + + {"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"}, - {"a wrong word in a namespace", []string{"report:delete"}, `myapp: unknown command "report:delete", ` + + {"an unknown command in a namespace", []string{"report:delete"}, `myapp: unknown command "report:delete", ` + reportCommands + "\n"}, - {"an empty word in a namespace", []string{"report:"}, `myapp: unknown command "report:", ` + + {"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"}, @@ -124,7 +124,7 @@ func TestRunRefusesALineThatNamesNoCommand(t *testing.T) { } } -func TestRunKeepsTheBaseWordsForTheEngine(t *testing.T) { +func TestRunKeepsTheBaseCommandsForTheEngine(t *testing.T) { t.Parallel() got := execute(t, single(echo("list")), "list") diff --git a/gonsole/text_test.go b/gonsole/text_test.go index 6f71459..1c82c08 100644 --- a/gonsole/text_test.go +++ b/gonsole/text_test.go @@ -82,14 +82,14 @@ Usage: myapp report:revoke <id> ` -// listPage is the help page of the list base word. +// 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 word. +// helpPage is the help page of the help base command. const helpPage = `print the help of one command Usage: @@ -109,12 +109,12 @@ func TestListingShowsEveryCommandUnderItsNamespace(t *testing.T) { name string args []string }{ - {"no word", nil}, - {"the list word", []string{"list"}}, - {"the help word", []string{"help"}}, + {"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 word with a help flag", []string{"help", "-h"}}, + {"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 { @@ -200,7 +200,7 @@ func TestHelpPrintsThePageOfTheNamedCommand(t *testing.T) { stdout string stderr string }{ - {"the help word", []string{"help", "report:create"}, createPage, ""}, + {"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, ""}, @@ -208,11 +208,11 @@ func TestHelpPrintsThePageOfTheNamedCommand(t *testing.T) { {"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 word", []string{"list", "-h"}, listPage, ""}, - {"the help word itself", []string{"help", "help"}, helpPage, ""}, + {"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 word before an old spelling", []string{"help", "report", "new"}, createPage, + {"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, ""}, @@ -246,7 +246,7 @@ func TestHelpRefusesANameNoCommandOwns(t *testing.T) { args []string stderr string }{ - {"the help word", []string{"help", "reprot"}, + {"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"}, @@ -287,7 +287,7 @@ func TestHelpFlagAfterADoubleDashIsAnArgument(t *testing.T) { } } -func TestHelpWordCountsOnlyAsTheFirstWord(t *testing.T) { +func TestHelpCountsOnlyAsTheFirstArgument(t *testing.T) { t.Parallel() got := execute(t, catalog(), "report:create", "help") @@ -362,8 +362,8 @@ func TestRunFailsWhenTheAnswerCannotBeWritten(t *testing.T) { name string args []string }{ - {"the listing of no word", nil}, - {"the listing of the list word", []string{"list"}}, + {"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"}}, @@ -396,7 +396,7 @@ func TestMisuseOfAKnownCommandEndsWithItsHelpPage(t *testing.T) { {"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 word", []string{"list", "extra"}, + {"a stray argument to a base command", []string{"list", "extra"}, "myapp: list takes no arguments, got 1\n\n" + listPage}, } for _, tc := range cases { From 170552f8fb47e20ac27bcb1fd5cb28e956df642a Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Wed, 23 Sep 2026 22:18:40 +0200 Subject: [PATCH 09/24] feat(gonsole): check command names and recover a crashing command --- gonsole/actor.go | 25 ++- gonsole/base.go | 12 + gonsole/base_test.go | 7 + gonsole/check.go | 233 +++++++++++++++++++ gonsole/check_test.go | 484 ++++++++++++++++++++++++++++++++++++++++ gonsole/parse.go | 16 +- gonsole/plugins.go | 17 ++ gonsole/program.go | 11 + gonsole/resolve.go | 6 +- gonsole/resolve_test.go | 10 - gonsole/text_test.go | 3 + 11 files changed, 807 insertions(+), 17 deletions(-) create mode 100644 gonsole/check.go create mode 100644 gonsole/check_test.go create mode 100644 gonsole/plugins.go diff --git a/gonsole/actor.go b/gonsole/actor.go index 90a0670..8ee07e6 100644 --- a/gonsole/actor.go +++ b/gonsole/actor.go @@ -2,7 +2,11 @@ package gonsole -import "context" +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 { @@ -39,3 +43,22 @@ func (r *runner) record(ctx context.Context, cmd Command, call Call) error { } return r.program.Record(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) +} + +// 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/base.go b/gonsole/base.go index 1495aec..4cb4a41 100644 --- a/gonsole/base.go +++ b/gonsole/base.go @@ -20,6 +20,7 @@ func (r *runner) base() []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}) @@ -34,6 +35,17 @@ func (r *runner) base() []Command { return commands } +// check runs the program's settings check and answers that the settings and command names are 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 + } + } + _, err := io.WriteString(call.Stdout, "settings, plugins and command names are valid\n") + return err +} + // 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)") diff --git a/gonsole/base_test.go b/gonsole/base_test.go index 8bfc922..1f45202 100644 --- a/gonsole/base_test.go +++ b/gonsole/base_test.go @@ -133,6 +133,7 @@ Usage: ` + 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 @@ -211,6 +212,11 @@ Flags: Usage: myapp serve +`}, + {"check", `check every setting, every plugin and every command name + +Usage: + myapp check `}, } for _, tc := range cases { @@ -237,6 +243,7 @@ func TestBaseCommandsFailWhenTheirAnswerCannotBeWritten(t *testing.T) { {"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) { diff --git a/gonsole/check.go b/gonsole/check.go new file mode 100644 index 0000000..331c04e --- /dev/null +++ b/gonsole/check.go @@ -0,0 +1,233 @@ +// 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.group(group) + } + return errors.Join(a.offences...) +} + +// audit collects the offences one Check finds. +type audit struct { + program Program + offences []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, 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") + } +} + +// group refuses the offences of one plugin's command group. +func (a *audit) group(g Group) { + a.claims(g.Namespace) + for _, cmd := range g.Commands { + 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) + } + } +} + +// 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 a base command, a core command or a core or reserved namespace holds. +func (a *audit) claims(namespace string) { + switch { + case slices.Contains(baseCommands, namespace): + a.refuse("plugin %s takes the name of the base command %s", namespace, namespace) + case a.names[namespace]: + a.refuse("plugin %s takes the name of the core command %s", namespace, namespace) + case a.namespaces[namespace]: + a.refuse("plugin %s takes the core namespace %s", namespace, namespace) + case a.reserved[namespace]: + a.refuse("plugin %s takes the reserved namespace %s", namespace, namespace) + } +} + +// 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..ce9e0c7 --- /dev/null +++ b/gonsole/check_test.go @@ -0,0 +1,484 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "context" + "errors" + "flag" + "io" + "os" + "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 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 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) + } +} diff --git a/gonsole/parse.go b/gonsole/parse.go index 30ea49c..cde1053 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -61,11 +61,13 @@ type switches struct { } // 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 { +func flagSet(cmd Command, s *switches) (*flag.FlagSet, error) { fs := flag.NewFlagSet(cmd.Name, flag.ContinueOnError) fs.SetOutput(io.Discard) if cmd.Flags != nil { - cmd.Flags(fs) + 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") @@ -76,11 +78,12 @@ func flagSet(cmd Command, s *switches) *flag.FlagSet { if cmd.Capability != "" { fs.StringVar(&s.as, "as", "", "`email` address of the account acting") } - return fs + 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) error { +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)) @@ -95,7 +98,10 @@ func (r *runner) invoke(ctx context.Context, cmd Command, args []string) error { // 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 := flagSet(cmd, &s) + 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 { diff --git a/gonsole/plugins.go b/gonsole/plugins.go new file mode 100644 index 0000000..3716cf6 --- /dev/null +++ b/gonsole/plugins.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole + +// 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 +} + +// Loaded is what registering the plugins answers. +type Loaded struct { + // Groups are the command groups, one per plugin that offers commands. + Groups []Group +} diff --git a/gonsole/program.go b/gonsole/program.go index 3ff3f62..bc7fb22 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -62,12 +62,16 @@ type Program struct { 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. @@ -91,6 +95,9 @@ func Main(p Program) int { 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())) + if err := p.Check(Loaded{}); err != nil { + return r.exit(err) + } return r.exit(r.dispatch(ctx, args)) } @@ -114,6 +121,10 @@ func (r *runner) exit(err error) int { for line := range strings.SplitSeq(err.Error(), "\n") { r.warn("%s", line) } + var crash panicked + if errors.As(err, &crash) { + _, _ = r.stderr.Write(crash.stack) + } if !errors.Is(err, ErrMisused) { return ExitFailed } diff --git a/gonsole/resolve.go b/gonsole/resolve.go index d4f84da..768a790 100644 --- a/gonsole/resolve.go +++ b/gonsole/resolve.go @@ -62,7 +62,11 @@ func (r *runner) help(args []string) error { if err != nil { return err } - _, err = io.WriteString(r.stdout, r.page(cmd, flagSet(cmd, &switches{}))) + fs, err := flagSet(cmd, &switches{}) + if err != nil { + return err + } + _, err = io.WriteString(r.stdout, r.page(cmd, fs)) return err } diff --git a/gonsole/resolve_test.go b/gonsole/resolve_test.go index 07e29ef..aaff1e8 100644 --- a/gonsole/resolve_test.go +++ b/gonsole/resolve_test.go @@ -124,16 +124,6 @@ func TestRunRefusesALineThatNamesNoCommand(t *testing.T) { } } -func TestRunKeepsTheBaseCommandsForTheEngine(t *testing.T) { - t.Parallel() - - got := execute(t, single(echo("list")), "list") - - if !strings.HasPrefix(got.stdout, "myapp\n\nUsage:\n") { - t.Errorf("stdout = %q, want the listing", got.stdout) - } -} - func TestRunNamesTheAlternativesOfANamespaceInPlainEnglish(t *testing.T) { t.Parallel() diff --git a/gonsole/text_test.go b/gonsole/text_test.go index 1c82c08..79684a8 100644 --- a/gonsole/text_test.go +++ b/gonsole/text_test.go @@ -48,6 +48,7 @@ Usage: ` + 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 @@ -155,6 +156,7 @@ Usage: ` + 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 @@ -171,6 +173,7 @@ Usage: ` + 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 From 1fe9db376e1bbefbeb29750f7785d5fee2b5ed15 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 10:03:57 +0200 Subject: [PATCH 10/24] feat(gonsole): serve HTTP until a signal ends the run --- gonsole/exec_test.go | 103 +++++- gonsole/internal/exampleapp/program.go | 24 ++ gonsole/program.go | 9 +- gonsole/serve.go | 96 +++++ gonsole/serve_internal_test.go | 98 +++++ gonsole/serve_test.go | 482 +++++++++++++++++++++++++ 6 files changed, 808 insertions(+), 4 deletions(-) create mode 100644 gonsole/serve.go create mode 100644 gonsole/serve_internal_test.go create mode 100644 gonsole/serve_test.go diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 4bba517..6da56a7 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -3,13 +3,16 @@ package gonsole_test import ( + "bufio" "bytes" "errors" "os" "os/exec" "slices" "strings" + "syscall" "testing" + "time" "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/internal/exampleapp" @@ -25,12 +28,17 @@ func TestMain(m *testing.M) { 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() - inherited := slices.DeleteFunc(os.Environ(), func(entry string) bool { return strings.HasPrefix(entry, "MYAPP_") }) cmd := exec.CommandContext(t.Context(), os.Args[0], args...) - cmd.Env = append(append(inherited, exampleSwitch+"=1"), variables...) + cmd.Env = exampleEnvironment(variables...) cmd.Stdin = strings.NewReader(stdin) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -105,3 +113,94 @@ Flags: }) } } + +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:create", "-yes", "Q3") + cmd.Env = exampleEnvironment() + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatalf("piping stdin: %v", err) + } + defer func() { _ = stdin.Close() }() + if err := cmd.Start(); err != nil { + t.Fatalf("starting the example program: %v", err) + } + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + time.Sleep(300 * time.Millisecond) + + _ = 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/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go index 66b42a2..cf2912f 100644 --- a/gonsole/internal/exampleapp/program.go +++ b/gonsole/internal/exampleapp/program.go @@ -5,12 +5,16 @@ package exampleapp import ( "bufio" + "cmp" "context" "errors" "fmt" "io" + "log/slog" + "net/http" "slices" "strings" + "time" "github.com/gopherium/framework/gonsole" ) @@ -26,11 +30,31 @@ func Program(getenv func(string) string) 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(), listCommand(), revokeCommand()}, } } +// 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{ diff --git a/gonsole/program.go b/gonsole/program.go index bc7fb22..4810708 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -9,8 +9,10 @@ import ( "fmt" "io" "os" + "os/signal" "slices" "strings" + "syscall" ) // ExitDone is the code of a finished command, a help page or a dry run. @@ -86,9 +88,12 @@ type Program struct { Record func(ctx context.Context, call Call, command string) error } -// Main runs p over the process arguments and the standard streams and returns the exit code. +// 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 { - return p.Run(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + 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. diff --git a/gonsole/serve.go b/gonsole/serve.go new file mode 100644 index 0000000..1481785 --- /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), stopWithin(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, stopWithin(grace, stop)) +} + +// stopWithin calls stop under ctx, nothing when stop is nil. +func stopWithin(ctx context.Context, stop func(context.Context) error) error { + if stop == nil { + return nil + } + return stop(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) +} From 76b770a337e5a2f47b8e56f88dda25903cb16de3 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 10:03:57 +0200 Subject: [PATCH 11/24] feat(gonsole): record an applied write even after a signal --- gonsole/actor.go | 4 ++-- gonsole/actor_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/gonsole/actor.go b/gonsole/actor.go index 8ee07e6..faf7e91 100644 --- a/gonsole/actor.go +++ b/gonsole/actor.go @@ -36,12 +36,12 @@ func (r *runner) authorize(ctx context.Context, cmd Command, call Call) error { return r.program.Authorize(ctx, call, cmd.Capability) } -// record stores the applied run of cmd when cmd names a 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(ctx, call, cmd.Name) + return r.program.Record(context.WithoutCancel(ctx), call, cmd.Name) } // panicked is a panic recovered from the run of one command. diff --git a/gonsole/actor_test.go b/gonsole/actor_test.go index d7a1747..f787844 100644 --- a/gonsole/actor_test.go +++ b/gonsole/actor_test.go @@ -159,6 +159,34 @@ func TestRunHandsTheHooksTheRunContext(t *testing.T) { } } +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() From 3f5b307a9212802806b2e98e048402a145a1be69 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 15:33:18 +0200 Subject: [PATCH 12/24] feat(gonsole): collect the commands each plugin provides --- gonsole/plugins.go | 42 ++++++++ gonsole/plugins_test.go | 234 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 gonsole/plugins_test.go diff --git a/gonsole/plugins.go b/gonsole/plugins.go index 3716cf6..ae9a0e0 100644 --- a/gonsole/plugins.go +++ b/gonsole/plugins.go @@ -2,6 +2,11 @@ package gonsole +import ( + "errors" + "fmt" +) + // 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. @@ -10,6 +15,43 @@ type Group struct { 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. diff --git a/gonsole/plugins_test.go b/gonsole/plugins_test.go new file mode 100644 index 0000000..5f4d19b --- /dev/null +++ b/gonsole/plugins_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Apache-2.0 + +package gonsole_test + +import ( + "errors" + "reflect" + "slices" + "testing" + + "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) + } +} From 75fbe753c996963080308afd81119b025bc78d5a Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 16:40:46 +0200 Subject: [PATCH 13/24] feat(gonsole): register the plugins once per run and release them --- gonsole/actor.go | 17 ++ gonsole/command.go | 10 + gonsole/parse.go | 2 +- gonsole/plugins.go | 45 ++++ gonsole/plugins_test.go | 550 ++++++++++++++++++++++++++++++++++++++++ gonsole/program.go | 20 +- 6 files changed, 640 insertions(+), 4 deletions(-) diff --git a/gonsole/actor.go b/gonsole/actor.go index faf7e91..ce19c17 100644 --- a/gonsole/actor.go +++ b/gonsole/actor.go @@ -56,6 +56,23 @@ 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 { diff --git a/gonsole/command.go b/gonsole/command.go index b3d2fcd..50b6ff9 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -52,6 +52,8 @@ type Call struct { Actor string // 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. @@ -62,6 +64,14 @@ func (c Call) DatabaseURL() (string, error) { 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) +} + // Step is one named schema step. type Step struct { // Name is the step's name in its output line. diff --git a/gonsole/parse.go b/gonsole/parse.go index cde1053..3f7c36a 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -115,7 +115,7 @@ func (r *runner) prepare(cmd Command, args []string) (Call, error) { } return Call{ Args: positional, 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, + JSON: s.json, Apply: s.yes || !cmd.Writes, Actor: s.as, database: r.program.Database, plugins: r.plugins, }, nil } diff --git a/gonsole/plugins.go b/gonsole/plugins.go index ae9a0e0..ae035f7 100644 --- a/gonsole/plugins.go +++ b/gonsole/plugins.go @@ -3,8 +3,10 @@ package gonsole import ( + "context" "errors" "fmt" + "sync" ) // Group is the commands one plugin offers under the namespace equal to its id. @@ -56,4 +58,47 @@ func provided(id string, p Provider) (commands []Command, err error) { type Loaded struct { // Groups are the command groups, one per plugin that offers commands. Groups []Group + // 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 + mu sync.Mutex + done bool + loaded Loaded + err error +} + +// answer registers the plugins on its first call and returns that registration's answer on every call. +func (m *memo) answer(ctx context.Context) (Loaded, error) { + if m.register == nil { + return Loaded{}, nil + } + m.mu.Lock() + defer m.mu.Unlock() + if !m.done { + m.done = true + m.loaded, m.err = m.registered(ctx) + } + return m.loaded, m.err +} + +// 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 := stopWithin(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 index 5f4d19b..a4c26d1 100644 --- a/gonsole/plugins_test.go +++ b/gonsole/plugins_test.go @@ -3,10 +3,17 @@ package gonsole_test import ( + "bytes" + "context" "errors" + "flag" + "fmt" "reflect" "slices" + "strings" + "sync" "testing" + "time" "github.com/gopherium/framework/gonsole" ) @@ -232,3 +239,546 @@ func TestWalkJoinsEveryPanic(t *testing.T) { 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 + fail error + lost error + withoutRelease bool + calls []gonsole.Call + log []string +} + +// register answers the groups 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, "register") + loaded := gonsole.Loaded{Groups: r.groups} + 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, offering demo:sync. +func demoGroups() []gonsole.Group { + return []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{echo("demo: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, + } +} + +// 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", "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") + + if want := []string{"register", "release live=true"}; 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 != "" { + t.Errorf("registration call = %+v, want the program settings and nothing of the command", call) + } +} + +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 index 4810708..ace2aa7 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -82,6 +82,8 @@ type Program struct { 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. @@ -103,7 +105,10 @@ func (p Program) Run(ctx context.Context, args []string, stdin io.Reader, stdout if err := p.Check(Loaded{}); err != nil { return r.exit(err) } - return r.exit(r.dispatch(ctx, args)) + r.plugins = &memo{register: p.Plugins, 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. @@ -111,6 +116,7 @@ type runner struct { program Program commands map[string]Command namespaces map[string][]string + plugins *memo stdin io.Reader stdout io.Writer stderr io.Writer @@ -118,6 +124,15 @@ type runner struct { 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) { @@ -126,8 +141,7 @@ func (r *runner) exit(err error) int { for line := range strings.SplitSeq(err.Error(), "\n") { r.warn("%s", line) } - var crash panicked - if errors.As(err, &crash) { + for _, crash := range crashes(err) { _, _ = r.stderr.Write(crash.stack) } if !errors.Is(err, ErrMisused) { From ea77cb6575c0dbb42d27f1d46d3e11f766a56f76 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 17:27:21 +0200 Subject: [PATCH 14/24] feat(gonsole): run the commands of registered plugins --- gonsole/check.go | 60 ++- gonsole/check_test.go | 161 ++++++++ gonsole/command.go | 4 +- gonsole/exec_test.go | 27 ++ gonsole/internal/exampleapp/program.go | 37 ++ gonsole/plugins.go | 34 +- gonsole/plugins_test.go | 71 +++- gonsole/program.go | 9 +- gonsole/resolve.go | 89 ++++- gonsole/resolve_test.go | 487 +++++++++++++++++++++++++ 10 files changed, 932 insertions(+), 47 deletions(-) diff --git a/gonsole/check.go b/gonsole/check.go index 331c04e..c1103ed 100644 --- a/gonsole/check.go +++ b/gonsole/check.go @@ -33,7 +33,7 @@ func (p Program) Check(loaded Loaded) error { a := newAudit(p) a.core() for _, group := range loaded.Groups { - a.group(group) + a.admit(group) } return errors.Join(a.offences...) } @@ -42,6 +42,7 @@ func (p Program) Check(loaded Loaded) error { type audit struct { program Program offences []error + dropped map[string][]error declared map[string]bool names map[string]bool namespaces map[string]bool @@ -51,7 +52,7 @@ type audit struct { // newAudit returns an audit of p knowing the names and namespaces its commands use. func newAudit(p Program) *audit { a := &audit{ - program: p, declared: map[string]bool{}, names: map[string]bool{}, + 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 { @@ -87,18 +88,33 @@ func (a *audit) core() { } } -// group refuses the offences of one plugin's command group. -func (a *audit) group(g Group) { - a.claims(g.Namespace) +// 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 { - 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 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. @@ -210,18 +226,28 @@ func (a *audit) renamed() { } } -// claims refuses a plugin whose namespace a base command, a core command or a core or reserved namespace holds. -func (a *audit) claims(namespace string) { +// 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): - a.refuse("plugin %s takes the name of the base command %s", namespace, namespace) + return fmt.Sprintf("plugin %s takes the name of the base command %s", namespace, namespace) case a.names[namespace]: - a.refuse("plugin %s takes the name of the core command %s", namespace, namespace) + return fmt.Sprintf("plugin %s takes the name of the core command %s", namespace, namespace) case a.namespaces[namespace]: - a.refuse("plugin %s takes the core namespace %s", namespace, namespace) + return fmt.Sprintf("plugin %s takes the core namespace %s", namespace, namespace) case a.reserved[namespace]: - a.refuse("plugin %s takes the reserved namespace %s", namespace, 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. diff --git a/gonsole/check_test.go b/gonsole/check_test.go index ce9e0c7..4a2d5bc 100644 --- a/gonsole/check_test.go +++ b/gonsole/check_test.go @@ -219,6 +219,12 @@ func TestCheckRefusesThePluginOffences(t *testing.T) { {"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`}}, @@ -482,3 +488,158 @@ func TestCheckWritesNothingToTheProcessStderr(t *testing.T) { 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 index 50b6ff9..100ec41 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -50,6 +50,8 @@ type Call struct { 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. @@ -69,7 +71,7 @@ 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) + return c.plugins.answer(ctx, false) } // Step is one named schema step. diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 6da56a7..78589a2 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -50,6 +50,21 @@ func runExample(t *testing.T, stdin string, variables []string, args ...string) return result{code: cmd.ProcessState.ExitCode(), stdout: stdout.String(), stderr: stderr.String()} } +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() @@ -80,6 +95,18 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { `, ""}, {"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", dryRun}, + {"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, ""}, + {"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, "", diff --git a/gonsole/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go index cf2912f..dd0ab0c 100644 --- a/gonsole/internal/exampleapp/program.go +++ b/gonsole/internal/exampleapp/program.go @@ -33,9 +33,46 @@ func Program(getenv func(string) string) gonsole.Program { Serve: serve, Migrations: []gonsole.Step{{Name: "reports", Run: func(context.Context, string) error { return nil }}}, Commands: []gonsole.Command{createCommand(), 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{ diff --git a/gonsole/plugins.go b/gonsole/plugins.go index ae035f7..a448953 100644 --- a/gonsole/plugins.go +++ b/gonsole/plugins.go @@ -58,22 +58,27 @@ func provided(id string, p Provider) (commands []Command, err error) { type Loaded struct { // Groups are the command groups, one per plugin that offers commands. Groups []Group + // 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 - mu sync.Mutex - done bool - loaded Loaded - err error + 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 on its first call and returns that registration's answer on every call. -func (m *memo) answer(ctx context.Context) (Loaded, error) { +// 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 } @@ -81,11 +86,24 @@ func (m *memo) answer(ctx context.Context) (Loaded, error) { 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 } +// 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) diff --git a/gonsole/plugins_test.go b/gonsole/plugins_test.go index a4c26d1..31dc880 100644 --- a/gonsole/plugins_test.go +++ b/gonsole/plugins_test.go @@ -244,20 +244,29 @@ func TestWalkJoinsEveryPanic(t *testing.T) { type registry struct { mu sync.Mutex groups []gonsole.Group + failed error fail error lost error + refuse error withoutRelease bool calls []gonsole.Call log []string } -// register answers the groups and the failure r holds, with a release unless r goes without one. +// 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, "register") - loaded := gonsole.Loaded{Groups: r.groups} + 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 } @@ -272,9 +281,27 @@ func (r *registry) release(ctx context.Context) error { return r.lost } -// demoGroups returns one plugin group, demo, offering demo:sync. +// demoGroups returns one plugin group, demo, whose demo:list answers a document, demo:move acts and demo:sync writes. func demoGroups() []gonsole.Group { - return []gonsole.Group{{Namespace: "demo", Commands: []gonsole.Command{echo("demo:sync")}}} + 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. @@ -297,6 +324,14 @@ func plugged(r *registry) gonsole.Program { 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 + }, } } @@ -381,7 +416,7 @@ func TestRunRegistersThePluginsOnceAndReleasesThemOnce(t *testing.T) { if want := slices.Repeat([]string{tc.want}, 4); !slices.Equal(answers, want) { t.Errorf("answers = %q, want %q", answers, want) } - if want := []string{"register", "release live=true"}; !slices.Equal(r.log, want) { + if want := []string{"register describe=false", "release live=true"}; !slices.Equal(r.log, want) { t.Errorf("calls = %q, want %q", r.log, want) } }) @@ -404,7 +439,8 @@ func TestPluginsRegisterOnceForEveryGoroutineOfARun(t *testing.T) { got := execute(t, p, "report:plugins") - if want := []string{"register", "release live=true"}; got.code != gonsole.ExitDone || !slices.Equal(r.log, want) { + 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) } } @@ -428,11 +464,30 @@ func TestPluginsRegisterWithTheStreamsAndSettingsOfTheRun(t *testing.T) { 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 != "" { + 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() diff --git a/gonsole/program.go b/gonsole/program.go index ace2aa7..7df18b8 100644 --- a/gonsole/program.go +++ b/gonsole/program.go @@ -11,7 +11,6 @@ import ( "os" "os/signal" "slices" - "strings" "syscall" ) @@ -102,10 +101,12 @@ func Main(p Program) int { 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())) - if err := p.Check(Loaded{}); err != nil { + a := newAudit(p) + a.core() + if err := errors.Join(a.offences...); err != nil { return r.exit(err) } - r.plugins = &memo{register: p.Plugins, call: Call{ + 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))) @@ -138,7 +139,7 @@ func (r *runner) exit(err error) int { if err == nil || errors.Is(err, flag.ErrHelp) { return ExitDone } - for line := range strings.SplitSeq(err.Error(), "\n") { + for _, line := range lines(err) { r.warn("%s", line) } for _, crash := range crashes(err) { diff --git a/gonsole/resolve.go b/gonsole/resolve.go index 768a790..7ce7801 100644 --- a/gonsole/resolve.go +++ b/gonsole/resolve.go @@ -4,8 +4,10 @@ package gonsole import ( "context" + "errors" "fmt" "io" + "maps" "slices" "strings" ) @@ -29,13 +31,13 @@ func index(commands []Command) (map[string]Command, map[string][]string) { // 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(args) + return r.help(ctx, args) } if len(args) == 0 { return r.commandless(ctx) } args = r.rename(args) - cmd, err := r.find(args[0]) + cmd, err := r.find(ctx, args[0], false) if err != nil { return err } @@ -52,13 +54,13 @@ func (r *runner) commandless(ctx context.Context) error { } // help prints the help page of the command args name, or the listing when they name none. -func (r *runner) help(args []string) error { +func (r *runner) help(ctx context.Context, args []string) error { named := r.rename(subject(args)) if len(named) == 0 { _, err := io.WriteString(r.stdout, r.listing()) return err } - cmd, err := r.find(named[0]) + cmd, err := r.find(ctx, named[0], true) if err != nil { return err } @@ -84,16 +86,85 @@ func (r *runner) rename(args []string) []string { return append([]string{name}, args[2:]...) } -// find returns the command called name. -func (r *runner) find(name string) (Command, error) { +// 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, _, _ := strings.Cut(name, ":") + namespace, _, namespaced := strings.Cut(name, ":") if members := r.namespaces[namespace]; len(members) > 0 { - return Command{}, Misuse(fmt.Errorf("unknown command %q, want %s", name, alternatives(members))) + return Command{}, want(name, members) } - return Command{}, Misuse(fmt.Errorf("unknown command %q, run %q to see every command", name, r.program.Name+" list")) + 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. diff --git a/gonsole/resolve_test.go b/gonsole/resolve_test.go index aaff1e8..d348428 100644 --- a/gonsole/resolve_test.go +++ b/gonsole/resolve_test.go @@ -4,7 +4,9 @@ package gonsole_test import ( "context" + "errors" "fmt" + "slices" "strings" "testing" @@ -148,3 +150,488 @@ func TestRunNamesTheAlternativesOfANamespaceInPlainEnglish(t *testing.T) { }) } } + +// 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) +} + +// dryRun is the line a dry run of myapp ends with on stderr. +const dryRun = "myapp: dry run, nothing changed, pass -yes to apply\n" + +// 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", dryRun, 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 + dryRun}, + {"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" + dryRun}, + {"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) + } + }) + } +} From 7a4bb28a5e1206a4905ea5ff57d96d6ed29298c3 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 18:25:19 +0200 Subject: [PATCH 15/24] feat(gonsole): list the commands of registered plugins --- gonsole/base.go | 5 +- gonsole/exec_test.go | 23 +++++ gonsole/plugins.go | 12 +++ gonsole/resolve.go | 6 +- gonsole/text.go | 81 +++++++++++++--- gonsole/text_test.go | 219 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 325 insertions(+), 21 deletions(-) diff --git a/gonsole/base.go b/gonsole/base.go index 4cb4a41..d764887 100644 --- a/gonsole/base.go +++ b/gonsole/base.go @@ -12,9 +12,8 @@ import ( // base returns the commands the engine owns in the program. func (r *runner) base() []Command { - list := func(_ context.Context, call Call) error { - _, err := io.WriteString(call.Stdout, r.listing()) - return err + 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}, diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 78589a2..66137bd 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -50,6 +50,28 @@ func runExample(t *testing.T, stdin string, variables []string, args ...string) return result{code: cmd.ProcessState.ExitCode(), stdout: stdout.String(), stderr: stderr.String()} } +// exampleListing is the listing the example program prints. +const exampleListing = `myapp + +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 + migrate apply every schema step + serve run the server + version print the version + demo plugin + demo:sync sync the demo + report + report:create create a report + report:list list every report + report:revoke revoke one report +` + func TestExamplePluginsDescribeTheDemoGroup(t *testing.T) { t.Parallel() @@ -103,6 +125,7 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { "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"}, diff --git a/gonsole/plugins.go b/gonsole/plugins.go index a448953..772a748 100644 --- a/gonsole/plugins.go +++ b/gonsole/plugins.go @@ -95,6 +95,18 @@ func (m *memo) answer(ctx context.Context, describe bool) (Loaded, error) { 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 diff --git a/gonsole/resolve.go b/gonsole/resolve.go index 7ce7801..76a91c1 100644 --- a/gonsole/resolve.go +++ b/gonsole/resolve.go @@ -49,16 +49,14 @@ func (r *runner) commandless(ctx context.Context) error { if r.program.BareServes { return r.dispatch(ctx, []string{"serve"}) } - _, err := io.WriteString(r.stdout, r.listing()) - return err + 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 { - _, err := io.WriteString(r.stdout, r.listing()) - return err + return r.list(ctx, r.stdout) } cmd, err := r.find(ctx, named[0], true) if err != nil { diff --git a/gonsole/text.go b/gonsole/text.go index 4aa4235..cf524ed 100644 --- a/gonsole/text.go +++ b/gonsole/text.go @@ -4,27 +4,43 @@ package gonsole import ( "cmp" + "context" + "errors" "flag" "fmt" + "io" "maps" "slices" "strings" ) -// listing returns the list of every command the run knows. -func (r *runner) listing() string { +// 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 := r.width() - for _, name := range r.bare() { - fmt.Fprintf(&b, " %-*s%s\n", width, name, r.commands[name].Summary) - } - for _, namespace := range slices.Sorted(maps.Keys(r.namespaces)) { - fmt.Fprintf(&b, " %s\n", namespace) - for _, name := range r.namespaces[namespace] { - fmt.Fprintf(&b, " %-*s%s\n", width, name, r.commands[name].Summary) + 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 != "" { @@ -33,6 +49,43 @@ func (r *runner) listing() string { 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) @@ -43,9 +96,9 @@ func (r *runner) heading() string { } // bare returns the sorted names of the commands outside every namespace. -func (r *runner) bare() []string { +func (s shelf) bare() []string { var names []string - for name := range r.commands { + for name := range s.commands { if !strings.Contains(name, ":") { names = append(names, name) } @@ -55,9 +108,9 @@ func (r *runner) bare() []string { } // width returns the width of the listing's name column, the longest name and two spaces. -func (r *runner) width() int { +func (s shelf) width() int { longest := 0 - for name := range r.commands { + for name := range s.commands { longest = max(longest, len(name)) } return longest + 2 diff --git a/gonsole/text_test.go b/gonsole/text_test.go index 79684a8..8d8db78 100644 --- a/gonsole/text_test.go +++ b/gonsole/text_test.go @@ -6,6 +6,8 @@ import ( "bytes" "context" "errors" + "flag" + "slices" "strings" "testing" @@ -420,3 +422,220 @@ func TestMisuseOfAKnownCommandEndsWithItsHelpPage(t *testing.T) { }) } } + +// 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 + 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 + 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 + 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) + } + }) + } +} From 39d1e912a3717a87062d4aa380cbe23f76893ac3 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 18:59:05 +0200 Subject: [PATCH 16/24] feat(gonsole): migrate, seed and check the registered plugins --- gonsole/base.go | 84 +++++++++++-- gonsole/base_test.go | 272 ++++++++++++++++++++++++++++++++++++++++-- gonsole/check_test.go | 58 +++++++++ gonsole/exec_test.go | 3 +- gonsole/plugins.go | 6 +- gonsole/serve.go | 12 +- gonsole/text_test.go | 6 + 7 files changed, 419 insertions(+), 22 deletions(-) diff --git a/gonsole/base.go b/gonsole/base.go index d764887..e45b756 100644 --- a/gonsole/base.go +++ b/gonsole/base.go @@ -24,27 +24,42 @@ func (r *runner) base() []Command { 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 { - migrate := func(ctx context.Context, call Call) error { return r.migrate(ctx, call, call.Stdout) } + 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 { + 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 and answers that the settings and command names are valid. +// 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)") @@ -64,10 +79,21 @@ func (r *runner) seed(ctx context.Context, call Call) error { _, err := io.WriteString(call.Stdout, "would store the demo data\n") return err } - if err := r.migrate(ctx, call, call.Stderr); err != nil { + loaded, err := r.migrateAll(ctx, call, call.Stderr) + if err != nil { return err } - if err := r.program.Seed(ctx, call); err != nil { + 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") @@ -75,7 +101,27 @@ func (r *runner) seed(ctx context.Context, call Call) error { } // 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) (err error) { +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 @@ -85,6 +131,11 @@ func (r *runner) migrate(ctx context.Context, call Call, w io.Writer) (err error 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) @@ -96,6 +147,25 @@ func (r *runner) migrate(ctx context.Context, call Call, w io.Writer) (err error 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 { diff --git a/gonsole/base_test.go b/gonsole/base_test.go index 1f45202..c68e72d 100644 --- a/gonsole/base_test.go +++ b/gonsole/base_test.go @@ -23,12 +23,45 @@ const demoNotice = "myapp: demo data is for development only, never seed a produ // 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 - log []string + 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. @@ -289,6 +322,23 @@ func TestSchemaHooksGetTheRunContext(t *testing.T) { 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) @@ -296,7 +346,11 @@ func TestSchemaHooksGetTheRunContext(t *testing.T) { if code != gonsole.ExitDone { t.Errorf("code = %d, want %d", code, gonsole.ExitDone) } - want := []string{"lock run live=true", "step run live=true", "release run live=true", "seed run live=true"} + 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) } @@ -458,6 +512,51 @@ func TestMigrateStopsAndReleasesWhenItsAnswerCannotBeWritten(t *testing.T) { } } +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() @@ -541,6 +640,165 @@ func TestSeedStoresTheDemoDataOnlyWithYes(t *testing.T) { } } +// 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 <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 + 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() diff --git a/gonsole/check_test.go b/gonsole/check_test.go index 4a2d5bc..9235b45 100644 --- a/gonsole/check_test.go +++ b/gonsole/check_test.go @@ -8,6 +8,7 @@ import ( "flag" "io" "os" + "slices" "strings" "testing" @@ -356,6 +357,63 @@ func TestCheckCommandReportsTheSettings(t *testing.T) { } } +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() diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 66137bd..5d42ef4 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -62,6 +62,7 @@ Available commands: 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 @@ -101,7 +102,7 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { }{ {"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\n", ""}, + []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"}, diff --git a/gonsole/plugins.go b/gonsole/plugins.go index 772a748..3a42432 100644 --- a/gonsole/plugins.go +++ b/gonsole/plugins.go @@ -58,6 +58,10 @@ func provided(id string, p Provider) (commands []Command, err error) { 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. @@ -127,7 +131,7 @@ func (m *memo) release(ctx context.Context) (err error) { m.mu.Lock() defer m.mu.Unlock() defer recoverRun("plugins", &err) - if err := stopWithin(context.WithoutCancel(ctx), m.loaded.Release); err != nil { + 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/serve.go b/gonsole/serve.go index 1481785..26d1e8d 100644 --- a/gonsole/serve.go +++ b/gonsole/serve.go @@ -54,7 +54,7 @@ func Serve( if err != nil { grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), t.Grace) defer cancel() - return errors.Join(fmt.Errorf("http server: %w", err), stopWithin(grace, stop)) + return errors.Join(fmt.Errorf("http server: %w", err), optional(grace, stop)) } return serveOn(ctx, srv, listener, t, stop, logger) } @@ -84,13 +84,13 @@ func serveOn( if failed == nil { <-served } - return errors.Join(failed, shut, stopWithin(grace, stop)) + return errors.Join(failed, shut, optional(grace, stop)) } -// stopWithin calls stop under ctx, nothing when stop is nil. -func stopWithin(ctx context.Context, stop func(context.Context) error) error { - if stop == nil { +// 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 stop(ctx) + return fn(ctx) } diff --git a/gonsole/text_test.go b/gonsole/text_test.go index 8d8db78..d43d967 100644 --- a/gonsole/text_test.go +++ b/gonsole/text_test.go @@ -437,6 +437,8 @@ Available commands: 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 @@ -483,6 +485,8 @@ func TestListingWidensTheNameColumnOnlyForALoadedPluginCommand(t *testing.T) { 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 @@ -504,6 +508,8 @@ func TestListingShowsWhatFailedToLoadBeforeTheFooter(t *testing.T) { 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 ` From 5438bc762a14c70fef534b6691985bc6ef26f1ff Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 19:29:39 +0200 Subject: [PATCH 17/24] test(gonsole): use one constant for the dry-run notice --- gonsole/exec_test.go | 2 +- gonsole/resolve_test.go | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 5d42ef4..04d90ed 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -121,7 +121,7 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { {"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", dryRun}, + 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, diff --git a/gonsole/resolve_test.go b/gonsole/resolve_test.go index d348428..915632d 100644 --- a/gonsole/resolve_test.go +++ b/gonsole/resolve_test.go @@ -161,9 +161,6 @@ func demoLine(name string) string { return fmt.Sprintf("myapp: unknown command %q, want demo:list, demo:move or demo:sync\n", name) } -// dryRun is the line a dry run of myapp ends with on stderr. -const dryRun = "myapp: dry run, nothing changed, pass -yes to apply\n" - // ranPlugins is the log of a run that registered the plugins to run a command. var ranPlugins = []string{"register describe=false", "release live=true"} @@ -207,7 +204,7 @@ func TestRunRunsThePluginCommandTheLineNames(t *testing.T) { stderr string log []string }{ - {"a dry run of a write", []string{"demo:sync"}, "sync apply=false\n", dryRun, ranPlugins}, + {"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", @@ -433,11 +430,11 @@ func TestRunWarnsOfFailedPluginsBeforeAPluginCommand(t *testing.T) { code int stderr string }{ - {"a run", []string{"demo:sync"}, errors.Join(billing, mail), gonsole.ExitDone, warnings + dryRun}, + {"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" + dryRun}, + 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}, } From cf7ce8ab44aeeb76a0530811ddeeaa8feb1c2d8c Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 19:29:39 +0200 Subject: [PATCH 18/24] feat(gonsole): hand each call the flags its line set --- gonsole/command.go | 2 ++ gonsole/parse.go | 14 +++++++- gonsole/parse_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/gonsole/command.go b/gonsole/command.go index 100ec41..13d9a1a 100644 --- a/gonsole/command.go +++ b/gonsole/command.go @@ -36,6 +36,8 @@ type Command struct { 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. diff --git a/gonsole/parse.go b/gonsole/parse.go index 3f7c36a..e0160cb 100644 --- a/gonsole/parse.go +++ b/gonsole/parse.go @@ -8,6 +8,7 @@ import ( "flag" "fmt" "io" + "slices" "strings" ) @@ -114,11 +115,22 @@ func (r *runner) prepare(cmd Command, args []string) (Call, error) { return Call{}, Misuse(fmt.Errorf("%s wants -as <email>", cmd.Name)) } return Call{ - Args: positional, Stdin: r.stdin, Stdout: r.stdout, Stderr: r.stderr, Env: r.settings(), + 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 diff --git a/gonsole/parse_test.go b/gonsole/parse_test.go index c6c10f5..7d6672d 100644 --- a/gonsole/parse_test.go +++ b/gonsole/parse_test.go @@ -7,6 +7,7 @@ import ( "errors" "flag" "fmt" + "reflect" "testing" "github.com/gopherium/framework/gonsole" @@ -394,3 +395,76 @@ func TestRunKeepsTheDryRunDocumentAloneOnStdout(t *testing.T) { 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) + } + } + }) + } +} From 410fcf7f4fe80714a637b049460eaf3491bda96d Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 19:58:18 +0200 Subject: [PATCH 19/24] feat(gonsole): add a testkit for programs built on gonsole --- gonsole/testkit/testkit.go | 80 ++++++++++ gonsole/testkit/testkit_test.go | 263 ++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 gonsole/testkit/testkit.go create mode 100644 gonsole/testkit/testkit_test.go 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) + } +} From f3c86629a6e628e1d9b9c8e3d1757e33b8be9e1b Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 20:05:20 +0200 Subject: [PATCH 20/24] ci: test, lint and scan the gonsole module --- .github/workflows/ci.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 ./... From 6f8b6a1e28a9a77f3f69d67905f9991039898831 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 20:05:20 +0200 Subject: [PATCH 21/24] docs(gonsole): list the module and its unreleased changes --- README.md | 15 ++++++++------- gonsole/CHANGELOG.md | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) 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/CHANGELOG.md b/gonsole/CHANGELOG.md index 65d2d94..f4a8481 100644 --- a/gonsole/CHANGELOG.md +++ b/gonsole/CHANGELOG.md @@ -9,3 +9,20 @@ 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. From b14e9b6c3d1ef14940c7680ad41cbaafae510cd9 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 20:05:21 +0200 Subject: [PATCH 22/24] test(gottext): use a generic product name in the error fixture --- gottext/test/errors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.' From 8ed77782aef93e73865096cef89ed3b7796cb348 Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 22:17:33 +0200 Subject: [PATCH 23/24] test(gonsole): wait for a ready line before the first signal --- gonsole/exec_test.go | 25 ++++++++++++++++++++++--- gonsole/internal/exampleapp/program.go | 26 +++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 04d90ed..6cdaa4e 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -6,6 +6,7 @@ import ( "bufio" "bytes" "errors" + "io" "os" "os/exec" "slices" @@ -50,6 +51,9 @@ func runExample(t *testing.T, stdin string, variables []string, args ...string) 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 @@ -69,6 +73,7 @@ Available commands: 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 ` @@ -109,6 +114,8 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { 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"}, {"a command that answers a document", "", nil, []string{"report:list", "-json"}, gonsole.ExitDone, `{ "reports": [ "quarterly", @@ -210,19 +217,31 @@ func TestMainServesUntilASignalEndsTheRun(t *testing.T) { func TestMainLetsASecondSignalEndACommandThatIgnoresTheFirst(t *testing.T) { t.Parallel() - cmd := exec.CommandContext(t.Context(), os.Args[0], "report:create", "-yes", "Q3") + 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() { exited <- cmd.Wait() }() - time.Sleep(300 * time.Millisecond) + go func() { + _, _ = io.Copy(io.Discard, stderr) + exited <- cmd.Wait() + }() _ = cmd.Process.Signal(os.Interrupt) select { diff --git a/gonsole/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go index dd0ab0c..dbed65e 100644 --- a/gonsole/internal/exampleapp/program.go +++ b/gonsole/internal/exampleapp/program.go @@ -32,7 +32,7 @@ func Program(getenv func(string) string) gonsole.Program { Database: "DATABASE_URL", Serve: serve, Migrations: []gonsole.Step{{Name: "reports", Run: func(context.Context, string) error { return nil }}}, - Commands: []gonsole.Command{createCommand(), listCommand(), revokeCommand()}, + Commands: []gonsole.Command{createCommand(), importCommand(), listCommand(), revokeCommand()}, Plugins: plugins, } } @@ -114,6 +114,30 @@ func createCommand() gonsole.Command { } } +// 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 + } + input, err := io.ReadAll(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, len(strings.Fields(string(input)))) + return err + }, + } +} + // listCommand returns report:list, which lists every report. func listCommand() gonsole.Command { return gonsole.Command{ From a1ff707d05ae14b3059f71b3def4277c4ac8b8ac Mon Sep 17 00:00:00 2001 From: SirLouen <sir.louen@gmail.com> Date: Thu, 24 Sep 2026 22:31:25 +0200 Subject: [PATCH 24/24] test(gonsole): count one imported report per line of input --- gonsole/exec_test.go | 2 ++ gonsole/internal/exampleapp/program.go | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/gonsole/exec_test.go b/gonsole/exec_test.go index 6cdaa4e..6d1a128 100644 --- a/gonsole/exec_test.go +++ b/gonsole/exec_test.go @@ -116,6 +116,8 @@ func TestMainExitsWithTheCodeOfTheRun(t *testing.T) { "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", diff --git a/gonsole/internal/exampleapp/program.go b/gonsole/internal/exampleapp/program.go index dbed65e..ef84946 100644 --- a/gonsole/internal/exampleapp/program.go +++ b/gonsole/internal/exampleapp/program.go @@ -124,7 +124,7 @@ func importCommand() gonsole.Command { if _, err := fmt.Fprintln(call.Stderr, "reading the reports to import from the input"); err != nil { return err } - input, err := io.ReadAll(call.Stdin) + count, err := countNames(call.Stdin) if err != nil { return err } @@ -132,12 +132,24 @@ func importCommand() gonsole.Command { if call.Apply { verb = "imported" } - _, err = fmt.Fprintf(call.Stdout, "%s %d reports\n", verb, len(strings.Fields(string(input)))) + _, 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{