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

@@ -12,7 +12,9 @@ For each `generate`, `run`, or `compare` action, `Runner` constructs one project
The CLI dispatches generation, batch, and comparison actions. It has no persisted-run or inspection dispatch. Generation and batch summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. Comparison summaries retain their ordered profile results and published bundle paths when available. All summaries intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned unless `--quiet` is set.
CLI code owns no report policy, weather collection, output publication, provider execution, or notification policy. Focused checks:
CLI code owns report-date flag acceptance and date resolution, but not report
composition, weather collection, output publication, provider execution, or
notification policy. Focused checks:
```sh
go test ./internal/cli

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
}