// internal/config/config.go package config import ( "fmt" "os" "path/filepath" "sync" "github.com/fsnotify/fsnotify" "github.com/spf13/viper" ) type Config struct { Server ServerConfig Database DatabaseConfig Redis RedisConfig SMTP SMTPConfig JWT JWTConfig } type ServerConfig struct { Port string Env string } type DatabaseConfig struct { Host string Port string User string Password string Name string SSLMode string } type RedisConfig struct { Addr string Password string DB int } type SMTPConfig struct { Host string Port int Username string Password string From string } type JWTConfig struct { Secret string PublicKey string Expiry string } // Package-level state for a single, watched config instance var ( vip *viper.Viper cfgCache *Config cfgMu sync.RWMutex initOnce sync.Once initErr error listeners []func(*Config) listenersMu sync.RWMutex ) // LoadConfig returns the current configuration. On first call it initializes // Viper, loads the config from a deterministic location, and starts watching // for changes. Subsequent calls are cheap and thread-safe. func LoadConfig() (*Config, error) { initOnce.Do(func() { vip = viper.New() configureViper(vip) // Initial read must succeed; later changes are handled via WatchConfig if err := vip.ReadInConfig(); err != nil { initErr = fmt.Errorf("failed to load config: %w", err) return } // Build cache from initial values cfg := buildFromViper(vip) cfgMu.Lock() cfgCache = cfg cfgMu.Unlock() // Watch for changes and update cache atomically vip.WatchConfig() vip.OnConfigChange(func(_ fsnotify.Event) { // viper already re-reads on change; rebuild view and swap newCfg := buildFromViper(vip) cfgMu.Lock() cfgCache = newCfg cfgMu.Unlock() notifyListeners(newCfg) }) }) if initErr != nil { return nil, initErr } cfgMu.RLock() defer cfgMu.RUnlock() return cloneConfig(cfgCache), nil } // DSN returns the Data Source Name for PostgreSQL connection func (db DatabaseConfig) DSN() string { return fmt.Sprintf( "postgres://%s:%s@%s:%s/%s?sslmode=%s", db.User, db.Password, db.Host, db.Port, db.Name, db.SSLMode, ) } // RegisterChangeListener registers a callback invoked whenever the configuration // file changes and the in-memory configuration is updated. func RegisterChangeListener(listener func(*Config)) { listenersMu.Lock() defer listenersMu.Unlock() listeners = append(listeners, listener) } // --- internal helpers --- func configureViper(v *viper.Viper) { // Allow env vars to override any key (expects exact key names used below) v.AutomaticEnv() if path, ok := resolveConfigFilePath(); ok { v.SetConfigFile(path) return } // Fallback to looking for a .env in stable, nearby locations. v.SetConfigName(".env") v.SetConfigType("env") v.AddConfigPath(".") v.AddConfigPath("..") } func resolveConfigFilePath() (string, bool) { if p := os.Getenv("CONFIG_FILE"); p != "" { return p, true } cwd, _ := os.Getwd() candidates := []string{ filepath.Join(cwd, ".env"), filepath.Join(cwd, "go-server", ".env"), filepath.Join(cwd, "..", ".env"), filepath.Join(cwd, "..", "go-server", ".env"), } for _, c := range candidates { if info, err := os.Stat(c); err == nil && !info.IsDir() { return c, true } } return "", false } func buildFromViper(v *viper.Viper) *Config { return &Config{ Server: ServerConfig{ Port: v.GetString("SERVER_PORT"), Env: v.GetString("SERVER_ENV"), }, Database: DatabaseConfig{ Host: v.GetString("DB_HOST"), Port: v.GetString("DB_PORT"), User: v.GetString("DB_USER"), Password: v.GetString("DB_PASSWORD"), Name: v.GetString("DB_NAME"), SSLMode: v.GetString("DB_SSL_MODE"), }, Redis: RedisConfig{ Addr: v.GetString("REDIS_ADDR"), Password: v.GetString("REDIS_PASSWORD"), DB: v.GetInt("REDIS_DB"), }, SMTP: SMTPConfig{ Host: v.GetString("SMTP_HOST"), Port: v.GetInt("SMTP_PORT"), Username: v.GetString("SMTP_USERNAME"), Password: v.GetString("SMTP_PASSWORD"), From: v.GetString("SMTP_FROM"), }, JWT: JWTConfig{ Secret: v.GetString("JWT_SECRET"), Expiry: v.GetString("JWT_EXPIRY"), PublicKey: v.GetString("JWT_PUBLIC_KEY"), }, } } func cloneConfig(c *Config) *Config { if c == nil { return nil } cc := *c return &cc } func notifyListeners(c *Config) { listenersMu.RLock() defer listenersMu.RUnlock() for _, l := range listeners { // Call listeners in-goroutine to avoid blocking the watcher go l(cloneConfig(c)) } }