15 Commits

29 changed files with 610 additions and 90 deletions

View File

@@ -84,6 +84,12 @@ func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
// "true"; explicit values are converted with strconv.ParseBool. Set distinguishes
// an omitted flag from an explicit false value.
//
// A BoolFlag named debug receives the shared reporting flag's input. Its
// default and requiredness apply to application input, not reporting mode;
// explicit false satisfies requiredness. Its optional short spelling and
// completion metadata follow ordinary flag rules. The final input value can
// differ from [Invocation.ReportingMode] when other mode flags are supplied.
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
if set == nil {

View File

@@ -95,7 +95,8 @@ type FlagMeta struct {
// Name is a nonempty long name without leading dashes. It consists of
// Unicode lowercase letters or digits in segments separated by single
// hyphens; digit-leading and numeric-only names are valid. The names help,
// quiet, verbose, debug, progress and format are reserved.
// quiet, verbose, debug, progress and format are reserved, except that a
// BoolFlag named debug attaches to the shared reporting flag.
Name string
// Short is an optional one-byte spelling other than '-' or the reserved 'h'.
Short string
@@ -498,7 +499,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
if binding.meta.Name == "help" {
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
}
if commandmeta.IsReportingFlag(binding.meta.Name) {
if commandmeta.IsReportingFlag(binding.meta.Name) && !(binding.meta.Name == "debug" && binding.boolean) {
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
}
if _, exists := flagNames[binding.meta.Name]; exists {

View File

@@ -35,8 +35,7 @@ type completionFlagState struct {
func completionFlags(context completionContext) []commandmeta.Flag {
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
flags = append(flags, commandmeta.HelpFlag())
flags = append(flags, context.command.CoreFlags...)
flags = append(flags, context.command.Flags...)
flags = append(flags, commandmeta.EffectiveFlags(context.command.Flags, context.command.CoreFlags)...)
return flags
}

View File

@@ -70,6 +70,34 @@ func TestCompletionFlagAndValuePositions(t *testing.T) {
}
}
func TestSharedDebugCompletionHints(t *testing.T) {
for _, tc := range []struct {
name string
hint CompletionHint
want commandmeta.CompletionKind
}{
{"none", NoCompletion(), commandmeta.CompletionNone},
{"choices", CompleteChoices(CompletionChoice{Value: "true"}), commandmeta.CompletionChoices},
{"files", CompleteFiles(), commandmeta.CompletionFiles},
{"directories", CompleteDirectories(), commandmeta.CompletionDirectories},
} {
t.Run(tc.name, func(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Completion: tc.hint}, false, func(*struct{}, Supplied[bool]) {})}, Run: func(Invocation, struct{}) Outcome { return Success() }})
app, err := New(Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
for _, current := range []string{"--debug=", "-d="} {
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, current))
if target.kind != completionTargetValue || target.hint.Kind != tc.want {
t.Fatalf("%s: %#v", current, target)
}
}
})
}
}
func TestCompletionOperandAllocation(t *testing.T) {
arguments := []commandmeta.Argument{
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},

View File

@@ -120,3 +120,51 @@ func TestCompletionQueryDiscovery(t *testing.T) {
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
}
}
func TestSharedDebugHelpAndCompletion(t *testing.T) {
var factories, setters, checks, handlers int
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
NewInput: func() struct{} { factories++; return struct{}{} },
Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Usage: "application tracing", Required: true, Completion: CompleteChoices(CompletionChoice{Value: "true"}, CompletionChoice{Value: "trace"})}, true, func(*struct{}, Supplied[bool]) { setters++ })},
CheckInput: func(struct{}) error { checks++; return nil },
Run: func(Invocation, struct{}) Outcome { handlers++; return Success() },
})
app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"))
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"run", "--help"}, {"help", "run"}, {"shortcut", "--help"}, {"help", "shortcut"}, {"run", "--unknown"}, {"shortcut", "--unknown"}} {
var output, diagnostic bytes.Buffer
result := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostic})
text := output.String() + diagnostic.String()
if strings.Count(text, "--debug") != 1 || !strings.Contains(text, "-d") || !strings.Contains(text, "application tracing") || !strings.Contains(text, "application input defaults to true") || !strings.Contains(text, "required") || !strings.Contains(text, "default: false") {
t.Fatalf("%v: outcome=%#v text=%q", args, result, text)
}
}
for _, tc := range []struct {
args []string
want string
count int
}{
{[]string{"__complete", "run", "--deb"}, "--debug", 1},
{[]string{"__complete", "run", "-"}, "\n-d\t", 1},
{[]string{"__complete", "run", "--debug=tr"}, "trace", 1},
{[]string{"__complete", "run", "-d=tr"}, "trace", 1},
} {
var output bytes.Buffer
result := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: io.Discard})
if result.Kind != OutcomeSuccess || strings.Count(output.String(), tc.want) != tc.count {
t.Fatalf("%v: %#v %q", tc.args, result, output.String())
}
}
if factories != 0 || setters != 0 || checks != 0 || handlers != 0 {
t.Fatalf("callbacks: %d %d %d %d", factories, setters, checks, handlers)
}
if result := app.Execute(context.Background(), []string{"run", "--debug=invalid", "--help"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage {
t.Fatalf("help skipped boolean validation: %#v", result)
}
if result := app.Execute(context.Background(), []string{"run", "--debug=trace"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage {
t.Fatalf("suggestion bypassed boolean parsing: %#v", result)
}
}

View File

@@ -178,8 +178,11 @@ 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`,
`--progress`, and `--format`; application declarations cannot reuse them. There
are no built-in short spellings. Applications may define aliases such as `-q → --quiet`,
`--progress`, and `--format`. An application may attach its own boolean input
to the existing `--debug` option. Command help shows both purposes in one row,
including an application short spelling, requiredness, and any different
application default. Application input defaults do not select reporting mode.
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

@@ -18,20 +18,21 @@ The following lists the current release set and earlier module combinations:
| Selection | Core | ckconfig | ckprompt | Upstream libraries and status |
| --- | --- | --- | --- | --- |
| Published core | v0.5.0 | — | — | Usable independently. |
| ConfigKit integration | v0.5.0 | v0.5.0 | — | ConfigKit v1.1.0. |
| Published core | v0.6.0 | — | — | Usable independently. |
| ConfigKit integration | v0.6.0 | v0.6.0 | — | ConfigKit v1.1.0. |
| PromptKit integration | v0.5.0 | v0.5.0 | v0.5.0 | ConfigKit v1.1.0 and PromptKit v0.20.0. |
| Earlier matched integration set | v0.3.0 | v0.2.0 | v0.2.0 | ConfigKit v0.8.0 and PromptKit v0.16.0, using their earlier import paths. |
Install the published core with:
```sh
go get go.maximumdirect.net/commandkit@v0.5.0
go get go.maximumdirect.net/commandkit@v0.6.0
```
Select either integration with `go get go.maximumdirect.net/ckconfig@v0.5.0`
Select either integration with `go get go.maximumdirect.net/ckconfig@v0.6.0`
or `go get go.maximumdirect.net/ckprompt@v0.5.0`. The integrations select core
v0.5.0, and ckprompt selects ckconfig v0.5.0, which includes `AdaptDiagnostics`.
v0.6.0 for ckconfig and v0.5.0 for ckprompt; ckprompt selects ckconfig v0.5.0,
which includes `AdaptDiagnostics`.
They build independently against these released dependencies; a local workspace
is optional for development. Published ckconfig v0.3.0 also uses ConfigKit v1.1.0
and does not compose with ckprompt v0.2.0's earlier ConfigKit dependency.
@@ -154,6 +155,35 @@ Deferred submissions are accepted until finalization begins; later submissions
return `ErrReporterClosed`. The [CLI reference](../cli.md#reporting) owns flag
grammar, visibility, prefixes, and status behavior.
An application can attach a boolean input to the shared `--debug` option and
inspect the reporting selection separately. For example:
```go
type input struct { debug commandkit.Supplied[bool] }
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run",
Flags: []commandkit.Flag[input]{
commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Short: "d"}, false,
func(in *input, value commandkit.Supplied[bool]) { in.debug = value }),
},
Run: func(inv commandkit.Invocation, in input) commandkit.Outcome {
captureArtifacts := in.debug.Value
quiet := inv.ReportingMode() == commandkit.ReportingModeQuiet
_, _ = captureArtifacts, quiet // Apply application policy here.
return commandkit.Success()
},
})
_ = command
```
With `run --debug --quiet`, the input is true and explicitly supplied, while
the selected reporting mode is quiet. Application defaults and requiredness
affect input, not mode selection; explicit false counts as supplied. Captured
bindings retain the usual selection and application behavior. Applications own
any artifact creation and other effects. The mode reports selection rather than
successful delivery; see [Invocation.ReportingMode](../../identity.go).
Reports use JSON serialization to establish the common value representation
for YAML and JSON. A text template receives the original value; without one,
text uses readable scalar or string-list output and YAML for structured values.

View File

@@ -73,7 +73,8 @@ Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
current exported contracts; the CLI reference owns operator behavior. The
[maintainer release procedure](release.md) owns current release operations.
Completed roadmaps and implementation evidence remain available in Git history,
including [audit remediation and its declaration-path follow-up](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap),
including [shared debug binding and reporting-mode implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/56e3b013f9f83c9c9494f11602e802ce5ed8814e/docs/roadmap),
[audit remediation and its declaration-path follow-up](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap),
[shared reporting and PromptKit v0.20.0 implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap),
[PromptKit v0.18.0 adoption and implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap),
[ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap),
@@ -117,7 +118,8 @@ go work init . ./integrations/ckconfig ./integrations/ckprompt
If a workspace already exists, ensure it includes these three modules. Preserve
the ignored `go.work` and `go.work.sum`; do not commit them. Use
`GOWORK=off GOTOOLCHAIN=local` for dependency preparation
and independent module suites. Both integrations select released core v0.5.0;
and independent module suites. ckconfig selects released core v0.6.0 and
ckprompt selects core v0.5.0;
ckprompt also selects released ckconfig v0.5.0. Their declared dependencies
support independent builds. The aligned workspace exercises the local sources
together.

View File

@@ -38,6 +38,12 @@ collected parser events through that selection. The selected mode therefore
applies to command execution, help, and version; routing and parsing failures
remain visible in every mode.
An effective flag view combines a shared application debug binding with the
core declaration for parsing, help, and completion, while preserving the
application binding's position. The same occurrence stream selects reporting
mode independently of the prepared application value. The invocation's mode
accessor reads the reporter's selection under its existing state mutex.
The reporter owns invocation-scoped visibility, message formatting, managed
report rendering, delivery observations, and finalization. It presents queued
messages after the handler returns, then a failed outcome's explanation, and

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.6
**Last Updated**: 2026-10-11
**Last Updated**: 2026-11-08
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
@@ -96,6 +96,22 @@ Additional compatibility promises and deliberate exceptions belong in the
adopted policy or canonical contract documentation. Release notes explain
changes; they do not redefine current compatibility contracts.
### CommandKit downstream dependency alignment
Each new core or integration release requires dependency updates and new releases
of all downstream CommandKit-owned integration modules, following direct and
transitive dependencies. Each affected integration advances at least its minor
version to incorporate the dependency updates, even when the initiating release
is a patch. Use a major release where the compatibility rules above require it.
Versions remain independent and need not match across modules.
A module with no downstream CommandKit integrations may release independently;
its release does not require new versions of its dependencies. This policy does
not require releases of external consuming applications. The
[runbook](../release.md#scope-and-release-order) owns dependency-order sequencing.
Downstream releases are required follow-up work, not additional completion gates
for an already published source release.
## Consumer Minimum And Release Toolchain
Distinguish the minimum toolchain supported for consumers from the toolchain

View File

@@ -9,11 +9,28 @@ Gitea release page; there are no binaries,
checksums, or other delivery assets. CI, module proxy availability, and consumer
installation are not completion gates.
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
integration modules require the preceding CommandKit releases, so prepare and
publish each module only after its CommandKit dependencies are available. A root
release runs only the root module's default suite; it need not run suites for
independently released integrations.
Plan the initiating release and all downstream integration releases required by
the [dependency-alignment policy](policy/release.md#commandkit-downstream-dependency-alignment).
That policy sets the minimum version bump for affected integrations; select
each module's version independently according to its compatibility impact.
| Initiating release | Required downstream releases, in order |
| --- | --- |
| Core | `ckconfig`, then `ckprompt` |
| `ckconfig` | `ckprompt` |
| `ckprompt` | None with the current dependency graph |
As integrations are added, follow their actual dependencies to include every
affected downstream integration. Publish dependencies before their consumers.
Prepare each integration after its updated CommandKit dependencies are published,
incorporating all of those versions in one candidate. For a core update, release
`ckprompt` once after both core and `ckconfig`; it does not need separate releases
for each dependency bump. Unaffected dependencies do not need new releases.
Track any remaining downstream releases as pending coordination work. Each
successful source-tag push still completes that module's release; a downstream
failure does not undo it or authorize replacing its tag. A root release runs
only the root module's default suite; each downstream release runs its own suite.
Use branch `main`, remote `origin`, and expected effective push endpoint
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
@@ -98,14 +115,17 @@ standard offline suite and ReleaseKit’s candidate/note and publication guards.
## Prepare and publish one candidate
Prepare dependencies in the selected module before the candidate is finalized.
Do this after the preceding CommandKit module has been published and before the
selected candidate suite is run. For `core`, run the command at the repository
root; for integrations, run it from that module directory.
Do this after any CommandKit dependency releases required by the coordinated
update have been published and before the selected candidate suite is run.
For `core`, run the command at the repository root; for integrations, run it
from that module directory.
Before downloading dependencies, update each integration to the newly published
CommandKit versions. `go mod download` downloads the versions already selected;
it does not upgrade requirements. Replace the version placeholders below with
the actual published versions, which may differ between modules:
the actual published versions, which may differ between modules. When a
dependency is not being released in this update, use its current selected
release; a `ckconfig`-initiated update does not require a new core release:
```sh
# After publishing core, prepare ckconfig from its module directory.

