// @tag config // @tag core package config import ( "encoding/json" "fmt" "net" "os" "strconv" "time" "github.com/eleksir/pekoe/internal/logger" ) // Constants for default values - chosen to be production-ready defaults const ( DefaultBind = "0.0.0.0" DefaultPort = 6379 DefaultMaxClients = 10000 DefaultReadTimeout = 0 // 0 means no timeout (blocking I/O) DefaultWriteTimeout = 0 // 0 means no timeout DefaultMaxMsgBytes = 512 * 1024 * 1024 // 512 MB - allows large messages DefaultLogLevel = "info" DefaultLogFile = "" // empty = stdout only DefaultPidFile = "" // empty = no PID file created ) type Config struct { Bind string `json:"bind"` Port int `json:"port"` MaxClients int `json:"max_clients"` ReadTimeout int `json:"read_timeout"` // in seconds, 0 = no timeout WriteTimeout int `json:"write_timeout"` // in seconds, 0 = no timeout MaxMsgBytes int `json:"max_message_bytes"` LogLevel string `json:"log_level"` LogFile string `json:"log_file"` // empty = stdout only PidFile string `json:"pid_file"` // empty = no PID file } // SetDefaults applies sensible defaults when config values are missing or invalid. // This ensures the server can start even with incomplete configuration. // nolint: cyclop func (c *Config) SetDefaults() { if c.Bind == "" { c.Bind = DefaultBind } if c.Port <= 0 || c.Port > 65535 { c.Port = DefaultPort } if c.MaxClients <= 0 { c.MaxClients = DefaultMaxClients } if c.ReadTimeout < 0 { c.ReadTimeout = 0 } if c.WriteTimeout < 0 { c.WriteTimeout = 0 } if c.MaxMsgBytes <= 0 { c.MaxMsgBytes = DefaultMaxMsgBytes } if c.LogLevel == "" { c.LogLevel = DefaultLogLevel } if c.LogFile == "" { c.LogFile = DefaultLogFile } if c.PidFile == "" { c.PidFile = DefaultPidFile } } // Validate checks that all configuration values are within acceptable bounds. // Returns an error if validation fails, which should prevent server startup. func (c *Config) Validate() error { if c.Bind == "" { return fmt.Errorf("bind address cannot be empty") } if net.ParseIP(c.Bind) == nil { return fmt.Errorf("bind must be a valid IP address (e.g. 0.0.0.0, ::, ::1, 192.168.1.1)") } if c.Port <= 0 || c.Port > 65535 { return fmt.Errorf("port must be between 1 and 65535") } if c.MaxClients <= 0 { return fmt.Errorf("max_clients must be positive") } if c.MaxMsgBytes <= 0 { return fmt.Errorf("max_message_bytes must be positive") } // Validate log level against allowed values validLevels := map[string]bool{ "debug": true, "info": true, "warn": true, "error": true, } if !validLevels[c.LogLevel] { return fmt.Errorf("invalid log_level: %s (must be debug, info, warn, or error)", c.LogLevel) } return nil } // ReadTimeoutDuration converts the config's read timeout (in seconds) to a Duration. // Zero means no timeout, allowing the server to block on reads indefinitely. func (c *Config) ReadTimeoutDuration() time.Duration { if c.ReadTimeout == 0 { return 0 } return time.Duration(c.ReadTimeout) * time.Second } // WriteTimeoutDuration converts the config's write timeout (in seconds) to a Duration. func (c *Config) WriteTimeoutDuration() time.Duration { if c.WriteTimeout == 0 { return 0 } return time.Duration(c.WriteTimeout) * time.Second } // Load reads and parses the configuration from a JSON file. // It applies defaults for missing/invalid values and validates the final config. func Load(path string) (*Config, error) { //nolint: gosec data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) } // Apply defaults before validation to ensure minimum viable config cfg.SetDefaults() if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("config validation failed: %w", err) } logger.SetLevel(cfg.LogLevel) if cfg.LogFile != "" { if err := logger.SetOutput(cfg.LogFile); err != nil { return nil, fmt.Errorf("failed to set log file: %w", err) } } return &cfg, nil } // Addr returns the bind address in host:port format for use in net.Listen. // IPv6 addresses are wrapped in brackets: [::]:6379. func (c *Config) Addr() string { ip := net.ParseIP(c.Bind) if ip != nil && ip.To4() == nil { // IPv6 address — wrap in brackets return "[" + c.Bind + "]:" + strconv.Itoa(c.Port) } return c.Bind + ":" + strconv.Itoa(c.Port) } // String returns a JSON representation of the config for debugging. func (c *Config) String() string { b, _ := json.MarshalIndent(c, "", " ") return string(b) }