Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch Repository
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Install Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: '1.24.x'
go-version: '1.25.x'

- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v9
with:
version: v2.1.6
version: v2.7.2
6 changes: 3 additions & 3 deletions .github/workflows/pre-commit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch Repository
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.13'
python-version: '3.14'

- name: Install pre-commit
uses: pre-commit/action@v3.0.1
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-case-conflict
- id: check-json
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ endef
GOTESTSUM := go run gotest.tools/gotestsum@latest -f testname -- ./... -race -count=1
TESTFLAGS := -shuffle=on
COVERFLAGS := -covermode=atomic
GOLANGCI_LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6
GOLANGCI_LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2

check-pre-commit:
ifeq (, $(shell which pre-commit))
Expand Down
255 changes: 110 additions & 145 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,96 +11,22 @@ import (
)

const (
DefaultAppName = "app"
DefaultEnvName = "local"
AppName = "app-name"
EnvName = "env-name"
AppName = "app-name"
EnvName = "env-name"
)

// Config holds all global configuration for the application.
// Config holds the application configuration.
type Config struct {
AppName string
EnvName string
}

// ConfigLoader manages the configuration loading process.
type ConfigLoader struct {
envVarPrefix string
configPaths []string
configType string
var defaultConfig = &Config{
AppName: "app",
EnvName: "local",
}

// Option defines a function type for configuring ConfigLoader.
type Option func(*ConfigLoader)

// LoadOption defines a function type for configuring the load process.
type LoadOption func(*loadOptions)

// loadOptions holds configuration for the loading process.
type loadOptions struct {
loadConfigFile bool
configFileName string
}

// WithEnvPrefix sets the environment variable prefix.
func WithEnvPrefix(prefix string) Option {
return func(cl *ConfigLoader) {
cl.envVarPrefix = prefix
}
}

// WithConfigPaths sets the configuration file search paths.
func WithConfigPaths(paths ...string) Option {
return func(cl *ConfigLoader) {
cl.configPaths = paths
}
}

// WithConfigType sets the configuration file type.
func WithConfigType(configType string) Option {
return func(cl *ConfigLoader) {
cl.configType = configType
}
}

// WithConfigFile enables loading from config file.
func WithConfigFile(enabled bool) LoadOption {
return func(o *loadOptions) {
o.loadConfigFile = enabled
}
}

// WithCustomConfigFile enables loading from a custom config file name.
func WithCustomConfigFile(fileName string) LoadOption {
return func(o *loadOptions) {
o.loadConfigFile = true
o.configFileName = fileName
}
}

// NewConfigLoader creates a new ConfigLoader with the given options.
func NewConfigLoader(options ...Option) *ConfigLoader {
cl := &ConfigLoader{
envVarPrefix: "",
configPaths: []string{"./configs", "/configs"},
configType: "toml",
}

for _, option := range options {
option(cl)
}

return cl
}

// AddConfigPath adds a configuration file search path.
func (cl *ConfigLoader) AddConfigPath(path string) *ConfigLoader {
cl.configPaths = append(cl.configPaths, path)
return cl
}

// Validate checks if the configuration values are valid.
func (c *Config) Validate() error {
func (c *Config) validate() error {
if strings.TrimSpace(c.AppName) == "" {
return errors.New("application name cannot be empty")
}
Expand All @@ -110,77 +36,76 @@ func (c *Config) Validate() error {
return nil
}

// bindFlags adds all the flags to the provided flag set.
func (cl *ConfigLoader) bindFlags(fs *pflag.FlagSet, config *Config) {
fs.StringVar(&config.AppName, AppName, config.AppName, "The name of the application.")
fs.StringVar(&config.EnvName, EnvName, config.EnvName,
"The environment of the application. Used to load the right config file.")
type options struct {
appName *string
envName *string
configPaths []string
configType string
}

// normalizeFlags changes all flags that contain "_" separators to use "-".
func normalizeFlags(f *pflag.FlagSet, name string) pflag.NormalizedName {
if strings.Contains(name, "_") {
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
// Option configures Config creation.
type Option func(*options)

// WithAppName sets the application name.
func WithAppName(name string) Option {
return func(o *options) {
o.appName = &name
}
return pflag.NormalizedName(name)
}

// setupViper configures viper with environment variable settings.
func (cl *ConfigLoader) setupViper() {
if cl.envVarPrefix != "" {
viper.SetEnvPrefix(cl.envVarPrefix)
// WithEnvName sets the environment name.
func WithEnvName(env string) Option {
return func(o *options) {
o.envName = &env
}
replacer := strings.NewReplacer("-", "_")
viper.SetEnvKeyReplacer(replacer)
viper.AutomaticEnv()
}

// loadConfigFile loads the configuration file using viper.
func (cl *ConfigLoader) loadConfigFile(configName string) error {
viper.SetConfigName(configName)
viper.SetConfigType(cl.configType)

for _, path := range cl.configPaths {
viper.AddConfigPath(path)
// WithConfigPaths sets paths to search for config files.
func WithConfigPaths(paths ...string) Option {
return func(o *options) {
o.configPaths = paths
}
}

if err := viper.ReadInConfig(); err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configFileNotFoundError) {
return fmt.Errorf("config file '%s.%s' not found in paths %v: %w",
configName, cl.configType, cl.configPaths, err)
}
return fmt.Errorf("failed to read config file: %w", err)
// WithConfigType sets the config file type (toml, yaml, json, etc).
func WithConfigType(typ string) Option {
return func(o *options) {
o.configType = typ
}

return nil
}

// LoadConfig loads configuration with the specified options.
func (cl *ConfigLoader) LoadConfig(options []LoadOption, flagSets ...func(fs *pflag.FlagSet)) (*Config, error) {
opts := &loadOptions{
loadConfigFile: false,
configFileName: "",
}
// New creates a new Config.
// It registers app-name and env-name flags on fs, parses fs, then reads from options/flags/env/file.
// Environment variables are prefixed with the uppercased app name (e.g. MYAPP_PORT for app "myapp").
// Precedence: flags > environment variables > config file (if paths+type provided) > options > defaults.
func New(fs *pflag.FlagSet, opts ...Option) (*Config, error) {
cfg := *defaultConfig

for _, option := range options {
option(opts)
o := &options{}
for _, opt := range opts {
opt(o)
}

fs := pflag.NewFlagSet("config", pflag.ExitOnError)
optionAppNameSet := o.appName != nil
optionEnvNameSet := o.envName != nil

config := &Config{
AppName: DefaultAppName,
EnvName: DefaultEnvName,
if o.appName != nil {
cfg.AppName = *o.appName
}

cl.bindFlags(fs, config)

for _, flagSet := range flagSets {
flagSet(fs)
if o.envName != nil {
cfg.EnvName = *o.envName
}

fs.SetNormalizeFunc(normalizeFlags)
fs.String(
AppName,
cfg.AppName,
"The name of the application.",
)
fs.String(
EnvName,
cfg.EnvName,
"The environment of the application. Used to load the right config file.",
)

if err := fs.Parse(os.Args[1:]); err != nil {
return nil, fmt.Errorf("failed to parse flags: %w", err)
Expand All @@ -190,26 +115,66 @@ func (cl *ConfigLoader) LoadConfig(options []LoadOption, flagSets ...func(fs *pf
return nil, fmt.Errorf("failed to bind flags to viper: %w", err)
}

cl.setupViper()
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()

if opts.loadConfigFile {
configName := opts.configFileName
if configName == "" {
envName := viper.GetString(EnvName)
configName = fmt.Sprintf("config.%s", strings.ToLower(envName))
// read from env (only if option wasn't set and flag wasn't changed)
appNameFlag := fs.Lookup(AppName)
if !optionAppNameSet && (appNameFlag == nil || !appNameFlag.Changed) {
if v := viper.GetString(AppName); v != "" {
cfg.AppName = v
}
}

if err := cl.loadConfigFile(configName); err != nil {
return nil, err
envNameFlag := fs.Lookup(EnvName)
if !optionEnvNameSet && (envNameFlag == nil || !envNameFlag.Changed) {
if v := viper.GetString(EnvName); v != "" {
cfg.EnvName = v
}
}

config.AppName = viper.GetString(AppName)
config.EnvName = viper.GetString(EnvName)
// flags override everything
if appNameFlag != nil && appNameFlag.Changed {
cfg.AppName = appNameFlag.Value.String()
}
if envNameFlag != nil && envNameFlag.Changed {
cfg.EnvName = envNameFlag.Value.String()
}

if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}

// always set env prefix based on final app name
prefix := strings.ToUpper(strings.ReplaceAll(cfg.AppName, "-", "_"))
viper.SetEnvPrefix(prefix)

if err := config.Validate(); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
if len(o.configPaths) > 0 && o.configType != "" {
name := fmt.Sprintf("config.%s", strings.ToLower(cfg.EnvName))
viper.SetConfigName(name)
viper.SetConfigType(o.configType)

for _, path := range o.configPaths {
viper.AddConfigPath(path)
}

if err := viper.ReadInConfig(); err != nil {
var nf viper.ConfigFileNotFoundError
if errors.As(err, &nf) {
return nil, fmt.Errorf("config file '%s.%s' not found in paths %v: %w",
name, o.configType, o.configPaths, err)
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}

// AFTER loading file, read app-name from viper (file values)
// but only if NOT set by flag, env, or option (precedence: flag > env > file > option)
if (appNameFlag == nil || !appNameFlag.Changed) && !optionAppNameSet {
if v := viper.GetString(AppName); v != "" {
cfg.AppName = v
}
}
}

return config, nil
return &cfg, nil
}
Loading