diff --git a/cli/cmdx/cmdx.go b/cli/cmdx/cmdx.go index 3bccfe6..5a2a0e0 100644 --- a/cli/cmdx/cmdx.go +++ b/cli/cmdx/cmdx.go @@ -6,8 +6,8 @@ import ( "github.com/spf13/cobra" ) -// Manager manages and configures features for a CLI tool. -type Manager struct { +// Commander manages and configures features for a CLI tool. +type Commander struct { RootCmd *cobra.Command Help bool // Enable custom help. Reference bool // Enable reference command. @@ -32,19 +32,19 @@ type HookBehavior struct { Behavior func(cmd *cobra.Command) // Function to apply to commands. } -// NewManager creates a new CLI Manager using the provided root command and optional configurations. +// NewCommander creates a new CLI Commander using the provided root command and optional configurations. // // Parameters: // - rootCmd: The root Cobra command for the CLI. -// - options: Functional options for configuring the Manager. +// - options: Functional options for configuring the Commander. // // Example: // // rootCmd := &cobra.Command{Use: "mycli"} -// manager := cmdx.NewManager(rootCmd, cmdx.WithTopics(...), cmdx.WithHooks(...)) -func NewManager(rootCmd *cobra.Command, options ...func(*Manager)) *Manager { - // Create Manager with defaults - manager := &Manager{ +// manager := cmdx.NewCommander(rootCmd, cmdx.WithTopics(...), cmdx.WithHooks(...)) +func NewCommander(rootCmd *cobra.Command, options ...func(*Commander)) *Commander { + // Create Commander with defaults + manager := &Commander{ RootCmd: rootCmd, Help: true, // Default enabled Reference: true, // Default enabled @@ -62,11 +62,11 @@ func NewManager(rootCmd *cobra.Command, options ...func(*Manager)) *Manager { return manager } -// Init sets up the CLI features based on the Manager's configuration. +// Init sets up the CLI features based on the Commander's configuration. // // It enables or disables features like custom help, reference documentation, -// shell completion, help topics, and client hooks based on the Manager's settings. -func (m *Manager) Init() { +// shell completion, help topics, and client hooks based on the Commander's settings. +func (m *Commander) Init() { if m.Help { m.SetCustomHelp() } @@ -88,16 +88,16 @@ func (m *Manager) Init() { } } -// WithTopics sets the help topics for the Manager. -func WithTopics(topics []HelpTopic) func(*Manager) { - return func(m *Manager) { +// WithTopics sets the help topics for the Commander. +func WithTopics(topics []HelpTopic) func(*Commander) { + return func(m *Commander) { m.Topics = topics } } -// WithHooks sets the hook behaviors for the Manager. -func WithHooks(hooks []HookBehavior) func(*Manager) { - return func(m *Manager) { +// WithHooks sets the hook behaviors for the Commander. +func WithHooks(hooks []HookBehavior) func(*Commander) { + return func(m *Commander) { m.Hooks = hooks } } diff --git a/cli/cmdx/completion.go b/cli/cmdx/completion.go index 581d473..d6a12dd 100644 --- a/cli/cmdx/completion.go +++ b/cli/cmdx/completion.go @@ -14,14 +14,14 @@ import ( // // Example: // -// manager := cmdx.NewManager(rootCmd) +// manager := cmdx.NewCommander(rootCmd) // manager.AddCompletionCommand() // // Usage: // // $ mycli completion bash // $ mycli completion zsh -func (m *Manager) AddCompletionCommand() { +func (m *Commander) AddCompletionCommand() { summary := m.generateCompletionSummary(m.RootCmd.Use) completionCmd := &cobra.Command{ @@ -38,7 +38,7 @@ func (m *Manager) AddCompletionCommand() { } // runCompletionCommand executes the appropriate shell completion generation logic. -func (m *Manager) runCompletionCommand(cmd *cobra.Command, args []string) { +func (m *Commander) runCompletionCommand(cmd *cobra.Command, args []string) { switch args[0] { case "bash": cmd.Root().GenBashCompletion(os.Stdout) @@ -52,7 +52,7 @@ func (m *Manager) runCompletionCommand(cmd *cobra.Command, args []string) { } // generateCompletionSummary creates the long description for the `completion` command. -func (m *Manager) generateCompletionSummary(exec string) string { +func (m *Commander) generateCompletionSummary(exec string) string { var execs []interface{} for i := 0; i < 12; i++ { execs = append(execs, exec) diff --git a/cli/cmdx/doc.go b/cli/cmdx/doc.go index 7ca995e..f5e60f5 100644 --- a/cli/cmdx/doc.go +++ b/cli/cmdx/doc.go @@ -59,8 +59,8 @@ // }, // } // -// // Create the Manager with configurations -// manager := cmdx.NewManager( +// // Create the Commander with configurations +// manager := cmdx.NewCommander( // rootCmd, // cmdx.WithTopics(helpTopics), // cmdx.WithHooks(hooks), diff --git a/cli/cmdx/help.go b/cli/cmdx/help.go index ae3efde..b738dd3 100644 --- a/cli/cmdx/help.go +++ b/cli/cmdx/help.go @@ -11,17 +11,17 @@ import ( // Section Titles for Help Output const ( - USAGE = "Usage" - CORECMD = "Core commands" - OTHERCMD = "Other commands" - HELPCMD = "Help topics" - FLAGS = "Flags" - IFLAGS = "Inherited flags" - ARGUMENTS = "Arguments" - EXAMPLES = "Examples" - ENVS = "Environment variables" - LEARN = "Learn more" - FEEDBACK = "Feedback" + usage = "Usage" + corecmd = "Core commands" + othercmd = "Other commands" + helpcmd = "Help topics" + flags = "Flags" + iflags = "Inherited flags" + arguments = "Arguments" + examples = "Examples" + envs = "Environment variables" + learn = "Learn more" + feedback = "Feedback" ) // SetCustomHelp configures a custom help function for the CLI. @@ -31,9 +31,9 @@ const ( // // Example: // -// manager := cmdx.NewManager(rootCmd) +// manager := cmdx.NewCommander(rootCmd) // manager.SetCustomHelp() -func (m *Manager) SetCustomHelp() { +func (m *Commander) SetCustomHelp() { m.RootCmd.PersistentFlags().Bool("help", false, "Show help for command") m.RootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { @@ -120,39 +120,39 @@ func buildHelpEntries(cmd *cobra.Command) []helpEntry { helpEntries = append(helpEntries, helpEntry{"", text}) } - helpEntries = append(helpEntries, helpEntry{USAGE, cmd.UseLine()}) + helpEntries = append(helpEntries, helpEntry{usage, cmd.UseLine()}) if len(coreCommands) > 0 { - helpEntries = append(helpEntries, helpEntry{CORECMD, strings.Join(coreCommands, "\n")}) + helpEntries = append(helpEntries, helpEntry{corecmd, strings.Join(coreCommands, "\n")}) } for group, cmds := range groupCommands { helpEntries = append(helpEntries, helpEntry{fmt.Sprintf("%s commands", toTitle(group)), strings.Join(cmds, "\n")}) } if len(otherCommands) > 0 { - helpEntries = append(helpEntries, helpEntry{OTHERCMD, strings.Join(otherCommands, "\n")}) + helpEntries = append(helpEntries, helpEntry{othercmd, strings.Join(otherCommands, "\n")}) } if len(helpCommands) > 0 { - helpEntries = append(helpEntries, helpEntry{HELPCMD, strings.Join(helpCommands, "\n")}) + helpEntries = append(helpEntries, helpEntry{helpcmd, strings.Join(helpCommands, "\n")}) } if flagUsages := cmd.LocalFlags().FlagUsages(); flagUsages != "" { - helpEntries = append(helpEntries, helpEntry{FLAGS, dedent(flagUsages)}) + helpEntries = append(helpEntries, helpEntry{flags, dedent(flagUsages)}) } if inheritedFlagUsages := cmd.InheritedFlags().FlagUsages(); inheritedFlagUsages != "" { - helpEntries = append(helpEntries, helpEntry{IFLAGS, dedent(inheritedFlagUsages)}) + helpEntries = append(helpEntries, helpEntry{iflags, dedent(inheritedFlagUsages)}) } if argsAnnotation, ok := cmd.Annotations["help:arguments"]; ok { - helpEntries = append(helpEntries, helpEntry{ARGUMENTS, argsAnnotation}) + helpEntries = append(helpEntries, helpEntry{arguments, argsAnnotation}) } if cmd.Example != "" { - helpEntries = append(helpEntries, helpEntry{EXAMPLES, cmd.Example}) + helpEntries = append(helpEntries, helpEntry{examples, cmd.Example}) } if argsAnnotation, ok := cmd.Annotations["help:environment"]; ok { - helpEntries = append(helpEntries, helpEntry{ENVS, argsAnnotation}) + helpEntries = append(helpEntries, helpEntry{envs, argsAnnotation}) } if argsAnnotation, ok := cmd.Annotations["help:learn"]; ok { - helpEntries = append(helpEntries, helpEntry{LEARN, argsAnnotation}) + helpEntries = append(helpEntries, helpEntry{learn, argsAnnotation}) } if argsAnnotation, ok := cmd.Annotations["help:feedback"]; ok { - helpEntries = append(helpEntries, helpEntry{FEEDBACK, argsAnnotation}) + helpEntries = append(helpEntries, helpEntry{feedback, argsAnnotation}) } return helpEntries } diff --git a/cli/cmdx/hooks.go b/cli/cmdx/hooks.go index 7524a08..1182a7e 100644 --- a/cli/cmdx/hooks.go +++ b/cli/cmdx/hooks.go @@ -1,7 +1,7 @@ package cmdx // AddClientHooks applies all configured hooks to commands annotated with `client:true`. -func (m *Manager) AddClientHooks() { +func (m *Commander) AddClientHooks() { for _, cmd := range m.RootCmd.Commands() { for _, hook := range m.Hooks { if cmd.Annotations["client"] == "true" { diff --git a/cli/cmdx/markdown.go b/cli/cmdx/markdown.go index 56d9332..73d9eda 100644 --- a/cli/cmdx/markdown.go +++ b/cli/cmdx/markdown.go @@ -11,7 +11,7 @@ import ( // AddMarkdownCommand integrates a hidden `markdown` command into the root command. // This command generates a Markdown documentation tree for all commands in the hierarchy. -func (m *Manager) AddMarkdownCommand(outputPath string) { +func (m *Commander) AddMarkdownCommand(outputPath string) { markdownCmd := &cobra.Command{ Use: "markdown", Short: "Generate Markdown documentation for all commands", @@ -35,7 +35,7 @@ func (m *Manager) AddMarkdownCommand(outputPath string) { // // Returns: // - An error if any part of the process (file creation, directory creation) fails. -func (m *Manager) generateMarkdownTree(rootOutputPath string, cmd *cobra.Command) error { +func (m *Commander) generateMarkdownTree(rootOutputPath string, cmd *cobra.Command) error { dirFilePath := filepath.Join(rootOutputPath, cmd.Name()) // Handle subcommands by creating a directory and iterating through subcommands. diff --git a/cli/cmdx/reference.go b/cli/cmdx/reference.go index 3e3ec91..a537c08 100644 --- a/cli/cmdx/reference.go +++ b/cli/cmdx/reference.go @@ -18,9 +18,9 @@ import ( // // Example: // -// manager := cmdx.NewManager(rootCmd) +// manager := cmdx.NewCommander(rootCmd) // manager.AddReferenceCommand() -func (m *Manager) AddReferenceCommand() { +func (m *Commander) AddReferenceCommand() { var isPlain bool refCmd := &cobra.Command{ Use: "reference", @@ -39,7 +39,7 @@ func (m *Manager) AddReferenceCommand() { // runReferenceCommand handles the output generation for the `reference` command. // It renders the documentation either as plain markdown or with ANSI color. -func (m *Manager) runReferenceCommand(isPlain *bool) func(cmd *cobra.Command, args []string) { +func (m *Commander) runReferenceCommand(isPlain *bool) func(cmd *cobra.Command, args []string) { return func(cmd *cobra.Command, args []string) { var ( output string @@ -62,7 +62,7 @@ func (m *Manager) runReferenceCommand(isPlain *bool) func(cmd *cobra.Command, ar // generateReferenceMarkdown generates a complete markdown representation // of the command tree for the `reference` command. -func (m *Manager) generateReferenceMarkdown() string { +func (m *Commander) generateReferenceMarkdown() string { buf := bytes.NewBufferString(fmt.Sprintf("# %s reference\n\n", m.RootCmd.Name())) for _, c := range m.RootCmd.Commands() { if c.Hidden { @@ -75,7 +75,7 @@ func (m *Manager) generateReferenceMarkdown() string { // generateCommandReference recursively generates markdown for a given command // and its subcommands. -func (m *Manager) generateCommandReference(w io.Writer, cmd *cobra.Command, depth int) { +func (m *Commander) generateCommandReference(w io.Writer, cmd *cobra.Command, depth int) { // Name + Description fmt.Fprintf(w, "%s `%s`\n\n", strings.Repeat("#", depth), cmd.UseLine()) fmt.Fprintf(w, "%s\n\n", cmd.Short) diff --git a/cli/cmdx/topics.go b/cli/cmdx/topics.go index df67561..2901f58 100644 --- a/cli/cmdx/topics.go +++ b/cli/cmdx/topics.go @@ -10,14 +10,14 @@ import ( // // Help topics provide detailed information about specific subjects, // such as environment variables or configuration. -func (m *Manager) AddHelpTopics() { +func (m *Commander) AddHelpTopics() { for _, topic := range m.Topics { m.addHelpTopicCommand(topic) } } // addHelpTopicCommand adds a single help topic command to the CLI. -func (m *Manager) addHelpTopicCommand(topic HelpTopic) { +func (m *Commander) addHelpTopicCommand(topic HelpTopic) { helpCmd := &cobra.Command{ Use: topic.Name, Short: topic.Short, diff --git a/cli/config/cmd.go b/cli/config/commands.go similarity index 100% rename from cli/config/cmd.go rename to cli/config/commands.go diff --git a/cli/config/config.go b/cli/config/config.go index 22c3226..1ce89c3 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -46,10 +46,10 @@ func WithFlags(pfs *pflag.FlagSet) Opts { // Load reads the configuration file into the Config's Data map. func (c *Config) Load(cfg interface{}) error { - loaderOpts := []config.LoaderOption{config.WithFile(c.path)} + loaderOpts := []config.Option{config.WithFile(c.path)} if c.flags != nil { - loaderOpts = append(loaderOpts, config.WithBindPFlags(c.flags, cfg)) + loaderOpts = append(loaderOpts, config.WithFlags(c.flags)) } loader := config.NewLoader(loaderOpts...) diff --git a/config/README.md b/config/README.md deleted file mode 100644 index cafa450..0000000 --- a/config/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# Config Package - -The `config` package simplifies configuration management in Go projects by integrating multiple sources (files, environment variables, flags) and decoding them into structured Go objects. It provides defaults, overrides, and extensibility for various use cases. - -## Features - -- **Flexible Sources**: Load configuration from files (YAML, JSON, etc.), environment variables, and command-line flags. -- **Defaults and Overrides**: Apply default values, with support for environment variable and flag-based overrides. -- **Powerful Decoding**: Decode nested structures, custom types, and JSON strings into Go structs. -- **Customizable**: Configure behavior with options like file paths, environment variable prefixes, and key replacers. - -## Installation - -Install the package using: - -```bash -go get github.com/raystack/salt/config -``` - -## Usage - -### 1. Basic Configuration Loading - -Define your configuration struct: - -```go -type Config struct { - Host string `yaml:"host" default:"localhost"` - Port int `yaml:"port" default:"8080"` -} -``` - -Load the configuration: - -```go -package main - -import ( - "fmt" - "github.com/raystack/salt/config" -) - -func main() { - var cfg Config - loader := config.NewLoader( - config.WithFile("config.yaml"), - config.WithEnvPrefix("MYAPP"), - ) - if err := loader.Load(&cfg); err != nil { - fmt.Println("Error loading configuration:", err) - return - } - fmt.Printf("Configuration: %+v -", cfg) -} -``` - -### 2. Using Command-Line Flags - -Define your flags and bind them to the configuration struct: - -```go -import ( - "github.com/spf13/pflag" - "github.com/yourusername/config" -) - -type Config struct { - Host string `yaml:"host" cmdx:"host"` - Port int `yaml:"port" cmdx:"port"` -} - -func main() { - var cfg Config - - pflags := pflag.NewFlagSet("example", pflag.ExitOnError) - pflags.String("host", "localhost", "Server host") - pflags.Int("port", 8080, "Server port") - pflags.Parse([]string{"--host", "127.0.0.1", "--port", "9090"}) - - loader := config.NewLoader( - config.WithFile("config.yaml"), - config.WithBindPFlags(pflags, &cfg), - ) - if err := loader.Load(&cfg); err != nil { - fmt.Println("Error loading configuration:", err) - return - } - fmt.Printf("Configuration: %+v -", cfg) -} -``` - -### 3. Environment Variable Overrides - -Override configuration values using environment variables: - -```go -loader := config.NewLoader( - config.WithEnvPrefix("MYAPP"), -) -``` - -Set environment variables like `MYAPP_HOST` or `MYAPP_PORT` to override `host` and `port` values. - -## Advanced Features - -- **Custom Decode Hooks**: Parse custom formats like JSON strings into maps. -- **Error Handling**: Handles missing files gracefully and provides detailed error messages. -- **Multiple Config Paths**: Search for configuration files in multiple directories using `WithPath`. - -## API Reference - -### Loader Options - -- `WithFile(file string)`: Set the explicit file path for the configuration file. -- `WithPath(path string)`: Add directories to search for configuration files. -- `WithName(name string)`: Set the name of the configuration file (without extension). -- `WithType(type string)`: Set the file type (e.g., "json", "yaml"). -- `WithEnvPrefix(prefix string)`: Set a prefix for environment variables. -- `WithBindPFlags(flagSet *pflag.FlagSet, config interface{})`: Bind CLI flags to configuration fields. - -### Custom Hooks - -- `StringToJsonFunc()`: Decode JSON strings into maps or other structures. - -### Struct Tags - -- `yaml`: Maps struct fields to YAML keys. -- `default`: Specifies default values for struct fields. -- `cmdx`: Binds struct fields to command-line flags. diff --git a/config/config.go b/config/config.go index 25b595d..51b1588 100644 --- a/config/config.go +++ b/config/config.go @@ -4,163 +4,166 @@ import ( "encoding/json" "errors" "fmt" - "io/fs" + "os" "reflect" "strings" + "github.com/go-playground/validator" "github.com/jeremywohl/flatten" "github.com/mcuadros/go-defaults" "github.com/mitchellh/mapstructure" "github.com/spf13/pflag" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) -// FileNotFoundError indicates that the configuration file was not found. -// In this case, Viper will attempt to load configurations from environment variables or defaults. -type FileNotFoundError struct { - Err error -} - -// Error returns the error message for ConfigFileNotFoundError. -func (e FileNotFoundError) Error() string { - return fmt.Sprintf("config file not found, falling back to environment and defaults: %v", e.Err) -} - -// Unwrap provides compatibility for error unwrapping. -func (e FileNotFoundError) Unwrap() error { - return e.Err -} - -// Loader is responsible for managing configuration loading and decoding. +// Loader is responsible for managing configuration type Loader struct { - viperInstance *viper.Viper - decoderOpts []viper.DecoderConfigOption -} + v *viper.Viper + flags *pflag.FlagSet +} + +// Option defines a functional option for configuring the Loader. +type Option func(c *Loader) + +// NewLoader creates a new Loader instance with the provided options. +// It initializes Viper with defaults for YAML configuration files and environment variable handling. +// +// Example: +// +// loader := config.NewLoader( +// config.WithFile("./config.yaml"), +// config.WithEnvPrefix("MYAPP"), +// ) +func NewLoader(options ...Option) *Loader { + v := viper.New() -// LoaderOption defines a functional option for configuring a Loader instance. -type LoaderOption func(*Loader) + v.SetConfigName("config") + v.SetConfigType("yaml") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() -// WithViper allows using a custom Viper instance. -func WithViper(v *viper.Viper) LoaderOption { - return func(l *Loader) { - l.viperInstance = v + loader := &Loader{v: v} + for _, opt := range options { + opt(loader) } + return loader } -// WithFile specifies an explicit configuration file path. -func WithFile(file string) LoaderOption { +// WithFile specifies the configuration file to use. +func WithFile(configFilePath string) Option { return func(l *Loader) { - l.viperInstance.SetConfigFile(file) + l.v.SetConfigFile(configFilePath) } } -// WithName sets the base name of the configuration file (excluding extension). -func WithName(name string) LoaderOption { +// WithEnvPrefix specifies a prefix for ENV variables. +func WithEnvPrefix(prefix string) Option { return func(l *Loader) { - l.viperInstance.SetConfigName(name) + l.v.SetEnvPrefix(prefix) } } -// WithPath adds a directory to search for the configuration file. -// Can be called multiple times to add multiple paths. -func WithPath(path string) LoaderOption { +// WithFlags specifies a command-line flag set to bind dynamically based on `cmdx` tags. +func WithFlags(flags *pflag.FlagSet) Option { return func(l *Loader) { - l.viperInstance.AddConfigPath(path) + l.flags = flags } } -// WithType specifies the configuration file format (e.g., "yaml", "json"). -func WithType(fileType string) LoaderOption { - return func(l *Loader) { - l.viperInstance.SetConfigType(fileType) +// Load reads the configuration from the file, environment variables, and command-line flags, +// and merges them into the provided configuration struct. It validates the configuration +// using struct tags. +// +// The priority order is: +// 1. Command-line flags +// 2. Environment variables +// 3. Configuration file +// 4. Default values +func (l *Loader) Load(config interface{}) error { + if err := validateStructPtr(config); err != nil { + return err } -} -// WithBindPFlags binds command-line flags to the configuration based on struct tags (`cmdx`). -func WithBindPFlags(flagSet *pflag.FlagSet, config interface{}) LoaderOption { - return func(l *Loader) { - structType := reflect.TypeOf(config).Elem() - for i := 0; i < structType.NumField(); i++ { - if tag := structType.Field(i).Tag.Get("cmdx"); tag != "" { - l.viperInstance.BindPFlag(tag, flagSet.Lookup(tag)) - } + // Apply default values before reading configuration + defaults.SetDefaults(config) + + // Bind flags dynamically using reflection on `cmdx` tags if a flag set is provided + if l.flags != nil { + if err := bindFlags(l.v, l.flags, reflect.TypeOf(config).Elem(), ""); err != nil { + return fmt.Errorf("failed to bind flags: %w", err) } } -} -// WithEnvPrefix sets a prefix for environment variable keys. -func WithEnvPrefix(prefix string) LoaderOption { - return func(l *Loader) { - l.viperInstance.SetEnvPrefix(prefix) + // Bind environment variables for all keys in the config + keys, err := extractFlattenedKeys(config) + if err != nil { + return fmt.Errorf("failed to extract config keys: %w", err) } -} - -// WithEnvKeyReplacer customizes key transformation for environment variables. -func WithEnvKeyReplacer(old, new string) LoaderOption { - return func(l *Loader) { - l.viperInstance.SetEnvKeyReplacer(strings.NewReplacer(old, new)) + for _, key := range keys { + if err := l.v.BindEnv(key); err != nil { + return fmt.Errorf("failed to bind environment variable for key %q: %w", key, err) + } } -} -// WithDecoderConfigOption sets custom decoding options for the configuration loader. -func WithDecoderConfigOption(opts ...viper.DecoderConfigOption) LoaderOption { - return func(l *Loader) { - l.decoderOpts = append(l.decoderOpts, opts...) + // Attempt to read the configuration file + if err := l.v.ReadInConfig(); err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + if errors.As(err, &configFileNotFoundError) { + fmt.Println("Warning: Config file not found. Falling back to defaults and environment variables.") + } } -} -// NewLoader initializes a Loader instance with the specified options. -func NewLoader(options ...LoaderOption) *Loader { - loader := &Loader{ - viperInstance: defaultViperInstance(), - decoderOpts: []viper.DecoderConfigOption{ - viper.DecodeHook( - mapstructure.ComposeDecodeHookFunc( - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToSliceHookFunc(","), - StringToJsonFunc(), - ), - ), - }, + // Unmarshal the merged configuration into the provided struct + if err := l.v.Unmarshal(config); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) } - for _, opt := range options { - opt(loader) + + // Validate the resulting configuration + if err := validator.New().Struct(config); err != nil { + return fmt.Errorf("invalid configuration: %w", err) } - return loader + + return nil } -// Load populates the provided config struct with values from the configuration sources. -func (l *Loader) Load(config interface{}) error { - if err := validateStructPtr(config); err != nil { - return err - } +// Get retrieves a configuration value by key. +func (l *Loader) Get(key string) interface{} { + return l.v.Get(key) +} - l.viperInstance.AutomaticEnv() +// Set updates a configuration value in memory (not persisted to file). +func (l *Loader) Set(key string, value interface{}) { + l.v.Set(key, value) +} - if err := l.viperInstance.ReadInConfig(); err != nil { - var pathErr *fs.PathError - if errors.As(err, &pathErr) || errors.As(err, &viper.ConfigFileNotFoundError{}) { - return FileNotFoundError{Err: err} - } - return fmt.Errorf("failed to read config file: %w", err) +// Save writes the current configuration to the file specified during initialization. +func (l *Loader) Save() error { + configFile := l.v.ConfigFileUsed() + if configFile == "" { + return errors.New("no configuration file specified for saving") } - keys, err := extractFlattenedKeys(config) + settings := l.v.AllSettings() + content, err := yaml.Marshal(settings) if err != nil { - return fmt.Errorf("failed to extract config keys: %w", err) + return fmt.Errorf("failed to marshal configuration: %w", err) } - for _, key := range keys { - l.viperInstance.BindEnv(key) + if err := os.WriteFile(configFile, content, 0644); err != nil { + return fmt.Errorf("failed to write configuration to file: %w", err) } + return nil +} - defaults.SetDefaults(config) - - if err := l.viperInstance.Unmarshal(config, l.decoderOpts...); err != nil { - return fmt.Errorf("failed to unmarshal config: %w", err) +// View returns the current configuration as a formatted JSON string. +func (l *Loader) View() (string, error) { + settings := l.v.AllSettings() + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return "", fmt.Errorf("failed to format configuration as JSON: %w", err) } - - return nil + return string(data), nil } // validateStructPtr ensures the provided value is a pointer to a struct. @@ -172,15 +175,6 @@ func validateStructPtr(value interface{}) error { return nil } -// defaultViperInstance initializes a Viper instance with default settings. -func defaultViperInstance() *viper.Viper { - v := viper.New() - v.SetConfigName("config") - v.SetConfigType("yaml") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - return v -} - // extractFlattenedKeys retrieves all keys from the struct in a flattened format. func extractFlattenedKeys(config interface{}) ([]string, error) { var structMap map[string]interface{} @@ -198,15 +192,33 @@ func extractFlattenedKeys(config interface{}) ([]string, error) { return keys, nil } -// StringToJsonFunc is a decode hook for parsing JSON strings into maps. -func StringToJsonFunc() mapstructure.DecodeHookFunc { - return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) { - if f.Kind() == reflect.String && t.Kind() == reflect.Map { - var result map[string]interface{} - if err := json.Unmarshal([]byte(data.(string)), &result); err == nil { - return result, nil +// bindFlags dynamically binds flags to configuration fields based on `cmdx` tags. +func bindFlags(v *viper.Viper, flagSet *pflag.FlagSet, structType reflect.Type, parentKey string) error { + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + tag := field.Tag.Get("cmdx") + if tag == "" { + continue + } + + if parentKey != "" { + tag = parentKey + "." + tag + } + + if field.Type.Kind() == reflect.Struct { + // Recurse into nested structs + if err := bindFlags(v, flagSet, field.Type, tag); err != nil { + return err + } + } else { + flag := flagSet.Lookup(tag) + if flag == nil { + return fmt.Errorf("missing flag for tag: %s", tag) + } + if err := v.BindPFlag(tag, flag); err != nil { + return fmt.Errorf("failed to bind flag for tag: %s, error: %w", tag, err) } } - return data, nil } + return nil } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..2fa0bc5 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,303 @@ +package config_test + +import ( + "os" + "strings" + "testing" + + "github.com/mcuadros/go-defaults" + "github.com/raystack/salt/config" + "github.com/spf13/pflag" +) + +type Config struct { + Server struct { + Port int `mapstructure:"port" default:"8000" validate:"required,min=1" cmdx:"port"` + Host string `mapstructure:"host" cmdx:"host"` + } `mapstructure:"server" cmdx:"server"` + LogLevel string `mapstructure:"log_level" cmdx:"log_level"` +} + +func setEnv(t *testing.T, key, value string) { + t.Helper() + if err := os.Setenv(key, value); err != nil { + t.Fatalf("Failed to set environment variable %s: %v", key, err) + } +} + +func unsetEnv(t *testing.T, key string) { + t.Helper() + if err := os.Unsetenv(key); err != nil { + t.Fatalf("Failed to unset environment variable %s: %v", key, err) + } +} + +func TestDefaultsAreApplied(t *testing.T) { + cfg := &Config{} + loader := config.NewLoader() + + loader.Load(cfg) + if cfg.Server.Port != 8000 || cfg.Server.Host != "" { + t.Errorf("Defaults were not applied: %+v", cfg) + } +} + +func TestEnvironmentVariableBinding(t *testing.T) { + cfg := &Config{} + loader := config.NewLoader() + + setEnv(t, "SERVER_PORT", "9090") + setEnv(t, "SERVER_HOST", "localhost") + defer unsetEnv(t, "SERVER_PORT") + defer unsetEnv(t, "SERVER_HOST") + + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + if cfg.Server.Port != 9090 { + t.Errorf("Expected SERVER_PORT to be 9090, got %d", cfg.Server.Port) + } + + if cfg.Server.Host != "localhost" { + t.Errorf("Expected SERVER_HOST to be 'localhost', got %s", cfg.Server.Host) + } +} + +func TestConfigFileLoading(t *testing.T) { + configFileContent := ` +server: + port: 8080 + host: example.com +log_level: debug +` + configFilePath := "./test_config.yaml" + os.WriteFile(configFilePath, []byte(configFileContent), 0644) + defer os.Remove(configFilePath) + + cfg := &Config{} + loader := config.NewLoader(config.WithFile(configFilePath)) + + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + if cfg.Server.Port != 8080 { + t.Errorf("Expected server.port to be 8080, got %d", cfg.Server.Port) + } + + if cfg.Server.Host != "example.com" { + t.Errorf("Expected server.host to be 'example.com', got %s", cfg.Server.Host) + } + + if cfg.LogLevel != "debug" { + t.Errorf("Expected log_level to be 'debug', got %s", cfg.LogLevel) + } +} + +func TestMissingConfigFile(t *testing.T) { + cfg := &Config{} + loader := config.NewLoader(config.WithFile("./nonexistent_config.yaml")) + + if err := loader.Load(cfg); err != nil { + t.Errorf("Unexpected error for missing config file: %v", err) + } +} + +func TestInvalidConfigurationValidation(t *testing.T) { + cfg := &Config{} + + setEnv(t, "SERVER_PORT", "0") + loader := config.NewLoader() + err := loader.Load(cfg) + + if err == nil { + t.Fatalf("Expected validation error, got nil") + } + + if !strings.Contains(err.Error(), "invalid configuration") { + t.Errorf("Expected validation error message, got: %v", err) + } +} + +func TestEnvOverrideConfig(t *testing.T) { + // Create a temporary config file with values + configFileContent := ` +server: + port: 8080 + host: "file-host.com" +log_level: "info" +` + configFilePath := "./test_config.yaml" + if err := os.WriteFile(configFilePath, []byte(configFileContent), 0644); err != nil { + t.Fatalf("Failed to create test config file: %v", err) + } + defer os.Remove(configFilePath) + + // Set environment variables that should override file values + os.Setenv("SERVER_PORT", "9090") + os.Setenv("SERVER_HOST", "env-host.com") + defer os.Unsetenv("SERVER_PORT") + defer os.Unsetenv("SERVER_HOST") + + // Define the config struct and loader + cfg := &Config{} + loader := config.NewLoader(config.WithFile(configFilePath)) + + // Apply defaults + defaults.SetDefaults(cfg) + cfg.Server.Port = 3000 // Default value + cfg.Server.Host = "default-host.com" + cfg.LogLevel = "debug" + + // Load the configuration + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + // Validate override order + if cfg.Server.Port != 9090 { + t.Errorf("Expected SERVER_PORT (env) to override file and defaults, got %d", cfg.Server.Port) + } + + if cfg.Server.Host != "env-host.com" { + t.Errorf("Expected SERVER_HOST (env) to override file and defaults, got %s", cfg.Server.Host) + } + + if cfg.LogLevel != "info" { + t.Errorf("Expected log_level from file to override defaults, got %s", cfg.LogLevel) + } +} + +func TestFlagsOverrideFileAndEnvVars(t *testing.T) { + // Create a temporary config file + configFileContent := ` +server: + port: 8080 + host: "file-host.com" +log_level: "info" +` + configFilePath := "./test_config.yaml" + if err := os.WriteFile(configFilePath, []byte(configFileContent), 0644); err != nil { + t.Fatalf("Failed to create test config file: %v", err) + } + defer os.Remove(configFilePath) + + // Set environment variables + setEnv(t, "SERVER_PORT", "9090") + setEnv(t, "SERVER_HOST", "env-host.com") + defer unsetEnv(t, "SERVER_PORT") + defer unsetEnv(t, "SERVER_HOST") + + // Define flags + flags := pflag.NewFlagSet("test", pflag.ExitOnError) + flags.Int("server.port", 1000, "Server port") + flags.String("server.host", "flag-host.com", "Server host") + flags.String("log_level", "debug", "Log level") + + // Parse command-line flags (simulate CLI args) + flags.Parse([]string{"--server.port=1234", "--server.host=flag-host.com", "--log_level=trace"}) + + // Initialize Loader with flag set + loader := config.NewLoader( + config.WithFile(configFilePath), + config.WithFlags(flags), + ) + + // Load configuration into the struct + cfg := &Config{} + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + // Assert final values + if cfg.Server.Port != 1234 { + t.Errorf("Expected Server.Port to be 1234 from flags, got %d", cfg.Server.Port) + } + if cfg.Server.Host != "flag-host.com" { + t.Errorf("Expected Server.Host to be 'flag-host.com' from flags, got %s", cfg.Server.Host) + } + if cfg.LogLevel != "trace" { + t.Errorf("Expected LogLevel to be 'trace' from flags, got %s", cfg.LogLevel) + } +} + +func TestMissingFlags(t *testing.T) { + // Define flags + flags := pflag.NewFlagSet("test", pflag.ExitOnError) + flags.Int("server.port", 8080, "Server port") + + // Initialize Loader with the incomplete flag set + loader := config.NewLoader(config.WithFlags(flags)) + + // Load configuration into the struct + cfg := &Config{} + err := loader.Load(cfg) + + // Expect an error because `server.host` and `log.level` flags are missing + if err == nil { + t.Fatal("Expected an error due to missing flags, but got nil") + } + if !strings.Contains(err.Error(), "missing flag for tag") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestNestedStructFlags(t *testing.T) { + // Define flags + flags := pflag.NewFlagSet("test", pflag.ExitOnError) + flags.Int("server.port", 8080, "Server port") + flags.String("server.host", "localhost", "Server host") + flags.String("log_level", "debug", "Log level") + + // Initialize Loader with the flag set + loader := config.NewLoader(config.WithFlags(flags)) + + // Parse flags + flags.Parse([]string{"--server.port=1234", "--server.host=nested-host.com"}) + + // Load configuration into the struct + cfg := &Config{} + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + // Assert nested struct values + if cfg.Server.Port != 1234 { + t.Errorf("Expected Server.Port to be 1234, got %d", cfg.Server.Port) + } + if cfg.Server.Host != "nested-host.com" { + t.Errorf("Expected Server.Host to be 'nested-host.com', got %s", cfg.Server.Host) + } +} + +func TestFlagsOnly(t *testing.T) { + // Define flags + flags := pflag.NewFlagSet("test", pflag.ExitOnError) + flags.Int("server.port", 8080, "Server port") + flags.String("server.host", "localhost", "Server host") + flags.String("log_level", "info", "Log level") + + // Parse flags + flags.Parse([]string{"--server.port=9000", "--server.host=flag-only-host", "--log_level=warn"}) + + // Initialize Loader with the flag set + loader := config.NewLoader(config.WithFlags(flags)) + + // Load configuration into the struct + cfg := &Config{} + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + // Assert values from flags + if cfg.Server.Port != 9000 { + t.Errorf("Expected Server.Port to be 9000, got %d", cfg.Server.Port) + } + if cfg.Server.Host != "flag-only-host" { + t.Errorf("Expected Server.Host to be 'flag-only-host', got %s", cfg.Server.Host) + } + if cfg.LogLevel != "warn" { + t.Errorf("Expected LogLevel to be 'warn', got %s", cfg.LogLevel) + } +} diff --git a/config/doc.go b/config/doc.go new file mode 100644 index 0000000..260371f --- /dev/null +++ b/config/doc.go @@ -0,0 +1,93 @@ +/* +Package config provides a flexible and extensible configuration management solution for Go applications. + +It integrates configuration files, environment variables, command-line flags, and default values to populate +and validate user-defined structs. + +Configuration Precedence: +The `Loader` merges configuration values from multiple sources in the following order of precedence (highest to lowest): + 1. Command-line flags: Defined using `pflag.FlagSet` and dynamically bound via `cmdx` tags. + 2. Environment variables: Dynamically bound to configuration keys, optionally prefixed using `WithEnvPrefix`. + 3. Configuration file: YAML configuration files specified via `WithFile`. + 4. Default values: Struct fields annotated with `default` tags are populated if no other source provides a value. + +Defaults: +Default values are specified using the `default` struct tag. Fields annotated with `default` are populated +before any other source (flags, environment variables, or files). + +Example: + + type Config struct { + ServerPort int `mapstructure:"server.port" default:"8080"` + LogLevel string `mapstructure:"log.level" default:"info"` + } + +In the absence of higher-priority sources, `ServerPort` will default to `8080` and `LogLevel` to `info`. + +Validation: +Validation is performed using the `go-playground/validator` package. Fields annotated with `validate` tags +are validated after merging all configuration sources. + +Example: + + type Config struct { + ServerPort int `mapstructure:"server.port" validate:"required,min=1"` + LogLevel string `mapstructure:"log.level" validate:"required,oneof=debug info warn error"` + } + +If validation fails, the `Load` method returns a detailed error indicating the invalid fields. + +Annotations: +Configuration structs use the following struct tags to define behavior: + - `mapstructure`: Maps YAML or environment variables to struct fields. + - `default`: Provides fallback values for fields when no source overrides them. + - `validate`: Ensures the final configuration meets application-specific requirements. + +Example: + + type Config struct { + Server struct { + Port int `mapstructure:"server.port" default:"8080" validate:"required,min=1"` + Host string `mapstructure:"server.host" default:"localhost" validate:"required"` + } `mapstructure:"server"` + + LogLevel string `mapstructure:"log.level" default:"info" validate:"required,oneof=debug info warn error"` + } + +The `Loader` will merge all sources, apply defaults, and validate the result in a single call to `Load`. + +Features: + - Merges configurations from multiple sources: flags, environment variables, files, and defaults. + - Supports nested structs with dynamic field mapping using `cmdx` tags. + - Validates fields with constraints defined in `validate` tags. + - Saves and views the final configuration in YAML or JSON formats. + +Example Usage: + + type Config struct { + ServerPort int `mapstructure:"server.port" cmdx:"server.port" default:"8080" validate:"required,min=1"` + LogLevel string `mapstructure:"log.level" cmdx:"log.level" default:"info" validate:"required,oneof=debug info warn error"` + } + + func main() { + flags := pflag.NewFlagSet("example", pflag.ExitOnError) + flags.Int("server.port", 8080, "Server port") + flags.String("log.level", "info", "Log level") + + loader := config.NewLoader( + config.WithFile("./config.yaml"), + config.WithEnvPrefix("MYAPP"), + config.WithFlags(flags), + ) + + flags.Parse(os.Args[1:]) + + cfg := &Config{} + if err := loader.Load(cfg); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + fmt.Printf("Configuration: %+v\n", cfg) + } +*/ +package config diff --git a/go.mod b/go.mod index 92e38cc..a7ca4e1 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/briandowns/spinner v1.18.0 github.com/charmbracelet/glamour v0.3.0 github.com/cli/safeexec v1.0.0 + github.com/go-playground/validator v9.31.0+incompatible github.com/golang-migrate/migrate/v4 v4.16.0 github.com/google/go-cmp v0.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 @@ -80,6 +81,8 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -96,6 +99,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jzelinskie/stringz v0.0.0-20210414224931-d6a8ce844a70 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -145,6 +149,7 @@ require ( golang.org/x/tools v0.22.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect + gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/v3 v3.5.1 // indirect diff --git a/go.sum b/go.sum index 274a252..244838b 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,12 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= +github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -198,6 +204,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= @@ -496,7 +504,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+cfg= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -539,6 +547,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=