25
docs/releases/v0.6.0.md Normal file
View File

@@ -0,0 +1,25 @@
# CommandKit v0.6.0
CommandKit v0.6.0 lets applications bind the shared debug option to typed
input and inspect the selected reporting mode during and after an invocation.
## Changes
- A `BoolFlag` named `debug` attaches to the existing reporting option. Its
application default, requiredness, short spelling, and completion hints are
retained; help describes the combined option.
- `Invocation.ReportingMode` returns the resolved visibility selection, which
can differ from the bound debug value when other mode flags are supplied.
## Compatibility and upgrade
Other reporting flag names remain reserved. Applications choosing a required
debug binding require an explicit value; an explicit false satisfies it. A
true application default does not enable debug reporting. See the
[consumer guide](../consumers/guide.md#invocation-reporting) and
[CLI reference](../cli.md#reporting). The core consumer minimum remains Go
1.26.0 and the maintainer release toolchain remains Go 1.27.0.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/docs/oss/attribution.md)

View File

@@ -1,7 +1,6 @@
# Future work
Status: pending ckprompt v0.5.0 publication, optional shell validation, and
future capability proposals.
Status: optional shell validation and future capability proposals.
No delivery dates are selected here.
## Purpose and authority
@@ -9,6 +8,8 @@ No delivery dates are selected here.
Earlier completed roadmaps, implementation plans, and review findings have been retired.
This document preserves unfinished work and unselected ideas under the
[release policy](../policy/release.md#release-documentation-and-preparation).
The completed shared debug binding and reporting-mode implementation is recorded
in its [retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/56e3b013f9f83c9c9494f11602e802ce5ed8814e/docs/roadmap).
The completed [documentation refresh](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/5c960532552037d38119941389e558f78bf73aad/docs/roadmap/documentation-refresh.md) remains available in Git history.
The completed audits, consolidated remediation, implementation plan, and
declaration-path follow-up remain available in the
@@ -29,14 +30,7 @@ govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
an idea here does not select its syntax, API, priority, or delivery date.
## Pending release and integration work
### ckprompt release
Core and ckconfig v0.5.0 are published. The ckprompt v0.5.0 candidate now selects
both releases and replaces the unpublished v0.3.0 draft. Complete validation and
publication using the [release procedure](../release.md). Hosted delivery is a
separate operation.
## Optional verification
### Optional shell completion validation

View File

@@ -1,6 +1,7 @@
package commandkit
import (
"strconv"
"strings"
"testing"
@@ -313,3 +314,27 @@ func TestFlagAliasSharedSuffixAndUnavailableDestination(t *testing.T) {
}
}
}
func TestSharedDebugDeclarationValidation(t *testing.T) {
valid := BoolFlag(FlagMeta{Name: "debug", Short: "d"}, true, func(*struct{}, Supplied[bool]) {})
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("run", valid)); err != nil {
t.Fatalf("valid debug: %v", err)
}
for _, flags := range [][]Flag[struct{}]{
{valid, valid},
{BoolFlag[struct{}](FlagMeta{Name: "debug"}, false, nil)},
{BoolFlag(FlagMeta{Name: "debug", Short: "h"}, false, func(*struct{}, Supplied[bool]) {})},
{ValueFlag(FlagMeta{Name: "debug"}, (*bool)(nil), strconv.ParseBool, func(*struct{}, Supplied[bool]) {})},
{OccurrenceFlag(FlagMeta{Name: "debug"}, strconv.ParseBool, func(*struct{}, Supplied[bool]) {})},
} {
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("bad", flags...)); err == nil {
t.Fatalf("invalid binding accepted: %#v", flags)
}
}
for _, name := range []string{"quiet", "verbose", "progress", "format"} {
flag := BoolFlag(FlagMeta{Name: name}, false, func(*struct{}, Supplied[bool]) {})
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("bad", flag)); err == nil {
t.Errorf("reserved boolean --%s was accepted", name)
}
}
}

