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

View File

@@ -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
}
}

View File

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