diff --git a/docs/cli.md b/docs/cli.md index 32fa3ce..532f205 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,6 +20,7 @@ the managed Markdown report after final metadata is saved. ```text weatherreporter --help weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] +weatherreporter generate today [--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 hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] @@ -38,15 +39,15 @@ weatherreporter inspect sources [--config PATH] RUN_ID Implemented `generate` commands write a JSON module snapshot, YAML data package, preflight artifact, managed Markdown report, and metadata under the configured workspace. `--out` writes an extra Markdown copy for the operator; distributor -notification uses the managed report path, not the extra copy. `generate -tomorrow` and `generate hourly` write managed generated-text artifacts, +notification uses the managed report path, not the extra copy. `generate today`, +`generate tomorrow`, and `generate hourly` write managed generated-text artifacts, validate structured text from Scriptorium, and render the managed Markdown report from embedded templates. `generate hourly` covers the next six hours in the effective report timezone and does not accept date or event window flags. `generate storm` requires explicit event-window bounds with `--start` and `--end`. -`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook +`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook except on Sunday. `run evening` generates the Tomorrow Report. Batch runs continue independent reports after a failure, print a JSON summary to stdout, write compact status lines to stderr, and return nonzero when any report @@ -70,7 +71,7 @@ They do not fetch weather data or invoke `scriptorium`. - `--tz NAME`: override configured Weather API timezone for `generate` and `run`. - `--out PATH`: write an extra Markdown report copy where supported by the `generate` command. - `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`. -- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. +- `--date YYYY-MM-DD`: optional date for `generate daily` and `generate today`; defaults to the current local date in the configured timezone. - `--start TIME`: required start time for `generate storm`. - `--end TIME`: required end time for `generate storm`. - `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit. @@ -85,6 +86,7 @@ are no distributor-specific CLI flags. ```sh weatherreporter generate tomorrow --out ./tomorrow.md +weatherreporter generate today --out ./today.md weatherreporter generate hourly weatherreporter generate three-day --out ./three-day.md weatherreporter generate weekend --out ./weekend.md diff --git a/internal/app/app.go b/internal/app/app.go index fef21dd..be2a307 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -30,6 +30,7 @@ type ReportKind string const ( ReportDaily ReportKind = ReportKind(report.CommandNameDaily) + ReportToday ReportKind = ReportKind(report.CommandNameToday) ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow) ReportHourly ReportKind = ReportKind(report.CommandNameHourly) ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6899cd1..6c1dd72 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -597,6 +597,108 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T) } } +func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + cfg.Notify.Distributor.Enabled = true + cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}" + cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}" + cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}" + cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"} + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportToday, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + outputPath := filepath.Join(t.TempDir(), "today-copy.md") + notifier := &recordingNotifier{ + result: &NotificationResult{ + RunID: "distributor-run", + Status: "succeeded", + UploadStatus: "accepted", + PipelineID: "reports", + Report: []byte(`{"actions":[{"action":"replace_older"}]}`), + }, + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + structuredRunBody: validTodayGeneratedTextJSON(), + } + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + OutputPath: outputPath, + Renderer: renderer, + Notifier: notifier, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + if result.OutputPath != outputPath { + t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath) + } + assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath) + reportData, err := os.ReadFile(result.ReportPath) + if err != nil { + t.Fatalf("read managed report: %v", err) + } + copyData, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output copy: %v", err) + } + if string(copyData) != string(reportData) { + t.Fatalf("output copy differs from managed report") + } + if len(notifier.requests) != 1 { + t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) + } + req := notifier.requests[0] + if req.ReportID != report.Today { + t.Fatalf("notification ReportID = %q, want today", req.ReportID) + } + if req.ReportPath != result.ReportPath { + t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath) + } + if req.ReportPath == outputPath { + t.Fatalf("notification used output copy %q, want managed report path", outputPath) + } + if req.PipelineID != "weatherreporter.today.today" { + t.Fatalf("PipelineID = %q, want Today report and artifact values", req.PipelineID) + } + if req.BundleID != "today.today.md.today" { + t.Fatalf("BundleID = %q, want Today artifact group, output name, and report id", req.BundleID) + } + wantBundlePaths := []string{"2026-05-29/today/today.md"} + if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { + t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) + } + notificationData, err := os.ReadFile(result.NotificationPath) + if err != nil { + t.Fatalf("read notification artifact: %v", err) + } + var notificationArtifact state.DistributorNotificationArtifact + if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil { + t.Fatalf("decode notification artifact: %v", err) + } + if notificationArtifact.ReportID != report.Today || + notificationArtifact.PipelineID != "weatherreporter.today.today" || + notificationArtifact.BundleID != "today.today.md.today" || + notificationArtifact.SourcePath != result.ReportPath || + strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || + notificationArtifact.RunStatus == nil || + !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") { + t.Fatalf("notification artifact = %#v, want Today managed-source notification", notificationArtifact) + } +} + func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) { cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) notifier.err = errors.New("upload rejected") @@ -1350,7 +1452,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) { } priorResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, - Report: ReportKind(report.CommandNameToday), + Report: ReportToday, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T04:00:00-05:00")) if err != nil { @@ -1360,7 +1462,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) { currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, - Report: ReportKind(report.CommandNameToday), + Report: ReportToday, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { @@ -2302,6 +2404,49 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) { } } +func TestRunBatchMorningUsesTodayOutputName(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + outputDir := filepath.Join(t.TempDir(), "reports") + + result, err := RunBatchDetailed(context.Background(), BatchRequest{ + Config: cfg, + Batch: BatchMorning, + Now: mustParse("2026-05-29T05:00:00-05:00"), + OutputDir: outputDir, + Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, + }) + if err != nil { + t.Fatalf("RunBatchDetailed() error = %v", err) + } + if result.Failed != 0 || len(result.Reports) != 3 { + t.Fatalf("summary = %#v, want successful morning batch", result) + } + var todayItem *BatchReportResult + for i := range result.Reports { + if result.Reports[i].ReportID == report.Today { + todayItem = &result.Reports[i] + break + } + } + if todayItem == nil { + t.Fatalf("reports = %#v, want Today item", result.Reports) + } + want := filepath.Join(outputDir, "today.md") + if todayItem.OutputPath != want { + t.Fatalf("Today OutputPath = %q, want %q", todayItem.OutputPath, want) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected Today output copy %q: %v", want, err) + } + if strings.Contains(todayItem.ReportPath, outputDir) { + t.Fatalf("Today ReportPath = %q, want managed report path separate from output copy", todayItem.ReportPath) + } +} + func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) { definition, err := report.DefaultRegistry().Lookup(report.Hourly) if err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index e0bbcc4..7125bdd 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -18,6 +18,7 @@ const helpText = `weatherreporter prepares weather reports from normalized forec Usage: weatherreporter --help weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] + weatherreporter generate today [--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 hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] @@ -216,7 +217,7 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { } switch reportKind { - case app.ReportDaily: + case app.ReportDaily, app.ReportToday: if opts.Date == "" { req.Date = timeutil.LocalDate(r.Clock.Now(), location) } else { @@ -278,7 +279,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, fs.SetOutput(io.Discard) opts := generateOptions{} addCommonFlags(fs, &opts.commonOptions, true) - if report == app.ReportDaily { + if report == app.ReportDaily || report == app.ReportToday { fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") } if report == app.ReportStorm { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index a8421be..ac27c1d 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -26,6 +26,9 @@ func TestRunHelpLongFlag(t *testing.T) { if !strings.Contains(output.stdout, "generate daily") { t.Fatalf("help output missing generate command:\n%s", output.stdout) } + if !strings.Contains(output.stdout, "generate today") { + t.Fatalf("help output missing today generate command:\n%s", output.stdout) + } if !strings.Contains(output.stdout, "weatherreporter generate hourly") { t.Fatalf("help output missing hourly generate command:\n%s", output.stdout) } @@ -570,6 +573,66 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { } } +func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir) + workspaceRoot := filepath.Join(tempDir, "workspace") + configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) + outPath := filepath.Join(tempDir, "today.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{ + "generate", "today", + "--config", configPath, + "--date", "2026-05-29", + "--out", outPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + report, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + for _, want := range []string{ + "# Today's Weather", + "Today starts with showers before improving.", + "Morning showers should taper as drier air arrives.", + } { + if !strings.Contains(string(report), want) { + t.Fatalf("today report output missing %q:\n%s", want, string(report)) + } + } + dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") + dataPackage, err := os.ReadFile(dataPackagePath) + if err != nil { + t.Fatalf("read managed data package: %v", err) + } + if !strings.Contains(string(dataPackage), "id: today") || + !strings.Contains(string(dataPackage), "prompt_id: weather.today_generated_text") || + !strings.Contains(string(dataPackage), "today_planning:") { + t.Fatalf("data package output missing Today content:\n%s", string(dataPackage)) + } + dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) + if err != nil { + t.Fatalf("glob daily packages: %v", err) + } + if len(dailyPackages) != 0 { + t.Fatalf("daily packages = %#v, want generate today to stay separate", dailyPackages) + } + rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json") + validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.json") + renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json") + managedReportPath := oneArtifact(t, workspaceRoot, "reports", "today", "*.md") + assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`) + assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`) + assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`) + assertFileContains(t, managedReportPath, "# Today's Weather") +} + func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() @@ -627,6 +690,65 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { assertFileContains(t, managedReportPath, "# Hourly Report") } +func TestRunInspectTodayArtifacts(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + configPath := filepath.Join(tempDir, "config.yml") + workspaceRoot := filepath.Join(tempDir, "workspace") + configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + runner := Runner{Clock: fixedClock()} + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runner.Run(context.Background(), []string{ + "generate", "today", + "--config", configPath, + "--date", "2026-05-29", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run(generate) error = %v", err) + } + dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")) + if err != nil { + t.Fatalf("glob data package: %v", err) + } + if len(dataPackageMatches) != 1 { + t.Fatalf("data package files = %#v, want one", dataPackageMatches) + } + runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml") + + stdout.Reset() + err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr) + if err != nil { + t.Fatalf("Run(inspect reports) error = %v", err) + } + if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"reportId": "today"`) { + t.Fatalf("inspect reports output missing Today run:\n%s", stdout.String()) + } + + var sourcesOutput string + for _, command := range []string{"metadata", "modules", "data-package", "sources"} { + stdout.Reset() + err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr) + if err != nil { + t.Fatalf("Run(inspect %s) error = %v", command, err) + } + if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), "today") { + t.Fatalf("inspect %s output missing Today run id:\n%s", command, stdout.String()) + } + if command == "sources" { + sourcesOutput = stdout.String() + } + } + if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) { + t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput) + } +} + func TestRunInspectGeneratedArtifacts(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() @@ -753,6 +875,7 @@ func TestResolveGenerateCommands(t *testing.T) { want app.ReportKind }{ {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, + {name: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday}, {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, {name: "hourly", args: []string{"hourly"}, want: app.ReportHourly}, {name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay}, @@ -778,7 +901,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) { for _, name := range report.CommandNames() { t.Run(name, func(t *testing.T) { args := []string{name} - if name == report.CommandNameDaily { + if name == report.CommandNameDaily || name == report.CommandNameToday { args = append(args, "--date", "2026-05-29") } if name == report.CommandNameStorm { @@ -865,6 +988,39 @@ func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) { } } +func TestResolveGenerateTodayDate(t *testing.T) { + runner := Runner{Clock: fixedClock()} + + defaultReq, err := runner.resolveGenerate([]string{"today"}) + if err != nil { + t.Fatalf("resolveGenerate(default) error = %v", err) + } + if defaultReq.Report != app.ReportToday { + t.Fatalf("Report = %q, want today", defaultReq.Report) + } + if got := defaultReq.Date.Format(timeutil.DateLayout); got != "2026-05-29" { + t.Fatalf("default Date = %s, want 2026-05-29", got) + } + + explicitReq, err := runner.resolveGenerate([]string{"today", "--date", "2026-05-30", "--tz", "UTC"}) + if err != nil { + t.Fatalf("resolveGenerate(explicit) error = %v", err) + } + if got := explicitReq.Date.Format(timeutil.DateLayout); got != "2026-05-30" { + t.Fatalf("explicit Date = %s, want 2026-05-30", got) + } + resolved, err := app.ResolveGenerate(explicitReq, explicitReq.Now) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + if resolved.Definition.ID != report.Today { + t.Fatalf("resolved ID = %q, want today", resolved.Definition.ID) + } + if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-30T00:00:00Z" { + t.Fatalf("valid period start = %s, want explicit UTC date", got) + } +} + func TestResolveGenerateAppliesSharedFlags(t *testing.T) { runner := Runner{Clock: fixedClock()}