Add shared report format flag

This commit is contained in:
2026-11-07 03:45:12 +00:00
parent 3a6c66f550
commit 924692b541
18 changed files with 206 additions and 101 deletions

View File

@@ -4,10 +4,10 @@ import (
"fmt" "fmt"
"io" "io"
"runtime/debug" "runtime/debug"
"text/template"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra" internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
yaml "go.yaml.in/yaml/v4"
) )
const commandKitModulePath = "go.maximumdirect.net/commandkit" const commandKitModulePath = "go.maximumdirect.net/commandkit"
@@ -17,16 +17,17 @@ type helpInput struct {
} }
type versionInput struct { type versionInput struct {
Scope string Scope string
Format string
} }
type versionDocument struct { type versionDocument struct {
Name string `yaml:"name"` Name string `json:"name"`
Version string `yaml:"version"` Version string `json:"version"`
Revision string `yaml:"revision"` Revision string `json:"revision"`
} }
var versionTextTemplate = template.Must(ParseTextTemplate("version", "{{.Name}} {{.Version}}{{if .Revision}} ({{.Revision}}){{end}}\n", nil))
func (a *App) builtinCommands() []Declaration { func (a *App) builtinCommands() []Declaration {
help := captureCommand(CommandSpec[helpInput]{ help := captureCommand(CommandSpec[helpInput]{
Name: "help", Name: "help",
@@ -39,14 +40,12 @@ func (a *App) builtinCommands() []Declaration {
return current.renderHelpTargets(input.Targets.Value) return current.renderHelpTargets(input.Targets.Value)
}) })
scopeDefault := "app" scopeDefault := "app"
formatDefault := "text"
version := NewCommand(CommandSpec[versionInput]{ version := NewCommand(CommandSpec[versionInput]{
Name: "version", Name: "version",
Summary: "Show application or CommandKit version.", Summary: "Show application or CommandKit version.",
Description: "Report identity and build metadata without loading application configuration.", Description: "Report identity and build metadata without loading application configuration.",
Flags: []Flag[versionInput]{ Flags: []Flag[versionInput]{
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }), ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
ValueFlag(FlagMeta{Name: "format", Usage: "output format", Completion: CompleteChoices(CompletionChoice{Value: "text"}, CompletionChoice{Value: "yaml"})}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
}, },
Run: func(invocation Invocation, input versionInput) Outcome { Run: func(invocation Invocation, input versionInput) Outcome {
identity := a.identity identity := a.identity
@@ -56,7 +55,7 @@ func (a *App) builtinCommands() []Declaration {
if identity.Version == "" { if identity.Version == "" {
identity.Version = "dev" identity.Version = "dev"
} }
return renderVersionOutcome(identity, input.Format, invocation.Reporter()) return renderVersionOutcome(identity, invocation.Reporter())
}, },
}) })
return []Declaration{help, version} return []Declaration{help, version}
@@ -69,13 +68,6 @@ func parseVersionScope(value string) (string, error) {
return value, nil return value, nil
} }
func parseVersionFormat(value string) (string, error) {
if value != "text" && value != "yaml" {
return "", fmt.Errorf("format must be text or yaml, got %q", value)
}
return value, nil
}
func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome { func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
content, failure := session.RenderHelpView(view) content, failure := session.RenderHelpView(view)
if failure != nil { if failure != nil {
@@ -91,36 +83,14 @@ func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, report
return Success() return Success()
} }
func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome { func renderVersionOutcome(identity Identity, reporter *Reporter) Outcome {
if identity.Version == "" { if identity.Version == "" {
identity.Version = "dev" identity.Version = "dev"
} }
switch format { _ = reporter.Report(Report{Value: versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}, TextTemplate: versionTextTemplate})
case "text":
line := identity.Name + " " + identity.Version
if identity.Revision != "" {
line += " (" + identity.Revision + ")"
}
_ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, line+"\n")
return err
})
case "yaml":
_ = reporter.RenderOutput(func(w io.Writer) error {
return encodeYAML(w, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision})
})
default:
return Usage(fmt.Sprintf("unsupported version format %q", format))
}
return Success() return Success()
} }
func encodeYAML(writer io.Writer, document any) error {
encoder := yaml.NewEncoder(writer)
encoder.SetIndent(2)
return encoder.Encode(document)
}
func readBuildInfo() *debug.BuildInfo { func readBuildInfo() *debug.BuildInfo {
info, ok := debug.ReadBuildInfo() info, ok := debug.ReadBuildInfo()
if !ok { if !ok {

View File

@@ -43,6 +43,7 @@ func TestCompletionQueryDispatch(t *testing.T) {
{"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false}, {"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false},
{"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false}, {"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false},
{"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false}, {"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false},
{"format value", []string{"__complete", "run", "--format", "j"}, "json\n", false},
{"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false}, {"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false},
{"unknown route", []string{"__complete", "missing", ""}, emptyReply, true}, {"unknown route", []string{"__complete", "missing", ""}, emptyReply, true},
} { } {

View File

@@ -54,7 +54,7 @@ func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
t.Fatalf("no completion: %#v", got) t.Fatalf("no completion: %#v", got)
} }
for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) { for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) {
if !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) { if flag.Boolean && !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) {
t.Fatalf("reporting flag choices: %#v", flag) t.Fatalf("reporting flag choices: %#v", flag)
} }
} }

