feat(diary): boot list, help and completion requests quietly (quick-261028-rqc)

- helpOnly also counts no command word, list, help, cobra's shell
  completion requests and --help=<bool>, read the way pflag does
- TestHelpOnlyAgreesWithCobra pins the classification against bonfire's
  real dispatch, with the passthrough --help case as the exception
- console and logging docs describe which invocations boot quietly
This commit is contained in:
Jakub Zych
2026-10-28 21:47:42 +01:00
parent 6b9becd994
commit 6c606c666b
7 changed files with 231 additions and 24 deletions

View File

@@ -34,7 +34,7 @@ Use cases:
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
- Console global options: `diary.FromGlobals` turns `-v`/`-vv`/`-vvv`, `-q` and `--ansi`/`--no-ansi` into `diary.Options`, and `diary.ApplyGlobals` applies them to the running setup (the generated main registers it with `bonfire.OnGlobals`).
- Quiet boot for help: `diary.FromGlobals` also records the process arguments (`os.Args[1:]`) in `diary.Options`, and `diary.Bootstrap` and `diary.Setup` start the console channels at warnings, as `-q` would, when those arguments only ask for help (`-h` or `--help`) and no `-v` is given. Daily files and stderr channels keep their levels, warnings and errors still print, and an existing generated main gets it from a framework upgrade alone. A `diary.Options` literal without arguments does no check.
- Quiet boot for help: `diary.FromGlobals` also records the process arguments (`os.Args[1:]`) in `diary.Options`, and `diary.Bootstrap` and `diary.Setup` start the console channels at warnings, as `-q` would, when those arguments only ask the binary to describe itself and no `-v` is given: no command, `list`, `help`, `-h` or `--help` (also after a command name) and shell completion requests. A command that passes its arguments through unparsed counts as asking for help when `--help` follows it; `diary.ApplyGlobals` restores the configured console levels before it runs. Daily files and stderr channels keep their levels, warnings and errors still print, and an existing generated main gets it from a framework upgrade alone. A `diary.Options` literal without arguments does no check.
- The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component.
## Usage
@@ -158,4 +158,4 @@ Environment variables use the `SUMMER_LOG__` prefix with a double underscore bet
go test ./modules/diary/...
```
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. `TestBootQuietSmoke` runs the generated main's sequence with a plugin that logs while it registers and boots, and checks that `serve --help` keeps its info lines off the console (but not its warning, nor the daily file) while `serve` prints them. The tests write to temporary directories and need no external services.
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. `TestBootQuietSmoke` runs the generated main's sequence with a plugin that logs while it registers and boots, and checks that `serve --help` keeps its info lines off the console (but not its warning, nor the daily file) while `serve` prints them. `TestHelpOnly` lists the argument shapes that count as asking for help, and `TestHelpOnlyAgreesWithCobra` runs each through a bonfire root and checks that a command runs exactly when they do not. The tests write to temporary directories and need no external services.

View File

@@ -82,6 +82,12 @@ func TestBootQuietSmoke(t *testing.T) {
{name: "serve --help", args: []string{"serve", "--help"}},
{name: "serve", args: []string{"serve"}, wantInfo: true},
{name: "literal options", args: []string{"serve", "--help"}, literal: true, wantInfo: true},
{name: "no args", args: []string{}},
{name: "list", args: []string{"list"}},
{name: "help serve", args: []string{"help", "serve"}},
{name: "-h", args: []string{"-h"}},
{name: "-v serve --help", args: []string{"-v", "serve", "--help"}, wantInfo: true},
{name: "queue:work", args: []string{"queue:work"}, wantInfo: true},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {

View File

@@ -0,0 +1,10 @@
package diary
// HelpOnly exposes helpOnly to the external tests.
var HelpOnly = helpOnly
// The completion command names helpOnly recognises, for the cobra check.
const (
CompleteRequestCmd = completeRequestCmd
CompleteNoDescRequestCmd = completeNoDescRequestCmd
)

View File

@@ -1,47 +1,100 @@
package diary
import (
"strconv"
"strings"
)
// The hidden commands of cobra's shell completion (cobra.ShellCompRequestCmd
// and cobra.ShellCompNoDescRequestCmd), named here so diary does not link
// cobra; TestHelpOnlyAgreesWithCobra pins them.
const (
completeRequestCmd = "__complete"
completeNoDescRequestCmd = "__completeNoDesc"
)
// helpOnly reports whether args, a binary's command line without the
// program name, only ask for help: before "--" they hold --help or a
// single-dash cluster that reaches h. It is a tolerant pre-scan of the raw
// arguments, made before plugins boot, and reads them the way
// bonfire.PeekGlobals does: it stops at "--", skips the value of --env and
// reads a single-dash cluster letter by letter, stopping at the first
// letter that is not a global shorthand (v, q, n, h). It never fails and
// cannot tell a passthrough command (whose --help reaches its Run) from
// another, so "<passthrough> --help" counts; the root's OnGlobals hook
// (ApplyGlobals) restores the console levels before that command runs.
// program name, only ask the binary to describe itself, so no command runs:
//
// - there is no command word (the first token before "--" that is neither
// a flag nor the value of --env, bonfire's rule under which Execute
// prints the list): a bare invocation, only global options, -h or
// --help;
// - the command word is list, help or one of cobra's shell completion
// requests (__complete, __completeNoDesc);
// - the help flag is set before "--", also after the command word, the
// way cobra honours it: --help, --help=<bool> read by strconv.ParseBool
// the way pflag does (the last occurrence wins, an unparsable value is
// ignored), or a single-dash cluster that reaches h (-h, -vh, -nh).
//
// It is a tolerant pre-scan of the raw arguments, made before plugins boot,
// and reads them the way bonfire.PeekGlobals does: it stops at "--", skips
// the value of --env and reads a single-dash cluster letter by letter,
// stopping at the first letter that is not a global shorthand (v, q, n, h).
// It never fails. An unknown or misspelled command word is not help-only
// (that is unknowable before boot). It cannot tell a passthrough command
// (whose --help reaches its Run) from another, so "<passthrough> --help"
// counts; the root's OnGlobals hook (ApplyGlobals) restores the console
// levels before that command runs.
func helpOnly(args []string) bool {
help := false
word := ""
scan:
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--":
return false
break scan
case a == "--env":
i++
case a == "--help":
return true
case len(a) >= 2 && a[0] == '-' && a[1] != '-':
if clusterHasHelp(a[1:]) {
return true
help = true
case strings.HasPrefix(a, "--help="):
if v, err := strconv.ParseBool(strings.TrimPrefix(a, "--help=")); err == nil {
help = v
}
case len(a) >= 2 && a[0] == '-' && a[1] != '-':
if v, ok := clusterHelp(a[1:]); ok {
help = v
}
case strings.HasPrefix(a, "-"):
case word == "":
word = a
}
}
return false
switch word {
case "", "list", "help", completeRequestCmd, completeNoDescRequestCmd:
return true
}
return help
}
// clusterHasHelp reports whether a shorthand cluster (the letters after the
// dash) reaches h before a letter that is not a global shorthand.
func clusterHasHelp(cluster string) bool {
// clusterHelp reads a shorthand cluster (the letters after the dash) the
// way bonfire.PeekGlobals does and reports the help value it sets: ok is
// true when the cluster reaches h before a letter that is not a global
// shorthand, and -h=<bool> (-vh=false) sets the value read by
// strconv.ParseBool.
func clusterHelp(cluster string) (help, ok bool) {
for i := 0; i < len(cluster); i++ {
switch cluster[i] {
r := cluster[i]
if i+1 < len(cluster) && cluster[i+1] == '=' {
if r != 'h' {
return help, ok
}
if v, err := strconv.ParseBool(cluster[i+2:]); err == nil {
return v, true
}
return help, ok
}
switch r {
case 'h':
return true
help, ok = true, true
case 'v', 'q', 'n':
default:
return false
return help, ok
}
}
return false
return help, ok
}
// bootQuiet applies the help-only rule of Bootstrap and Setup: with Args

View File

@@ -0,0 +1,133 @@
package diary_test
import (
"context"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/diary"
)
// helpOnlyCases are the rows shared by TestHelpOnly and
// TestHelpOnlyAgreesWithCobra. usage marks the rows cobra rejects as a
// usage error, which the agreement check leaves out.
var helpOnlyCases = []struct {
args []string
want bool
usage bool
}{
{args: []string{}, want: true},
{args: []string{"--help"}, want: true},
{args: []string{"-h"}, want: true},
{args: []string{"serve", "--help"}, want: true},
{args: []string{"serve", "-h"}, want: true},
{args: []string{"-h", "serve"}, want: true},
{args: []string{"serve", "--help=true"}, want: true},
{args: []string{"serve", "--help=1"}, want: true},
{args: []string{"-nh", "serve"}, want: true},
{args: []string{"-vh", "serve"}, want: true},
{args: []string{"serve", "-vh=true"}, want: true},
{args: []string{"list"}, want: true},
{args: []string{"list", "--format=json"}, want: true},
{args: []string{"help"}, want: true},
{args: []string{"help", "serve"}, want: true},
{args: []string{"--env", "staging"}, want: true},
{args: []string{"--env=staging"}, want: true},
{args: []string{"-v"}, want: true},
{args: []string{"--help=false"}, want: true},
{args: []string{"--env", "help"}, want: true},
{args: []string{"__complete", "serve", ""}, want: true},
{args: []string{"__completeNoDesc", "se"}, want: true},
{args: []string{"serve"}},
{args: []string{"migrate", "-v"}},
{args: []string{"queue:work"}},
{args: []string{"--env", "list", "serve"}},
{args: []string{"serve", "--", "--help"}},
{args: []string{"serve", "--help=false"}},
{args: []string{"serve", "--help", "--help=false"}},
{args: []string{"serve", "-h=false"}},
{args: []string{"serve", "-qv"}},
{args: []string{"serve", "-xh"}, usage: true},
{args: []string{"serve", "--help=yes"}, usage: true},
{args: []string{"blog:import", "https://example.com/help"}},
{args: []string{"blog:import", "help"}},
{args: []string{"-q", "--env=list", "migrate"}},
}
func TestHelpOnly(t *testing.T) {
for _, tc := range helpOnlyCases {
if got := diary.HelpOnly(tc.args); got != tc.want {
t.Errorf("helpOnly(%q) = %v, want %v", tc.args, got, tc.want)
}
}
}
// helpOnlyRoot builds a root whose commands record whether their Run ran.
func helpOnlyRoot(t *testing.T, ran *bool) *cobra.Command {
t.Helper()
run := func(context.Context, bonfire.Input, bonfire.Output) error {
*ran = true
return nil
}
optional := []bonfire.Arg{{Name: "target", Description: "An optional argument"}}
commands := []bonfire.Command{
{Name: "serve", Description: "Start the HTTP server", Args: optional, Run: run},
{Name: "migrate", Description: "Run the migrations", Run: run},
{Name: "queue:work", Description: "Run the job workers", Run: run},
{Name: "blog:import", Description: "Import posts", Args: optional, Run: run},
{Name: "acme:tool", Description: "Run a tool with its own flags", Passthrough: true, Run: run},
}
root, err := bonfire.NewRootIO("acme", commands, strings.NewReader(""), io.Discard, io.Discard,
bonfire.WithVersion(bonfire.Version{App: "v1.0.0", Framework: "v1.0.0"}))
if err != nil {
t.Fatal(err)
}
return root
}
// TestHelpOnlyAgreesWithCobra: for every row cobra accepts, a command runs
// exactly when helpOnly reports false. A passthrough command is the
// documented exception: its --help reaches its Run, while helpOnly, which
// cannot know the command before boot, counts it.
func TestHelpOnlyAgreesWithCobra(t *testing.T) {
for _, tc := range helpOnlyCases {
var ran bool
code := bonfire.Execute(context.Background(), helpOnlyRoot(t, &ran), tc.args)
if tc.usage {
if code != bonfire.ExitUsage {
t.Errorf("Execute(%q) = %d, want the usage error %d", tc.args, code, bonfire.ExitUsage)
}
continue
}
if code != bonfire.ExitOK {
t.Errorf("Execute(%q) = %d, want 0", tc.args, code)
continue
}
if ran != !diary.HelpOnly(tc.args) {
t.Errorf("Execute(%q) ran a command = %v, but helpOnly = %v", tc.args, ran, diary.HelpOnly(tc.args))
}
}
t.Run("passthrough --help", func(t *testing.T) {
args := []string{"acme:tool", "--help"}
var ran bool
if code := bonfire.Execute(context.Background(), helpOnlyRoot(t, &ran), args); code != bonfire.ExitOK {
t.Fatalf("Execute(%q) = %d, want 0", args, code)
}
if !ran || !diary.HelpOnly(args) {
t.Errorf("Execute(%q) ran = %v, helpOnly = %v; want both true (the documented exception)", args, ran, diary.HelpOnly(args))
}
})
if diary.CompleteRequestCmd != cobra.ShellCompRequestCmd {
t.Errorf("completion command %q, cobra uses %q", diary.CompleteRequestCmd, cobra.ShellCompRequestCmd)
}
if diary.CompleteNoDescRequestCmd != cobra.ShellCompNoDescRequestCmd {
t.Errorf("completion command %q, cobra uses %q", diary.CompleteNoDescRequestCmd, cobra.ShellCompNoDescRequestCmd)
}
}