package configurator import ( "fmt" "log" "reflect" "strings" "github.com/manifoldco/promptui" ) // EditConfig invokes a user dialog to present and optionally // change the current values in the 'config' structure func EditConfig[T any](config *T) error { return editConfig(config, &promptUiSeamNoop{}, 100) } // editConfig provides a testable version of EditConfig func editConfig[T any](config *T, seam promptUiSeam, maxTimes int) error { loopCounter := 0 for { cfgTagItems, getterErr := GetConfigEnvItems(*config) if getterErr != nil { return getterErr } var promptErr error for _, cti := range cfgTagItems { var result string if cti.Kind == reflect.Bool { prompt := promptui.Select{ Label: cti.Name, Items: []string{"False", "True"}, CursorPos: map[bool]int{false: 0, true: 1}[cti.Val == true], } _, result, promptErr = seam.getSelector(&prompt).Run() } else { prompt := promptui.Prompt{ Label: cti.Name, Default: fmt.Sprintf("%v", cti.Val), AllowEdit: true, } if cti.Secret != "" { prompt.HideEntered = true prompt.AllowEdit = false if cti.Secret == "mask" { prompt.Mask = '*' } } result, promptErr = seam.getPrompter(&prompt).Run() } if promptErr != nil { return promptErr } if setErr := SetConfigEnvItem(config, cti.Name, result); setErr != nil { log.Printf("NOTE: while setting item: %v\n", setErr) } } prompt := promptui.Prompt{ Label: "Done", Default: "n", IsConfirm: true, } var isDone string isDone, promptErr = seam.getPrompter(&prompt).Run() if strings.ToLower(strings.Trim(isDone, " \t")) == "y" { break } loopCounter++ if loopCounter >= maxTimes { return fmt.Errorf("too many edit attempts(%d)", loopCounter) } } return nil } type promptRunner interface { Run() (string, error) } type selectRunner interface { Run() (int, string, error) } type promptUiSeam interface { getPrompter(pr promptRunner) promptRunner getSelector(sr selectRunner) selectRunner } type promptUiSeamNoop struct{} func (*promptUiSeamNoop) getPrompter(pr promptRunner) promptRunner { return pr } func (*promptUiSeamNoop) getSelector(sr selectRunner) selectRunner { return sr }
package configurator import ( "fmt" "reflect" "strings" ) // ConfigEnvItem contains properties related to a tagged environment item found within a passed structure type ConfigEnvItem struct { Name string Val any Secret string Kind reflect.Kind } const envTagKey = "env" // GetConfigEnvItems gets a list of 'ConfigEnvItem' values from 'config' // elements tagged as environment items. See https://go.dev/blog/laws-of-reflection func GetConfigEnvItems[T any](config T) ([]ConfigEnvItem, error) { cfgStructType, cfgStructElements, getConfigInfoErr := getConfigStructInfo(&config) if getConfigInfoErr != nil { return nil, getConfigInfoErr } var cfgTagItems []ConfigEnvItem for fieldIndex := 0; fieldIndex < cfgStructType.NumField(); fieldIndex++ { cfgStructFieldTag := cfgStructType.Field(fieldIndex).Tag var ok bool var cfgStructFieldEnvTagValue string if cfgStructFieldEnvTagValue, ok = cfgStructFieldTag.Lookup(envTagKey); !ok { continue } tagParts := strings.Split(cfgStructFieldEnvTagValue, ",") if len(tagParts) == 0 { continue } cfgStructFieldElement := cfgStructElements.Field(fieldIndex) if !cfgStructFieldElement.CanInterface() { // e.g., private visibility continue } envItem := ConfigEnvItem{Name: tagParts[0], Kind: cfgStructFieldElement.Kind()} if secretTagVal, okS := cfgStructFieldTag.Lookup("secret"); okS { envItem.Secret = secretTagVal } envItem.Val = reflect.ValueOf(cfgStructFieldElement.Interface()).Interface() cfgTagItems = append(cfgTagItems, envItem) } return cfgTagItems, nil } func getConfigStructInfo[T any](config *T) (reflect.Type, reflect.Value, error) { cfgStructType := reflect.TypeOf(*config) cfgStructElements := reflect.ValueOf(config).Elem() if cfgStructElements.Kind() == reflect.Interface { cfgStructElements = cfgStructElements.Elem() } if cfgStructElements.Kind() != reflect.Struct { return nil, reflect.Value{}, fmt.Errorf("unsupported config kind(%d)", cfgStructElements.Kind()) } return cfgStructType, cfgStructElements, nil }
package configurator import ( "context" "log" "github.com/joho/godotenv" "github.com/sethvargo/go-envconfig" ) // LoadConfig loads uninitialized configuration values from the environment or from // 'configFile', applying the defaults as specified in the 'config' structure's tags. // Upon successful return, all environment values on publicly accessible, supported // properties of the 'config' structure are loaded both into the config structure // and into the environment. func LoadConfig[T any](configFile string, config *T) error { if err := godotenv.Load(configFile); err != nil { log.Printf("NOTE: ignored %v\n", err) } ctx := context.Background() if err := envconfig.Process(ctx, config); err != nil { return err } return nil }
package configurator import ( "fmt" "io" "log" "os" "sort" ) // SaveConfig saves the current 'config' values into 'configFile', and // updates the values of the corresponding environment variables. func SaveConfig[T any](configFileName string, config T) error { envItems, getterErr := GetConfigEnvItems(config) if getterErr != nil { return getterErr } configMap := make(map[string]any, len(envItems)) for _, envItem := range envItems { configMap[envItem.Name] = envItem.Val } return SaveConfigMap(configFileName, configMap) } // SaveConfigMap saves the map of environment name: environment value entries into 'configFile'. func SaveConfigMap(configFileName string, configMap map[string]any) error { configFile, openErr := os.OpenFile(configFileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if openErr != nil { return openErr } defer func() { if closeErr := configFile.Close(); closeErr != nil { log.Printf("NOTE: error closing %s: %v\n", configFileName, closeErr) } }() return updateConfigFromMap(configFile, configMap) } // updateConfigFromMap updates both the written configuration and the environment // to match the contents of the supplied map containing all configuration entries. // Configuration entries with nil values will be removed from both targets. NOTE: // no transactional guarantees are provided; if an error is returned, partial // update(s) may have been made. func updateConfigFromMap(truncatedConfigFile io.Writer, fullConfigMap map[string]any) error { sortedEnvVarNames := make([]string, len(fullConfigMap)) for envVarName := range fullConfigMap { sortedEnvVarNames = append(sortedEnvVarNames, envVarName) } sort.Strings(sortedEnvVarNames) // write the new configuration entries for _, envVarName := range sortedEnvVarNames { envVal := fullConfigMap[envVarName] if envVal == nil { continue } if _, printErr := fmt.Fprintf(truncatedConfigFile, "%s=%v\n", envVarName, envVal); printErr != nil { return printErr } } // update the environment cantUpdateVars := make(map[string][]string) for _, envVarName := range sortedEnvVarNames { envVal := fullConfigMap[envVarName] if envVal == nil { _, found := os.LookupEnv(envVarName) if found { if unSetEnvErr := os.Unsetenv(envVarName); unSetEnvErr != nil { cantUpdateVars[unSetEnvErr.Error()] = append(cantUpdateVars["unset "+unSetEnvErr.Error()], envVarName) } } continue } if setEnvErr := os.Setenv(envVarName, fmt.Sprintf("%v", envVal)); setEnvErr != nil { cantUpdateVars[setEnvErr.Error()] = append(cantUpdateVars["set "+setEnvErr.Error()], envVarName) } } if len(cantUpdateVars) != 0 { return fmt.Errorf("couldn't update environment variable(s): %s", cantUpdateVars) } return nil }
package configurator import ( "fmt" "reflect" "strconv" "strings" ) // SetConfigEnvItem allows setting in-place config values by the Name of their corresponding environment variable. // See https://go.dev/blog/laws-of-reflection and https://research.swtch.com/interfaces func SetConfigEnvItem[T any](config *T, envName, newValueAsString string) error { cfgStructType, cfgStructElements, getConfigInfoErr := getConfigStructInfo(config) if getConfigInfoErr != nil { return getConfigInfoErr } var isSet bool for fieldIndex := 0; fieldIndex < cfgStructType.NumField(); fieldIndex++ { cfgStructFieldTag := cfgStructType.Field(fieldIndex).Tag var ok bool var cfgStructFieldEnvTagValue string if cfgStructFieldEnvTagValue, ok = cfgStructFieldTag.Lookup(envTagKey); !ok { continue } tagParts := strings.Split(cfgStructFieldEnvTagValue, ",") if len(tagParts) == 0 || envName != tagParts[0] { continue } cfgStructFieldElement := cfgStructElements.Field(fieldIndex) if !cfgStructFieldElement.CanSet() { return fmt.Errorf("can't set(%s); not settable", envName) } cfgStructFieldElementKind := cfgStructFieldElement.Kind() switch cfgStructFieldElementKind { case reflect.String: cfgStructFieldElement.SetString(newValueAsString) isSet = true case reflect.Bool: parseBool, parseBoolErr := strconv.ParseBool(newValueAsString) if parseBoolErr != nil { return parseBoolErr } cfgStructFieldElement.SetBool(parseBool) isSet = true case reflect.Float64, reflect.Float32: parseFloat, parseFloatErr := strconv.ParseFloat(newValueAsString, map[reflect.Kind]int{reflect.Float64: 64, reflect.Float32: 32}[cfgStructFieldElementKind]) if parseFloatErr != nil { return parseFloatErr } cfgStructFieldElement.SetFloat(parseFloat) isSet = true case reflect.Int, reflect.Int64, reflect.Int32, reflect.Int16, reflect.Int8: parseInt, parseIntErr := strconv.ParseInt(newValueAsString, 10, map[reflect.Kind]int{reflect.Int: strconv.IntSize, reflect.Int64: 64, reflect.Int32: 32, reflect.Int16: 16, reflect.Int8: 8}[cfgStructFieldElementKind]) if parseIntErr != nil { return parseIntErr } cfgStructFieldElement.SetInt(parseInt) isSet = true case reflect.Uint, reflect.Uint64, reflect.Uint32, reflect.Uint16, reflect.Uint8: parseUint, parseUintErr := strconv.ParseUint(newValueAsString, 10, map[reflect.Kind]int{reflect.Uint: strconv.IntSize, reflect.Uint64: 64, reflect.Uint32: 32, reflect.Uint16: 16, reflect.Uint8: 8}[cfgStructFieldElementKind]) if parseUintErr != nil { return parseUintErr } cfgStructFieldElement.SetUint(parseUint) isSet = true default: return fmt.Errorf("unrecognized Kind(%v)", cfgStructFieldElementKind) } break } if !isSet { return fmt.Errorf("env value(%s) wan't set; not found", envName) } return nil }