From 924692b541f31b54079bfd4e6f90f16fa2a4f438 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 7 Nov 2026 03:45:12 +0000 Subject: [PATCH] Add shared report format flag --- builtins.go | 50 ++++------------ completion_query_test.go | 1 + completion_test.go | 2 +- declaration_external_test.go | 14 ++--- docs/cli.md | 18 ++++-- docs/consumers/guide.md | 3 +- docs/roadmap/implementation.md | 7 ++- examples/core/main.go | 15 +++-- execute_external_test.go | 7 ++- flag_aliases_test.go | 34 +++++------ format_flag_test.go | 82 +++++++++++++++++++++++++++ internal/cobra/parse_test.go | 6 +- internal/commandmeta/metadata.go | 8 ++- internal/commandmeta/metadata_test.go | 9 ++- parser.go | 12 ++++ reporter.go | 1 + route_operands_test.go | 6 +- route_test.go | 32 +++++------ 18 files changed, 206 insertions(+), 101 deletions(-) create mode 100644 format_flag_test.go diff --git a/builtins.go b/builtins.go index ffc62cb..848c69f 100644 --- a/builtins.go +++ b/builtins.go @@ -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 { diff --git a/completion_query_test.go b/completion_query_test.go index 03f39ba..04f6e60 100644 --- a/completion_query_test.go +++ b/completion_query_test.go @@ -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}, } { diff --git a/completion_test.go b/completion_test.go index 4aaafc7..4c986a9 100644 --- a/completion_test.go +++ b/completion_test.go @@ -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) } } diff --git a/declaration_external_test.go b/declaration_external_test.go index b21d524..3cc8157 100644 --- a/declaration_external_test.go +++ b/declaration_external_test.go @@ -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) } diff --git a/docs/cli.md b/docs/cli.md index 50a2adf..4d2cb58 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. diff --git a/docs/consumers/guide.md b/docs/consumers/guide.md index e16a526..b9d6d81 100644 --- a/docs/consumers/guide.md +++ b/docs/consumers/guide.md @@ -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, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 8c6b398..d5bc8c5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/examples/core/main.go b/examples/core/main.go index ac88841..d95a2ea 100644 --- a/examples/core/main.go +++ b/examples/core/main.go @@ -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() }, }) diff --git a/execute_external_test.go b/execute_external_test.go index b85b408..31d67b5 100644 --- a/execute_external_test.go +++ b/execute_external_test.go @@ -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()) diff --git a/flag_aliases_test.go b/flag_aliases_test.go index 66885dc..fddf918 100644 --- a/flag_aliases_test.go +++ b/flag_aliases_test.go @@ -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")}, diff --git a/format_flag_test.go b/format_flag_test.go new file mode 100644 index 0000000..8f9025e --- /dev/null +++ b/format_flag_test.go @@ -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) + } +} diff --git a/internal/cobra/parse_test.go b/internal/cobra/parse_test.go index 74224a2..3b58f4d 100644 --- a/internal/cobra/parse_test.go +++ b/internal/cobra/parse_test.go @@ -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) } } diff --git a/internal/commandmeta/metadata.go b/internal/commandmeta/metadata.go index 52f8bf4..7e088bf 100644 --- a/internal/commandmeta/metadata.go +++ b/internal/commandmeta/metadata.go @@ -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. diff --git a/internal/commandmeta/metadata_test.go b/internal/commandmeta/metadata_test.go index ead103d..9704dd8 100644 --- a/internal/commandmeta/metadata_test.go +++ b/internal/commandmeta/metadata_test.go @@ -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") diff --git a/parser.go b/parser.go index 82a01cd..be99fe1 100644 --- a/parser.go +++ b/parser.go @@ -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] diff --git a/reporter.go b/reporter.go index dc4cf32..5548863 100644 --- a/reporter.go +++ b/reporter.go @@ -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 diff --git a/route_operands_test.go b/route_operands_test.go index 4053e8d..c0a1c2f 100644 --- a/route_operands_test.go +++ b/route_operands_test.go @@ -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}) diff --git a/route_test.go b/route_test.go index ede3747..fb5e99e 100644 --- a/route_test.go +++ b/route_test.go @@ -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",