View File

@@ -18,15 +18,15 @@ func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
Name: "show", Name: "show",
Summary: "Show a value.", Summary: "Show a value.",
Flags: []commandkit.Flag[struct{}]{ Flags: []commandkit.Flag[struct{}]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}), commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}),
}, },
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
}) })
declarations := []commandkit.Declaration{ declarations := []commandkit.Declaration{
command, command,
commandkit.Alias("inspect", "show"), commandkit.Alias("inspect", "show"),
commandkit.FlagAlias("--fmt", "--format"), commandkit.FlagAlias("--fmt", "--style"),
commandkit.FlagAliasFor("show", "-f", "--format"), commandkit.FlagAliasFor("show", "-f", "--style"),
} }
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...) app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil { if err != nil {
@@ -84,7 +84,7 @@ func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
func(value string) (string, error) { optionalParses++; return value, nil }, func(value string) (string, error) { optionalParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.profile = value }, func(target *showInput, value commandkit.Supplied[string]) { target.profile = value },
)}, )},
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value }, func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)}, )},
@@ -99,7 +99,7 @@ func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
commandkit.Alias("r full", "inspect"), commandkit.Alias("r full", "inspect"),
commandkit.Alias("inspect-shortcut", "inspect"), commandkit.Alias("inspect-shortcut", "inspect"),
commandkit.Alias("i", "inspect-shortcut"), commandkit.Alias("i", "inspect-shortcut"),
commandkit.FlagAlias("--fmt", "--format"), commandkit.FlagAlias("--fmt", "--style"),
} }
declarations := append([]commandkit.Declaration(nil), commands...) declarations := append([]commandkit.Declaration(nil), commands...)
if test.rulesFirst { if test.rulesFirst {
@@ -154,7 +154,7 @@ func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
var formats []string var formats []string
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{ show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show", Name: "show",
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { formatParses++; return value, nil }, func(value string) (string, error) { formatParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value }, func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)}, )},
@@ -181,7 +181,7 @@ func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
if err := app.Register(family, if err := app.Register(family,
commandkit.Alias("cfg-show", "config show"), commandkit.Alias("cfg-show", "config show"),
commandkit.Alias("cs", "cfg-show"), commandkit.Alias("cs", "cfg-show"),
commandkit.FlagAlias("--fmt", "--format"), commandkit.FlagAlias("--fmt", "--style"),
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -46,7 +46,7 @@ The supported flags are:
| Flag | Values | Default | Meaning | | Flag | Values | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--scope` | `app`, `commandkit` | `app` | Select the caller's application identity or the embedded CommandKit module identity. | | `--scope` | `app`, `commandkit` | `app` | Select the caller's application identity or the embedded CommandKit module identity. |
| `--format` | `text`, `yaml` | `text` | Select the output format. | | `--format` | `text`, `yaml`, `json` | automatic | Select the output format; automatic uses text for version. |
Text output is `NAME VERSION`, followed by ` (REVISION)` when the selected Text output is `NAME VERSION`, followed by ` (REVISION)` when the selected
identity has a revision. An empty application version is displayed as `dev`; identity has a revision. An empty application version is displayed as `dev`;
@@ -58,7 +58,7 @@ versionless replacement, an untagged module, or missing build information
displays `dev`. The CommandKit identity name is `CommandKit`; dependency build displays `dev`. The CommandKit identity name is `CommandKit`; dependency build
metadata does not supply a revision. metadata does not supply a revision.
YAML output uses these explicit fields, including an empty revision: YAML and JSON output use these fields, including an empty revision:
```yaml ```yaml
name: ExampleApp name: ExampleApp
@@ -111,6 +111,14 @@ caller passes result and diagnostic writers to `App.Execute`; substantive
results go to `Streams.Out`, and categorized messages, terminal explanations, results go to `Streams.Out`, and categorized messages, terminal explanations,
and usage guidance go to `Streams.Err`. and usage guidance go to `Streams.Err`.
The shared `--format` flag selects `text`, `yaml`, or `json` for reports
submitted through the core reporter. When omitted, a report's text template
selects text; otherwise scalar values and nonempty string lists use text, while
structured values and empty collections use YAML. Explicit text without a
template uses YAML for structured values. Invalid or empty format values are
usage errors. Raw output, help, completion, and diagnostics are not encoded as
reports.
### Modes and visibility ### Modes and visibility
Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug` Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug`
@@ -154,9 +162,9 @@ example version --quiet
Reporting flags do not go before or between command-path components. Tokens Reporting flags do not go before or between command-path components. Tokens
consumed as values and tokens after `--` retain their normal meaning. consumed as values and tokens after `--` retain their normal meaning.
The reserved long spellings are `--quiet`, `--verbose`, `--debug`, and The reserved long spellings are `--quiet`, `--verbose`, `--debug`,
`--progress`; application declarations cannot reuse them. There are no built-in `--progress`, and `--format`; application declarations cannot reuse them. There
short spellings. Applications may define aliases such as `-q → --quiet`, are no built-in short spellings. Applications may define aliases such as `-q → --quiet`,
`-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share `-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share
the same ordering. Thus `example export --verbose -q` selects quiet mode. the same ordering. Thus `example export --verbose -q` selects quiet mode.

View File

@@ -135,7 +135,8 @@ installation alongside aliases and integration commands.
## Invocation reporting ## Invocation reporting
`Invocation.Reporter()` supplies the reporter for the current handler. Use `Invocation.Reporter()` supplies the reporter for the current handler. Use
`Output()` or `RenderOutput()` for substantive results; use `Warn`, `Info`, `Report()` for formatted substantive values and optional text templates;
use `Output()` or `RenderOutput()` for deliberate raw output. Use `Warn`, `Info`,
`Progress`, and `Debug` for categorized messages. Queue a summary with `Progress`, and `Debug` for categorized messages. Queue a summary with
`AtEnd().Info` when it belongs after the handler returns. The `AtEnd().Info` when it belongs after the handler returns. The
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes, [CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,

View File

@@ -1,6 +1,6 @@
# Shared reporting and PromptKit v0.20.0 implementation plan # Shared reporting and PromptKit v0.20.0 implementation plan
Status: implementation in progress; Stages 1–3 complete. Status: implementation in progress; Stages 1–4 complete.
## Scope, authority, and execution ## Scope, authority, and execution
@@ -160,6 +160,11 @@ boundary in GoDoc.
## Stage 4: Universal format input, core version, and a core consumer ## Stage 4: Universal format input, core version, and a core consumer
**Complete.** Core format selection, version reporting, and a maintained core
consumer use the shared report path. Focused parser, routing, completion, and
reporting tests and the root workspace suite (`GOTOOLCHAIN=local go test ./...`
with Go 1.27.0) passed. Integration migrations remain in later stages.
Extend neutral core flag metadata and parser selection in Extend neutral core flag metadata and parser selection in
[metadata.go](../../internal/commandmeta/metadata.go), [metadata.go](../../internal/commandmeta/metadata.go),
[parser.go](../../parser.go), and the internal Cobra adapter. Add core-owned [parser.go](../../parser.go), and the internal Cobra adapter. Add core-owned

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"strconv" "strconv"
"text/template"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
) )
@@ -20,6 +21,15 @@ type reportInput struct {
Scenario string Scenario string
} }
type greetingReport struct {
Count string `json:"count"`
Tags string `json:"tags"`
Entered string `json:"entered"`
Canonical string `json:"canonical"`
}
var greetingTemplate = template.Must(commandkit.ParseTextTemplate("greeting", "Hello; count={{.Count}}; tags={{.Tags}}; entered={{printf \"%q\" .Entered}}; canonical={{printf \"%q\" .Canonical}}\n", nil))
var ( var (
errRecoveredFixture = errors.New("a local read failed and was recovered") errRecoveredFixture = errors.New("a local read failed and was recovered")
errWarningFixture = errors.New("a local warning has a cause") errWarningFixture = errors.New("a local warning has a cause")
@@ -50,10 +60,7 @@ func newApp() (*commandkit.App, error) {
if input.Tags.Set { if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value) tags = fmt.Sprintf("explicit %v", input.Tags.Value)
} }
_ = reporter.RenderOutput(func(writer io.Writer) error { _ = reporter.Report(commandkit.Report{Value: greetingReport{Count: count, Tags: tags, Entered: invocation.EnteredPath(), Canonical: invocation.CanonicalPath()}, TextTemplate: greetingTemplate})
_, err := fmt.Fprintf(writer, "Hello; count=%s; tags=%s; entered=%q; canonical=%q\n", count, tags, invocation.EnteredPath(), invocation.CanonicalPath())
return err
})
return commandkit.Success() return commandkit.Success()
}, },
}) })

View File

@@ -475,10 +475,15 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
} }
out.Reset() out.Reset()
yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic}) yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if yamlOutcome.Kind != commandkit.OutcomeSuccess || out.String() != "name: ExampleApp\nversion: dev\nrevision: abc123\n" { if yamlOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "name: ExampleApp\n") || !strings.Contains(out.String(), "version: dev\n") || !strings.Contains(out.String(), "revision: abc123\n") {
t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String()) t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String())
} }
out.Reset() out.Reset()
jsonOutcome := app.Execute(context.Background(), []string{"version", "--format=json"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if jsonOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), `"revision": "abc123"`) {
t.Fatalf("JSON version = %#v, output %q", jsonOutcome, out.String())
}
out.Reset()
component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic}) component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if component.Kind != commandkit.OutcomeSuccess || !strings.HasPrefix(out.String(), "CommandKit ") || !strings.HasSuffix(out.String(), "\n") { if component.Kind != commandkit.OutcomeSuccess || !strings.HasPrefix(out.String(), "CommandKit ") || !strings.HasSuffix(out.String(), "\n") {
t.Fatalf("CommandKit version = %#v, output %q", component, out.String()) t.Fatalf("CommandKit version = %#v, output %q", component, out.String())

View File

@@ -23,8 +23,8 @@ func stringFlag(name, short string) Flag[struct{}] {
} }
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) { func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f")) inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("format", "f")) show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("style", "f"))
group := Group("Tools", "tools", show) group := Group("Tools", "tools", show)
app, err := New(Identity{Name: "Example"}, app, err := New(Identity{Name: "Example"},
inspect, inspect,
@@ -35,8 +35,8 @@ func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
FlagAlias("-x", "--color"), FlagAlias("-x", "--color"),
FlagAlias("--tint", "-c"), FlagAlias("--tint", "-c"),
FlagAliasFor("Tools", "--group-help", "--help"), FlagAliasFor("Tools", "--group-help", "--help"),
FlagAliasFor("inspect", "--hue", "--format"), FlagAliasFor("inspect", "--hue", "--style"),
FlagAliasFor("inspect", "--local", "--format"), FlagAliasFor("inspect", "--local", "--style"),
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -44,11 +44,11 @@ func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
for source, target := range map[string]string{ for source, target := range map[string]string{
"--colour": "--format", "--colour": "--style",
"--hue": "--format", "--hue": "--style",
"-x": "--color", "-x": "--color",
"--tint": "-c", "--tint": "-c",
"--local": "--format", "--local": "--style",
} { } {
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))] alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
if !ok || alias.target.token() != target { if !ok || alias.target.token() != target {
@@ -126,7 +126,7 @@ func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(t *testing.T) {
} }
func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) { func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} { for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
command := flagAliasCommand("reserved", stringFlag(name, "")) command := flagAliasCommand("reserved", stringFlag(name, ""))
if len(command.record.issues) == 0 { if len(command.record.issues) == 0 {
t.Errorf("capturing --%s did not retain a declaration error", name) t.Errorf("capturing --%s did not retain a declaration error", name)
@@ -154,7 +154,7 @@ func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T)
} }
func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) { func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} { for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") { if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err) t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err)
} }
@@ -166,7 +166,7 @@ func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) { func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) {
app, err := New(Identity{Name: "Example"}, app, err := New(Identity{Name: "Example"},
flagAliasCommand("run", stringFlag("format", "f")), flagAliasCommand("run", stringFlag("style", "f")),
Group("tools", "", flagAliasCommand("show")), Group("tools", "", flagAliasCommand("show")),
) )
if err != nil { if err != nil {
@@ -174,7 +174,7 @@ func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testi
} }
var check func(*commandmeta.Command) var check func(*commandmeta.Command)
check = func(node *commandmeta.Command) { check = func(node *commandmeta.Command) {
if len(node.CoreFlags) != 4 { if len(node.CoreFlags) != 5 {
t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags) t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags)
} }
for _, flag := range node.Flags { for _, flag := range node.Flags {
@@ -190,15 +190,15 @@ func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testi
} }
func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) { func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f")) command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
duplicate := FlagAlias("--colour", "--color") duplicate := FlagAlias("--colour", "--color")
app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"), app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format")) FlagAliasFor("inspect", "--colour", "--style"))
if err != nil { if err != nil {
t.Fatalf("identical duplicates and scoped override were rejected: %v", err) t.Fatalf("identical duplicates and scoped override were rejected: %v", err)
} }
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--format" { if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--style" {
t.Fatalf("scoped alias target = %s, want --format", got) t.Fatalf("scoped alias target = %s, want --style", got)
} }
if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" { if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("effective global alias target = %s, want --color", got) t.Fatalf("effective global alias target = %s, want --color", got)
@@ -207,11 +207,11 @@ func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
for name, rules := range map[string][]RoutingRule{ for name, rules := range map[string][]RoutingRule{
"competing global targets": { "competing global targets": {
FlagAlias("--colour", "--color"), FlagAlias("--colour", "--color"),
FlagAlias("--colour", "--format"), FlagAlias("--colour", "--style"),
}, },
"competing scoped targets": { "competing scoped targets": {
FlagAliasFor("inspect", "--colour", "--color"), FlagAliasFor("inspect", "--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format"), FlagAliasFor("inspect", "--colour", "--style"),
}, },
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")}, "unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
"missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")}, "missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")},

82
format_flag_test.go Normal file
View File

@@ -0,0 +1,82 @@
package commandkit
import (
"bytes"
"context"
"encoding/json"
"reflect"
"strings"
"testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
func TestSharedFormatFlag(t *testing.T) {
factories, runs := 0, 0
command := NewCommand(CommandSpec[struct{}]{Name: "run", NewInput: func() struct{} { factories++; return struct{}{} }, Run: func(inv Invocation, _ struct{}) Outcome {
runs++
_ = inv.Reporter().Report(Report{Value: map[string]any{"answer": 42}})
return Success()
}})
app, err := New(Identity{Name: "Example", Version: "1"}, command, FlagAlias("--fmt", "--format"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
expected string
}{
{[]string{"run", "--format", "text"}, "yaml"},
{[]string{"run", "--format=yaml"}, "yaml"},
{[]string{"run", "--fmt=json"}, "json"},
{[]string{"run", "--format=yaml", "--fmt", "json"}, "json"},
{[]string{"run"}, "yaml"},
} {
var out bytes.Buffer
got := app.Execute(context.Background(), tc.args, Streams{Out: &out})
if got.Status != 0 {
t.Fatalf("%v: %#v", tc.args, got)
}
var value any
if tc.expected == "json" {
dec := json.NewDecoder(&out)
dec.UseNumber()
err = dec.Decode(&value)
} else {
value, err = DecodeYAMLReport(&out)
}
if err != nil || !reflect.DeepEqual(value, map[string]any{"answer": json.Number("42")}) {
t.Fatalf("%v: %#v %v", tc.args, value, err)
}
}
beforeFactories, beforeRuns := factories, runs
for _, args := range [][]string{{"run", "--format="}, {"run", "--fmt=xml"}, {"run", "--format=bad", "--format=json"}, {"run", "--format", "--quiet"}} {
var diagnostics bytes.Buffer
got := app.Execute(context.Background(), args, Streams{Err: &diagnostics})
if got.Kind != OutcomeUsage || !strings.Contains(diagnostics.String(), "format") {
t.Fatalf("%v: %#v %q", args, got, &diagnostics)
}
}
if factories != beforeFactories || runs != beforeRuns {
t.Fatalf("invalid formats ran domain code: factories %d, runs %d", factories, runs)
}
var help bytes.Buffer
got := app.Execute(context.Background(), []string{"run", "--help"}, Streams{Out: &help})
if got.Status != 0 || !strings.Contains(help.String(), "--format") || factories != beforeFactories || runs != beforeRuns {
t.Fatalf("help: %#v, %q", got, &help)
}
}
func TestSharedFormatParseOrderAndTerminator(t *testing.T) {
app, err := New(Identity{Name: "Example"}, flagAliasCommand("run", stringFlag("message", "")), FlagAlias("--fmt", "--format"))
if err != nil {
t.Fatal(err)
}
result := parseReportingArgs(app, []string{"run", "--verbose", "--fmt", "json", "--quiet", "--message=--format", "--", "--format=yaml"})
if result.Failure != nil || result.Reporting.Format != "json" || result.Reporting.Mode != commandmeta.ReportingModeQuiet || !reflect.DeepEqual(result.Operands, []string{"--format=yaml"}) {
t.Fatalf("%#v", result)
}
if result.FlagOccurrences[1].Spelling != "--fmt" || result.FlagOccurrences[1].RawValue != "json" {
t.Fatalf("%#v", result.FlagOccurrences)
}
}

View File

@@ -80,10 +80,10 @@ func TestParseInterspersedOperandsBooleanFlagsAndTerminator(t *testing.T) {
func TestReportingFlagsParseWithoutChangingConsumerFlagIndexes(t *testing.T) { func TestReportingFlagsParseWithoutChangingConsumerFlagIndexes(t *testing.T) {
tree := &Command{Name: "app", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{ tree := &Command{Name: "app", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{
{Name: "config", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{ {Name: "config", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{
{Name: "show", Runnable: true, CoreFlags: commandmeta.ReportingFlags(), Flags: []Flag{{Name: "format"}}}, {Name: "show", Runnable: true, CoreFlags: commandmeta.ReportingFlags(), Flags: []Flag{{Name: "style"}}},
}}, }},
}} }}
result := Parse(tree, []string{"config", "show", "--quiet", "-v", "--format=yaml"}, ParseOptions{ result := Parse(tree, []string{"config", "show", "--quiet", "-v", "--style=yaml"}, ParseOptions{
FlagAliases: []commandmeta.FlagAlias{{Source: "-v", Target: "--verbose"}}, FlagAliases: []commandmeta.FlagAlias{{Source: "-v", Target: "--verbose"}},
}) })
if result.Failure != nil { if result.Failure != nil {
@@ -92,7 +92,7 @@ func TestReportingFlagsParseWithoutChangingConsumerFlagIndexes(t *testing.T) {
if !reflect.DeepEqual(result.FlagValues, [][]string{{"yaml"}}) { if !reflect.DeepEqual(result.FlagValues, [][]string{{"yaml"}}) {
t.Fatalf("consumer binding values = %#v", result.FlagValues) t.Fatalf("consumer binding values = %#v", result.FlagValues)
} }
if !reflect.DeepEqual(result.FlagSpellings, [][]string{{"--format"}}) { if !reflect.DeepEqual(result.FlagSpellings, [][]string{{"--style"}}) {
t.Fatalf("consumer binding spellings = %#v", result.FlagSpellings) t.Fatalf("consumer binding spellings = %#v", result.FlagSpellings)
} }
} }

View File

@@ -79,6 +79,7 @@ var reportingFlags = [...]Flag{
{Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true}, {Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true},
{Name: "debug", Usage: "show debugging messages", Default: "false", Boolean: true}, {Name: "debug", Usage: "show debugging messages", Default: "false", Boolean: true},
{Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true}, {Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true},
{Name: "format", Usage: "report format: text, yaml, or json", Completion: CompletionHint{Kind: CompletionChoices, Choices: []CompletionChoice{{Value: "text"}, {Value: "yaml"}, {Value: "json"}}}},
} }
// BooleanCompletion returns the ordinary explicit boolean suggestions. // BooleanCompletion returns the ordinary explicit boolean suggestions.
@@ -95,7 +96,11 @@ func HelpFlag() Flag {
func ReportingFlags() []Flag { func ReportingFlags() []Flag {
flags := append([]Flag(nil), reportingFlags[:]...) flags := append([]Flag(nil), reportingFlags[:]...)
for index := range flags { for index := range flags {
flags[index].Completion = BooleanCompletion() if flags[index].Boolean {
flags[index].Completion = BooleanCompletion()
} else {
flags[index].Completion.Choices = append([]CompletionChoice(nil), flags[index].Completion.Choices...)
}
} }
return flags return flags
} }
@@ -160,6 +165,7 @@ type ReportingSelection struct {
Mode ReportingMode Mode ReportingMode
Progress bool Progress bool
ProgressSet bool ProgressSet bool
Format string
} }
// Result contains the selected command and raw input collected by the adapter. // Result contains the selected command and raw input collected by the adapter.

View File

@@ -5,7 +5,7 @@ import "testing"
func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) { func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
flags := ReportingFlags() flags := ReportingFlags()
want := []string{"quiet", "verbose", "debug", "progress"} want := []string{"quiet", "verbose", "debug", "progress"}
if len(flags) != len(want) { if len(flags) != len(want)+1 {
t.Fatalf("reporting flags = %#v", flags) t.Fatalf("reporting flags = %#v", flags)
} }
for index, name := range want { for index, name := range want {
@@ -16,6 +16,13 @@ func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
t.Errorf("%q is not recognized as a reporting flag", name) t.Errorf("%q is not recognized as a reporting flag", name)
} }
} }
if flags[4].Name != "format" || flags[4].Boolean || !IsReportingFlag("format") {
t.Fatalf("format metadata = %#v", flags[4])
}
flags[4].Completion.Choices[0].Value = "changed"
if ReportingFlags()[4].Completion.Choices[0].Value != "text" {
t.Fatal("caller mutation changed format choices")
}
flags[0].Name = "changed" flags[0].Name = "changed"
if ReportingFlags()[0].Name != "quiet" { if ReportingFlags()[0].Name != "quiet" {
t.Fatal("caller mutation changed shared reporting metadata") t.Fatal("caller mutation changed shared reporting metadata")

View File

@@ -24,6 +24,18 @@ func reduceReportingOccurrences(result commandmeta.Result) commandmeta.Result {
if !commandmeta.IsReportingFlag(occurrence.Name) { if !commandmeta.IsReportingFlag(occurrence.Name) {
continue continue
} }
if occurrence.Name == "format" {
switch Format(occurrence.RawValue) {
case FormatText, FormatYAML, FormatJSON:
selection.Format = occurrence.RawValue
default:
result.FlagOccurrences = result.FlagOccurrences[:index+1]
result.Reporting = selection
result.Failure = &commandmeta.Failure{Message: fmt.Sprintf("invalid format %q for %s (expected text, yaml, or json)", occurrence.RawValue, occurrence.Spelling), Usage: true}
return result
}
continue
}
enabled, err := strconv.ParseBool(occurrence.RawValue) enabled, err := strconv.ParseBool(occurrence.RawValue)
if err != nil { if err != nil {
result.FlagOccurrences = result.FlagOccurrences[:index+1] result.FlagOccurrences = result.FlagOccurrences[:index+1]

View File

@@ -134,6 +134,7 @@ func (r *Reporter) applySelection(selection commandmeta.ReportingSelection) {
} }
state.progress = selection.Progress state.progress = selection.Progress
state.progressSet = selection.ProgressSet state.progressSet = selection.ProgressSet
state.format = Format(selection.Format)
} }
// Output returns a managed writer for substantive command results. Writes are // Output returns a managed writer for substantive command results. Writes are

View File

@@ -27,7 +27,7 @@ func TestRunnableParentKeepsNonCommandTokensAsOperands(t *testing.T) {
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 0, -1, Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 0, -1,
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(target *input, value Supplied[[]string]) { target.words = value.Value })}, func(target *input, value Supplied[[]string]) { target.words = value.Value })},
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "format", Short: "f"}, (*string)(nil), Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "style", Short: "f"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(target *input, value Supplied[string]) { target.format = value })}, func(target *input, value Supplied[string]) { target.format = value })},
Run: func(inv Invocation, value input) Outcome { Run: func(inv Invocation, value input) Outcome {
@@ -36,7 +36,7 @@ func TestRunnableParentKeepsNonCommandTokensAsOperands(t *testing.T) {
return Success() return Success()
}, },
}) })
app, err := New(Identity{Name: "Demo"}, parent, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--format")) app, err := New(Identity{Name: "Demo"}, parent, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--style"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -56,7 +56,7 @@ func TestRunnableParentKeepsNonCommandTokensAsOperands(t *testing.T) {
} }
outcome = app.Execute(context.Background(), append(args, "--help"), Streams{Out: &output, Err: &diagnostics}) outcome = app.Execute(context.Background(), append(args, "--help"), Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || built != beforeBuilt+1 || parentRuns != beforeRuns+1 || if outcome.Kind != OutcomeSuccess || built != beforeBuilt+1 || parentRuns != beforeRuns+1 ||
!strings.Contains(output.String(), "Demo "+spelling+" [ARG...]") || !strings.Contains(output.String(), "--fmt → --format") { !strings.Contains(output.String(), "Demo "+spelling+" [ARG...]") || !strings.Contains(output.String(), "--fmt → --style") {
t.Fatalf("parent help = %#v, output %q, diagnostics %q", outcome, output.String(), diagnostics.String()) t.Fatalf("parent help = %#v, output %q, diagnostics %q", outcome, output.String(), diagnostics.String())
} }
outcome = app.Execute(context.Background(), []string{spelling, "--", operand, "child", "--fmt=yaml"}, Streams{Out: io.Discard, Err: &diagnostics}) outcome = app.Execute(context.Background(), []string{spelling, "--", operand, "child", "--fmt=yaml"}, Streams{Out: io.Discard, Err: &diagnostics})

View File

@@ -67,14 +67,14 @@ func TestParserFailuresRetainAliasUsageAndFlagSpellings(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{ command := NewCommand(CommandSpec[struct{}]{
Name: "inspect", Name: "inspect",
NewInput: func() struct{} { built++; return struct{}{} }, NewInput: func() struct{} { built++; return struct{}{} },
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(*struct{}, Supplied[string]) {})}, func(*struct{}, Supplied[string]) {})},
Run: func(Invocation, struct{}) Outcome { t.Fatal("invalid input executed"); return Success() }, Run: func(Invocation, struct{}) Outcome { t.Fatal("invalid input executed"); return Success() },
}) })
app, err := New(Identity{Name: "Demo"}, command, Alias("i", "inspect"), app, err := New(Identity{Name: "Demo"}, command, Alias("i", "inspect"),
FlagAliasFor("inspect", "--fmt", "--format"), FlagAliasFor("inspect", "--fmt", "--style"),
FlagAliasFor("inspect", "-F", "--format"), FlagAlias("--hlep", "--help")) FlagAliasFor("inspect", "-F", "--style"), FlagAlias("--hlep", "--help"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -111,7 +111,7 @@ func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
usage bool usage bool
}{ }{
{name: "interspersed alias", args: []string{"first", "--fmt=yaml", "second"}}, {name: "interspersed alias", args: []string{"first", "--fmt=yaml", "second"}},
{name: "interspersed canonical flag", args: []string{"first", "--format=yaml", "second"}}, {name: "interspersed canonical flag", args: []string{"first", "--style=yaml", "second"}},
{name: "partial help", args: []string{"first", "--help"}, help: true}, {name: "partial help", args: []string{"first", "--help"}, help: true},
{name: "partial help with flag alias", args: []string{"first", "--fmt=yaml", "--help"}, help: true}, {name: "partial help with flag alias", args: []string{"first", "--fmt=yaml", "--help"}, help: true},
{name: "excess operands", args: []string{"first", "second", "third", "--fmt=yaml"}, usage: true}, {name: "excess operands", args: []string{"first", "second", "third", "--fmt=yaml"}, usage: true},
@@ -125,7 +125,7 @@ func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
built, ran := 0, false built, ran := 0, false
command := NewCommand(CommandSpec[input]{ command := NewCommand(CommandSpec[input]{
Name: "run", NewInput: func() input { built++; return input{} }, Name: "run", NewInput: func() input { built++; return input{} },
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(target *input, value Supplied[string]) { target.format = value })}, func(target *input, value Supplied[string]) { target.format = value })},
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 2, 2, Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 2, 2,
@@ -142,7 +142,7 @@ func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
return Success() return Success()
}, },
}) })
app, err := New(Identity{Name: "Demo"}, command, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--format")) app, err := New(Identity{Name: "Demo"}, command, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--style"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -155,7 +155,7 @@ func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
} else if outcome.Kind != OutcomeSuccess { } else if outcome.Kind != OutcomeSuccess {
t.Fatalf("outcome = %#v, diagnostics %q", outcome, diagnostics.String()) t.Fatalf("outcome = %#v, diagnostics %q", outcome, diagnostics.String())
} }
if test.help && (!strings.Contains(output.String(), "Demo "+spelling+" ARG") || !strings.Contains(output.String(), "--fmt → --format")) { if test.help && (!strings.Contains(output.String(), "Demo "+spelling+" ARG") || !strings.Contains(output.String(), "--fmt → --style")) {
t.Errorf("command help = %q", output.String()) t.Errorf("command help = %q", output.String())
} }
if test.help || test.usage { if test.help || test.usage {
@@ -229,13 +229,13 @@ func TestDiscoveryHelpComposesGroupShortcutsAndOverrides(t *testing.T) {
initialized := false initialized := false
inspect := NewCommand(CommandSpec[struct{}]{ inspect := NewCommand(CommandSpec[struct{}]{
Name: "inspect", NewInput: func() struct{} { initialized = true; return struct{}{} }, Name: "inspect", NewInput: func() struct{} { initialized = true; return struct{}{} },
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})}, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
Run: func(Invocation, struct{}) Outcome { return Success() }, Run: func(Invocation, struct{}) Outcome { return Success() },
}) })
declarations := []Declaration{Group("box", "Box"), inspect, simpleRunnable("alternate"), declarations := []Declaration{Group("box", "Box"), inspect, simpleRunnable("alternate"),
Alias("pk", "box"), Alias("box admin pick", "inspect"), Alias("box admin other", "inspect"), Alias("pk", "box"), Alias("box admin pick", "inspect"), Alias("box admin other", "inspect"),
FlagAlias("--hlep", "--help"), FlagAliasFor("inspect", "--fmt", "--format")} FlagAlias("--hlep", "--help"), FlagAliasFor("inspect", "--fmt", "--style")}
wantPick := "pick → inspect" wantPick := "pick → inspect"
if override { if override {
declarations = append(declarations, Alias("pk admin pick", "alternate"), Alias("pk admin nested choose", "inspect")) declarations = append(declarations, Alias("pk admin pick", "alternate"), Alias("pk admin nested choose", "inspect"))
@@ -308,7 +308,7 @@ func TestHelpDiscoversRoutesThroughIntermediateAliases(t *testing.T) {
makeCommand := func(name string) Command { makeCommand := func(name string) Command {
return NewCommand(CommandSpec[struct{}]{ return NewCommand(CommandSpec[struct{}]{
Name: name, NewInput: func() struct{} { built++; return struct{}{} }, Name: name, NewInput: func() struct{} { built++; return struct{}{} },
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})}, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
Run: func(inv Invocation, _ struct{}) Outcome { Run: func(inv Invocation, _ struct{}) Outcome {
runs++ runs++
@@ -327,7 +327,7 @@ func TestHelpDiscoversRoutesThroughIntermediateAliases(t *testing.T) {
} }
declarations := []Declaration{group, makeCommand("inspect"), makeCommand("alternate"), declarations := []Declaration{group, makeCommand("inspect"), makeCommand("alternate"),
Alias("p", "q"), Alias("q", target), Alias(middle+" pick", "inspect"), Alias(middle+" other", "inspect"), Alias("p", "q"), Alias("q", target), Alias(middle+" pick", "inspect"), Alias(middle+" other", "inspect"),
FlagAlias("--hlep", "--help"), FlagAliasFor("inspect", "--fmt", "--format")} FlagAlias("--hlep", "--help"), FlagAliasFor("inspect", "--fmt", "--style")}
wantTarget := "inspect" wantTarget := "inspect"
if test.override { if test.override {
declarations = append(declarations, Alias(prefix+" pick", "alternate")) declarations = append(declarations, Alias(prefix+" pick", "alternate"))
@@ -352,7 +352,7 @@ func TestHelpDiscoversRoutesThroughIntermediateAliases(t *testing.T) {
t.Errorf("help %q omits %q", output.String(), fragment) t.Errorf("help %q omits %q", output.String(), fragment)
} }
} }
if strings.Contains(output.String(), "--fmt") || strings.Contains(output.String(), "--format") || if strings.Contains(output.String(), "--fmt") || strings.Contains(output.String(), "--style") ||
(test.override && strings.Contains(output.String(), "pick → inspect")) { (test.override && strings.Contains(output.String(), "pick → inspect")) {
t.Errorf("help exposes a descendant flag or overridden destination: %q", output.String()) t.Errorf("help exposes a descendant flag or overridden destination: %q", output.String())
} }
@@ -920,7 +920,7 @@ func TestAliasHelpTargetsGroupsAndDiscoveryViews(t *testing.T) {
inputBuilds++ inputBuilds++
return struct{}{} return struct{}{}
}, },
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(*struct{}, Supplied[string]) {}, func(*struct{}, Supplied[string]) {},
)}, )},
@@ -939,7 +939,7 @@ func TestAliasHelpTargetsGroupsAndDiscoveryViews(t *testing.T) {
Alias("pk", "promptkit"), Alias("pk", "promptkit"),
Alias("pk list bundles", "promptkit show prompt"), Alias("pk list bundles", "promptkit show prompt"),
FlagAlias("--hlep", "--help"), FlagAlias("--hlep", "--help"),
FlagAliasFor("promptkit show prompt", "--fmt", "--format"), FlagAliasFor("promptkit show prompt", "--fmt", "--style"),
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -985,13 +985,13 @@ func TestAliasHelpTargetsGroupsAndDiscoveryViews(t *testing.T) {
{ {
name: "scoped flag alias appears on canonical command help", name: "scoped flag alias appears on canonical command help",
args: []string{"promptkit", "show", "prompt", "--hlep"}, args: []string{"promptkit", "show", "prompt", "--hlep"},
want: []string{"--fmt → --format"}, want: []string{"--fmt → --style"},
}, },
{ {
name: "group excludes descendant scoped flag aliases", name: "group excludes descendant scoped flag aliases",
args: []string{"promptkit", "--help"}, args: []string{"promptkit", "--help"},
want: []string{"Command Aliases:"}, want: []string{"Command Aliases:"},
omit: []string{"--fmt → --format"}, omit: []string{"--fmt → --style"},
}, },
{ {
name: "root lists explicit command aliases separately", name: "root lists explicit command aliases separately",