Consolidate CLI report date policy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
75
internal/cli/report_date.go
Normal file
75
internal/cli/report_date.go
Normal 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)
|
||||
}
|
||||
101
internal/cli/report_date_test.go
Normal file
101
internal/cli/report_date_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user