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"
"io"
"runtime/debug"
"text/template"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
yaml "go.yaml.in/yaml/v4"
)
const commandKitModulePath = "go.maximumdirect.net/commandkit"
@@ -17,16 +17,17 @@ type helpInput struct {
}
type versionInput struct {
Scope string
Format string
Scope string
}
type versionDocument struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Revision string `yaml:"revision"`
Name string `json:"name"`
Version string `json:"version"`
Revision string `json:"revision"`
}
var versionTextTemplate = template.Must(ParseTextTemplate("version", "{{.Name}} {{.Version}}{{if .Revision}} ({{.Revision}}){{end}}\n", nil))
func (a *App) builtinCommands() []Declaration {
help := captureCommand(CommandSpec[helpInput]{
Name: "help",
@@ -39,14 +40,12 @@ func (a *App) builtinCommands() []Declaration {
return current.renderHelpTargets(input.Targets.Value)
})
scopeDefault := "app"
formatDefault := "text"
version := NewCommand(CommandSpec[versionInput]{
Name: "version",
Summary: "Show application or CommandKit version.",
Description: "Report identity and build metadata without loading application configuration.",
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: "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 {
identity := a.identity
@@ -56,7 +55,7 @@ func (a *App) builtinCommands() []Declaration {
if identity.Version == "" {
identity.Version = "dev"
}
return renderVersionOutcome(identity, input.Format, invocation.Reporter())
return renderVersionOutcome(identity, invocation.Reporter())
},
})
return []Declaration{help, version}
@@ -69,13 +68,6 @@ func parseVersionScope(value string) (string, error) {
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 {
content, failure := session.RenderHelpView(view)
if failure != nil {
@@ -91,36 +83,14 @@ func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, report
return Success()
}
func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome {
func renderVersionOutcome(identity Identity, reporter *Reporter) Outcome {
if identity.Version == "" {
identity.Version = "dev"
}
switch format {
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))
}
_ = reporter.Report(Report{Value: versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}, TextTemplate: versionTextTemplate})
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 {
info, ok := debug.ReadBuildInfo()
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},
{"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},
{"format value", []string{"__complete", "run", "--format", "j"}, "json\n", false},
{"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false},
{"unknown route", []string{"__complete", "missing", ""}, emptyReply, true},
} {

View File

@@ -54,7 +54,7 @@ func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
t.Fatalf("no completion: %#v", got)
}
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)
}
}

View File

@@ -18,15 +18,15 @@ func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
Name: "show",
Summary: "Show a value.",
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() },
})
declarations := []commandkit.Declaration{
command,
commandkit.Alias("inspect", "show"),
commandkit.FlagAlias("--fmt", "--format"),
commandkit.FlagAliasFor("show", "-f", "--format"),
commandkit.FlagAlias("--fmt", "--style"),
commandkit.FlagAliasFor("show", "-f", "--style"),
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil {
@@ -84,7 +84,7 @@ func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
func(value string) (string, error) { optionalParses++; return value, nil },
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(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("inspect-shortcut", "inspect"),
commandkit.Alias("i", "inspect-shortcut"),
commandkit.FlagAlias("--fmt", "--format"),
commandkit.FlagAlias("--fmt", "--style"),
}
declarations := append([]commandkit.Declaration(nil), commands...)
if test.rulesFirst {
@@ -154,7 +154,7 @@ func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
var formats []string
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
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(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)},
@@ -181,7 +181,7 @@ func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
if err := app.Register(family,
commandkit.Alias("cfg-show", "config show"),
commandkit.Alias("cs", "cfg-show"),
commandkit.FlagAlias("--fmt", "--format"),
commandkit.FlagAlias("--fmt", "--style"),
); err != nil {
t.Fatal(err)
}

View File

@@ -46,7 +46,7 @@ The supported flags are:
| Flag | Values | Default | Meaning |
| --- | --- | --- | --- |
| `--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
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
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
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,
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
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
consumed as values and tokens after `--` retain their normal meaning.
The reserved long spellings are `--quiet`, `--verbose`, `--debug`, and
`--progress`; application declarations cannot reuse them. There are no built-in
short spellings. Applications may define aliases such as `-q → --quiet`,
The reserved long spellings are `--quiet`, `--verbose`, `--debug`,
`--progress`, and `--format`; application declarations cannot reuse them. There
are no built-in short spellings. Applications may define aliases such as `-q → --quiet`,
`-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share
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.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
`AtEnd().Info` when it belongs after the handler returns. The
[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
Status: implementation in progress; Stages 1–3 complete.
Status: implementation in progress; Stages 1–4 complete.
## Scope, authority, and execution
@@ -160,6 +160,11 @@ boundary in GoDoc.
## 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
[metadata.go](../../internal/commandmeta/metadata.go),
[parser.go](../../parser.go), and the internal Cobra adapter. Add core-owned

View File

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

View File

@@ -475,10 +475,15 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
}
out.Reset()
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())
}
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})
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())

View File

@@ -23,8 +23,8 @@ func stringFlag(name, short string) Flag[struct{}] {
}
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f"))
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("format", "f"))
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("style", "f"))
group := Group("Tools", "tools", show)
app, err := New(Identity{Name: "Example"},
inspect,
@@ -35,8 +35,8 @@ func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
FlagAlias("-x", "--color"),
FlagAlias("--tint", "-c"),
FlagAliasFor("Tools", "--group-help", "--help"),
FlagAliasFor("inspect", "--hue", "--format"),
FlagAliasFor("inspect", "--local", "--format"),
FlagAliasFor("inspect", "--hue", "--style"),
FlagAliasFor("inspect", "--local", "--style"),
)
if err != nil {
t.Fatal(err)
@@ -44,11 +44,11 @@ func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
for source, target := range map[string]string{
"--colour": "--format",
"--hue": "--format",
"--colour": "--style",
"--hue": "--style",
"-x": "--color",
"--tint": "-c",
"--local": "--format",
"--local": "--style",
} {
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
if !ok || alias.target.token() != target {
@@ -126,7 +126,7 @@ func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(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, ""))
if len(command.record.issues) == 0 {
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) {
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") {
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) {
app, err := New(Identity{Name: "Example"},
flagAliasCommand("run", stringFlag("format", "f")),
flagAliasCommand("run", stringFlag("style", "f")),
Group("tools", "", flagAliasCommand("show")),
)
if err != nil {
@@ -174,7 +174,7 @@ func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testi
}
var check func(*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)
}
for _, flag := range node.Flags {
@@ -190,15 +190,15 @@ func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testi
}
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")
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 {
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" {
t.Fatalf("scoped alias target = %s, want --format", got)
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--style" {
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" {
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{
"competing global targets": {
FlagAlias("--colour", "--color"),
FlagAlias("--colour", "--format"),
FlagAlias("--colour", "--style"),
},
"competing scoped targets": {
FlagAliasFor("inspect", "--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format"),
FlagAliasFor("inspect", "--colour", "--style"),
},
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
"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) {
tree := &Command{Name: "app", 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"}},
})
if result.Failure != nil {
@@ -92,7 +92,7 @@ func TestReportingFlagsParseWithoutChangingConsumerFlagIndexes(t *testing.T) {
if !reflect.DeepEqual(result.FlagValues, [][]string{{"yaml"}}) {
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)
}
}

View File

@@ -79,6 +79,7 @@ var reportingFlags = [...]Flag{
{Name: "verbose", Usage: "show warning and informational 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: "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.
@@ -95,7 +96,11 @@ func HelpFlag() Flag {
func ReportingFlags() []Flag {
flags := append([]Flag(nil), reportingFlags[:]...)
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
}
@@ -160,6 +165,7 @@ type ReportingSelection struct {
Mode ReportingMode
Progress bool
ProgressSet bool
Format string
}
// 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) {
flags := ReportingFlags()
want := []string{"quiet", "verbose", "debug", "progress"}
if len(flags) != len(want) {
if len(flags) != len(want)+1 {
t.Fatalf("reporting flags = %#v", flags)
}
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)
}
}
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"
if ReportingFlags()[0].Name != "quiet" {
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) {
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)
if err != nil {
result.FlagOccurrences = result.FlagOccurrences[:index+1]

View File

@@ -134,6 +134,7 @@ func (r *Reporter) applySelection(selection commandmeta.ReportingSelection) {
}
state.progress = selection.Progress
state.progressSet = selection.ProgressSet
state.format = Format(selection.Format)
}
// 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,
func(value string) (string, error) { return value, nil },
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(target *input, value Supplied[string]) { target.format = value })},
Run: func(inv Invocation, value input) Outcome {
@@ -36,7 +36,7 @@ func TestRunnableParentKeepsNonCommandTokensAsOperands(t *testing.T) {
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 {
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})
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())
}
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{}]{
Name: "inspect",
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]) {})},
Run: func(Invocation, struct{}) Outcome { t.Fatal("invalid input executed"); return Success() },
})
app, err := New(Identity{Name: "Demo"}, command, Alias("i", "inspect"),
FlagAliasFor("inspect", "--fmt", "--format"),
FlagAliasFor("inspect", "-F", "--format"), FlagAlias("--hlep", "--help"))
FlagAliasFor("inspect", "--fmt", "--style"),
FlagAliasFor("inspect", "-F", "--style"), FlagAlias("--hlep", "--help"))
if err != nil {
t.Fatal(err)
}
@@ -111,7 +111,7 @@ func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
usage bool
}{
{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 with flag alias", args: []string{"first", "--fmt=yaml", "--help"}, help: 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
command := NewCommand(CommandSpec[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(target *input, value Supplied[string]) { target.format = value })},
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 2, 2,
@@ -142,7 +142,7 @@ func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
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 {
t.Fatal(err)
}
@@ -155,7 +155,7 @@ func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
} else if outcome.Kind != OutcomeSuccess {
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())
}
if test.help || test.usage {
@@ -229,13 +229,13 @@ func TestDiscoveryHelpComposesGroupShortcutsAndOverrides(t *testing.T) {
initialized := false
inspect := NewCommand(CommandSpec[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]) {})},
Run: func(Invocation, struct{}) Outcome { return Success() },
})
declarations := []Declaration{Group("box", "Box"), inspect, simpleRunnable("alternate"),
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"
if override {
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 {
return NewCommand(CommandSpec[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]) {})},
Run: func(inv Invocation, _ struct{}) Outcome {
runs++
@@ -327,7 +327,7 @@ func TestHelpDiscoversRoutesThroughIntermediateAliases(t *testing.T) {
}
declarations := []Declaration{group, makeCommand("inspect"), makeCommand("alternate"),
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"
if test.override {
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)
}
}
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")) {
t.Errorf("help exposes a descendant flag or overridden destination: %q", output.String())
}
@@ -920,7 +920,7 @@ func TestAliasHelpTargetsGroupsAndDiscoveryViews(t *testing.T) {
inputBuilds++
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]) {},
)},
@@ -939,7 +939,7 @@ func TestAliasHelpTargetsGroupsAndDiscoveryViews(t *testing.T) {
Alias("pk", "promptkit"),
Alias("pk list bundles", "promptkit show prompt"),
FlagAlias("--hlep", "--help"),
FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
FlagAliasFor("promptkit show prompt", "--fmt", "--style"),
)
if err != nil {
t.Fatal(err)
@@ -985,13 +985,13 @@ func TestAliasHelpTargetsGroupsAndDiscoveryViews(t *testing.T) {
{
name: "scoped flag alias appears on canonical command help",
args: []string{"promptkit", "show", "prompt", "--hlep"},
want: []string{"--fmt → --format"},
want: []string{"--fmt → --style"},
},
{
name: "group excludes descendant scoped flag aliases",
args: []string{"promptkit", "--help"},
want: []string{"Command Aliases:"},
omit: []string{"--fmt → --format"},
omit: []string{"--fmt → --style"},
},
{
name: "root lists explicit command aliases separately",