package config import ( "errors" "fmt" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) const ( defaultConfigFile = "config.yaml" envPrefix = "NETTS" ) // Config represents the full application configuration. type Config struct { Server ServerConfig `yaml:"server"` Netts NettsConfig `yaml:"netts"` Energy EnergyConfig `yaml:"energy"` Logging LoggingConfig `yaml:"logging"` } // ServerConfig controls HTTP server behaviour. type ServerConfig struct { Address string `yaml:"address"` ReadTimeout Duration `yaml:"readTimeout"` WriteTimeout Duration `yaml:"writeTimeout"` IdleTimeout Duration `yaml:"idleTimeout"` ShutdownTimeout Duration `yaml:"shutdownTimeout"` } // NettsConfig contains credentials and settings for the Netts API. type NettsConfig struct { APIKey string `yaml:"apiKey"` BaseURL string `yaml:"baseUrl"` RealIP string `yaml:"realIp"` CallbackURL string `yaml:"callbackUrl"` HTTPTimeout Duration `yaml:"httpTimeout"` Retry Retry `yaml:"retry"` } // Retry controls API retry behaviour. type Retry struct { MaxAttempts int `yaml:"maxAttempts"` Backoff Duration `yaml:"backoff"` MaxBackoff Duration `yaml:"maxBackoff"` } // EnergyConfig controls orchestration thresholds. type EnergyConfig struct { AutoAddHost bool `yaml:"autoAddHost"` MinCycles int `yaml:"minCycles"` TargetCycles int `yaml:"targetCycles"` MinEnergyThreshold int `yaml:"minEnergyThreshold"` PostOrderWait Duration `yaml:"postOrderWait"` DefaultAnalyzeValue string `yaml:"defaultAnalyzeValue"` } // LoggingConfig controls logger behaviour. type LoggingConfig struct { Level string `yaml:"level"` Format string `yaml:"format"` } // Load reads configuration from the provided path or default locations. func Load(path string) (*Config, error) { cfg := defaultConfig() configFile, err := resolveConfigPath(path) if err != nil { return nil, err } if configFile != "" { raw, err := os.ReadFile(configFile) if err != nil { return nil, fmt.Errorf("read config file %q: %w", configFile, err) } if err := yaml.Unmarshal(raw, &cfg); err != nil { return nil, fmt.Errorf("parse config file %q: %w", configFile, err) } } applyDefaults(&cfg) applyEnvOverrides(&cfg) if err := cfg.Validate(); err != nil { return nil, err } return &cfg, nil } // DefaultConfig returns a configuration populated with defaults. func defaultConfig() Config { return Config{ Server: ServerConfig{ Address: ":8080", ReadTimeout: Duration(10 * time.Second), WriteTimeout: Duration(10 * time.Second), IdleTimeout: Duration(60 * time.Second), ShutdownTimeout: Duration(15 * time.Second), }, Netts: NettsConfig{ BaseURL: "https://netts.io", HTTPTimeout: Duration(15 * time.Second), Retry: Retry{ MaxAttempts: 3, Backoff: Duration(2 * time.Second), MaxBackoff: Duration(10 * time.Second), }, }, Energy: EnergyConfig{ AutoAddHost: true, MinCycles: 3, TargetCycles: 10, MinEnergyThreshold: 32000, PostOrderWait: Duration(3 * time.Second), DefaultAnalyzeValue: "100.00", }, Logging: LoggingConfig{ Level: "info", Format: "text", }, } } func applyDefaults(cfg *Config) { if strings.TrimSpace(cfg.Server.Address) == "" { cfg.Server.Address = ":8080" } cfg.Server.ReadTimeout.SetDefault(10 * time.Second) cfg.Server.WriteTimeout.SetDefault(10 * time.Second) cfg.Server.IdleTimeout.SetDefault(60 * time.Second) cfg.Server.ShutdownTimeout.SetDefault(15 * time.Second) if strings.TrimSpace(cfg.Netts.BaseURL) == "" { cfg.Netts.BaseURL = "https://netts.io" } cfg.Netts.HTTPTimeout.SetDefault(15 * time.Second) if cfg.Netts.Retry.MaxAttempts <= 0 { cfg.Netts.Retry.MaxAttempts = 3 } cfg.Netts.Retry.Backoff.SetDefault(2 * time.Second) cfg.Netts.Retry.MaxBackoff.SetDefault(10 * time.Second) if cfg.Energy.MinCycles <= 0 { cfg.Energy.MinCycles = 3 } if cfg.Energy.TargetCycles <= 0 { cfg.Energy.TargetCycles = 10 } if cfg.Energy.TargetCycles < cfg.Energy.MinCycles { cfg.Energy.TargetCycles = cfg.Energy.MinCycles } if cfg.Energy.MinEnergyThreshold <= 0 { cfg.Energy.MinEnergyThreshold = 32000 } cfg.Energy.PostOrderWait.SetDefault(3 * time.Second) if cfg.Energy.DefaultAnalyzeValue == "" { cfg.Energy.DefaultAnalyzeValue = "100.00" } if cfg.Logging.Level == "" { cfg.Logging.Level = "info" } if cfg.Logging.Format == "" { cfg.Logging.Format = "text" } } func applyEnvOverrides(cfg *Config) { if v := os.Getenv(envVar("API_KEY")); v != "" { cfg.Netts.APIKey = v } if v := os.Getenv(envVar("BASE_URL")); v != "" { cfg.Netts.BaseURL = v } if v := os.Getenv(envVar("REAL_IP")); v != "" { cfg.Netts.RealIP = v } if v := os.Getenv(envVar("CALLBACK_URL")); v != "" { cfg.Netts.CallbackURL = v } if v := os.Getenv(envVar("AUTO_ADD_HOST")); v != "" { cfg.Energy.AutoAddHost = strings.EqualFold(v, "true") || v == "1" } if v := os.Getenv(envVar("MIN_CYCLES")); v != "" { if parsed, err := parseInt(v); err == nil { cfg.Energy.MinCycles = parsed } } if v := os.Getenv(envVar("TARGET_CYCLES")); v != "" { if parsed, err := parseInt(v); err == nil { cfg.Energy.TargetCycles = parsed } } if v := os.Getenv(envVar("MIN_ENERGY_THRESHOLD")); v != "" { if parsed, err := parseInt(v); err == nil { cfg.Energy.MinEnergyThreshold = parsed } } if v := os.Getenv(envVar("LOG_LEVEL")); v != "" { cfg.Logging.Level = v } if v := os.Getenv(envVar("LOG_FORMAT")); v != "" { cfg.Logging.Format = v } } // Validate performs configuration validation. func (c *Config) Validate() error { var problems []string if strings.TrimSpace(c.Netts.APIKey) == "" { problems = append(problems, "netts.apiKey is required (or NETTS_API_KEY env)") } if !strings.HasPrefix(c.Netts.BaseURL, "http") { problems = append(problems, "netts.baseUrl must be an absolute URL") } if c.Energy.MinCycles <= 0 { problems = append(problems, "energy.minCycles must be > 0") } if c.Energy.TargetCycles < c.Energy.MinCycles { problems = append(problems, "energy.targetCycles must be >= energy.minCycles") } if c.Energy.MinEnergyThreshold <= 0 { problems = append(problems, "energy.minEnergyThreshold must be > 0") } if len(problems) > 0 { return errors.New(strings.Join(problems, "; ")) } return nil } func resolveConfigPath(path string) (string, error) { if path != "" { if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("config file %q: %w", path, err) } return path, nil } searchPaths := []string{ defaultConfigFile, filepath.Join("config", defaultConfigFile), filepath.Join("..", defaultConfigFile), } for _, candidate := range searchPaths { if _, err := os.Stat(candidate); err == nil { return candidate, nil } } return "", nil } func envVar(key string) string { return fmt.Sprintf("%s_%s", envPrefix, strings.ToUpper(strings.ReplaceAll(key, ".", "_"))) } func parseInt(value string) (int, error) { var parsed int _, err := fmt.Sscanf(value, "%d", &parsed) if err != nil { return 0, fmt.Errorf("parse int %q: %w", value, err) } return parsed, nil }