Add configuration and CLI foundation

This commit is contained in:
2026-05-29 16:58:18 +00:00
parent e5cd23de48
commit 8c065751c2
19 changed files with 1068 additions and 43 deletions

71
internal/config/config.go Normal file
View 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"`
}

View 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)
}
}

View 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,
},
}
}

View File

@@ -1,3 +0,0 @@
// Package config owns application configuration structures, defaults, loading,
// precedence, and validation.
package config

68
internal/config/load.go Normal file
View 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
View 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
}