Add configuration and CLI foundation
This commit is contained in:
10
README.md
10
README.md
@@ -3,20 +3,20 @@
|
|||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
`weatherreporter` is a Go application for preparing human-facing weather
|
||||||
reports from normalized forecast data.
|
reports from normalized forecast data.
|
||||||
|
|
||||||
The repository currently contains the application skeleton and a minimal CLI
|
The application currently resolves configuration and CLI requests. Weather API
|
||||||
help command. Report generation, configuration loading, weather API fetching,
|
fetching, report generation, and `scriptorium` rendering are tracked in the
|
||||||
and `scriptorium` rendering are tracked in the roadmap and are not implemented
|
roadmap and are not implemented yet.
|
||||||
yet.
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter --help
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [CLI reference](docs/cli.md)
|
- [CLI reference](docs/cli.md)
|
||||||
|
- [Configuration reference](docs/config.md)
|
||||||
- [Architecture policy](docs/policy/architecture.md)
|
- [Architecture policy](docs/policy/architecture.md)
|
||||||
- [Development policy](docs/policy/development.md)
|
- [Development policy](docs/policy/development.md)
|
||||||
- [Implementation roadmap](docs/roadmap/initial.md)
|
- [Implementation roadmap](docs/roadmap/initial.md)
|
||||||
|
|||||||
32
docs/cli.md
32
docs/cli.md
@@ -1,23 +1,43 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter` currently exposes only the root help command while the
|
`weatherreporter` currently resolves configuration and command requests, then
|
||||||
application skeleton is being established.
|
returns a not-implemented error for report generation and scheduled runs.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```sh
|
```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
|
## Command Overview
|
||||||
|
|
||||||
```text
|
```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
|
`generate` commands resolve one report request. `run` commands resolve a
|
||||||
external services.
|
scheduled batch request. All report and batch execution currently returns
|
||||||
|
`not implemented` after request resolution.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
- `-h`, `--help`: show help.
|
- `-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.
|
||||||
|
|||||||
46
docs/config.md
Normal file
46
docs/config.md
Normal file
@@ -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.
|
||||||
46
examples/config.yml
Normal file
46
examples/config.yml
Normal file
@@ -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
|
||||||
2
go.mod
2
go.mod
@@ -1,3 +1,5 @@
|
|||||||
module gitea.maximumdirect.net/eric/weatherreporter
|
module gitea.maximumdirect.net/eric/weatherreporter
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||||
53
internal/app/app.go
Normal file
53
internal/app/app.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Package app owns application orchestration and top-level use cases.
|
|
||||||
package app
|
|
||||||
@@ -5,41 +5,239 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"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.
|
const helpText = `weatherreporter prepares weather reports from normalized forecast data.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
weatherreporter --help
|
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:
|
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 {
|
||||||
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
Clock timeutil.Clock
|
||||||
_ = ctx
|
}
|
||||||
_ = stderr
|
|
||||||
|
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||||
fs := flag.NewFlagSet("weatherreporter", flag.ContinueOnError)
|
return Runner{Clock: timeutil.SystemClock{}}.Run(ctx, args, stdout, stderr)
|
||||||
fs.SetOutput(io.Discard)
|
}
|
||||||
|
|
||||||
help := fs.Bool("help", false, "show help")
|
func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||||
fs.BoolVar(help, "h", false, "show help")
|
_ = stderr
|
||||||
|
if r.Clock == nil {
|
||||||
if err := fs.Parse(args); err != nil {
|
r.Clock = timeutil.SystemClock{}
|
||||||
return err
|
}
|
||||||
}
|
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||||
|
_, err := fmt.Fprint(stdout, helpText)
|
||||||
if *help {
|
return err
|
||||||
_, err := fmt.Fprint(stdout, helpText)
|
}
|
||||||
return err
|
|
||||||
}
|
switch args[0] {
|
||||||
|
case "generate":
|
||||||
if fs.NArg() > 0 {
|
req, err := r.resolveGenerate(args[1:])
|
||||||
return fmt.Errorf("unknown argument %q", fs.Arg(0))
|
if err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
_, err := fmt.Fprint(stdout, helpText)
|
return app.Generate(ctx, req)
|
||||||
return err
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunHelpLongFlag(t *testing.T) {
|
func TestRunHelpLongFlag(t *testing.T) {
|
||||||
@@ -16,8 +20,8 @@ func TestRunHelpLongFlag(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(stdout.String(), "Usage:") {
|
if !strings.Contains(stdout.String(), "generate daily") {
|
||||||
t.Fatalf("help output missing usage:\n%s", stdout.String())
|
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)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(stdout.String(), "weatherreporter --help") {
|
if !strings.Contains(stdout.String(), "weatherreporter run evening") {
|
||||||
t.Fatalf("help output missing root help command:\n%s", stdout.String())
|
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 stdout bytes.Buffer
|
||||||
var stderr 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 {
|
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"`) {
|
if !strings.Contains(err.Error(), `unknown command "inspect"`) {
|
||||||
t.Fatalf("Run() error = %q, want unknown argument message", err.Error())
|
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)}
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
22
internal/timeutil/clock.go
Normal file
22
internal/timeutil/clock.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
49
internal/timeutil/parse.go
Normal file
49
internal/timeutil/parse.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
49
internal/timeutil/parse_test.go
Normal file
49
internal/timeutil/parse_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user