Consolidate CLI report date policy

This commit is contained in:
2026-08-13 04:06:37 +00:00
parent 71b7a74d3d
commit 17468cb8dd
5 changed files with 187 additions and 67 deletions

View File

@@ -69,36 +69,6 @@ func TestResolveComparisonActionBuildsExplicitRequest(t *testing.T) {
}
}
func TestResolveComparisonActionMatchesReportDatePolicies(t *testing.T) {
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n timezone: America/Chicago\n")
runner := comparisonRunner(t, t.TempDir())
for _, test := range []struct {
name string
args []string
wantDay string
wantErr bool
}{
{name: "daily requires date", args: []string{"daily", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
{name: "today uses current local date", args: []string{"today", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-29"},
{name: "today accepts date", args: []string{"today", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-30"},
{name: "tomorrow rejects date", args: []string{"tomorrow", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
{name: "hourly rejects date", args: []string{"hourly", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
req, _, err := runner.resolveComparisonAction(test.args)
if test.wantErr {
if err == nil {
t.Fatal("resolveComparisonAction() error = nil")
}
return
}
if err != nil || req.Date.Format("2006-01-02") != test.wantDay {
t.Fatalf("request/error = %#v/%v", req, err)
}
})
}
}
func TestResolveComparisonActionLeavesConfiguredOutputWithoutOverride(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: configured/../reports\n")

View File

@@ -0,0 +1,75 @@
package cli
import (
"errors"
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
var (
errReportDateRequired = errors.New("report date is required")
errReportDateNotAccepted = errors.New("report does not accept a date")
)
type reportDatePolicy struct {
acceptsDate bool
requiresDate bool
defaultsToLocalDate bool
}
func reportDatePolicyFor(report app.ReportKind) reportDatePolicy {
switch report {
case app.ReportDaily:
return reportDatePolicy{acceptsDate: true, requiresDate: true}
case app.ReportToday:
return reportDatePolicy{acceptsDate: true, defaultsToLocalDate: true}
default:
return reportDatePolicy{}
}
}
func addReportDateFlag(fs interface {
StringVar(*string, string, string, string)
}, report app.ReportKind, value *string) {
if reportDatePolicyFor(report).acceptsDate {
fs.StringVar(value, "date", "", "report date in YYYY-MM-DD")
}
}
func reportDateRequiredError(action string, report app.ReportKind, value string) error {
if reportDatePolicyFor(report).requiresDate && value == "" {
return fmt.Errorf("%s %s requires --date YYYY-MM-DD", action, report)
}
return nil
}
func resolveActionReportDate(action string, report app.ReportKind, value string, location *time.Location, now time.Time) (time.Time, error) {
date, err := resolveReportDate(report, value, location, now)
if errors.Is(err, errReportDateRequired) {
return time.Time{}, fmt.Errorf("%s %s requires --date YYYY-MM-DD", action, report)
}
return date, err
}
func resolveReportDate(report app.ReportKind, value string, location *time.Location, now time.Time) (time.Time, error) {
policy := reportDatePolicyFor(report)
if !policy.acceptsDate {
if value != "" {
return time.Time{}, errReportDateNotAccepted
}
return time.Time{}, nil
}
if value == "" {
if policy.requiresDate {
return time.Time{}, errReportDateRequired
}
if policy.defaultsToLocalDate {
return timeutil.LocalDate(now, location), nil
}
return time.Time{}, nil
}
return timeutil.ParseLocalDate(value, location)
}

View File

@@ -0,0 +1,101 @@
package cli
import (
"errors"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
)
func TestReportDatePolicy(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
for _, tt := range []struct {
name string
report app.ReportKind
value string
wantDay string
wantErr error
malformed bool
}{
{name: "DailyRequiresDate", report: app.ReportDaily, wantErr: errReportDateRequired},
{name: "DailyParsesDate", report: app.ReportDaily, value: "2026-05-30", wantDay: "2026-05-30"},
{name: "TodayDefaultsInConfiguredTimezone", report: app.ReportToday, wantDay: "2026-05-29"},
{name: "TodayParsesDate", report: app.ReportToday, value: "2026-05-30", wantDay: "2026-05-30"},
{name: "TodayRejectsMalformedDate", report: app.ReportToday, value: "not-a-date", malformed: true},
{name: "TomorrowHasNoDate", report: app.ReportTomorrow},
{name: "TomorrowRejectsDate", report: app.ReportTomorrow, value: "2026-05-30", wantErr: errReportDateNotAccepted},
{name: "HourlyHasNoDate", report: app.ReportHourly},
{name: "HourlyRejectsDate", report: app.ReportHourly, value: "2026-05-30", wantErr: errReportDateNotAccepted},
} {
t.Run(tt.name, func(t *testing.T) {
date, err := resolveReportDate(tt.report, tt.value, location, now)
if tt.malformed {
if err == nil {
t.Fatal("resolveReportDate() error = nil")
}
return
}
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("resolveReportDate() error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveReportDate() error = %v", err)
}
if tt.wantDay == "" {
if !date.IsZero() {
t.Fatalf("resolveReportDate() = %v, want zero date", date)
}
return
}
if date.Location() != location || date.Format("2006-01-02") != tt.wantDay {
t.Fatalf("resolveReportDate() = %v, want %s in %s", date, tt.wantDay, location)
}
})
}
}
func TestReportDateFlagsFollowPolicy(t *testing.T) {
for _, tt := range []struct {
report app.ReportKind
acceptsDate bool
}{
{report: app.ReportDaily, acceptsDate: true},
{report: app.ReportToday, acceptsDate: true},
{report: app.ReportTomorrow},
{report: app.ReportHourly},
} {
t.Run(string(tt.report), func(t *testing.T) {
_, generateErr := parseGenerateFlags(tt.report, []string{"--date", "2026-05-30"})
_, comparisonErr := parseComparisonFlags(tt.report, []string{"--date", "2026-05-30"})
if tt.acceptsDate && (generateErr != nil || comparisonErr != nil) {
t.Fatalf("date flag errors = %v/%v, want accepted", generateErr, comparisonErr)
}
if !tt.acceptsDate && (generateErr == nil || comparisonErr == nil) {
t.Fatalf("date flag errors = %v/%v, want rejected", generateErr, comparisonErr)
}
})
}
}
func TestResolveActionReportDateKeepsActionSpecificMissingDateErrors(t *testing.T) {
location := time.UTC
now := time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
for _, action := range []string{"generate", "compare"} {
t.Run(action, func(t *testing.T) {
_, err := resolveActionReportDate(action, app.ReportDaily, "", location, now)
want := action + " daily requires --date YYYY-MM-DD"
if err == nil || err.Error() != want {
t.Fatalf("error = %v, want %q", err, want)
}
})
}
}

View File

@@ -201,8 +201,8 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
if reportKind == app.ReportDaily && opts.Date == "" {
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
if err := reportDateRequiredError("generate", reportKind, opts.Date); err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
@@ -224,21 +224,9 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
Now: now,
}
switch reportKind {
case app.ReportDaily:
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
case app.ReportToday:
if opts.Date == "" {
req.Date = timeutil.LocalDate(now, location)
} else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
}
req.Date, err = resolveActionReportDate("generate", reportKind, opts.Date, location, now)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
workingDir, err := r.workingDir()
@@ -307,19 +295,7 @@ func (r Runner) resolveComparisonAction(args []string) (app.ComparisonRequest, c
Config: cfg, Report: reportKind, ProfileIDs: append([]string(nil), opts.ProfileIDs...),
WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock,
}
switch reportKind {
case app.ReportDaily:
if opts.Date == "" {
return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare daily requires --date YYYY-MM-DD")
}
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
case app.ReportToday:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
}
}
req.Date, err = resolveActionReportDate("compare", reportKind, opts.Date, location, r.Clock.Now())
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
@@ -382,9 +358,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true)
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
addReportDateFlag(fs, report, &opts.Date)
if err := fs.Parse(args); err != nil {
return generateOptions{}, err
}
@@ -419,9 +393,7 @@ func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptio
fs.BoolVar(&opts.Replace, "replace", false, "replace a recognized comparison bundle")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID")
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
addReportDateFlag(fs, report, &opts.Date)
if err := fs.Parse(args); err != nil {
return comparisonOptions{}, err
}