325 lines
10 KiB
Go
325 lines
10 KiB
Go
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
|
|
}
|