Add configuration and CLI foundation
This commit is contained in:
71
internal/config/config.go
Normal file
71
internal/config/config.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package config owns application configuration structures, defaults, loading,
|
||||
// precedence, and validation.
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type MissingSourcePolicy string
|
||||
|
||||
const (
|
||||
MissingSourceError MissingSourcePolicy = "error"
|
||||
MissingSourceWarn MissingSourcePolicy = "warn"
|
||||
MissingSourceNone MissingSourcePolicy = "none"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Reports ReportOutputConfig `yaml:"reports"`
|
||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||
}
|
||||
|
||||
type WeatherAPIConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Precision int `yaml:"precision"`
|
||||
Units string `yaml:"units"`
|
||||
Timezone string `yaml:"timezone"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type MissingSourceConfig struct {
|
||||
Default MissingSourcePolicy `yaml:"default"`
|
||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||
}
|
||||
|
||||
type ScriptoriumConfig struct {
|
||||
Binary string `yaml:"binary"`
|
||||
ConfigPath string `yaml:"config_path"`
|
||||
Profile string `yaml:"profile"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
ExtraArgs []string `yaml:"extra_args"`
|
||||
}
|
||||
|
||||
type WorkspaceConfig struct {
|
||||
Root string `yaml:"root"`
|
||||
SnapshotsDir string `yaml:"snapshots_dir"`
|
||||
ReportsDir string `yaml:"reports_dir"`
|
||||
DataPackagesDir string `yaml:"data_packages_dir"`
|
||||
PreflightDir string `yaml:"preflight_dir"`
|
||||
}
|
||||
|
||||
type ReportOutputConfig struct {
|
||||
OutputDir string `yaml:"output_dir"`
|
||||
Paths map[string]string `yaml:"paths"`
|
||||
}
|
||||
|
||||
type DaypartConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Start string `yaml:"start"`
|
||||
End string `yaml:"end"`
|
||||
}
|
||||
|
||||
type RecentChangeConfig struct {
|
||||
TemperatureDegrees float64 `yaml:"temperature_degrees"`
|
||||
PrecipProbabilityPoints int `yaml:"precip_probability_points"`
|
||||
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
|
||||
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
|
||||
}
|
||||
88
internal/config/config_test.go
Normal file
88
internal/config/config_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
cfg, err := Load(LoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.WeatherAPI.Units != "us" {
|
||||
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
||||
}
|
||||
if cfg.WeatherAPI.Timezone != "Chicago" {
|
||||
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
|
||||
}
|
||||
if cfg.WeatherAPI.Format != "json" {
|
||||
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
||||
}
|
||||
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExampleConfig(t *testing.T) {
|
||||
cfg, err := LoadFile(filepath.Join("..", "..", "examples", "config.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
|
||||
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
|
||||
}
|
||||
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
||||
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
||||
}
|
||||
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
||||
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
||||
if err == nil {
|
||||
t.Fatal("LoadFile() error = nil, want missing file error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "read config") {
|
||||
t.Fatalf("error = %q, want read config context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidConfigProducesActionableError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
if err := os.WriteFile(path, []byte("missing_source:\n default: explode\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("LoadFile() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_source.default") {
|
||||
t.Fatalf("error = %q, want field path", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesOverrides(t *testing.T) {
|
||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "UTC", Output: "./out"})
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.WeatherAPI.Units != "metric" {
|
||||
t.Fatalf("Units = %q, want metric", cfg.WeatherAPI.Units)
|
||||
}
|
||||
if cfg.WeatherAPI.Timezone != "UTC" {
|
||||
t.Fatalf("Timezone = %q, want UTC", cfg.WeatherAPI.Timezone)
|
||||
}
|
||||
if cfg.Reports.OutputDir != "./out" {
|
||||
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
|
||||
}
|
||||
}
|
||||
48
internal/config/defaults.go
Normal file
48
internal/config/defaults.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
const DefaultPath = "/usr/local/etc/weatherreporter/config.yml"
|
||||
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
WeatherAPI: WeatherAPIConfig{
|
||||
Timeout: 10 * time.Second,
|
||||
Precision: 1,
|
||||
Units: "us",
|
||||
Timezone: "Chicago",
|
||||
Format: "json",
|
||||
},
|
||||
MissingSource: MissingSourceConfig{
|
||||
Default: MissingSourceWarn,
|
||||
Sources: map[string]MissingSourcePolicy{},
|
||||
},
|
||||
Scriptorium: ScriptoriumConfig{
|
||||
Binary: "scriptorium",
|
||||
Timeout: 2 * time.Minute,
|
||||
},
|
||||
Workspace: WorkspaceConfig{
|
||||
Root: "workspace",
|
||||
SnapshotsDir: "snapshots",
|
||||
ReportsDir: "reports",
|
||||
DataPackagesDir: "data-packages",
|
||||
PreflightDir: "preflight",
|
||||
},
|
||||
Reports: ReportOutputConfig{
|
||||
OutputDir: "reports",
|
||||
Paths: map[string]string{},
|
||||
},
|
||||
Dayparts: []DaypartConfig{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
},
|
||||
RecentChange: RecentChangeConfig{
|
||||
TemperatureDegrees: 5,
|
||||
PrecipProbabilityPoints: 20,
|
||||
WindGustMilesPerHour: 10,
|
||||
PrecipTimingShiftMinutes: 120,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Package config owns application configuration structures, defaults, loading,
|
||||
// precedence, and validation.
|
||||
package config
|
||||
68
internal/config/load.go
Normal file
68
internal/config/load.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type LoadOptions struct {
|
||||
Path string
|
||||
Units string
|
||||
Timezone string
|
||||
Output string
|
||||
}
|
||||
|
||||
func Load(opts LoadOptions) (Config, error) {
|
||||
cfg := Defaults()
|
||||
|
||||
path := opts.Path
|
||||
if path == "" {
|
||||
path = DefaultPath
|
||||
}
|
||||
|
||||
if err := mergeFile(&cfg, path); err != nil {
|
||||
if opts.Path != "" || !errors.Is(err, os.ErrNotExist) {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Units != "" {
|
||||
cfg.WeatherAPI.Units = opts.Units
|
||||
}
|
||||
if opts.Timezone != "" {
|
||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||
}
|
||||
if opts.Output != "" {
|
||||
cfg.Reports.OutputDir = opts.Output
|
||||
}
|
||||
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func LoadFile(path string) (Config, error) {
|
||||
return Load(LoadOptions{Path: path})
|
||||
}
|
||||
|
||||
func mergeFile(cfg *Config, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config %q: %w", path, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("parse config %q: %w", path, err)
|
||||
}
|
||||
if cfg.MissingSource.Sources == nil {
|
||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
||||
}
|
||||
if cfg.Reports.Paths == nil {
|
||||
cfg.Reports.Paths = map[string]string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
124
internal/config/validate.go
Normal file
124
internal/config/validate.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Validate(cfg Config) error {
|
||||
if cfg.WeatherAPI.BaseURL != "" {
|
||||
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("weather_api.base_url must be an absolute URL")
|
||||
}
|
||||
}
|
||||
if cfg.WeatherAPI.Timeout <= 0 {
|
||||
return fmt.Errorf("weather_api.timeout must be greater than zero")
|
||||
}
|
||||
if cfg.WeatherAPI.Precision < 0 {
|
||||
return fmt.Errorf("weather_api.precision must be zero or greater")
|
||||
}
|
||||
if cfg.WeatherAPI.Units == "" {
|
||||
return fmt.Errorf("weather_api.units is required")
|
||||
}
|
||||
if cfg.WeatherAPI.Timezone == "" {
|
||||
return fmt.Errorf("weather_api.timezone is required")
|
||||
}
|
||||
if _, err := loadLocation(cfg.WeatherAPI.Timezone); err != nil {
|
||||
return fmt.Errorf("weather_api.timezone %q is invalid: %w", cfg.WeatherAPI.Timezone, err)
|
||||
}
|
||||
if cfg.WeatherAPI.Format == "" {
|
||||
return fmt.Errorf("weather_api.format is required")
|
||||
}
|
||||
if cfg.WeatherAPI.Format != "json" {
|
||||
return fmt.Errorf("weather_api.format must be json")
|
||||
}
|
||||
|
||||
if err := validatePolicy("missing_source.default", cfg.MissingSource.Default); err != nil {
|
||||
return err
|
||||
}
|
||||
for source, policy := range cfg.MissingSource.Sources {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return fmt.Errorf("missing_source.sources contains an empty source name")
|
||||
}
|
||||
if err := validatePolicy("missing_source.sources."+source, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Scriptorium.Binary == "" {
|
||||
return fmt.Errorf("scriptorium.binary is required")
|
||||
}
|
||||
if cfg.Scriptorium.Timeout <= 0 {
|
||||
return fmt.Errorf("scriptorium.timeout must be greater than zero")
|
||||
}
|
||||
if cfg.Workspace.Root == "" {
|
||||
return fmt.Errorf("workspace.root is required")
|
||||
}
|
||||
if cfg.Reports.OutputDir == "" {
|
||||
return fmt.Errorf("reports.output_dir is required")
|
||||
}
|
||||
if len(cfg.Dayparts) == 0 {
|
||||
return fmt.Errorf("dayparts must contain at least one entry")
|
||||
}
|
||||
for i, daypart := range cfg.Dayparts {
|
||||
if strings.TrimSpace(daypart.Name) == "" {
|
||||
return fmt.Errorf("dayparts[%d].name is required", i)
|
||||
}
|
||||
if err := validateClockTime(daypart.Start); err != nil {
|
||||
return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err)
|
||||
}
|
||||
if err := validateClockTime(daypart.End); err != nil {
|
||||
return fmt.Errorf("dayparts[%d].end is invalid: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadLocation(name string) (*time.Location, error) {
|
||||
location, err := time.LoadLocation(name)
|
||||
if err == nil {
|
||||
return location, nil
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, err
|
||||
}
|
||||
return time.LoadLocation("America/" + name)
|
||||
}
|
||||
|
||||
func validatePolicy(name string, policy MissingSourcePolicy) error {
|
||||
switch policy {
|
||||
case MissingSourceError, MissingSourceWarn, MissingSourceNone:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s must be one of error, warn, or none", name)
|
||||
}
|
||||
}
|
||||
|
||||
func validateClockTime(value string) error {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("expected HH:MM")
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid hour")
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid minute")
|
||||
}
|
||||
if hour < 0 || hour > 24 {
|
||||
return fmt.Errorf("hour must be between 00 and 24")
|
||||
}
|
||||
if minute < 0 || minute > 59 {
|
||||
return fmt.Errorf("minute must be between 00 and 59")
|
||||
}
|
||||
if hour == 24 && minute != 0 {
|
||||
return fmt.Errorf("24 is only valid as 24:00")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user