Add configuration scope suite registration
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user