diff --git a/docs/internal/cli.md b/docs/internal/cli.md index f61aaa8..e7aa0ed 100644 --- a/docs/internal/cli.md +++ b/docs/internal/cli.md @@ -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 diff --git a/internal/cli/comparison_test.go b/internal/cli/comparison_test.go index 9015c5e..06a70f7 100644 --- a/internal/cli/comparison_test.go +++ b/internal/cli/comparison_test.go @@ -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") diff --git a/internal/cli/report_date.go b/internal/cli/report_date.go new file mode 100644 index 0000000..e74d6fa --- /dev/null +++ b/internal/cli/report_date.go @@ -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) +} diff --git a/internal/cli/report_date_test.go b/internal/cli/report_date_test.go new file mode 100644 index 0000000..7044d75 --- /dev/null +++ b/internal/cli/report_date_test.go @@ -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) + } + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 9cd60cc..b72c99b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 }