Add configuration scope suite registration
This commit is contained in:
5
app.go
5
app.go
@@ -4,8 +4,9 @@ import "fmt"
|
|||||||
|
|
||||||
// App owns one command registry and its application identity.
|
// App owns one command registry and its application identity.
|
||||||
type App struct {
|
type App struct {
|
||||||
identity Identity
|
identity Identity
|
||||||
commands map[string]*commandNode
|
commands map[string]*commandNode
|
||||||
|
configRegistered bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandNode struct {
|
type commandNode struct {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
|
|||||||
return Flag[I]{binding: binding}
|
return Flag[I]{binding: binding}
|
||||||
}
|
}
|
||||||
binding.valid = true
|
binding.valid = true
|
||||||
|
binding.clear = func(input *I) { set(input, Supplied[V]{}) }
|
||||||
binding.prepare = func(values []string) (func(*I), error) {
|
binding.prepare = func(values []string) (func(*I), error) {
|
||||||
value := fallback
|
value := fallback
|
||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
@@ -42,6 +43,7 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
|
|||||||
return Flag[I]{binding: binding}
|
return Flag[I]{binding: binding}
|
||||||
}
|
}
|
||||||
binding.valid = true
|
binding.valid = true
|
||||||
|
binding.clear = func(input *I) { set(input, Supplied[bool]{}) }
|
||||||
binding.prepare = func(values []string) (func(*I), error) {
|
binding.prepare = func(values []string) (func(*I), error) {
|
||||||
value := defaultValue
|
value := defaultValue
|
||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
@@ -67,6 +69,7 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
|
|||||||
return Flag[I]{binding: binding}
|
return Flag[I]{binding: binding}
|
||||||
}
|
}
|
||||||
binding.valid = true
|
binding.valid = true
|
||||||
|
binding.clear = func(input *I) { set(input, Supplied[[]V]{}) }
|
||||||
binding.prepare = func(values []string) (func(*I), error) {
|
binding.prepare = func(values []string) (func(*I), error) {
|
||||||
parsedValues := append([]V(nil), fallback...)
|
parsedValues := append([]V(nil), fallback...)
|
||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type flagBinding[I any] struct {
|
|||||||
repeated bool
|
repeated bool
|
||||||
boolean bool
|
boolean bool
|
||||||
prepare func([]string) (func(*I), error)
|
prepare func([]string) (func(*I), error)
|
||||||
|
clear func(*I)
|
||||||
valid bool
|
valid bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
324
config.go
Normal file
324
config.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selection identifies the application configuration inputs supplied to a
|
||||||
|
// configuration scope.
|
||||||
|
type Selection struct {
|
||||||
|
Config Supplied[string]
|
||||||
|
Overlay Supplied[string]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigOperation identifies a supported configuration inspection operation.
|
||||||
|
type ConfigOperation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConfigValidate ConfigOperation = "validate"
|
||||||
|
ConfigShow ConfigOperation = "show"
|
||||||
|
ConfigSources ConfigOperation = "sources"
|
||||||
|
ConfigExplain ConfigOperation = "explain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigRequest describes one configuration operation and its selected inputs.
|
||||||
|
type ConfigRequest struct {
|
||||||
|
Operation ConfigOperation
|
||||||
|
Selection Selection
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveWriter writes an owning library's native effective configuration.
|
||||||
|
type EffectiveWriter interface {
|
||||||
|
WriteEffective(io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceReader reports the sources used to construct a configuration.
|
||||||
|
type SourceReader interface {
|
||||||
|
Sources() []configkit.SourceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplanationReader writes an owning library's path explanation.
|
||||||
|
type ExplanationReader interface {
|
||||||
|
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||||
|
WriteExplanation(io.Writer, configkit.Path) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigView contains optional views relevant to supported operations.
|
||||||
|
type ConfigView struct {
|
||||||
|
Effective EffectiveWriter
|
||||||
|
Sources SourceReader
|
||||||
|
Explanation ExplanationReader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquired contains a configuration view and diagnostics from its acquisition.
|
||||||
|
type Acquired[T any] struct {
|
||||||
|
Value T
|
||||||
|
Diagnostics []Diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigScope declares one named configuration and its supported operations.
|
||||||
|
type ConfigScope[C any] struct {
|
||||||
|
Name string
|
||||||
|
Summary string
|
||||||
|
Operations []ConfigOperation
|
||||||
|
Acquire func(context.Context, ConfigRequest, C) (Acquired[ConfigView], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selector binds one typed command flag to selected configuration scopes.
|
||||||
|
type Selector[C any] struct {
|
||||||
|
Binding Flag[C]
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigSuite declares selectors and configuration scopes for an App.
|
||||||
|
type ConfigSuite[C any] struct {
|
||||||
|
NewContext func() C
|
||||||
|
Selectors []Selector[C]
|
||||||
|
CheckInput func(Selection, C) error
|
||||||
|
Scopes []ConfigScope[C]
|
||||||
|
}
|
||||||
|
|
||||||
|
type configScopeInfo struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Operations []ConfigOperation `yaml:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type scopesInventory struct {
|
||||||
|
Scopes []configScopeInfo `yaml:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configScopesInput struct {
|
||||||
|
Format string
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectorState[C any] struct {
|
||||||
|
scopes []string
|
||||||
|
supplied bool
|
||||||
|
apply func(*C)
|
||||||
|
clear func(*C)
|
||||||
|
}
|
||||||
|
|
||||||
|
type configInput[C any] struct {
|
||||||
|
Selection Selection
|
||||||
|
Scope string
|
||||||
|
Format string
|
||||||
|
SelectorContext C
|
||||||
|
selectors []selectorState[C]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterConfig installs the config family and its scope discovery command.
|
||||||
|
func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
|
||||||
|
if app == nil {
|
||||||
|
return fmt.Errorf("cannot register configuration on a nil app")
|
||||||
|
}
|
||||||
|
if app.configRegistered {
|
||||||
|
return fmt.Errorf("configuration suite is already registered")
|
||||||
|
}
|
||||||
|
normalized, err := normalizeConfigSuite(suite)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, exists := app.commands["config"]; exists {
|
||||||
|
return fmt.Errorf("command path %q is already registered", "config")
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
root, err := Group("config", "Inspect configuration.", command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := app.Register(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
app.configRegistered = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
|
||||||
|
normalized := suite
|
||||||
|
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
|
||||||
|
normalized.Scopes = make([]ConfigScope[C], 0, len(suite.Scopes))
|
||||||
|
scopeNames := make(map[string]struct{}, len(suite.Scopes))
|
||||||
|
for _, scope := range suite.Scopes {
|
||||||
|
if scope.Name == "" {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name must not be empty")
|
||||||
|
}
|
||||||
|
if scope.Name == "all" {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name %q is reserved", scope.Name)
|
||||||
|
}
|
||||||
|
if _, exists := scopeNames[scope.Name]; exists {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration scope %q", scope.Name)
|
||||||
|
}
|
||||||
|
scopeNames[scope.Name] = struct{}{}
|
||||||
|
operations, err := normalizeConfigOperations(scope.Operations)
|
||||||
|
if err != nil {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q: %w", scope.Name, err)
|
||||||
|
}
|
||||||
|
if len(operations) > 0 && scope.Acquire == nil {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)
|
||||||
|
}
|
||||||
|
scope.Operations = operations
|
||||||
|
normalized.Scopes = append(normalized.Scopes, scope)
|
||||||
|
}
|
||||||
|
for index := range normalized.Selectors {
|
||||||
|
selector := &normalized.Selectors[index]
|
||||||
|
binding := selector.Binding.binding
|
||||||
|
if !binding.valid || binding.clear == nil || binding.meta.Name == "" {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration selector %d has an invalid flag binding", index)
|
||||||
|
}
|
||||||
|
if !validFlagName(binding.meta.Name) {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag name %q is invalid", binding.meta.Name)
|
||||||
|
}
|
||||||
|
if binding.meta.Name == "config" || binding.meta.Name == "overlay" || binding.meta.Name == "scope" || binding.meta.Name == "format" || binding.meta.Name == "help" {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag --%s conflicts with a standard flag", binding.meta.Name)
|
||||||
|
}
|
||||||
|
if binding.meta.Short == "h" {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short flag -%s conflicts with help", binding.meta.Short)
|
||||||
|
}
|
||||||
|
if binding.meta.Short != "" && (len(binding.meta.Short) != 1 || binding.meta.Short == "-") {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short spelling %q must be one byte", binding.meta.Short)
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(selector.Scopes))
|
||||||
|
unique := make([]string, 0, len(selector.Scopes))
|
||||||
|
for _, scopeName := range selector.Scopes {
|
||||||
|
if _, exists := scopeNames[scopeName]; !exists {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("selector --%s references unknown configuration scope %q", binding.meta.Name, scopeName)
|
||||||
|
}
|
||||||
|
if _, exists := seen[scopeName]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[scopeName] = struct{}{}
|
||||||
|
unique = append(unique, scopeName)
|
||||||
|
}
|
||||||
|
selector.Scopes = append([]string(nil), unique...)
|
||||||
|
for previous := 0; previous < index; previous++ {
|
||||||
|
other := normalized.Selectors[previous].Binding.binding.meta
|
||||||
|
if other.Name == binding.meta.Name || binding.meta.Short != "" && other.Short == binding.meta.Short {
|
||||||
|
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeConfigOperations(operations []ConfigOperation) ([]ConfigOperation, error) {
|
||||||
|
seen := make(map[ConfigOperation]struct{}, len(operations))
|
||||||
|
for _, operation := range operations {
|
||||||
|
switch operation {
|
||||||
|
case ConfigValidate, ConfigShow, ConfigSources, ConfigExplain:
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown operation %q", operation)
|
||||||
|
}
|
||||||
|
seen[operation] = struct{}{}
|
||||||
|
}
|
||||||
|
ordered := make([]ConfigOperation, 0, len(seen))
|
||||||
|
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
|
||||||
|
if _, exists := seen[operation]; exists {
|
||||||
|
ordered = append(ordered, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigScopesCommandWithScopes[C any](scopes []ConfigScope[C], installed []ConfigOperation) (Command, error) {
|
||||||
|
formatDefault := "text"
|
||||||
|
spec := CommandSpec[configScopesInput]{
|
||||||
|
Name: "scopes",
|
||||||
|
Summary: "List configuration scopes and supported operations.",
|
||||||
|
Flags: []Flag[configScopesInput]{ValueFlag[configScopesInput](FlagMeta{
|
||||||
|
Name: "format", Usage: "Output format: text or yaml.",
|
||||||
|
}, &formatDefault, parseConfigFormat, func(input *configScopesInput, value Supplied[string]) {
|
||||||
|
input.Format = value.Value
|
||||||
|
})},
|
||||||
|
CheckInput: func(input configScopesInput) error { return checkConfigFormat(input.Format) },
|
||||||
|
Run: func(invocation Invocation, input configScopesInput) Outcome {
|
||||||
|
inventory := scopesInventory{Scopes: make([]configScopeInfo, 0, len(scopes))}
|
||||||
|
for _, scope := range orderedConfigScopes(scopes) {
|
||||||
|
operations := make([]ConfigOperation, 0, len(scope.Operations))
|
||||||
|
for _, operation := range scope.Operations {
|
||||||
|
if containsOperation(installed, operation) {
|
||||||
|
operations = append(operations, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inventory.Scopes = append(inventory.Scopes, configScopeInfo{Name: scope.Name, Operations: operations})
|
||||||
|
}
|
||||||
|
var output []byte
|
||||||
|
var err error
|
||||||
|
if input.Format == "yaml" {
|
||||||
|
output, err = yaml.Marshal(inventory)
|
||||||
|
} else {
|
||||||
|
output = []byte(formatScopeInventory(inventory))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Failure(err)
|
||||||
|
}
|
||||||
|
if err := writeReport(invocation.Streams.Out, output); err != nil {
|
||||||
|
return incompleteReport(err)
|
||||||
|
}
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return NewCommand(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedConfigScopes[C any](scopes []ConfigScope[C]) []ConfigScope[C] {
|
||||||
|
ordered := append([]ConfigScope[C](nil), scopes...)
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
return configScopeOrder(ordered[i].Name) < configScopeOrder(ordered[j].Name)
|
||||||
|
})
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func configScopeOrder(name string) string {
|
||||||
|
switch name {
|
||||||
|
case "app":
|
||||||
|
return "\x00"
|
||||||
|
case "promptkit":
|
||||||
|
return "\x01"
|
||||||
|
default:
|
||||||
|
return "\x02" + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatScopeInventory(inventory scopesInventory) string {
|
||||||
|
var output strings.Builder
|
||||||
|
for _, scope := range inventory.Scopes {
|
||||||
|
output.WriteString(scope.Name)
|
||||||
|
output.WriteString(":")
|
||||||
|
if len(scope.Operations) > 0 {
|
||||||
|
output.WriteString(" ")
|
||||||
|
for index, operation := range scope.Operations {
|
||||||
|
if index > 0 {
|
||||||
|
output.WriteString(", ")
|
||||||
|
}
|
||||||
|
output.WriteString(string(operation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return output.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfigFormat(value string) (string, error) {
|
||||||
|
if err := checkConfigFormat(value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkConfigFormat(value string) error {
|
||||||
|
if value != "text" && value != "yaml" {
|
||||||
|
return fmt.Errorf("format must be text or yaml")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
219
config_test.go
Normal file
219
config_test.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "example"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newContextCalls := 0
|
||||||
|
acquireCalls := 0
|
||||||
|
suite := ConfigSuite[struct{}]{
|
||||||
|
NewContext: func() struct{} { newContextCalls++; return struct{}{} },
|
||||||
|
Scopes: []ConfigScope[struct{}]{
|
||||||
|
{Name: "zeta", Operations: []ConfigOperation{ConfigExplain, ConfigShow, ConfigExplain}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
|
||||||
|
acquireCalls++
|
||||||
|
return Acquired[ConfigView]{}, nil
|
||||||
|
}},
|
||||||
|
{Name: "promptkit", Operations: []ConfigOperation{ConfigSources}, Acquire: noopConfigAcquire[struct{}]},
|
||||||
|
{Name: "app", Operations: []ConfigOperation{ConfigShow, ConfigValidate, ConfigShow}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
|
||||||
|
acquireCalls++
|
||||||
|
return Acquired[ConfigView]{}, nil
|
||||||
|
}},
|
||||||
|
{Name: "metadata", Summary: "No provider is required."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalized, err := normalizeConfigSuite(suite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(normalized.Scopes[0].Operations, []ConfigOperation{ConfigShow, ConfigExplain}) || !reflect.DeepEqual(normalized.Scopes[2].Operations, []ConfigOperation{ConfigValidate, ConfigShow}) {
|
||||||
|
t.Fatalf("normalized capabilities = %#v", normalized.Scopes)
|
||||||
|
}
|
||||||
|
if err := RegisterConfig(app, suite); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var textOutput bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &textOutput})
|
||||||
|
if outcome.Kind != OutcomeSuccess {
|
||||||
|
t.Fatalf("text inventory outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
wantText := "app:\npromptkit:\nmetadata:\nzeta:\n"
|
||||||
|
if textOutput.String() != wantText {
|
||||||
|
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
|
||||||
|
}
|
||||||
|
|
||||||
|
var yamlOutput bytes.Buffer
|
||||||
|
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "yaml"}, Streams{Out: &yamlOutput})
|
||||||
|
if outcome.Kind != OutcomeSuccess {
|
||||||
|
t.Fatalf("YAML inventory outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if got := yamlOutput.String(); !strings.Contains(got, "scopes:") || strings.Index(got, "name: app") > strings.Index(got, "name: promptkit") || !strings.Contains(got, "operations: []") {
|
||||||
|
t.Fatalf("YAML inventory = %q", got)
|
||||||
|
}
|
||||||
|
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--help"}, Streams{})
|
||||||
|
if outcome.Kind != OutcomeSuccess {
|
||||||
|
t.Fatalf("scope help outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "json"}, Streams{})
|
||||||
|
if outcome.Kind != OutcomeUsage {
|
||||||
|
t.Fatalf("invalid format outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if newContextCalls != 0 || acquireCalls != 0 {
|
||||||
|
t.Fatalf("discovery called runtime callbacks: NewContext=%d Acquire=%d", newContextCalls, acquireCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigInputDefaultsToAppAndRetainsAbsentSelections(t *testing.T) {
|
||||||
|
suite := ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{{Name: "app"}}}
|
||||||
|
prepared, err := prepareInput(CommandSpec[configInput[struct{}]]{Flags: configInputFlags(suite, "yaml")}, make([][]string, 4), nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := prepared.build()
|
||||||
|
if input.Scope != "app" || input.Format != "yaml" || input.Selection.Config.Set || input.Selection.Overlay.Set {
|
||||||
|
t.Fatalf("configuration defaults = %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterConfigRejectsInvalidSuitesAndCollisions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scope ConfigScope[struct{}]
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty name", scope: ConfigScope[struct{}]{}, want: "name must not be empty"},
|
||||||
|
{name: "all name", scope: ConfigScope[struct{}]{Name: "all"}, want: "reserved"},
|
||||||
|
{name: "unknown operation", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{"diff"}}, want: "unknown operation"},
|
||||||
|
{name: "missing acquisition", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{ConfigValidate}}, want: "without an acquisition"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
app, _ := New(Identity{Name: "example"})
|
||||||
|
err := RegisterConfig(app, ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{test.scope}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("RegisterConfig error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app, _ := New(Identity{Name: "example"})
|
||||||
|
custom, err := NewCommand(CommandSpec[struct{}]{Name: "config", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Register(custom); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RegisterConfig(app, ConfigSuite[struct{}]{}); err == nil {
|
||||||
|
t.Fatal("replaced a pre-existing config command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSelectorPreflightAndScopedContextProjection(t *testing.T) {
|
||||||
|
type selectorInput struct {
|
||||||
|
Count Supplied[int]
|
||||||
|
Enabled Supplied[bool]
|
||||||
|
Other Supplied[int]
|
||||||
|
Runtime string
|
||||||
|
}
|
||||||
|
baseContextCalls := 0
|
||||||
|
checkCalls := 0
|
||||||
|
var checked selectorInput
|
||||||
|
countDefault := 9
|
||||||
|
suite := ConfigSuite[selectorInput]{
|
||||||
|
NewContext: func() selectorInput { baseContextCalls++; return selectorInput{Runtime: "invocation cache"} },
|
||||||
|
Selectors: []Selector[selectorInput]{
|
||||||
|
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "count"}, &countDefault, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Count = value }), Scopes: []string{"app"}},
|
||||||
|
{Binding: BoolFlag[selectorInput](FlagMeta{Name: "enabled"}, true, func(input *selectorInput, value Supplied[bool]) { input.Enabled = value }), Scopes: []string{"promptkit"}},
|
||||||
|
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "other"}, nil, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Other = value })},
|
||||||
|
},
|
||||||
|
CheckInput: func(_ Selection, input selectorInput) error { checkCalls++; checked = input; return nil },
|
||||||
|
Scopes: []ConfigScope[selectorInput]{
|
||||||
|
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
|
||||||
|
{Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalized, err := normalizeConfigSuite(suite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec := CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}
|
||||||
|
prepared, err := prepareInput(spec, [][]string{{"all"}, {"text"}, {""}, nil, {"0"}, {"false"}, nil}, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := prepared.build()
|
||||||
|
scopeNames, err := preflightConfig(normalized, input, ConfigValidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(scopeNames, []string{"app", "promptkit"}) || checkCalls != 1 || checked.Count != (Supplied[int]{Value: 0, Set: true}) || checked.Enabled != (Supplied[bool]{Value: false, Set: true}) {
|
||||||
|
t.Fatalf("preflight result=%v checked=%#v calls=%d", scopeNames, checked, checkCalls)
|
||||||
|
}
|
||||||
|
if !input.Selection.Config.Set || input.Selection.Config.Value != "" || input.Selection.Overlay.Set {
|
||||||
|
t.Fatalf("configuration selection presence = %#v", input.Selection)
|
||||||
|
}
|
||||||
|
if baseContextCalls != 0 {
|
||||||
|
t.Fatalf("NewContext called during preflight %d times", baseContextCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
contexts := contextsForScopes(normalized, input, scopeNames)
|
||||||
|
if baseContextCalls != 1 {
|
||||||
|
t.Fatalf("NewContext calls = %d, want one", baseContextCalls)
|
||||||
|
}
|
||||||
|
if contexts["app"].Count != (Supplied[int]{Value: 0, Set: true}) || contexts["app"].Enabled != (Supplied[bool]{}) || contexts["app"].Other != (Supplied[int]{}) {
|
||||||
|
t.Fatalf("app selectors = %#v", contexts["app"])
|
||||||
|
}
|
||||||
|
if contexts["promptkit"].Count != (Supplied[int]{}) || contexts["promptkit"].Enabled != (Supplied[bool]{Value: false, Set: true}) || contexts["promptkit"].Runtime != "invocation cache" {
|
||||||
|
t.Fatalf("promptkit selectors = %#v", contexts["promptkit"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPreflightRejectsUnsupportedAggregateAndInapplicableSelector(t *testing.T) {
|
||||||
|
type selectorInput struct{ Region Supplied[string] }
|
||||||
|
newContextCalls := 0
|
||||||
|
suite := ConfigSuite[selectorInput]{
|
||||||
|
NewContext: func() selectorInput { newContextCalls++; return selectorInput{} },
|
||||||
|
Selectors: []Selector[selectorInput]{{Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }), Scopes: []string{"promptkit"}}},
|
||||||
|
Scopes: []ConfigScope[selectorInput]{
|
||||||
|
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
|
||||||
|
{Name: "promptkit", Operations: []ConfigOperation{ConfigShow}, Acquire: noopConfigAcquire[selectorInput]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalized, err := normalizeConfigSuite(suite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prepared, err := prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"all"}, {"text"}, nil, nil, nil}, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := preflightConfig(normalized, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "does not support validate") {
|
||||||
|
t.Fatalf("unsupported aggregate error = %v", err)
|
||||||
|
}
|
||||||
|
if newContextCalls != 0 {
|
||||||
|
t.Fatalf("unsupported aggregate created context %d times", newContextCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err = prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"app"}, {"text"}, nil, nil, {"west"}}, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := preflightConfig(normalized, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "not applicable") {
|
||||||
|
t.Fatalf("inapplicable selector error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func noopConfigAcquire[C any](context.Context, ConfigRequest, C) (Acquired[ConfigView], error) {
|
||||||
|
return Acquired[ConfigView]{}, nil
|
||||||
|
}
|
||||||
19
docs/cli.md
19
docs/cli.md
@@ -83,6 +83,25 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
|
|||||||
false, or zero value is supplied. Help and version are available without
|
false, or zero value is supplied. Help and version are available without
|
||||||
constructing application command inputs.
|
constructing application command inputs.
|
||||||
|
|
||||||
|
## Configuration scopes
|
||||||
|
|
||||||
|
An application installs one configuration suite with `RegisterConfig`. Its
|
||||||
|
`config scopes` command lists scope names and the configuration operations
|
||||||
|
currently installed for those scopes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
example config scopes
|
||||||
|
example config scopes --format yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Text output uses one `scope: operation, operation` line per scope. YAML uses a
|
||||||
|
`scopes` sequence with explicit `name` and `operations` fields. Scope order is
|
||||||
|
`app`, then `promptkit`, then other names alphabetically. A metadata-only scope
|
||||||
|
can be listed without an acquisition callback. Discovery does not construct a
|
||||||
|
runtime context or acquire configuration. It reports the intersection between
|
||||||
|
declared scope capabilities and installed handlers, so an operation is listed
|
||||||
|
only when the corresponding command is available.
|
||||||
|
|
||||||
## Outcome status
|
## Outcome status
|
||||||
|
|
||||||
CommandKit usage failures use status `2`; operational failures use status `1`.
|
CommandKit usage failures use status `2`; operational failures use status `1`.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ CommandKit currently contains adopted repository policies, accepted architectura
|
|||||||
decisions, implementation plans, identity and outcome primitives, typed command
|
decisions, implementation plans, identity and outcome primitives, typed command
|
||||||
declarations, an instance-owned registry, a Cobra-backed routing and parsing
|
declarations, an instance-owned registry, a Cobra-backed routing and parsing
|
||||||
adapter, public `App.Execute` coordination, and generated help/version commands.
|
adapter, public `App.Execute` coordination, and generated help/version commands.
|
||||||
Standard command families remain pending. The
|
Configuration suite registration and scope discovery are implemented; the
|
||||||
|
configuration operations remain pending. The
|
||||||
[README](../README.md) provides project orientation; the
|
[README](../README.md) provides project orientation; the
|
||||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||||
canonical documentation owners.
|
canonical documentation owners.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Initial CommandKit implementation plan
|
# Initial CommandKit implementation plan
|
||||||
|
|
||||||
Status: implementation underway; Stages 1-6 complete and Stages 7-14 pending.
|
Status: implementation underway; Stages 1-7 complete and Stages 8-14 pending.
|
||||||
|
|
||||||
Date: 2026-10-07.
|
Date: 2026-10-07.
|
||||||
|
|
||||||
@@ -448,7 +448,7 @@ implemented boundary.
|
|||||||
|
|
||||||
## Stage 7: implement configuration suites, scopes, and selector preflight
|
## Stage 7: implement configuration suites, scopes, and selector preflight
|
||||||
|
|
||||||
**Status:** pending. **Prerequisite:** Stage 6.
|
**Status:** complete. **Prerequisite:** Stage 6.
|
||||||
|
|
||||||
Add the following root contracts in `config.go`/`selectors.go`:
|
Add the following root contracts in `config.go`/`selectors.go`:
|
||||||
|
|
||||||
@@ -539,6 +539,16 @@ unsupported all before callbacks, explicit/omitted selector applicability,
|
|||||||
typed empty/false/zero context selectors, and factories uninvoked on help/usage.
|
typed empty/false/zero context selectors, and factories uninvoked on help/usage.
|
||||||
Use a small custom scope fixture; owning library helpers arrive next.
|
Use a small custom scope fixture; owning library helpers arrive next.
|
||||||
|
|
||||||
|
**Completion evidence:** added typed configuration suite, scope, selector, view,
|
||||||
|
and request contracts; validates scope IDs, capability metadata, selector
|
||||||
|
bindings, and family collisions. `config scopes` reports installed capability
|
||||||
|
intersections in stable text/YAML inventories without creating a runtime context
|
||||||
|
or acquiring configuration. Added selector preflight and per-scope context
|
||||||
|
projection for later operation handlers, including explicit empty/false/zero
|
||||||
|
values and clearing inapplicable bound fields. `go test ./...` passes; the CLI
|
||||||
|
reference describes scope discovery. Configuration operation handlers remain
|
||||||
|
sequenced in Stages 8-11.
|
||||||
|
|
||||||
## Stage 8: integrate ConfigKit and implement validation
|
## Stage 8: integrate ConfigKit and implement validation
|
||||||
|
|
||||||
**Status:** pending. **Prerequisite:** Stage 7.
|
**Status:** pending. **Prerequisite:** Stage 7.
|
||||||
|
|||||||
198
selectors.go
Normal file
198
selectors.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func configInputFlags[C any](suite ConfigSuite[C], formatDefault string) []Flag[configInput[C]] {
|
||||||
|
scopeDefault := "app"
|
||||||
|
flags := []Flag[configInput[C]]{
|
||||||
|
ValueFlag[configInput[C]](FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &scopeDefault, parseString, func(input *configInput[C], value Supplied[string]) {
|
||||||
|
input.Scope = value.Value
|
||||||
|
}),
|
||||||
|
ValueFlag[configInput[C]](FlagMeta{Name: "format", Usage: "Output format."}, &formatDefault, parseString, func(input *configInput[C], value Supplied[string]) {
|
||||||
|
input.Format = value.Value
|
||||||
|
}),
|
||||||
|
ValueFlag[configInput[C]](FlagMeta{Name: "config", Usage: "Configuration source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
|
||||||
|
input.Selection.Config = value
|
||||||
|
}),
|
||||||
|
ValueFlag[configInput[C]](FlagMeta{Name: "overlay", Usage: "Overlay source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
|
||||||
|
input.Selection.Overlay = value
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
for _, selector := range suite.Selectors {
|
||||||
|
flags = append(flags, adaptSelector(selector))
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
|
||||||
|
binding := selector.Binding.binding
|
||||||
|
meta := binding.meta
|
||||||
|
if len(selector.Scopes) == 0 {
|
||||||
|
meta.Usage += " (applies to all scopes)"
|
||||||
|
} else {
|
||||||
|
meta.Usage += " (applies to: " + joinNames(selector.Scopes) + ")"
|
||||||
|
}
|
||||||
|
return Flag[configInput[C]]{binding: flagBinding[configInput[C]]{
|
||||||
|
meta: meta,
|
||||||
|
defaultText: binding.defaultText,
|
||||||
|
repeated: binding.repeated,
|
||||||
|
boolean: binding.boolean,
|
||||||
|
valid: binding.valid,
|
||||||
|
prepare: func(values []string) (func(*configInput[C]), error) {
|
||||||
|
apply, err := binding.prepare(values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state := selectorState[C]{
|
||||||
|
scopes: append([]string(nil), selector.Scopes...),
|
||||||
|
supplied: len(values) > 0,
|
||||||
|
apply: apply,
|
||||||
|
clear: binding.clear,
|
||||||
|
}
|
||||||
|
return func(input *configInput[C]) {
|
||||||
|
apply(&input.SelectorContext)
|
||||||
|
input.selectors = append(input.selectors, state)
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseString(value string) (string, error) { return value, nil }
|
||||||
|
|
||||||
|
func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
|
||||||
|
if err := checkConfigFormat(input.Format); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.Scope == "all" {
|
||||||
|
if operation != ConfigValidate && operation != ConfigShow {
|
||||||
|
return nil, fmt.Errorf("scope all is supported only for validate and show")
|
||||||
|
}
|
||||||
|
} else if input.Scope == "" {
|
||||||
|
return nil, fmt.Errorf("configuration scope must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes := make([]string, 0, len(suite.Scopes))
|
||||||
|
if input.Scope == "all" {
|
||||||
|
for _, scope := range orderedConfigScopes(suite.Scopes) {
|
||||||
|
scopes = append(scopes, scope.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scope, ok := findConfigScope(suite.Scopes, input.Scope)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown configuration scope %q", input.Scope)
|
||||||
|
}
|
||||||
|
if !containsOperation(scope.Operations, operation) {
|
||||||
|
return nil, unsupportedConfigOperation(scope, operation)
|
||||||
|
}
|
||||||
|
scopes = append(scopes, scope.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Scope == "all" {
|
||||||
|
for _, scopeName := range scopes {
|
||||||
|
scope, _ := findConfigScope(suite.Scopes, scopeName)
|
||||||
|
if !containsOperation(scope.Operations, operation) {
|
||||||
|
return nil, unsupportedConfigOperation(scope, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, selector := range input.selectors {
|
||||||
|
if !selector.supplied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applies := false
|
||||||
|
for _, scopeName := range scopes {
|
||||||
|
if selectorApplies(selector.scopes, scopeName) {
|
||||||
|
applies = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
return nil, fmt.Errorf("selector is not applicable to configuration scope %q", input.Scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if suite.CheckInput != nil {
|
||||||
|
if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scopes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextsForScopes[C any](suite ConfigSuite[C], input configInput[C], scopeNames []string) map[string]C {
|
||||||
|
base := *new(C)
|
||||||
|
if suite.NewContext != nil {
|
||||||
|
base = suite.NewContext()
|
||||||
|
}
|
||||||
|
contexts := make(map[string]C, len(scopeNames))
|
||||||
|
for _, name := range scopeNames {
|
||||||
|
context := base
|
||||||
|
for _, selector := range input.selectors {
|
||||||
|
if selectorApplies(selector.scopes, name) {
|
||||||
|
selector.apply(&context)
|
||||||
|
} else {
|
||||||
|
selector.clear(&context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contexts[name] = context
|
||||||
|
}
|
||||||
|
return contexts
|
||||||
|
}
|
||||||
|
|
||||||
|
func findConfigScope[C any](scopes []ConfigScope[C], name string) (ConfigScope[C], bool) {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope.Name == name {
|
||||||
|
return scope, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ConfigScope[C]{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsOperation(operations []ConfigOperation, requested ConfigOperation) bool {
|
||||||
|
for _, operation := range operations {
|
||||||
|
if operation == requested {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsupportedConfigOperation[C any](scope ConfigScope[C], operation ConfigOperation) error {
|
||||||
|
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.Name, operation, joinOperations(scope.Operations))
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectorApplies(scopes []string, name string) bool {
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinNames(names []string) string {
|
||||||
|
ordered := append([]string(nil), names...)
|
||||||
|
sort.Strings(ordered)
|
||||||
|
return joinStrings(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinOperations(operations []ConfigOperation) string {
|
||||||
|
values := make([]string, len(operations))
|
||||||
|
for index, operation := range operations {
|
||||||
|
values[index] = string(operation)
|
||||||
|
}
|
||||||
|
return joinStrings(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinStrings(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
return strings.Join(values, ", ")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user