View File

@@ -24,6 +24,8 @@ type FlagValues[I any] struct {
// each adapted flag stores its prepared value in target, associated with the
// original inner binding identity. Copies of an original handle remain
// selectable; an independently constructed lookalike does not.
// A captured debug BoolFlag shares reporting input as described by [BoolFlag];
// its requiredness is checked when selected for application.
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
captured := make([]Flag[Outer], len(flags))
for index, original := range flags {

View File

@@ -59,6 +59,21 @@ type Invocation struct {
// manually constructed Invocation has no reporter.
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
// ReportingMode returns the selected reporting mode, independently of delivery
// success. The selection is stable during the handler and remains available
// after finalization. A zero Invocation returns ReportingModeOrdinary. Reads
// are safe alongside concurrent reporting; reporting callbacks must not reenter
// the same reporter.
func (inv Invocation) ReportingMode() ReportingMode {
if inv.reporter == nil || inv.reporter.state == nil {
return ReportingModeOrdinary
}
state := inv.reporter.state
state.mu.Lock()
defer state.mu.Unlock()
return state.mode
}
// Help renders the selected command's help through this invocation's output.
// It returns an operational failure when the invocation has no help renderer.
func (inv Invocation) Help() Outcome {

View File

@@ -0,0 +1,16 @@
# CommandKit ckconfig v0.6.0
CommandKit ckconfig v0.6.0 selects CommandKit core v0.6.0, which supports
shared debug input bindings and access to the selected reporting mode.
ConfigKit remains at v1.1.0.
## Compatibility and upgrade
Select `go.maximumdirect.net/ckconfig v0.6.0` with CommandKit core v0.6.0.
The consumer minimum remains Go 1.27.0. See the
[consumer guide](../../../../docs/consumers/guide.md#invocation-reporting)
for the new core reporting and debug input APIs.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.6.0/integrations/ckconfig/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.6.0/integrations/ckconfig/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.6.0/integrations/ckconfig/docs/oss/attribution.md)

View File

@@ -3,7 +3,7 @@ module go.maximumdirect.net/ckconfig
go 1.27.0
require (
go.maximumdirect.net/commandkit v0.5.0
go.maximumdirect.net/commandkit v0.6.0
go.maximumdirect.net/configkit v1.1.0
go.yaml.in/yaml/v4 v4.0.0-rc.6
)

View File

@@ -10,6 +10,8 @@ go.maximumdirect.net/commandkit v0.4.0 h1:bWqflDWDn98FUCgh+0e+w6LGmvWIkaeJK519H7
go.maximumdirect.net/commandkit v0.4.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/commandkit v0.5.0 h1:Ln+bCp+FwCg4tZD3NaMKQXUDB3mPusgwsnRZmb0+3cM=
go.maximumdirect.net/commandkit v0.5.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/commandkit v0.6.0 h1:4PHhMIlZuuvreIxZhUazeweg6zoYixEvhM9LJnkbGNA=
go.maximumdirect.net/commandkit v0.6.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=

View File

@@ -77,9 +77,7 @@ func (session *Session) Parse(argv []string) commandmeta.Result {
}
remaining := append(commandpath.CloneTokens(unconsumed), tail...)
localFlags := make([]commandmeta.Flag, 0, len(selectedNode.Flags)+len(selectedNode.CoreFlags))
localFlags = append(localFlags, selectedNode.Flags...)
localFlags = append(localFlags, selectedNode.CoreFlags...)
localFlags := commandmeta.EffectiveFlags(selectedNode.Flags, selectedNode.CoreFlags)
aliases := session.flagAliases
if session.flagAliasesForPath != nil {
aliases = session.flagAliasesForPath(result.Path)
@@ -254,7 +252,7 @@ func displayCommandPath(rootName string, path []string) string {
return strings.Join(parts, " ")
}
// UsageLine returns Cobra's usage line for an exact path on this session.
// UsageLine returns usage for an exact path on this session.
func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) {
if session == nil || session.root == nil {
return "", &commandmeta.Failure{Message: "command tree is nil"}
@@ -269,6 +267,22 @@ func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure)
if selected.HasSubCommands() {
line += " [command]"
}
for _, flag := range declaration.Flags {
if flag.Name != "debug" || !flag.Boolean {
continue
}
for _, effective := range commandmeta.EffectiveFlags(declaration.Flags, declaration.CoreFlags) {
if effective.Name == "debug" {
spelling := "--debug"
if effective.Short != "" {
spelling = "-" + effective.Short + ", " + spelling
}
line += "\n " + spelling + " " + flagUsage(effective)
break
}
}
break
}
return line, nil
}
@@ -316,7 +330,7 @@ func buildTree(root *commandmeta.Command) (*spfcobra.Command, map[*spfcobra.Comm
helpFlag := backend.Flags().Lookup("help")
helpFlag.NoOptDefVal = "true"
helpFlag.Usage = "help for this command (default: false)"
for _, declarationFlag := range append(append([]commandmeta.Flag(nil), declaration.Flags...), declaration.CoreFlags...) {
for _, declarationFlag := range commandmeta.EffectiveFlags(declaration.Flags, declaration.CoreFlags) {
value := &rawValue{boolean: declarationFlag.Boolean}
backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, flagUsage(declarationFlag))
backendFlag := backend.Flags().Lookup(declarationFlag.Name)

View File

@@ -281,3 +281,14 @@ func TestMissingSessionTreeReturnsNeutralFailures(t *testing.T) {
t.Fatalf("missing-tree usage failure = %#v", failure)
}
}
func TestSharedDebugExtractionPreservesConsumerIndexes(t *testing.T) {
tree := &Command{Name: "app", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{{Name: "run", Runnable: true, CoreFlags: commandmeta.ReportingFlags(), Flags: []Flag{{Name: "before"}, {Name: "debug", Short: "d", Boolean: true}, {Name: "after"}}}}}
result := Parse(tree, []string{"run", "--before=one", "-d", "--after=two", "-x=false"}, ParseOptions{FlagAliases: []commandmeta.FlagAlias{{Source: "-x", Target: "--debug"}}})
if result.Failure != nil {
t.Fatal(result.Failure)
}
if !reflect.DeepEqual(result.FlagValues, [][]string{{"one"}, {"true", "false"}, {"two"}}) || !reflect.DeepEqual(result.FlagSpellings[1], []string{"-d", "-x"}) || len(result.FlagOccurrences) != 4 {
t.Fatalf("parse = %#v", result)
}
}

View File

@@ -2,6 +2,8 @@
// its command-line backend.
package commandmeta
import "strings"
// Key identifies one command inside one routing snapshot.
type Key uint64
@@ -100,6 +102,35 @@ func ReportingFlags() []Flag {
return flags
}
// EffectiveFlags combines the shared debug option while preserving consumer order.
func EffectiveFlags(flags, core []Flag) []Flag {
result := append([]Flag(nil), flags...)
shared := -1
for i, flag := range result {
if flag.Name == "debug" && flag.Boolean {
shared = i
break
}
}
for _, flag := range core {
if flag.Name == "debug" && shared >= 0 {
app := result[shared]
usage := flag.Usage
if app.Usage != "" {
usage += "; " + app.Usage
}
if strings.EqualFold(app.Default, "true") {
usage += "; application input defaults to true (reporting defaults to ordinary)"
}
flag.Usage, flag.Short, flag.Required, flag.Completion = usage, app.Short, app.Required, app.Completion
result[shared] = flag
continue
}
result = append(result, flag)
}
return result
}
// IsReportingFlag reports whether name is reserved for a shared reporting flag.
func IsReportingFlag(name string) bool {
for _, flag := range reportingFlags {

View File

@@ -1,6 +1,9 @@
package commandmeta
import "testing"
import (
"strings"
"testing"
)
func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
flags := ReportingFlags()
@@ -33,3 +36,15 @@ func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
}
}
}
func TestEffectiveFlagsCombinesDebugMetadata(t *testing.T) {
flags := []Flag{{Name: "before"}, {Name: "debug", Short: "d", Boolean: true, Default: "true", Required: true, Usage: "application diagnostics", Completion: CompletionHint{Kind: CompletionFiles}}, {Name: "after"}}
core := ReportingFlags()
got := EffectiveFlags(flags, core)
if len(got) != len(flags)+len(core)-1 || got[1].Name != "debug" || got[1].Default != "false" || got[1].Short != "d" || !got[1].Required || got[1].Completion.Kind != CompletionFiles || !strings.Contains(got[1].Usage, "application diagnostics") || !strings.Contains(got[1].Usage, "application input defaults to true") {
t.Fatalf("effective debug = %#v", got)
}
if flags[1].Default != "true" || core[2].Short != "" {
t.Fatal("input metadata changed")
}
}

View File

@@ -10,13 +10,19 @@ import (
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
type reportingMode uint8
// ReportingMode identifies the invocation's selected reporting visibility.
// Its zero value is ReportingModeOrdinary.
type ReportingMode uint8
const (
modeOrdinary reportingMode = iota
modeQuiet
modeVerbose
modeDebug
// ReportingModeOrdinary uses ordinary reporting visibility.
ReportingModeOrdinary ReportingMode = iota
// ReportingModeQuiet suppresses ordinary output and messages.
ReportingModeQuiet
// ReportingModeVerbose enables verbose reporting.
ReportingModeVerbose
// ReportingModeDebug enables debug reporting.
ReportingModeDebug
)
type reportingDestination uint8
@@ -41,7 +47,7 @@ type reportingState struct {
diagnostics destinationState
closed bool
deferredClosed bool
mode reportingMode
mode ReportingMode
format Format
progress bool
progressSet bool
@@ -109,7 +115,7 @@ func newReporter(streams Streams) *Reporter {
return &Reporter{state: &reportingState{
output: destinationState{writer: streams.Out},
diagnostics: destinationState{writer: streams.Err},
mode: modeOrdinary,
mode: ReportingModeOrdinary,
}}
}
@@ -125,13 +131,13 @@ func (r *Reporter) applySelection(selection commandmeta.ReportingSelection) {
}
switch selection.Mode {
case commandmeta.ReportingModeQuiet:
state.mode = modeQuiet
state.mode = ReportingModeQuiet
case commandmeta.ReportingModeVerbose:
state.mode = modeVerbose
state.mode = ReportingModeVerbose
case commandmeta.ReportingModeDebug:
state.mode = modeDebug
state.mode = ReportingModeDebug
default:
state.mode = modeOrdinary
state.mode = ReportingModeOrdinary
}
state.progress = selection.Progress
state.progressSet = selection.ProgressSet
@@ -189,7 +195,7 @@ func (r *Reporter) RenderOutput(write func(io.Writer) error) error {
destination.submitted = true
writer := &coordinatedWriter{state: state, destination: destinationOutput}
renderErr := write(writer)
if state.mode == modeQuiet {
if state.mode == ReportingModeQuiet {
destination.suppressed = true
} else if renderErr == nil && writer.writeError == nil {
destination.delivered = true
@@ -489,17 +495,17 @@ func renderDetailsLocked(state *reportingState, details []Detail, visible bool)
func (state *reportingState) visible(category messageCategory) bool {
switch category {
case categoryInfo, categoryWarning:
return state.mode == modeVerbose || state.mode == modeDebug
return state.mode == ReportingModeVerbose || state.mode == ReportingModeDebug
case categoryDebug:
return state.mode == modeDebug
return state.mode == ReportingModeDebug
case categoryProgress:
if state.mode == modeQuiet {
if state.mode == ReportingModeQuiet {
return false
}
if state.progressSet {
return state.progress
}
return state.mode == modeDebug
return state.mode == ReportingModeDebug
default:
return false
}
@@ -617,7 +623,7 @@ func (state *reportingState) destination(destination reportingDestination) *dest
func (state *reportingState) suppressed(destination reportingDestination) bool {
if destination == destinationOutput {
return state.mode == modeQuiet
return state.mode == ReportingModeQuiet
}
return false
}

View File

@@ -10,7 +10,7 @@ import (
)
func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
for _, mode := range []reportingMode{modeOrdinary, modeQuiet} {
for _, mode := range []ReportingMode{ReportingModeOrdinary, ReportingModeQuiet} {
t.Run(fmt.Sprint(mode), func(t *testing.T) {
reporter := newReporter(Streams{})
setReporterVisibility(reporter, mode, false, false)
@@ -45,7 +45,7 @@ func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
failure := errors.New("hidden rendering failed")
var order []string
details := []Detail{func(w io.Writer) error {
@@ -87,7 +87,7 @@ func TestReporterDeferredAcceptanceEndsBeforeDelivery(t *testing.T) {
writer := &gatedDiagnosticWriter{entered: make(chan struct{}), release: make(chan struct{})}
entered := writer.entered
reporter := newReporter(Streams{Err: writer})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
deferred := reporter.AtEnd()
if err := deferred.Warn("accepted"); err != nil {
t.Fatal(err)

View File

@@ -16,7 +16,7 @@ func TestReporterExplainsItsOwnFailuresWithoutExposingCauses(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
if quiet {
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
}
failure := errors.New("private rendering detail")
render := func(io.Writer) error { return failure }
@@ -84,7 +84,7 @@ func TestReporterPreservesJoinedRendererEvidence(t *testing.T) {
writeErr := errors.Join(errors.New("sink failure"), errors.New("sink detail"))
destination := &reporterTestWriter{err: writeErr}
reporter := newReporter(Streams{Out: destination, Err: destination})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
render := func(w io.Writer) error {
_, err := io.WriteString(w, "report")
return test.combine(renderErr, err)

View File

@@ -12,7 +12,7 @@ import (
func TestReporterReportQuietAndClosed(t *testing.T) {
var out bytes.Buffer
reporter := newReporter(Streams{Out: &out})
reporter.state.mode = modeQuiet
reporter.state.mode = ReportingModeQuiet
err := reporter.Report(Report{Value: make(chan int)})
if err == nil {
t.Fatal("expected serialization error")

View File

@@ -80,7 +80,7 @@ func TestReporterLeavesCallerWriterOpen(t *testing.T) {
func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) {
var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
writer := reporter.Output()
if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") {
@@ -177,7 +177,7 @@ func TestReporterClosedHandlesReturnClosedErrorIncludingQuiet(t *testing.T) {
var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination})
writer := reporter.Output()
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
_ = reporter.finalize(Success(), nil)
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
@@ -193,11 +193,11 @@ func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
if reporter.state.output.writer != io.Discard || reporter.state.diagnostics.writer != io.Discard {
t.Fatalf("nil destinations were not normalized: %#v", reporter.state)
}
if reporter.state.mode != modeOrdinary || reporter.state.progress || reporter.state.progressSet {
if reporter.state.mode != ReportingModeOrdinary || reporter.state.progress || reporter.state.progressSet {
t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
}
setReporterVisibility(reporter, modeDebug, true, true)
if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet {
setReporterVisibility(reporter, ReportingModeDebug, true, true)
if reporter.state.mode != ReportingModeDebug || !reporter.state.progress || !reporter.state.progressSet {
t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
}
}
@@ -205,17 +205,17 @@ func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
tests := []struct {
name string
mode reportingMode
mode ReportingMode
progress bool
progressSet bool
want string
}{
{name: "ordinary", mode: modeOrdinary},
{name: "verbose", mode: modeVerbose, want: "not used\nWARN: not used\n"},
{name: "debug", mode: modeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"},
{name: "quiet overrides progress", mode: modeQuiet, progress: true, progressSet: true},
{name: "explicit ordinary progress", mode: modeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"},
{name: "explicit debug disable", mode: modeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"},
{name: "ordinary", mode: ReportingModeOrdinary},
{name: "verbose", mode: ReportingModeVerbose, want: "not used\nWARN: not used\n"},
{name: "debug", mode: ReportingModeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"},
{name: "quiet overrides progress", mode: ReportingModeQuiet, progress: true, progressSet: true},
{name: "explicit ordinary progress", mode: ReportingModeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"},
{name: "explicit debug disable", mode: ReportingModeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"},
}
for _, test := range tests {
@@ -237,7 +237,7 @@ func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
if err := reporter.Warn("Native warning\nwith second line\n", nil,
func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err },
func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil {
@@ -258,7 +258,7 @@ func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
renderErr := errors.New("native renderer failed")
calls := 0
err := reporter.Warn("hidden warning", func(w io.Writer) error {
@@ -284,7 +284,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
renderErr := errors.New("output renderer failed")
diagnosticErr := errors.New("diagnostic renderer failed")
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
_ = reporter.RenderOutput(func(io.Writer) error { return renderErr })
_ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr })
@@ -297,7 +297,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) {
destination := &reporterTestWriter{}
reporter := newReporter(Streams{Out: destination})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
renderErr := errors.New("quiet encoder failed")
calls := 0
_ = reporter.RenderOutput(func(w io.Writer) error {
@@ -321,7 +321,7 @@ func TestReporterCountsSuccessfulZeroByteRenderAsSubmitted(t *testing.T) {
if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil {
t.Fatal(err)
}
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil {
t.Fatal(err)
}
@@ -409,7 +409,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
t.Run("render callback and message", func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
start := make(chan struct{})
ready := make(chan struct{}, 2)
done := make(chan struct{}, 2)
@@ -443,7 +443,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
t.Run("message details and render callback", func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
start := make(chan struct{})
ready := make(chan struct{}, 2)
done := make(chan struct{}, 2)
@@ -481,7 +481,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
if err := reporter.Warn("", func(w io.Writer) error {
_, err := io.WriteString(w, "native multiline\nsecond line\n")
return err
@@ -499,7 +499,7 @@ func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) {
var output, diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
setReporterVisibility(reporter, modeDebug, false, false)
setReporterVisibility(reporter, ReportingModeDebug, false, false)
if err := reporter.Info("started"); err != nil {
t.Fatal(err)
}
@@ -525,7 +525,7 @@ func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *test
func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
writeFirst := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "first detail\n"); return err })
writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err })
details := []Detail{writeFirst}
@@ -545,7 +545,7 @@ func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *te
renderErr := errors.New("deferred renderer failed")
failedReporter := newReporter(Streams{Err: io.Discard})
setReporterVisibility(failedReporter, modeQuiet, false, false)
setReporterVisibility(failedReporter, ReportingModeQuiet, false, false)
if err := failedReporter.AtEnd().Warn("hidden", func(io.Writer) error { return renderErr }); err != nil {
t.Fatal(err)
}
@@ -699,14 +699,14 @@ func TestReporterFinalizationClosesOutputAndDeferredHandles(t *testing.T) {
}
}
func setReporterVisibility(reporter *Reporter, mode reportingMode, progress, progressSet bool) {
func setReporterVisibility(reporter *Reporter, mode ReportingMode, progress, progressSet bool) {
selection := commandmeta.ReportingSelection{Progress: progress, ProgressSet: progressSet}
switch mode {
case modeQuiet:
case ReportingModeQuiet:
selection.Mode = commandmeta.ReportingModeQuiet
case modeVerbose:
case ReportingModeVerbose:
selection.Mode = commandmeta.ReportingModeVerbose
case modeDebug:
case ReportingModeDebug:
selection.Mode = commandmeta.ReportingModeDebug
}
reporter.applySelection(selection)

View File

@@ -0,0 +1,205 @@
package commandkit_test
import (
"context"
"errors"
"io"
"sync"
"testing"
commandkit "go.maximumdirect.net/commandkit"
)
func TestInvocationReportingMode(t *testing.T) {
if got := (commandkit.Invocation{}).ReportingMode(); got != commandkit.ReportingModeOrdinary {
t.Fatalf("zero invocation mode = %v", got)
}
var retained commandkit.Invocation
var observed commandkit.ReportingMode
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(inv commandkit.Invocation, _ struct{}) commandkit.Outcome {
retained = inv
observed = inv.ReportingMode()
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
flag string
want commandkit.ReportingMode
}{{"", commandkit.ReportingModeOrdinary}, {"--quiet", commandkit.ReportingModeQuiet}, {"--verbose", commandkit.ReportingModeVerbose}, {"--debug", commandkit.ReportingModeDebug}, {"", commandkit.ReportingModeOrdinary}} {
args := []string{"run"}
if tc.flag != "" {
args = append(args, tc.flag)
}
result := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || observed != tc.want || retained.ReportingMode() != tc.want {
t.Fatalf("%q: outcome=%#v mode=%v", tc.flag, result, retained.ReportingMode())
}
}
}
func TestInvocationReportingModeConcurrentReads(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(inv commandkit.Invocation, _ struct{}) commandkit.Outcome {
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < 100; j++ {
if inv.ReportingMode() != commandkit.ReportingModeDebug {
t.Error("mode changed")
}
_ = inv.Reporter().Debug("diagnostic")
}
}()
}
close(start)
wg.Wait()
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(context.Background(), []string{"run", "--debug"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatal(outcome)
}
}
func TestSharedDebugBindingAndReportingMode(t *testing.T) {
type input struct {
debug commandkit.Supplied[bool]
before, after commandkit.Supplied[string]
operand commandkit.Supplied[string]
}
var received input
var mode commandkit.ReportingMode
factories, calls := 0, 0
parse := func(s string) (string, error) { return s, nil }
cmd := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "run", NewInput: func() input { factories++; return input{} }, Flags: []commandkit.Flag[input]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "before"}, (*string)(nil), parse, func(i *input, v commandkit.Supplied[string]) { i.before = v }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Short: "d"}, false, func(i *input, v commandkit.Supplied[bool]) { i.debug = v }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "after"}, (*string)(nil), parse, func(i *input, v commandkit.Supplied[string]) { i.after = v }),
}, Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "operand"}, false, parse, func(i *input, v commandkit.Supplied[string]) { i.operand = v })}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
calls++
received = i
mode = inv.ReportingMode()
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, cmd, commandkit.FlagAlias("-x", "--debug"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
value, set bool
mode commandkit.ReportingMode
}{
{nil, false, false, commandkit.ReportingModeOrdinary}, {[]string{"--debug"}, true, true, commandkit.ReportingModeDebug}, {[]string{"--debug", "--quiet"}, true, true, commandkit.ReportingModeQuiet}, {[]string{"--quiet", "--debug"}, true, true, commandkit.ReportingModeDebug}, {[]string{"--debug", "--verbose"}, true, true, commandkit.ReportingModeVerbose}, {[]string{"--debug", "--debug=false"}, false, true, commandkit.ReportingModeOrdinary}, {[]string{"--verbose", "--debug", "--debug=false"}, false, true, commandkit.ReportingModeOrdinary}, {[]string{"--debug", "--quiet=false"}, true, true, commandkit.ReportingModeDebug}, {[]string{"--debug", "--progress=false"}, true, true, commandkit.ReportingModeDebug}, {[]string{"-x"}, true, true, commandkit.ReportingModeDebug}, {[]string{"-d=false"}, false, true, commandkit.ReportingModeOrdinary},
} {
args := append([]string{"run", "--before=one", "--after=two"}, tc.args...)
got := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if got.Kind != commandkit.OutcomeSuccess || received.debug.Value != tc.value || received.debug.Set != tc.set || mode != tc.mode || received.before.Value != "one" || received.after.Value != "two" {
t.Fatalf("%v: result=%#v input=%#v mode=%v", tc.args, got, received, mode)
}
}
for _, value := range []string{"--debug=", "--debug=invalid"} {
beforeFactories, beforeCalls := factories, calls
got := app.Execute(context.Background(), []string{"run", value}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if got.Kind != commandkit.OutcomeUsage || factories != beforeFactories || calls != beforeCalls {
t.Fatalf("%s result=%#v factories=%d calls=%d", value, got, factories, calls)
}
}
got := app.Execute(context.Background(), []string{"run", "--before", "--debug"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if got.Kind != commandkit.OutcomeSuccess || received.before.Value != "--debug" || received.debug.Set || mode != commandkit.ReportingModeOrdinary {
t.Fatalf("option-looking value: %#v %#v mode=%v", got, received, mode)
}
got = app.Execute(context.Background(), []string{"run", "--", "--debug"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if got.Kind != commandkit.OutcomeSuccess || received.operand.Value != "--debug" || received.debug.Set || mode != commandkit.ReportingModeOrdinary {
t.Fatalf("operand after terminator: %#v %#v mode=%v", got, received, mode)
}
}
func TestSharedDebugCapturedDefaultsAndRequiredness(t *testing.T) {
type inner struct{ debug commandkit.Supplied[bool] }
type outer struct{ flags commandkit.FlagValues[inner] }
for _, required := range []bool{false, true} {
flag := commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Short: "d", Required: required}, true, func(i *inner, v commandkit.Supplied[bool]) { i.debug = v })
captured := commandkit.CaptureFlags([]commandkit.Flag[inner]{flag}, func(i *outer) *commandkit.FlagValues[inner] { return &i.flags })
var applied, supplied inner
var applyErr error
var mode commandkit.ReportingMode
cmd := commandkit.NewCommand(commandkit.CommandSpec[outer]{Name: "run", Flags: captured, Run: func(inv commandkit.Invocation, i outer) commandkit.Outcome {
mode = inv.ReportingMode()
applied = inner{}
supplied = inner{debug: commandkit.Supplied[bool]{Value: true, Set: true}}
applyErr = i.flags.ApplyTo(&applied, flag)
if err := i.flags.ApplySuppliedTo(&supplied, flag); err != nil && applyErr == nil {
applyErr = err
}
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, cmd, commandkit.FlagAliasFor("run", "-x", "-d"))
if err != nil {
t.Fatal(err)
}
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
if result.Kind != commandkit.OutcomeSuccess || mode != commandkit.ReportingModeOrdinary {
t.Fatalf("omission=%#v mode=%v", result, mode)
}
if required {
if !errors.Is(applyErr, commandkit.ErrFlagInput) {
t.Fatalf("missing required: %v", applyErr)
}
} else if applyErr != nil || !applied.debug.Value || applied.debug.Set || !supplied.debug.Value || !supplied.debug.Set {
t.Fatalf("defaults=%#v %#v %v", applied, supplied, applyErr)
}
result = app.Execute(context.Background(), []string{"run", "-x=false"}, commandkit.Streams{})
if result.Kind != commandkit.OutcomeSuccess || applyErr != nil || applied.debug.Value || !applied.debug.Set || supplied.debug.Value || !supplied.debug.Set || mode != commandkit.ReportingModeOrdinary {
t.Fatalf("false=%#v %#v %#v mode=%v err=%v", result, applied, supplied, mode, applyErr)
}
}
}
func TestSharedDebugDirectRequiredAndRenamedBinding(t *testing.T) {
type input struct{ debug commandkit.Supplied[bool] }
var got input
var mode commandkit.ReportingMode
original := commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Required: true}, true, func(i *input, v commandkit.Supplied[bool]) { i.debug = v })
required := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "required", Flags: []commandkit.Flag[input]{original}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
got = i
mode = inv.ReportingMode()
return commandkit.Success()
}})
renamed := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "renamed", Flags: []commandkit.Flag[input]{commandkit.RenameFlag(original, "trace")}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
got = i
mode = inv.ReportingMode()
return commandkit.Success()
}})
ordinary := commandkit.BoolFlag(commandkit.FlagMeta{Name: "local"}, false, func(i *input, v commandkit.Supplied[bool]) { i.debug = v })
attached := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "attached", Flags: []commandkit.Flag[input]{commandkit.RenameFlag(ordinary, "debug")}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
got = i
mode = inv.ReportingMode()
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, required, renamed, attached)
if err != nil {
t.Fatal(err)
}
if r := app.Execute(context.Background(), []string{"required"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeUsage {
t.Fatalf("required omission=%#v", r)
}
if r := app.Execute(context.Background(), []string{"required", "--debug=false"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeSuccess || got.debug.Value || !got.debug.Set || mode != commandkit.ReportingModeOrdinary {
t.Fatalf("explicit false=%#v %#v", r, got)
}
if r := app.Execute(context.Background(), []string{"renamed", "--trace=false", "--debug"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeSuccess || got.debug.Value || !got.debug.Set || mode != commandkit.ReportingModeDebug {
t.Fatalf("renamed=%#v %#v mode=%v", r, got, mode)
}
if r := app.Execute(context.Background(), []string{"attached", "--debug"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeSuccess || !got.debug.Value || !got.debug.Set || mode != commandkit.ReportingModeDebug {
t.Fatalf("renamed to debug=%#v %#v mode=%v", r, got, mode)
}
}