diff --git a/README.md b/README.md index e19d1b0..66b0434 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,20 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The repository currently contains the application skeleton and a minimal CLI -help command. Report generation, configuration loading, weather API fetching, -and `scriptorium` rendering are tracked in the roadmap and are not implemented -yet. +The application currently resolves configuration and CLI requests. Weather API +fetching, report generation, and `scriptorium` rendering are tracked in the +roadmap and are not implemented yet. ## Quickstart ```sh -weatherreporter --help +weatherreporter generate daily --date 2026-05-29 --out ./daily.md ``` ## Documentation - [CLI reference](docs/cli.md) +- [Configuration reference](docs/config.md) - [Architecture policy](docs/policy/architecture.md) - [Development policy](docs/policy/development.md) - [Implementation roadmap](docs/roadmap/initial.md) diff --git a/docs/cli.md b/docs/cli.md index 3003049..3688755 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,23 +1,43 @@ # Weatherreporter CLI -`weatherreporter` currently exposes only the root help command while the -application skeleton is being established. +`weatherreporter` currently resolves configuration and command requests, then +returns a not-implemented error for report generation and scheduled runs. ## Shortest Useful Command ```sh -weatherreporter --help +weatherreporter generate daily --date 2026-05-29 --out ./daily.md ``` +The command parses flags, loads configuration, resolves the request, and then +stops before weather data fetching or report rendering. + ## Command Overview ```text -weatherreporter --help +weatherreporter generate daily +weatherreporter generate tomorrow +weatherreporter generate three-day +weatherreporter generate weekend +weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 +weatherreporter run morning +weatherreporter run evening ``` -Shows the available command-line help without loading configuration or calling -external services. +`generate` commands resolve one report request. `run` commands resolve a +scheduled batch request. All report and batch execution currently returns +`not implemented` after request resolution. ## Flags - `-h`, `--help`: show help. +- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. +- `--units VALUE`: override configured Weather API units. +- `--tz NAME`: override configured Weather API timezone. +- `--out PATH`: override report output path or directory for `generate` commands. +- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. +- `--start TIME`: required start time for `generate storm`. +- `--end TIME`: required end time for `generate storm`. + +Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339 +timestamps with explicit offsets. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..840c9d6 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,46 @@ +# Weatherreporter Configuration + +Configuration is loaded from `/usr/local/etc/weatherreporter/config.yml` by +default. Use `--config PATH` to load a different file. CLI flags override file +values. + +If the default file is absent, built-in defaults are used. + +## Minimal Config + +```yaml +weather_api: + base_url: https://weather.api.example.com/ +``` + +## Production-Oriented Config + +See [examples/config.yml](../examples/config.yml). + +## Reference + +- `weather_api.base_url`: single Weather API endpoint base URL. +- `weather_api.timeout`: HTTP timeout duration. Default: `10s`. +- `weather_api.precision`: numeric precision hint. Default: `1`. +- `weather_api.units`: Weather API units. Default: `us`. +- `weather_api.timezone`: report timezone. Default: `Chicago`. +- `weather_api.format`: Weather API response format. Default: `json`. +- `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`. +- `missing_source.sources`: optional per-source missing-source policy overrides. +- `scriptorium.binary`: `scriptorium` executable name. Default: `scriptorium`. +- `scriptorium.config_path`: optional `scriptorium` config path. +- `scriptorium.profile`: optional `scriptorium` profile. +- `scriptorium.timeout`: subprocess timeout. Default: `2m`. +- `scriptorium.extra_args`: optional extra arguments reserved for the adapter. +- `workspace.root`: workspace root. Default: `workspace`. +- `workspace.snapshots_dir`: snapshot directory under the workspace. +- `workspace.reports_dir`: managed report directory under the workspace. +- `workspace.data_packages_dir`: prompt input package directory under the workspace. +- `workspace.preflight_dir`: preflight output directory under the workspace. +- `reports.output_dir`: report output directory. Default: `reports`. +- `reports.paths`: optional report-specific output paths. +- `dayparts`: named daypart definitions with `start` and `end` `HH:MM` values. +- `recent_change.temperature_degrees`: temperature change threshold. +- `recent_change.precip_probability_points`: precipitation probability threshold. +- `recent_change.wind_gust_miles_per_hour`: wind gust change threshold. +- `recent_change.precip_timing_shift_minutes`: precipitation timing shift threshold. diff --git a/examples/config.yml b/examples/config.yml new file mode 100644 index 0000000..0f6e8e7 --- /dev/null +++ b/examples/config.yml @@ -0,0 +1,46 @@ +weather_api: + base_url: https://weather.api.example.com/ + timeout: 15s + precision: 1 + units: us + timezone: Chicago + format: json + +missing_source: + default: warn + sources: + alerts: none + +scriptorium: + binary: scriptorium + timeout: 2m + +workspace: + root: workspace + snapshots_dir: snapshots + reports_dir: reports + data_packages_dir: data-packages + preflight_dir: preflight + +reports: + output_dir: reports + +dayparts: + - 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" + +recent_change: + temperature_degrees: 5 + precip_probability_points: 20 + wind_gust_miles_per_hour: 10 + precip_timing_shift_minutes: 120 diff --git a/go.mod b/go.mod index b9acf78..8ae41ad 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module gitea.maximumdirect.net/eric/weatherreporter go 1.26 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..0fa1b85 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,53 @@ +// Package app owns application orchestration and top-level use cases. +package app + +import ( + "context" + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" +) + +type ReportKind string + +const ( + ReportDaily ReportKind = "daily" + ReportTomorrow ReportKind = "tomorrow" + ReportThreeDay ReportKind = "three-day" + ReportWeekend ReportKind = "weekend" + ReportStorm ReportKind = "storm" +) + +type BatchKind string + +const ( + BatchMorning BatchKind = "morning" + BatchEvening BatchKind = "evening" +) + +type GenerateRequest struct { + Config config.Config + Report ReportKind + OutputPath string + Date time.Time + StormStart time.Time + StormEnd time.Time +} + +type BatchRequest struct { + Config config.Config + Batch BatchKind +} + +func Generate(ctx context.Context, req GenerateRequest) error { + _ = ctx + _ = req + return fmt.Errorf("generate is not implemented") +} + +func RunBatch(ctx context.Context, req BatchRequest) error { + _ = ctx + _ = req + return fmt.Errorf("run is not implemented") +} diff --git a/internal/app/doc.go b/internal/app/doc.go deleted file mode 100644 index 3a5d8cf..0000000 --- a/internal/app/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package app owns application orchestration and top-level use cases. -package app diff --git a/internal/cli/root.go b/internal/cli/root.go index 5b57826..49d6c96 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,41 +5,239 @@ import ( "flag" "fmt" "io" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) const helpText = `weatherreporter prepares weather reports from normalized forecast data. Usage: weatherreporter --help + weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] + weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] + weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] + weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] + weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME + weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] + weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] Options: - -h, --help Show this help message. + -h, --help Show this help message. + --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. + --units VALUE Override weather API units. + --tz NAME Override weather API timezone. + --out PATH Override report output path or directory. ` -// Run parses the root command and executes the selected behavior. +type Runner struct { + Clock timeutil.Clock +} + func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { - _ = ctx + return Runner{Clock: timeutil.SystemClock{}}.Run(ctx, args, stdout, stderr) +} + +func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { _ = stderr - - fs := flag.NewFlagSet("weatherreporter", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - help := fs.Bool("help", false, "show help") - fs.BoolVar(help, "h", false, "show help") - - if err := fs.Parse(args); err != nil { - return err + if r.Clock == nil { + r.Clock = timeutil.SystemClock{} } - - if *help { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { _, err := fmt.Fprint(stdout, helpText) return err } - if fs.NArg() > 0 { - return fmt.Errorf("unknown argument %q", fs.Arg(0)) + switch args[0] { + case "generate": + req, err := r.resolveGenerate(args[1:]) + if err != nil { + return err + } + return app.Generate(ctx, req) + case "run": + req, err := resolveRun(args[1:]) + if err != nil { + return err + } + return app.RunBatch(ctx, req) + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +type commonOptions struct { + ConfigPath string + Units string + Timezone string + Output string +} + +type generateOptions struct { + commonOptions + Date string + Start string + End string +} + +func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { + if len(args) == 0 { + return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") + } + report, ok := reportKind(args[0]) + if !ok { + return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0]) } - _, err := fmt.Fprint(stdout, helpText) - return err + opts, err := parseGenerateFlags(report, args[1:]) + if err != nil { + return app.GenerateRequest{}, err + } + cfg, err := config.Load(config.LoadOptions{ + Path: opts.ConfigPath, + Units: opts.Units, + Timezone: opts.Timezone, + Output: opts.Output, + }) + if err != nil { + return app.GenerateRequest{}, err + } + location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) + if err != nil { + return app.GenerateRequest{}, err + } + + req := app.GenerateRequest{ + Config: cfg, + Report: report, + OutputPath: opts.Output, + } + + switch report { + case app.ReportDaily: + if opts.Date == "" { + req.Date = timeutil.LocalDate(r.Clock.Now(), location) + } else { + req.Date, err = timeutil.ParseLocalDate(opts.Date, location) + if err != nil { + return app.GenerateRequest{}, err + } + } + case app.ReportStorm: + if opts.Start == "" { + return app.GenerateRequest{}, fmt.Errorf("generate storm requires --start") + } + if opts.End == "" { + return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end") + } + req.StormStart, err = timeutil.ParseStormTime(opts.Start, location) + if err != nil { + return app.GenerateRequest{}, err + } + req.StormEnd, err = timeutil.ParseStormTime(opts.End, location) + if err != nil { + return app.GenerateRequest{}, err + } + if !req.StormEnd.After(req.StormStart) { + return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start") + } + } + + return req, nil +} + +func resolveRun(args []string) (app.BatchRequest, error) { + if len(args) == 0 { + return app.BatchRequest{}, fmt.Errorf("run requires a batch name") + } + batch, ok := batchKind(args[0]) + if !ok { + return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0]) + } + opts, err := parseRunFlags(args[1:]) + if err != nil { + return app.BatchRequest{}, err + } + cfg, err := config.Load(config.LoadOptions{ + Path: opts.ConfigPath, + Units: opts.Units, + Timezone: opts.Timezone, + }) + if err != nil { + return app.BatchRequest{}, err + } + return app.BatchRequest{Config: cfg, Batch: batch}, nil +} + +func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) { + fs := flag.NewFlagSet("generate "+string(report), flag.ContinueOnError) + fs.SetOutput(io.Discard) + opts := generateOptions{} + addCommonFlags(fs, &opts.commonOptions, true) + if report == app.ReportDaily { + fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") + } + if report == app.ReportStorm { + fs.StringVar(&opts.Start, "start", "", "storm start time") + fs.StringVar(&opts.End, "end", "", "storm end time") + } + if err := fs.Parse(args); err != nil { + return generateOptions{}, err + } + if fs.NArg() > 0 { + return generateOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + return opts, nil +} + +func parseRunFlags(args []string) (commonOptions, error) { + fs := flag.NewFlagSet("run", flag.ContinueOnError) + fs.SetOutput(io.Discard) + opts := commonOptions{} + addCommonFlags(fs, &opts, false) + if err := fs.Parse(args); err != nil { + return commonOptions{}, err + } + if fs.NArg() > 0 { + return commonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + return opts, nil +} + +func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { + fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") + fs.StringVar(&opts.Units, "units", "", "weather API units") + fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") + if includeOutput { + fs.StringVar(&opts.Output, "out", "", "report output path or directory") + } +} + +func reportKind(value string) (app.ReportKind, bool) { + switch value { + case string(app.ReportDaily): + return app.ReportDaily, true + case string(app.ReportTomorrow): + return app.ReportTomorrow, true + case string(app.ReportThreeDay): + return app.ReportThreeDay, true + case string(app.ReportWeekend): + return app.ReportWeekend, true + case string(app.ReportStorm): + return app.ReportStorm, true + default: + return "", false + } +} + +func batchKind(value string) (app.BatchKind, bool) { + switch value { + case string(app.BatchMorning): + return app.BatchMorning, true + case string(app.BatchEvening): + return app.BatchEvening, true + default: + return "", false + } } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 0ed4202..ad93519 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -5,6 +5,10 @@ import ( "context" "strings" "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestRunHelpLongFlag(t *testing.T) { @@ -16,8 +20,8 @@ func TestRunHelpLongFlag(t *testing.T) { t.Fatalf("Run() error = %v", err) } - if !strings.Contains(stdout.String(), "Usage:") { - t.Fatalf("help output missing usage:\n%s", stdout.String()) + if !strings.Contains(stdout.String(), "generate daily") { + t.Fatalf("help output missing generate command:\n%s", stdout.String()) } } @@ -30,21 +34,159 @@ func TestRunHelpShortFlag(t *testing.T) { t.Fatalf("Run() error = %v", err) } - if !strings.Contains(stdout.String(), "weatherreporter --help") { - t.Fatalf("help output missing root help command:\n%s", stdout.String()) + if !strings.Contains(stdout.String(), "weatherreporter run evening") { + t.Fatalf("help output missing run command:\n%s", stdout.String()) } } -func TestRunUnknownArgument(t *testing.T) { +func TestRunUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - err := Run(context.Background(), []string{"generate"}, &stdout, &stderr) + err := Run(context.Background(), []string{"inspect"}, &stdout, &stderr) if err == nil { - t.Fatal("Run() error = nil, want unknown argument error") + t.Fatal("Run() error = nil, want unknown command error") } - if !strings.Contains(err.Error(), `unknown argument "generate"`) { - t.Fatalf("Run() error = %q, want unknown argument message", err.Error()) + if !strings.Contains(err.Error(), `unknown command "inspect"`) { + t.Fatalf("Run() error = %q, want unknown command message", err.Error()) } } + +func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{"generate", "tomorrow", "--units", "metric"}, &stdout, &stderr) + if err == nil { + t.Fatal("Run() error = nil, want app not implemented error") + } + if !strings.Contains(err.Error(), "generate is not implemented") { + t.Fatalf("Run() error = %q, want app not implemented error", err.Error()) + } +} + +func TestResolveGenerateCommands(t *testing.T) { + runner := Runner{Clock: fixedClock()} + tests := []struct { + name string + args []string + want app.ReportKind + }{ + {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, + {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, + {name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay}, + {name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend}, + {name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := runner.resolveGenerate(tt.args) + if err != nil { + t.Fatalf("resolveGenerate() error = %v", err) + } + if req.Report != tt.want { + t.Fatalf("Report = %q, want %q", req.Report, tt.want) + } + }) + } +} + +func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) { + runner := Runner{Clock: fixedClock()} + + req, err := runner.resolveGenerate([]string{"daily"}) + if err != nil { + t.Fatalf("resolveGenerate() error = %v", err) + } + + if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" { + t.Fatalf("Date = %s, want 2026-05-29", got) + } +} + +func TestResolveGenerateAppliesSharedFlags(t *testing.T) { + runner := Runner{Clock: fixedClock()} + + req, err := runner.resolveGenerate([]string{"daily", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"}) + if err != nil { + t.Fatalf("resolveGenerate() error = %v", err) + } + + if req.Config.WeatherAPI.Units != "metric" { + t.Fatalf("Units = %q, want metric", req.Config.WeatherAPI.Units) + } + if req.Config.WeatherAPI.Timezone != "UTC" { + t.Fatalf("Timezone = %q, want UTC", req.Config.WeatherAPI.Timezone) + } + if req.OutputPath != "./daily.md" { + t.Fatalf("OutputPath = %q, want ./daily.md", req.OutputPath) + } +} + +func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) { + runner := Runner{Clock: fixedClock()} + + _, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"}) + if err == nil { + t.Fatal("resolveGenerate() error = nil, want missing end error") + } + if !strings.Contains(err.Error(), "requires --end") { + t.Fatalf("error = %q, want missing end", err.Error()) + } +} + +func TestResolveGenerateStormParsesRFC3339(t *testing.T) { + runner := Runner{Clock: fixedClock()} + + req, err := runner.resolveGenerate([]string{ + "storm", + "--start", "2026-05-29T18:00:00-05:00", + "--end", "2026-05-30T06:00:00-05:00", + }) + if err != nil { + t.Fatalf("resolveGenerate() error = %v", err) + } + if !req.StormEnd.After(req.StormStart) { + t.Fatalf("StormEnd = %s, want after %s", req.StormEnd, req.StormStart) + } +} + +func TestResolveRunCommands(t *testing.T) { + tests := []struct { + name string + args []string + want app.BatchKind + }{ + {name: "morning", args: []string{"morning"}, want: app.BatchMorning}, + {name: "evening", args: []string{"evening", "--tz", "UTC"}, want: app.BatchEvening}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := resolveRun(tt.args) + if err != nil { + t.Fatalf("resolveRun() error = %v", err) + } + if req.Batch != tt.want { + t.Fatalf("Batch = %q, want %q", req.Batch, tt.want) + } + }) + } +} + +func TestResolveRunRejectsOutputFlag(t *testing.T) { + _, err := resolveRun([]string{"morning", "--out", "./report.md"}) + if err == nil { + t.Fatal("resolveRun() error = nil, want flag error") + } + if !strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("error = %q, want undefined flag error", err.Error()) + } +} + +func fixedClock() timeutil.Clock { + return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)} +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..aacfed4 --- /dev/null +++ b/internal/config/config.go @@ -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"` +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e3acc24 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..0677387 --- /dev/null +++ b/internal/config/defaults.go @@ -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, + }, + } +} diff --git a/internal/config/doc.go b/internal/config/doc.go deleted file mode 100644 index 04a6075..0000000 --- a/internal/config/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package config owns application configuration structures, defaults, loading, -// precedence, and validation. -package config diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..7e4651a --- /dev/null +++ b/internal/config/load.go @@ -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 +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..756ebd8 --- /dev/null +++ b/internal/config/validate.go @@ -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 +} diff --git a/internal/timeutil/clock.go b/internal/timeutil/clock.go new file mode 100644 index 0000000..7169c96 --- /dev/null +++ b/internal/timeutil/clock.go @@ -0,0 +1,22 @@ +// Package timeutil provides time-zone, clock, and period helpers. +package timeutil + +import "time" + +type Clock interface { + Now() time.Time +} + +type SystemClock struct{} + +func (SystemClock) Now() time.Time { + return time.Now() +} + +type FixedClock struct { + Time time.Time +} + +func (c FixedClock) Now() time.Time { + return c.Time +} diff --git a/internal/timeutil/parse.go b/internal/timeutil/parse.go new file mode 100644 index 0000000..08d073d --- /dev/null +++ b/internal/timeutil/parse.go @@ -0,0 +1,49 @@ +package timeutil + +import ( + "fmt" + "strings" + "time" +) + +const DateLayout = "2006-01-02" +const LocalDateTimeLayout = "2006-01-02T15:04" + +func LoadLocation(name string) (*time.Location, error) { + location, err := time.LoadLocation(name) + if err == nil { + return location, nil + } + if strings.Contains(name, "/") { + return nil, fmt.Errorf("load timezone %q: %w", name, err) + } + location, chicagoErr := time.LoadLocation("America/" + name) + if chicagoErr == nil { + return location, nil + } + return nil, fmt.Errorf("load timezone %q: %w", name, err) +} + +func LocalDate(now time.Time, location *time.Location) time.Time { + local := now.In(location) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) +} + +func ParseLocalDate(value string, location *time.Location) (time.Time, error) { + parsed, err := time.ParseInLocation(DateLayout, value, location) + if err != nil { + return time.Time{}, fmt.Errorf("parse date %q as YYYY-MM-DD: %w", value, err) + } + return parsed, nil +} + +func ParseStormTime(value string, location *time.Location) (time.Time, error) { + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + parsed, err := time.ParseInLocation(LocalDateTimeLayout, value, location) + if err != nil { + return time.Time{}, fmt.Errorf("parse storm time %q as YYYY-MM-DDTHH:MM or RFC3339: %w", value, err) + } + return parsed, nil +} diff --git a/internal/timeutil/parse_test.go b/internal/timeutil/parse_test.go new file mode 100644 index 0000000..6be4843 --- /dev/null +++ b/internal/timeutil/parse_test.go @@ -0,0 +1,49 @@ +package timeutil + +import ( + "testing" + "time" +) + +func TestLoadLocationAcceptsChicagoAlias(t *testing.T) { + location, err := LoadLocation("Chicago") + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + if location.String() != "America/Chicago" { + t.Fatalf("location = %q, want America/Chicago", location.String()) + } +} + +func TestParseLocalDate(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + got, err := ParseLocalDate("2026-05-29", location) + if err != nil { + t.Fatalf("ParseLocalDate() error = %v", err) + } + if got.Format(DateLayout) != "2026-05-29" { + t.Fatalf("date = %s, want 2026-05-29", got.Format(DateLayout)) + } + if got.Location() != location { + t.Fatalf("location = %v, want test location", got.Location()) + } +} + +func TestParseStormTime(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + local, err := ParseStormTime("2026-05-29T18:00", location) + if err != nil { + t.Fatalf("ParseStormTime(local) error = %v", err) + } + if local.Location() != location { + t.Fatalf("local location = %v, want test location", local.Location()) + } + + rfc3339, err := ParseStormTime("2026-05-29T18:00:00-05:00", location) + if err != nil { + t.Fatalf("ParseStormTime(rfc3339) error = %v", err) + } + if rfc3339.Format(time.RFC3339) != "2026-05-29T18:00:00-05:00" { + t.Fatalf("rfc3339 = %s, want preserved offset time", rfc3339.Format(time.RFC3339)) + } +}