diff --git a/internal/cli/root.go b/internal/cli/root.go index f36d0c0..2ddcb19 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -50,7 +50,6 @@ func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) } func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { - _ = stderr if r.Clock == nil { r.Clock = timeutil.SystemClock{} } @@ -61,24 +60,32 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr switch args[0] { case "generate": - req, err := r.resolveGenerate(args[1:]) + req, opts, err := r.resolveGenerateAction(args[1:]) if err != nil { return err } - return app.Generate(ctx, req) + result, err := app.GenerateDetailed(ctx, req) + if result != nil { + summary := newGenerateSummary(result, err) + if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil { + return encodeErr + } + } + return err case "run": - req, err := r.resolveRun(args[1:]) + req, opts, err := r.resolveRunAction(args[1:]) if err != nil { return err } result, err := app.RunBatchDetailed(ctx, req) if result != nil { - if encodeErr := writeActionResult(stdout, stderr, result, outputOptions{}, func(w io.Writer) { + summary := newBatchSummary(result) + if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) { writeBatchStatus(w, result) }); encodeErr != nil { return encodeErr } - if result.Failed > 0 { + if summary.Status == summaryStatusFailed { return app.BatchError{Result: result} } } @@ -96,6 +103,7 @@ type commonOptions struct { Timezone string Output string OutputDir string + Quiet bool } type generateOptions struct { @@ -181,20 +189,25 @@ func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspect } func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { + req, _, err := r.resolveGenerateAction(args) + return req, err +} + +func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { - return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") + return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate requires a report name") } if _, err := report.IDForCommandName(args[0]); err != nil { - return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0]) + return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0]) } reportKind := app.ReportKind(args[0]) opts, err := parseGenerateFlags(reportKind, args[1:]) if err != nil { - return app.GenerateRequest{}, err + return app.GenerateRequest{}, commonOptions{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, @@ -202,11 +215,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { Timezone: opts.Timezone, }) if err != nil { - return app.GenerateRequest{}, err + return app.GenerateRequest{}, commonOptions{}, err } location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) if err != nil { - return app.GenerateRequest{}, err + return app.GenerateRequest{}, commonOptions{}, err } req := app.GenerateRequest{ @@ -219,11 +232,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { switch reportKind { case app.ReportDaily: if opts.Date == "" { - return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD") + return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD") } req.Date, err = timeutil.ParseLocalDate(opts.Date, location) if err != nil { - return app.GenerateRequest{}, err + return app.GenerateRequest{}, commonOptions{}, err } case app.ReportToday: if opts.Date == "" { @@ -231,41 +244,46 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { } else { req.Date, err = timeutil.ParseLocalDate(opts.Date, location) if err != nil { - return app.GenerateRequest{}, err + return app.GenerateRequest{}, commonOptions{}, err } } case app.ReportStorm: if opts.Start == "" { - return app.GenerateRequest{}, fmt.Errorf("generate storm requires --start") + return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --start") } if opts.End == "" { - return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end") + return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --end") } period, err := report.ParseStormPeriod(opts.Start, opts.End, location) if err != nil { - return app.GenerateRequest{}, err + return app.GenerateRequest{}, commonOptions{}, err } req.StormStart = period.Start req.StormEnd = period.End } - return req, nil + return req, opts.commonOptions, nil } func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { + req, _, err := r.resolveRunAction(args) + return req, err +} + +func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { - return app.BatchRequest{}, fmt.Errorf("run requires a batch name") + return app.BatchRequest{}, commonOptions{}, fmt.Errorf("run requires a batch name") } if _, err := report.BatchForCommandName(args[0]); err != nil { - return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0]) + return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0]) } batch := app.BatchKind(args[0]) opts, err := parseRunFlags(args[1:]) if err != nil { - return app.BatchRequest{}, err + return app.BatchRequest{}, commonOptions{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, @@ -273,9 +291,9 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { Timezone: opts.Timezone, }) if err != nil { - return app.BatchRequest{}, err + return app.BatchRequest{}, commonOptions{}, err } - return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil + return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, opts, nil } func resolveRun(args []string) (app.BatchRequest, error) { @@ -287,6 +305,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, fs.SetOutput(io.Discard) opts := generateOptions{} addCommonFlags(fs, &opts.commonOptions, true) + fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output") if report == app.ReportDaily || report == app.ReportToday { fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") } @@ -309,6 +328,7 @@ func parseRunFlags(args []string) (commonOptions, error) { opts := commonOptions{} addCommonFlags(fs, &opts, false) fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory") + fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output") if err := fs.Parse(args); err != nil { return commonOptions{}, err } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 174cc73..f915c76 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -131,7 +131,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) { outPath := fixture.path("three-day.md") runner := Runner{Clock: fixedClock()} - _, err := runTestCommand(t, runner, + output, err := runTestCommand(t, runner, "generate", "three-day", "--config", fixture.configPath, "--out", outPath, @@ -143,6 +143,20 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) { dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml") assertFileContains(t, dataPackagePath, "id: three_day") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") + + summary := decodeGenerateSummary(t, output.stdout) + if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.ThreeDay { + t.Fatalf("generate summary = %#v, want successful 3-day summary", summary) + } + if summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" { + t.Fatalf("summary paths = %#v, want managed artifact paths", summary) + } + if summary.OutputPath != outPath { + t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath) + } + if summary.GeneratedTextRawPath != "" || summary.GeneratedTextResultPath != "" || summary.GeneratedTextPath != "" || summary.RenderContextPath != "" { + t.Fatalf("generated-text paths = %#v, want omitted for markdown report", summary) + } } func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) { @@ -197,9 +211,9 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { t.Fatalf("Run() error = %q, want aggregate failure", err.Error()) } - var summary app.BatchResult - if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil { - t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout) + summary := decodeBatchSummary(t, output.stdout) + if summary.Command != "run" || summary.Status != "failed" { + t.Fatalf("summary command/status = %q/%q, want run/failed", summary.Command, summary.Status) } if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 { t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed) @@ -387,9 +401,9 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - var summary app.BatchResult - if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil { - t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout) + summary := decodeBatchSummary(t, output.stdout) + if summary.Command != "run" || summary.Status != "succeeded" { + t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) } if summary.Total != 1 || summary.Failed != 0 { t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed) @@ -402,6 +416,24 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { } } +func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) { + fixture := newCLIFixture(t, writeFakeScriptorium) + runner := Runner{Clock: fixedClock()} + + output, err := runTestCommand(t, runner, + "run", "evening", + "--config", fixture.configPath, + "--quiet", + ) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if output.stdout != "" || output.stderr != "" { + t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr) + } + _ = oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md") +} + func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) { server := dailyServer(t) var uploadCount int @@ -437,9 +469,9 @@ func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) { t.Fatalf("Run() error = %v", err) } - var summary app.BatchResult - if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil { - t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String()) + summary := decodeBatchSummary(t, stdout.String()) + if summary.Command != "run" || summary.Status != "succeeded" { + t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) } if len(summary.Reports) != 1 { t.Fatalf("reports = %#v, want one report", summary.Reports) @@ -489,9 +521,9 @@ func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T) t.Fatalf("Run() error = %v", err) } - var summary app.BatchResult - if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil { - t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String()) + summary := decodeBatchSummary(t, stdout.String()) + if summary.Command != "run" || summary.Status != "succeeded" { + t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) } if len(summary.Reports) != 1 { t.Fatalf("summary reports = %#v, want one report", summary.Reports) @@ -541,12 +573,12 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - report, err := os.ReadFile(outPath) + reportData, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } - if !strings.Contains(string(report), "# Friday's Weather") { - t.Fatalf("report output missing markdown:\n%s", string(report)) + if !strings.Contains(string(reportData), "# Friday's Weather") { + t.Fatalf("report output missing markdown:\n%s", string(reportData)) } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") data, err := os.ReadFile(dataPackagePath) @@ -598,7 +630,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - report, err := os.ReadFile(outPath) + reportData, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } @@ -607,8 +639,8 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { "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)) + if !strings.Contains(string(reportData), want) { + t.Fatalf("today report output missing %q:\n%s", want, string(reportData)) } } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") @@ -630,6 +662,20 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`) assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`) assertFileContains(t, managedReportPath, "# Today's Weather") + + summary := decodeGenerateSummary(t, stdout.String()) + if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today { + t.Fatalf("generate summary = %#v, want successful Today summary", summary) + } + if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" { + t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary) + } + if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { + t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary) + } + if summary.OutputPath != outPath { + t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath) + } } func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { @@ -647,7 +693,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - report, err := os.ReadFile(outPath) + reportData, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } @@ -657,8 +703,8 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { "A cold front is moving into the region.", "A front will keep the region unsettled.", } { - if !strings.Contains(string(report), want) { - t.Fatalf("report output missing %q:\n%s", want, string(report)) + if !strings.Contains(string(reportData), want) { + t.Fatalf("report output missing %q:\n%s", want, string(reportData)) } } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "data_package.*.yaml") @@ -685,6 +731,76 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { assertFileContains(t, managedReportPath, "# Hourly Report") } +func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) { + fixture := newCLIFixture(t, writeStructuredOutputScriptorium) + outPath := fixture.path("today.md") + runner := Runner{Clock: fixedClock()} + + output, err := runTestCommand(t, runner, + "generate", "today", + "--config", fixture.configPath, + "--date", "2026-05-29", + "--out", outPath, + "--quiet", + ) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if output.stdout != "" || output.stderr != "" { + t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr) + } + assertFileContains(t, outPath, "# Today's Weather") +} + +func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr) + if err == nil { + t.Fatal("Run() error = nil, want required date error") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial JSON", stdout.String()) + } +} + +func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) { + server := dailyServer(t) + distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected distributor request with unset token: %s", r.URL.Path) + })) + t.Cleanup(distributorServer.Close) + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + workspaceRoot := filepath.Join(tempDir, "workspace") + configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) + t.Setenv("CLI_DISTRIBUTOR_TOKEN", "") + runner := Runner{Clock: fixedClock()} + + output, err := runTestCommand(t, runner, + "generate", "three-day", + "--config", configPath, + ) + if err == nil { + t.Fatal("Run() error = nil, want notification failure") + } + summary := decodeGenerateSummary(t, output.stdout) + if summary.Command != "generate" || summary.Status != "failed" || summary.Error == "" { + t.Fatalf("summary = %#v, want failed generate summary", summary) + } + if !strings.Contains(summary.Error, "token environment variable") { + t.Fatalf("summary error = %q, want token environment context", summary.Error) + } + if summary.ReportPath == "" || summary.MetadataPath == "" || summary.NotificationPath == "" { + t.Fatalf("summary paths = %#v, want inspectable report, metadata, and notification paths", summary) + } + if strings.Contains(output.stdout, "CLI_DISTRIBUTOR_TOKEN_VALUE") || strings.Contains(output.stderr, "CLI_DISTRIBUTOR_TOKEN_VALUE") { + t.Fatalf("output contains distributor token value\nstdout=%s\nstderr=%s", output.stdout, output.stderr) + } +} + func TestRunInspectTodayArtifacts(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) runner := Runner{Clock: fixedClock()} @@ -791,6 +907,22 @@ func TestRunInspectMissingMetadata(t *testing.T) { } } +func TestRunInspectRejectsQuiet(t *testing.T) { + tempDir := t.TempDir() + configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr) + if err == nil { + t.Fatal("Run(inspect reports --quiet) error = nil, want unexpected flag error") + } + if !strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("error = %q, want unexpected quiet flag", err.Error()) + } +} + func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { tempDir := t.TempDir() configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) @@ -1179,6 +1311,24 @@ func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput, }, err } +func decodeGenerateSummary(t *testing.T, text string) generateSummary { + t.Helper() + var summary generateSummary + if err := json.Unmarshal([]byte(text), &summary); err != nil { + t.Fatalf("decode generate summary: %v\n%s", err, text) + } + return summary +} + +func decodeBatchSummary(t *testing.T, text string) batchSummary { + t.Helper() + var summary batchSummary + if err := json.Unmarshal([]byte(text), &summary); err != nil { + t.Fatalf("decode batch summary: %v\n%s", err, text) + } + return summary +} + func dailyServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {