Compare commits
6 Commits
f4f009b904
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd604afb4 | |||
| 52bb17c8fa | |||
| 7952e4fb25 | |||
| 0281327365 | |||
| bf76eae301 | |||
| 0d47662cf9 |
119
docs/cli.md
119
docs/cli.md
@@ -20,15 +20,15 @@ Markdown report after final metadata is saved.
|
||||
|
||||
```text
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
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]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
@@ -37,10 +37,13 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
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 daily`,
|
||||
Implemented `generate` commands emit a compact JSON summary to stdout on
|
||||
success. The summary includes command identity, report identity, RunID, status,
|
||||
valid period, and managed artifact paths. They also 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 daily`,
|
||||
`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 daily` requires
|
||||
@@ -71,8 +74,91 @@ report fails, the batch notification is skipped for the whole batch.
|
||||
Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
|
||||
not included in `run morning` or `run evening`.
|
||||
|
||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not collect weather data or invoke `scriptorium`.
|
||||
`inspect` commands read existing workspace artifacts and emit the requested
|
||||
JSON data to stdout. They do not collect weather data or invoke `scriptorium`.
|
||||
Inspection commands do not accept `--quiet`.
|
||||
|
||||
## Output
|
||||
|
||||
Action commands, meaning `generate` and `run`, emit JSON summaries to stdout by
|
||||
default. Pre-run errors, such as invalid flags, missing required arguments, or
|
||||
configuration load failures, return an error without emitting partial JSON.
|
||||
`--quiet` suppresses successful action-command stdout and routine stderr. It
|
||||
does not hide returned errors. Inspection commands are data-output commands;
|
||||
they always write the requested JSON to stdout and are not quietable.
|
||||
|
||||
Generate summaries have this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "generate",
|
||||
"reportId": "today",
|
||||
"reportName": "Today Report",
|
||||
"promptId": "weather.today_generated_text",
|
||||
"runId": "20260529T120000.000000000Z_today",
|
||||
"status": "succeeded",
|
||||
"generatedAt": "2026-05-29T12:00:00Z",
|
||||
"validPeriod": {
|
||||
"start": "2026-05-29T00:00:00-05:00",
|
||||
"end": "2026-05-30T00:00:00-05:00"
|
||||
},
|
||||
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
|
||||
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
|
||||
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
|
||||
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json",
|
||||
"generatedTextRawPath": "workspace/snapshots/today/2026-05-29/generated_text_raw.20260529T120000.000000000Z_today.json",
|
||||
"generatedTextResultPath": "workspace/snapshots/today/2026-05-29/generated_text_result.20260529T120000.000000000Z_today.json",
|
||||
"generatedTextPath": "workspace/snapshots/today/2026-05-29/generated_text.20260529T120000.000000000Z_today.json",
|
||||
"renderContextPath": "workspace/snapshots/today/2026-05-29/render_context.20260529T120000.000000000Z_today.json"
|
||||
}
|
||||
```
|
||||
|
||||
Markdown-path reports omit the generated-text fields. If distributor
|
||||
notification is attempted, summaries include `notificationPath`; successful
|
||||
notification also includes a compact `notification` object. If notification
|
||||
fails after report artifacts exist, the summary has `"status": "failed"` and an
|
||||
`error` string while retaining inspectable artifact paths.
|
||||
|
||||
Run summaries have this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "run",
|
||||
"batch": "morning",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2026-05-29T12:00:00Z",
|
||||
"finishedAt": "2026-05-29T12:01:00Z",
|
||||
"total": 1,
|
||||
"succeeded": 1,
|
||||
"failed": 0,
|
||||
"reports": [
|
||||
{
|
||||
"reportId": "today",
|
||||
"reportName": "Today Report",
|
||||
"promptId": "weather.today_generated_text",
|
||||
"runId": "20260529T120000.000000000Z_today",
|
||||
"status": "succeeded",
|
||||
"generatedAt": "2026-05-29T12:00:00Z",
|
||||
"validPeriod": {
|
||||
"start": "2026-05-29T00:00:00-05:00",
|
||||
"end": "2026-05-30T00:00:00-05:00"
|
||||
},
|
||||
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
|
||||
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
|
||||
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
|
||||
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`run` status is `failed` when any report failed or the top-level batch
|
||||
notification failed. Batch stderr uses compact status lines, for example:
|
||||
|
||||
```text
|
||||
report=today status=succeeded output="reports/today.md"
|
||||
batch=morning total=2 succeeded=2 failed=0
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -82,6 +168,7 @@ They do not collect 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`.
|
||||
- `--quiet`: suppress successful stdout and routine stderr for `generate` and `run`.
|
||||
- `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting 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`.
|
||||
@@ -105,6 +192,8 @@ weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
weatherreporter run evening --out-dir ./reports
|
||||
weatherreporter generate today --quiet
|
||||
weatherreporter run morning --quiet
|
||||
```
|
||||
|
||||
## Inspection
|
||||
|
||||
@@ -251,7 +251,7 @@ reports:
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- daily_planning
|
||||
- hourly_forecast
|
||||
@@ -292,7 +292,8 @@ Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||
combinations, duplicate stanza names, and invalid options fail config loading.
|
||||
`area_forecast_discussion.options.sections` may contain `product`,
|
||||
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
|
||||
includes all available AFD sections.
|
||||
includes all available AFD sections. Default report definitions may choose a
|
||||
smaller report-specific subset, such as daily reports using only `long_term`.
|
||||
|
||||
The module registry accepts all module IDs documented in
|
||||
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
||||
|
||||
@@ -84,7 +84,9 @@ defaults.
|
||||
|
||||
Single-report commands validate the report command, collect once through
|
||||
`internal/collect`, resolve the requested report, and pass the resolved report
|
||||
plus explicit collection into `GenerateReport`.
|
||||
plus explicit collection into `GenerateReport`. `GenerateDetailed` returns the
|
||||
resulting `ReportResult`; `Generate` wraps the same workflow for error-only
|
||||
callers.
|
||||
|
||||
`GenerateReport` then uses this setup:
|
||||
|
||||
@@ -191,7 +193,9 @@ inspection view.
|
||||
- Generated-text report errors preserve available intermediate artifacts and do
|
||||
not create extra output copies.
|
||||
- Single-report notification errors are wrapped with report ID, RunID, and
|
||||
managed report path context.
|
||||
managed report path context. Detailed generation returns the inspectable
|
||||
report, metadata, and notification artifact paths when finalization has
|
||||
already saved them.
|
||||
- Batch notification errors are recorded on the top-level batch notification
|
||||
result and do not change individual report item status.
|
||||
- Metadata and artifact path errors include filesystem context.
|
||||
|
||||
@@ -90,7 +90,7 @@ builders run. Configured `location` values are prompt context only; Weather API
|
||||
|
||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||
subset of discussion fields. Hourly Report defaults this module to
|
||||
`key_messages` and `short_term`.
|
||||
`key_messages` and `short_term`; Daily Report defaults it to `long_term`.
|
||||
|
||||
`spc_convective_outlooks` uses collected SPC run metadata and derived
|
||||
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||
|
||||
67
docs/internal/cli.md
Normal file
67
docs/internal/cli.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# CLI Internals
|
||||
|
||||
This document describes command output ownership in `internal/cli`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/cli` owns command parsing, app request construction, help text, and
|
||||
presentation of command results. It converts app-layer results into stable CLI
|
||||
summaries and writes stdout/stderr through shared output helpers.
|
||||
|
||||
## Command Categories
|
||||
|
||||
- Action commands: `generate` and `run`. These perform work, write artifacts,
|
||||
and return compact summaries.
|
||||
- Inspection commands: `inspect reports`, `inspect metadata`, `inspect
|
||||
modules`, `inspect data-package`, `inspect prior`, and `inspect sources`.
|
||||
These read existing artifacts and return requested data.
|
||||
|
||||
Future commands must declare which category they belong to before adding output
|
||||
behavior.
|
||||
|
||||
## Stdout And Stderr
|
||||
|
||||
Action commands write JSON summaries to stdout by default. `run` also writes
|
||||
compact status lines to stderr through `writeBatchStatus`. `generate` does not
|
||||
write routine stderr today. Pre-run errors return without partial JSON.
|
||||
|
||||
Inspection commands write requested JSON data to stdout with `writeJSON`. They
|
||||
do not use action output helpers and do not support quiet mode.
|
||||
|
||||
Returned errors are not hidden by output helpers. The caller remains
|
||||
responsible for displaying command errors.
|
||||
|
||||
## Quiet Mode
|
||||
|
||||
`--quiet` is supported only by action commands. It suppresses successful stdout
|
||||
and routine stderr by passing `outputOptions{Quiet: true}` to
|
||||
`writeActionResult`. It does not suppress returned errors.
|
||||
|
||||
Quiet mode is intentionally not accepted by inspection commands because
|
||||
inspection stdout is the command result.
|
||||
|
||||
## Summary Ownership
|
||||
|
||||
CLI-safe summary structs live in `internal/cli/result.go`.
|
||||
|
||||
- `newGenerateSummary` converts `*app.ReportResult` plus an optional error into
|
||||
the generate JSON contract.
|
||||
- `newBatchSummary` converts `*app.BatchResult` into the run JSON contract and
|
||||
derives the top-level run status.
|
||||
|
||||
Summary types must not expose full app internals, module contents, data package
|
||||
contents, raw generated text, Scriptorium result bodies, or full distributor
|
||||
payloads.
|
||||
|
||||
## Helper Path
|
||||
|
||||
New action commands should:
|
||||
|
||||
1. parse command-specific flags into CLI option structs;
|
||||
2. call the app-layer use case;
|
||||
3. convert app results into a CLI summary type;
|
||||
4. write through `writeActionResult`;
|
||||
5. use a status writer only for routine stderr status lines.
|
||||
|
||||
New inspection commands should call the app inspection use case and write the
|
||||
returned data through `writeJSON`.
|
||||
@@ -99,7 +99,8 @@ The default Daily Report module order is:
|
||||
14. `hourly_forecast`
|
||||
|
||||
The embedded Daily template uses selected deterministic fields from these
|
||||
module outputs after GeneratedText validation.
|
||||
module outputs after GeneratedText validation. Its `area_forecast_discussion`
|
||||
item is configured to include only `long_term`.
|
||||
|
||||
## Today Composition
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ effective report timezone and is not included in `run morning` or
|
||||
When distributor notification is enabled, weatherreporter uploads the managed
|
||||
Markdown report after report rendering succeeds and final metadata is saved.
|
||||
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
||||
as the distributor upload source.
|
||||
as the distributor upload source. Generate commands emit a compact JSON summary
|
||||
to stdout by default. Use `--quiet` to suppress successful stdout for cron jobs
|
||||
or other schedulers that only need nonzero exits and external logs.
|
||||
|
||||
Batch commands:
|
||||
|
||||
@@ -64,7 +66,8 @@ after every planned report succeeds. If any report fails, the batch upload is
|
||||
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
|
||||
using report default filenames such as `today.md` and `tomorrow.md`; dynamic
|
||||
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
|
||||
distributor upload sources.
|
||||
distributor upload sources. Use `--quiet` to suppress successful batch summary
|
||||
and status output; failures still return nonzero.
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
|
||||
@@ -121,8 +121,12 @@ The CLI is owned by `internal/cli`.
|
||||
When adding or changing a command or flag:
|
||||
|
||||
- update help text and parser behavior together;
|
||||
- declare whether the command is an action command or an inspection/data-output
|
||||
command;
|
||||
- convert parsed values into app-layer request structs;
|
||||
- keep domain decisions in `internal/app` or domain packages;
|
||||
- use the centralized output helpers in `internal/cli/output.go`;
|
||||
- keep action-command summary conversion in `internal/cli/result.go`;
|
||||
- add parser or command tests in `internal/cli`;
|
||||
- update `docs/cli.md`;
|
||||
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# CLI Output Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the intended final shape for weatherreporter CLI output.
|
||||
|
||||
The current CLI has drifted:
|
||||
|
||||
- `run` commands emit JSON summaries to stdout and compact status lines to
|
||||
stderr.
|
||||
- `inspect` commands emit JSON to stdout.
|
||||
- `generate` commands perform substantial work but are silent on success.
|
||||
|
||||
The target is a predictable command-line contract that is useful for operators,
|
||||
easy to consume from scripts, and explicit enough that future commands naturally
|
||||
reuse the same output path.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- Keep application orchestration and domain decisions in `internal/app`.
|
||||
- Keep CLI presentation, stdout/stderr policy, and quiet-mode behavior in
|
||||
`internal/cli`.
|
||||
- Successful non-help commands should have a machine-readable JSON stdout
|
||||
contract unless `--quiet` intentionally suppresses success output for an
|
||||
action command.
|
||||
- Help remains human-readable text.
|
||||
- Stderr is for compact operational status and errors, not primary command
|
||||
payloads.
|
||||
- Do not print partial JSON when command construction, flag parsing, config
|
||||
loading, or pre-run validation fails.
|
||||
- Do not serialize large internal app objects directly as CLI output.
|
||||
- Do not expose secret values in stdout or stderr.
|
||||
- Add `--quiet` for state-changing action commands.
|
||||
- Do not make `--quiet` suppress requested inspection data.
|
||||
|
||||
## Command Categories
|
||||
|
||||
CLI commands should be classified into one of these output categories.
|
||||
|
||||
### Help Commands
|
||||
|
||||
Examples:
|
||||
|
||||
- `weatherreporter --help`
|
||||
|
||||
Output:
|
||||
|
||||
- stdout: human-readable help text
|
||||
- stderr: none on success
|
||||
- `--quiet`: not applicable
|
||||
|
||||
### Action Commands
|
||||
|
||||
Examples:
|
||||
|
||||
- `weatherreporter generate today`
|
||||
- `weatherreporter generate daily --date YYYY-MM-DD`
|
||||
- `weatherreporter run morning`
|
||||
- `weatherreporter run evening`
|
||||
|
||||
Output:
|
||||
|
||||
- stdout: compact JSON summary after the action completes
|
||||
- stderr: compact status lines only when useful, especially for multi-report
|
||||
batch commands
|
||||
- `--quiet`: suppress success stdout and routine status stderr
|
||||
|
||||
Failure behavior:
|
||||
|
||||
- For flag/config/pre-run errors, stdout is empty and the command returns an
|
||||
error.
|
||||
- For completed actions that produce an inspectable failure result, default
|
||||
output may still include a JSON failure summary before returning nonzero.
|
||||
- With `--quiet`, failure diagnostics should remain concise and actionable on
|
||||
stderr through the existing top-level error path; routine success summaries
|
||||
stay suppressed.
|
||||
|
||||
### Inspection Commands
|
||||
|
||||
Examples:
|
||||
|
||||
- `weatherreporter inspect reports`
|
||||
- `weatherreporter inspect metadata RUN_ID`
|
||||
- `weatherreporter inspect modules RUN_ID`
|
||||
- `weatherreporter inspect data-package RUN_ID`
|
||||
- `weatherreporter inspect prior RUN_ID`
|
||||
- `weatherreporter inspect sources RUN_ID`
|
||||
|
||||
Output:
|
||||
|
||||
- stdout: requested JSON data
|
||||
- stderr: none on success
|
||||
- `--quiet`: not accepted unless a future inspection command has auxiliary
|
||||
status output to suppress
|
||||
|
||||
Inspection commands are already data-oriented. Their stdout payload should stay
|
||||
focused on the requested data rather than being hidden by quiet mode.
|
||||
|
||||
## Target Action Summary Shape
|
||||
|
||||
Action command JSON should be small, stable, and path-oriented. It should expose
|
||||
what an operator needs to find artifacts, inspect a run, and understand
|
||||
notification status.
|
||||
|
||||
### Generate Summary
|
||||
|
||||
Target shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "generate",
|
||||
"reportId": "today",
|
||||
"reportName": "Today Report",
|
||||
"promptId": "weather.today_generated_text",
|
||||
"runId": "20260529T100000.000000000Z_today",
|
||||
"status": "succeeded",
|
||||
"generatedAt": "2026-05-29T10:00:00Z",
|
||||
"validPeriod": {},
|
||||
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T100000.000000000Z_today.md",
|
||||
"outputPath": "./today.md",
|
||||
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T100000.000000000Z_today.json",
|
||||
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T100000.000000000Z_today.yaml",
|
||||
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T100000.000000000Z_today.json",
|
||||
"notificationPath": "workspace/notifications/today/2026-05-29/distributor.20260529T100000.000000000Z_today.json",
|
||||
"notification": {
|
||||
"status": "succeeded",
|
||||
"runId": "distributor-run",
|
||||
"pipelineId": "weatherreporter.today",
|
||||
"bundleId": "weatherreporter.home.today",
|
||||
"path": "workspace/notifications/today/2026-05-29/distributor.20260529T100000.000000000Z_today.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Omit absent optional paths with `omitempty`.
|
||||
- Include generated-text artifact paths only for report modes that produce them.
|
||||
- Include notification fields only when notification was attempted.
|
||||
- Keep module snapshot contents, data package contents, raw generated text,
|
||||
render result bodies, and full notification adapter payloads out of the CLI
|
||||
summary.
|
||||
|
||||
### Batch Summary
|
||||
|
||||
The current `BatchResult` shape is close to the target and should remain the
|
||||
basis for `run` output. The target update is to make the summary explicitly
|
||||
command-like and align status semantics with generate output:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "run",
|
||||
"batch": "morning",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2026-05-29T10:00:00Z",
|
||||
"finishedAt": "2026-05-29T10:01:00Z",
|
||||
"total": 3,
|
||||
"succeeded": 3,
|
||||
"failed": 0,
|
||||
"notification": {},
|
||||
"reports": []
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep per-report items compact and path-oriented.
|
||||
- Keep batch notification status at the top level.
|
||||
- Preserve nonzero exit behavior when one or more reports fail.
|
||||
- Preserve the existing behavior that batch report failures do not prevent the
|
||||
JSON summary from being available in default output mode.
|
||||
|
||||
## Quiet Mode
|
||||
|
||||
`--quiet` should be available on action commands:
|
||||
|
||||
```sh
|
||||
weatherreporter generate today --quiet
|
||||
weatherreporter run morning --quiet
|
||||
```
|
||||
|
||||
Quiet mode means:
|
||||
|
||||
- no stdout on successful action commands
|
||||
- no routine status lines on stderr on successful action commands
|
||||
- errors still return nonzero and are still reported by the top-level CLI error
|
||||
path
|
||||
- inspection output is not suppressed
|
||||
|
||||
Quiet mode does not mean:
|
||||
|
||||
- skipping artifact writes
|
||||
- skipping distributor notification
|
||||
- changing JSON shape when JSON is emitted
|
||||
- hiding errors
|
||||
|
||||
Future action commands should opt into quiet mode by using the centralized
|
||||
action-output writer rather than implementing their own flag or writer logic.
|
||||
|
||||
## Intended Code Structure
|
||||
|
||||
`internal/cli` should own a small output layer that future commands can reuse.
|
||||
The output layer should make the consistent path the easiest path.
|
||||
|
||||
Target files:
|
||||
|
||||
- `internal/cli/root.go`: command routing and flag parsing
|
||||
- `internal/cli/output.go`: stdout/stderr writers, quiet-mode handling, and
|
||||
output category helpers
|
||||
- `internal/cli/result.go`: CLI-safe summary structs and conversion helpers
|
||||
|
||||
Target app-layer shape:
|
||||
|
||||
- Add a detailed generate entry point that returns the generated report result.
|
||||
- Keep `app.Generate(ctx, GenerateRequest) error` as a convenience wrapper for
|
||||
callers that do not need CLI output.
|
||||
- Keep `app.RunBatchDetailed(ctx, BatchRequest) (*BatchResult, error)` as the
|
||||
batch command result source.
|
||||
|
||||
Target CLI output helpers:
|
||||
|
||||
- `writeJSON(io.Writer, any) error`
|
||||
- `writeActionResult(stdout, stderr io.Writer, result actionResult, opts outputOptions) error`
|
||||
- `writeBatchStatus(stderr io.Writer, result *app.BatchResult)`
|
||||
- `writeGenerateStatus(stderr io.Writer, result GenerateSummary)` only if
|
||||
single-report status lines become useful
|
||||
|
||||
The command router should not call `json.NewEncoder` directly outside the
|
||||
central output helpers.
|
||||
|
||||
## Deferred Questions
|
||||
|
||||
None of these are required for the initial harmonization:
|
||||
|
||||
- global `--format` support
|
||||
- NDJSON progress streams
|
||||
- human-readable success output
|
||||
- machine-readable error envelopes on stderr
|
||||
- making inspection commands use a common envelope
|
||||
|
||||
These should remain deferred until there is a real consumer need.
|
||||
@@ -1,326 +0,0 @@
|
||||
# CLI Output Implementation Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
This document is the staged implementation plan for
|
||||
[cli.md](cli.md). It is written for an LLM coding agent that will implement the
|
||||
CLI output harmonization in order.
|
||||
|
||||
The feature is complete when `generate`, `run`, and `inspect` have consistent
|
||||
stdout/stderr behavior, action commands support `--quiet`, CLI output logic is
|
||||
centralized in `internal/cli`, and maintained docs describe the implemented
|
||||
contract.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
- Review `docs/policy/architecture.md`, `docs/policy/development.md`, and
|
||||
`docs/policy/documentation.md` before editing code.
|
||||
- Keep application orchestration and domain behavior in `internal/app`.
|
||||
- Keep CLI presentation, output summaries, stdout/stderr policy, and quiet-mode
|
||||
behavior in `internal/cli`.
|
||||
- Do not serialize full `app.ReportResult` values directly to CLI stdout.
|
||||
- Do not add `--quiet` to inspection commands in this pass.
|
||||
- Do not add global `--format`, NDJSON progress, human-readable success output,
|
||||
or machine-readable error envelopes.
|
||||
- Preserve current command semantics except where this plan explicitly changes
|
||||
output behavior.
|
||||
|
||||
## Decisions
|
||||
|
||||
- CLI-safe summary structs live in `internal/cli/result.go`, not
|
||||
`internal/app`.
|
||||
- Add `app.GenerateDetailed(ctx, GenerateRequest) (*ReportResult, error)`.
|
||||
- Keep `app.Generate(ctx, GenerateRequest) error` as a wrapper around
|
||||
`GenerateDetailed`.
|
||||
- Keep `app.RunBatchDetailed(ctx, BatchRequest) (*BatchResult, error)` as the
|
||||
app-layer batch source.
|
||||
- Add `command` and `status` fields in CLI summary structs, not in
|
||||
`app.BatchResult`.
|
||||
- `BatchSummary.status` is `failed` when any report failed or the top-level
|
||||
batch notification status is `failed`; otherwise it is `succeeded`.
|
||||
- `GenerateSummary.status` is `succeeded` for a successful generated report and
|
||||
`failed` only when a non-nil `ReportResult` is returned with an error after
|
||||
inspectable artifacts exist.
|
||||
- Default action-command output may include a failure JSON summary when the app
|
||||
layer returns a non-nil result with an error.
|
||||
- Quiet mode suppresses successful action-command stdout and routine stderr. It
|
||||
does not hide returned errors.
|
||||
|
||||
## Stage 1: Detailed Generate Result
|
||||
|
||||
Goal: make single-report generation return the same kind of structured result
|
||||
that batch generation already uses internally.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add `GenerateDetailed(ctx, GenerateRequest) (*ReportResult, error)` in
|
||||
`internal/app`.
|
||||
- Move the current body of `Generate` into `GenerateDetailed`.
|
||||
- Change `Generate` to call `GenerateDetailed` and return only the error.
|
||||
- Ensure `GenerateDetailed` preserves existing behavior for:
|
||||
- collecting weather before resolving/generating;
|
||||
- unknown or unimplemented reports;
|
||||
- explicit Daily date requirements;
|
||||
- optional output copy behavior;
|
||||
- distributor notification behavior.
|
||||
- When `GenerateReport` or generated-template finalization receives a non-empty
|
||||
`finalizeRenderedReportResult` plus an error after managed artifacts exist,
|
||||
return a non-nil `ReportResult` together with that error. This is especially
|
||||
important for notification failures where the report and notification artifact
|
||||
are inspectable.
|
||||
- Do not return partial results for flag/config/pre-run validation failures or
|
||||
failures before a useful run identity exists.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add app tests for `GenerateDetailed` success.
|
||||
- Add an app test showing `Generate` still returns only the underlying error.
|
||||
- Add or adjust an app test for notification failure so `GenerateDetailed`
|
||||
returns a non-nil result with report, metadata, and notification artifact
|
||||
paths while also returning the notification error.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/app
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Existing app behavior is preserved for callers of `Generate`.
|
||||
- CLI callers can obtain a rich `ReportResult` from `GenerateDetailed`.
|
||||
|
||||
## Stage 2: CLI Summary Types
|
||||
|
||||
Goal: define small, stable CLI output contracts without exposing full app
|
||||
internals.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add `internal/cli/result.go`.
|
||||
- Define a `generateSummary` struct with these JSON fields:
|
||||
- `command`
|
||||
- `reportId`
|
||||
- `reportName`
|
||||
- `promptId`
|
||||
- `runId`
|
||||
- `status`
|
||||
- `generatedAt`
|
||||
- `validPeriod`
|
||||
- `reportPath,omitempty`
|
||||
- `outputPath,omitempty`
|
||||
- `metadataPath,omitempty`
|
||||
- `dataPackagePath,omitempty`
|
||||
- `preflightPath,omitempty`
|
||||
- `generatedTextRawPath,omitempty`
|
||||
- `generatedTextResultPath,omitempty`
|
||||
- `generatedTextPath,omitempty`
|
||||
- `renderContextPath,omitempty`
|
||||
- `notificationPath,omitempty`
|
||||
- `notification,omitempty`
|
||||
- `error,omitempty`
|
||||
- Define a `batchSummary` struct with these JSON fields:
|
||||
- `command`
|
||||
- `batch`
|
||||
- `status`
|
||||
- `startedAt`
|
||||
- `finishedAt`
|
||||
- `total`
|
||||
- `succeeded`
|
||||
- `failed`
|
||||
- `notification,omitempty`
|
||||
- `reports`
|
||||
- `error,omitempty`
|
||||
- Reuse existing app result substructures where they are already CLI-safe:
|
||||
`timeutil.Period`, `app.BatchNotificationResult`, and
|
||||
`app.BatchReportResult`.
|
||||
- Add conversion helpers:
|
||||
- `newGenerateSummary(result *app.ReportResult, err error) generateSummary`
|
||||
- `newBatchSummary(result *app.BatchResult) batchSummary`
|
||||
- Do not include module snapshot contents, data package contents, raw generated
|
||||
text bytes, render result bodies, or full distributor adapter payloads.
|
||||
- Keep error strings concise and avoid adding secrets.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add focused unit tests for summary conversion.
|
||||
- Cover generated-text reports, markdown reports, disabled notification, and
|
||||
notification failure with a non-nil result.
|
||||
- Cover batch status derivation for success, report failure, skipped
|
||||
notification, and failed notification.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/cli
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- CLI summary shapes are explicit and independent of full app result structs.
|
||||
|
||||
## Stage 3: Centralized Output Helpers
|
||||
|
||||
Goal: make the consistent output path the default path for current and future
|
||||
commands.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add `internal/cli/output.go`.
|
||||
- Move `writeJSON` from `root.go` into `output.go`.
|
||||
- Move `writeRunLogs` from `root.go` into `output.go` and rename it to
|
||||
`writeBatchStatus`.
|
||||
- Add an `outputOptions` struct with at least:
|
||||
- `Quiet bool`
|
||||
- Add a small action output helper such as:
|
||||
`writeActionResult(stdout, stderr io.Writer, value any, opts outputOptions, writeStatus func(io.Writer)) error`.
|
||||
- The helper must:
|
||||
- return without writing stdout or routine stderr when `opts.Quiet` is true;
|
||||
- write status before JSON for default action output when a status writer is
|
||||
provided;
|
||||
- use the shared JSON writer for stdout;
|
||||
- tolerate nil stderr when no status output is needed.
|
||||
- Keep inspect commands using `writeJSON` directly because inspection is data
|
||||
output, not quietable action output.
|
||||
- Ensure `root.go` no longer calls `json.NewEncoder` directly.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add unit tests for quiet/default action output helper behavior.
|
||||
- Keep existing batch stderr tests, updated for renamed helpers if needed.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/cli
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- JSON encoding and action status output are centralized outside command
|
||||
routing.
|
||||
|
||||
## Stage 4: Wire Generate And Run Output
|
||||
|
||||
Goal: make current action commands use the same output contract.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Extend `commonOptions` or action-specific options with `Quiet bool`.
|
||||
- Parse `--quiet` for `generate` and `run`.
|
||||
- Do not parse or accept `--quiet` for `inspect`.
|
||||
- Update `Runner.Run`:
|
||||
- `generate` should call `app.GenerateDetailed`;
|
||||
- when a non-nil result is returned, convert it to `generateSummary`;
|
||||
- write the summary through the centralized action output helper;
|
||||
- if an error is also returned, write default JSON only when a non-nil result
|
||||
exists and quiet is false, then return the error;
|
||||
- if no result is returned, return the error without writing partial JSON.
|
||||
- Update `run` command handling:
|
||||
- convert `*app.BatchResult` to `batchSummary`;
|
||||
- write through the centralized action output helper;
|
||||
- preserve the current behavior that a batch result is emitted in default
|
||||
mode before returning `app.BatchError` for failed reports;
|
||||
- preserve notification-only batch failure behavior.
|
||||
- Keep `inspect` commands unchanged except for using the relocated `writeJSON`.
|
||||
|
||||
Tests:
|
||||
|
||||
- `generate today` emits valid JSON on success.
|
||||
- The generate JSON includes `command: "generate"`, `status: "succeeded"`,
|
||||
`reportId`, `runId`, `reportPath`, `metadataPath`, `dataPackagePath`, and
|
||||
`preflightPath`.
|
||||
- Generated-text reports include generated-text artifact paths.
|
||||
- Markdown reports omit generated-text artifact paths.
|
||||
- `generate --quiet` emits no stdout or routine stderr on success.
|
||||
- Generate pre-run errors emit no partial JSON.
|
||||
- Generate notification failure with an inspectable result emits a failure JSON
|
||||
summary in default mode and returns nonzero.
|
||||
- `run morning` and `run evening` still emit JSON summaries by default.
|
||||
- Run JSON includes `command: "run"` and a derived `status`.
|
||||
- `run --quiet` suppresses successful summary/status output.
|
||||
- Failed batch runs still return nonzero and still emit default JSON when quiet
|
||||
is false.
|
||||
- Inspect commands still emit requested JSON and reject `--quiet` as an
|
||||
unexpected flag.
|
||||
- Output tests confirm distributor token values are not printed.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/cli ./internal/app
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Current action commands have consistent default JSON behavior.
|
||||
- Quiet mode is available for action commands and not inspection commands.
|
||||
|
||||
## Stage 5: Documentation
|
||||
|
||||
Goal: document the implemented CLI output contract in maintained docs and make
|
||||
future changes follow the same structure.
|
||||
|
||||
Documentation changes:
|
||||
|
||||
- Update `docs/cli.md`:
|
||||
- document default JSON stdout for `generate`, `run`, and `inspect`;
|
||||
- document compact status stderr for batch commands;
|
||||
- document `--quiet` for `generate` and `run`;
|
||||
- include representative generate and run JSON snippets;
|
||||
- state that inspection commands are not quietable.
|
||||
- Update `docs/operations.md` if cron/operator behavior changes need an
|
||||
operations note.
|
||||
- Add `docs/internal/cli.md` documenting:
|
||||
- command categories;
|
||||
- stdout/stderr rules;
|
||||
- quiet-mode behavior;
|
||||
- summary conversion ownership;
|
||||
- the expected helper path for future commands.
|
||||
- Update `docs/policy/development.md` CLI-change guidance so future CLI
|
||||
commands are expected to use the centralized output helpers and declare an
|
||||
output category.
|
||||
|
||||
Tests:
|
||||
|
||||
- Update CLI help-output tests for `--quiet`.
|
||||
- Add or update docs-related tests only if this repository already validates the
|
||||
touched docs/examples in tests.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/cli ./internal/app
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Non-roadmap docs describe only implemented behavior.
|
||||
- The roadmap can be removed after implementation if no deferred CLI-output
|
||||
feature remains in it.
|
||||
|
||||
## Final Verification
|
||||
|
||||
Before considering the feature complete, run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Also manually verify these command behaviors against test fixtures or a local
|
||||
test config when practical:
|
||||
|
||||
```bash
|
||||
weatherreporter generate today --quiet
|
||||
weatherreporter run morning --quiet
|
||||
weatherreporter inspect reports --limit 1
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The decisions above are sufficient for implementation.
|
||||
@@ -88,9 +88,6 @@ reports:
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
|
||||
@@ -268,29 +268,33 @@ func (e *NotificationError) Unwrap() error {
|
||||
}
|
||||
|
||||
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
_, err := GenerateDetailed(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult, error) {
|
||||
now := req.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := ResolveGenerate(req, now)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if resolved.Definition.Generated {
|
||||
_, err := GenerateReport(ctx, ReportRequest{
|
||||
return GenerateReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: req.OutputPath,
|
||||
Collection: *collection,
|
||||
Notifier: req.Notifier,
|
||||
})
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("generate is not implemented")
|
||||
return nil, fmt.Errorf("generate is not implemented")
|
||||
}
|
||||
|
||||
func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||
@@ -654,26 +658,37 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
noNotify: req.noNotify,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if finalizeResultEmpty(finalized) {
|
||||
return nil, err
|
||||
}
|
||||
return renderedReportResult(reportResultRequest{
|
||||
moduleSnapshot: moduleSnapshot,
|
||||
moduleSnapshotPath: moduleSnapshotPath,
|
||||
dataPackage: dataPackage,
|
||||
dataPackagePath: dataPackagePath,
|
||||
preflightPath: preflightPath,
|
||||
reportPath: reportPath,
|
||||
finalized: finalized,
|
||||
priorSnapshot: priorSnapshot,
|
||||
recentChanges: recentChanges,
|
||||
renderResult: renderResult,
|
||||
runResult: runResult,
|
||||
}), err
|
||||
}
|
||||
|
||||
return &ReportResult{
|
||||
ModuleSnapshot: moduleSnapshot,
|
||||
ModuleSnapshotPath: moduleSnapshotPath,
|
||||
DataPackage: dataPackage,
|
||||
DataPackagePath: dataPackagePath,
|
||||
PreflightPath: preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: finalized.OutputPath,
|
||||
NotificationPath: finalized.NotificationPath,
|
||||
Metadata: finalized.Metadata,
|
||||
MetadataPath: finalized.MetadataPath,
|
||||
PriorSnapshot: priorSnapshot,
|
||||
RecentChanges: recentChanges,
|
||||
RenderResult: renderResult,
|
||||
RunResult: runResult,
|
||||
Notification: finalized.Notification,
|
||||
}, nil
|
||||
return renderedReportResult(reportResultRequest{
|
||||
moduleSnapshot: moduleSnapshot,
|
||||
moduleSnapshotPath: moduleSnapshotPath,
|
||||
dataPackage: dataPackage,
|
||||
dataPackagePath: dataPackagePath,
|
||||
preflightPath: preflightPath,
|
||||
reportPath: reportPath,
|
||||
finalized: finalized,
|
||||
priorSnapshot: priorSnapshot,
|
||||
recentChanges: recentChanges,
|
||||
renderResult: renderResult,
|
||||
runResult: runResult,
|
||||
}), nil
|
||||
}
|
||||
|
||||
type generatedReportRequest struct {
|
||||
@@ -777,30 +792,97 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
||||
noNotify: req.noNotify,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if finalizeResultEmpty(finalized) {
|
||||
return nil, err
|
||||
}
|
||||
return renderedReportResult(reportResultRequest{
|
||||
moduleSnapshot: req.moduleSnapshot,
|
||||
moduleSnapshotPath: req.moduleSnapshotPath,
|
||||
dataPackage: req.dataPackage,
|
||||
dataPackagePath: req.dataPackagePath,
|
||||
preflightPath: req.preflightPath,
|
||||
reportPath: reportPath,
|
||||
finalized: finalized,
|
||||
priorSnapshot: req.priorSnapshot,
|
||||
recentChanges: req.recentChanges,
|
||||
renderResult: req.renderResult,
|
||||
structuredRunResult: structuredResult,
|
||||
generatedTextRawPath: req.paths.GeneratedTextRaw,
|
||||
generatedTextResultPath: generatedTextResultPath,
|
||||
generatedTextPath: generatedTextPath,
|
||||
renderContextPath: renderContextPath,
|
||||
}), err
|
||||
}
|
||||
|
||||
return renderedReportResult(reportResultRequest{
|
||||
moduleSnapshot: req.moduleSnapshot,
|
||||
moduleSnapshotPath: req.moduleSnapshotPath,
|
||||
dataPackage: req.dataPackage,
|
||||
dataPackagePath: req.dataPackagePath,
|
||||
preflightPath: req.preflightPath,
|
||||
reportPath: reportPath,
|
||||
finalized: finalized,
|
||||
priorSnapshot: req.priorSnapshot,
|
||||
recentChanges: req.recentChanges,
|
||||
renderResult: req.renderResult,
|
||||
structuredRunResult: structuredResult,
|
||||
generatedTextRawPath: req.paths.GeneratedTextRaw,
|
||||
generatedTextResultPath: generatedTextResultPath,
|
||||
generatedTextPath: generatedTextPath,
|
||||
renderContextPath: renderContextPath,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
|
||||
return result.OutputPath == "" &&
|
||||
result.NotificationPath == "" &&
|
||||
result.MetadataPath == "" &&
|
||||
result.Metadata.RunID == "" &&
|
||||
result.Notification == nil
|
||||
}
|
||||
|
||||
type reportResultRequest struct {
|
||||
moduleSnapshot module.Snapshot
|
||||
moduleSnapshotPath string
|
||||
dataPackage promptinput.Package
|
||||
dataPackagePath string
|
||||
preflightPath string
|
||||
reportPath string
|
||||
finalized finalizeRenderedReportResult
|
||||
priorSnapshot *state.PriorSnapshot
|
||||
recentChanges []changes.Change
|
||||
renderResult *scriptorium.RenderResult
|
||||
runResult *scriptorium.RunResult
|
||||
structuredRunResult *scriptorium.StructuredRunResult
|
||||
generatedTextRawPath string
|
||||
generatedTextResultPath string
|
||||
generatedTextPath string
|
||||
renderContextPath string
|
||||
}
|
||||
|
||||
func renderedReportResult(req reportResultRequest) *ReportResult {
|
||||
return &ReportResult{
|
||||
ModuleSnapshot: req.moduleSnapshot,
|
||||
ModuleSnapshotPath: req.moduleSnapshotPath,
|
||||
DataPackage: req.dataPackage,
|
||||
DataPackagePath: req.dataPackagePath,
|
||||
PreflightPath: req.preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: finalized.OutputPath,
|
||||
NotificationPath: finalized.NotificationPath,
|
||||
Metadata: finalized.Metadata,
|
||||
MetadataPath: finalized.MetadataPath,
|
||||
ReportPath: req.reportPath,
|
||||
OutputPath: req.finalized.OutputPath,
|
||||
NotificationPath: req.finalized.NotificationPath,
|
||||
Metadata: req.finalized.Metadata,
|
||||
MetadataPath: req.finalized.MetadataPath,
|
||||
PriorSnapshot: req.priorSnapshot,
|
||||
RecentChanges: req.recentChanges,
|
||||
RenderResult: req.renderResult,
|
||||
StructuredRunResult: structuredResult,
|
||||
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
|
||||
GeneratedTextResultPath: generatedTextResultPath,
|
||||
GeneratedTextPath: generatedTextPath,
|
||||
RenderContextPath: renderContextPath,
|
||||
Notification: finalized.Notification,
|
||||
}, nil
|
||||
RunResult: req.runResult,
|
||||
StructuredRunResult: req.structuredRunResult,
|
||||
GeneratedTextRawPath: req.generatedTextRawPath,
|
||||
GeneratedTextResultPath: req.generatedTextResultPath,
|
||||
GeneratedTextPath: req.generatedTextPath,
|
||||
RenderContextPath: req.renderContextPath,
|
||||
Notification: req.finalized.Notification,
|
||||
}
|
||||
}
|
||||
|
||||
type finalizeRenderedReportRequest struct {
|
||||
|
||||
@@ -168,6 +168,97 @@ func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedReturnsReportResult(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
cfg.Scriptorium.Binary = fakeScriptoriumBinary(t)
|
||||
collection := collectionForTest(t, cfg)
|
||||
collector := &recordingCollector{result: &collection}
|
||||
outputPath := filepath.Join(t.TempDir(), "three-day.md")
|
||||
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportThreeDay,
|
||||
OutputPath: outputPath,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDetailed() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("GenerateDetailed() result = nil, want report result")
|
||||
}
|
||||
if result.Metadata.ReportID != report.ThreeDay || result.Metadata.RunID == "" {
|
||||
t.Fatalf("metadata = %#v, want 3-day report metadata with run id", result.Metadata)
|
||||
}
|
||||
if result.OutputPath != outputPath {
|
||||
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
|
||||
}
|
||||
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||
if len(collector.requests) != 1 {
|
||||
t.Fatalf("collector requests = %d, want one collection", len(collector.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReturnsUnderlyingErrorOnly(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
wantErr := errors.New("collector unavailable")
|
||||
|
||||
err := Generate(context.Background(), GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportThreeDay,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: &recordingCollector{err: wantErr},
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Generate() error = %v, want underlying collector error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.T) {
|
||||
server := hourlyBundleServer(t)
|
||||
cfg := hourlyGeneratedTextConfig(t, server)
|
||||
cfg.Scriptorium.Binary = fakeScriptoriumBinary(t)
|
||||
collection := collectionForTest(t, cfg)
|
||||
notifier := &recordingNotifier{err: errors.New("upload rejected")}
|
||||
outputPath := filepath.Join(t.TempDir(), "hourly.md")
|
||||
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportHourly,
|
||||
OutputPath: outputPath,
|
||||
Now: mustParse("2026-05-29T08:30:00-05:00"),
|
||||
Collector: &recordingCollector{result: &collection},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateDetailed() error = nil, want notification error")
|
||||
}
|
||||
var notificationErr *NotificationError
|
||||
if !errors.As(err, ¬ificationErr) {
|
||||
t.Fatalf("GenerateDetailed() error = %T %v, want NotificationError", err, err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("GenerateDetailed() result = nil, want inspectable result on notification failure")
|
||||
}
|
||||
if result.Metadata.ReportID != report.Hourly || result.Metadata.NotificationPath != result.NotificationPath {
|
||||
t.Fatalf("metadata = %#v notificationPath=%q, want hourly notification artifact link", result.Metadata, result.NotificationPath)
|
||||
}
|
||||
if result.NotificationPath == "" || result.ReportPath == "" || result.MetadataPath == "" {
|
||||
t.Fatalf("result paths = report %q metadata %q notification %q, want inspectable artifact paths", result.ReportPath, result.MetadataPath, result.NotificationPath)
|
||||
}
|
||||
assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath)
|
||||
if result.Notification != nil {
|
||||
t.Fatalf("Notification = %#v, want nil notification result when notifier returned only an error", result.Notification)
|
||||
}
|
||||
if len(notifier.requests) != 1 {
|
||||
t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
@@ -357,8 +448,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
||||
}
|
||||
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
||||
if !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||
t.Fatalf("data package missing daily long-term AFD discussion:\n%s", string(data))
|
||||
}
|
||||
for _, omitted := range []string{"Short-term AFD narrative for generated report.", "Storms are most likely during the morning."} {
|
||||
if strings.Contains(string(data), omitted) {
|
||||
t.Fatalf("data package contains daily omitted AFD field %q:\n%s", omitted, string(data))
|
||||
}
|
||||
}
|
||||
preflight, err := os.ReadFile(result.PreflightPath)
|
||||
if err != nil {
|
||||
@@ -3496,6 +3592,66 @@ func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.
|
||||
return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
|
||||
}
|
||||
|
||||
func fakeScriptoriumBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "scriptorium")
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
|
||||
command_name="${1:-}"
|
||||
shift || true
|
||||
prompt=""
|
||||
output=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--prompt)
|
||||
shift
|
||||
prompt="${1:-}"
|
||||
;;
|
||||
--out)
|
||||
shift
|
||||
output="${1:-}"
|
||||
;;
|
||||
esac
|
||||
shift || true
|
||||
done
|
||||
|
||||
case "$command_name" in
|
||||
render)
|
||||
printf '{"prepared":true}\n'
|
||||
;;
|
||||
run)
|
||||
if [ -z "$output" ]; then
|
||||
printf 'missing output path\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$prompt" in
|
||||
weather.hourly_generated_text)
|
||||
cat > "$output" <<'EOF'
|
||||
{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
cat > "$output" <<'EOF'
|
||||
# Generated Report
|
||||
|
||||
Prepared report body.
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
printf 'unknown command: %s\n' "$command_name" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake scriptorium binary: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func validHourlyGeneratedTextJSON() string {
|
||||
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
|
||||
}
|
||||
|
||||
@@ -506,6 +506,34 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleUsesDailyDefaultSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Daily)
|
||||
var item module.ConfigItem
|
||||
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||
if candidate.ID == module.AreaForecastDiscussion {
|
||||
item = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
t.Fatal("daily default modules missing area_forecast_discussion")
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.LongTerm != "Periodic rain chances continue." {
|
||||
t.Fatalf("LongTerm = %q, want selected long term section", afd.LongTerm)
|
||||
}
|
||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.ShortTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only long term section", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func testModuleContext() ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||
|
||||
@@ -263,24 +263,56 @@ func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
||||
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||
}
|
||||
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
|
||||
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
|
||||
}
|
||||
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
id report.ID
|
||||
wantSections string
|
||||
}{
|
||||
{id: report.Daily, wantSections: "long_term"},
|
||||
{id: report.Hourly, wantSections: "key_messages,short_term"},
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("hourly default modules missing area_forecast_discussion")
|
||||
|
||||
registry := report.DefaultRegistry()
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
definition := registry.MustLookup(tt.id)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||
}
|
||||
if strings.Join(options.Sections, ",") != tt.wantSections {
|
||||
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("default modules missing area_forecast_discussion")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
definition := registry.MustLookup(id)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if item.Options != nil {
|
||||
t.Fatalf("AFD options = %#v, want default all sections", item.Options)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("default modules missing area_forecast_discussion")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
75
internal/cli/output.go
Normal file
75
internal/cli/output.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
)
|
||||
|
||||
type outputOptions struct {
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
func writeActionResult(stdout, stderr io.Writer, value any, opts outputOptions, writeStatus func(io.Writer)) error {
|
||||
if opts.Quiet {
|
||||
return nil
|
||||
}
|
||||
if writeStatus != nil && stderr != nil {
|
||||
writeStatus(stderr)
|
||||
}
|
||||
return writeJSON(stdout, value)
|
||||
}
|
||||
|
||||
func writeJSON(stdout io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
||||
if stderr == nil || result == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
notificationFields := ""
|
||||
if item.NotificationStatus != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
||||
}
|
||||
if item.NotificationRunID != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
||||
}
|
||||
if item.NotificationError != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
||||
}
|
||||
if item.Status == "failed" {
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||
}
|
||||
if result.Notification != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
|
||||
if result.Notification.Reason != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
|
||||
}
|
||||
if result.Notification.RunID != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
|
||||
}
|
||||
if result.Notification.PipelineID != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
|
||||
}
|
||||
if result.Notification.BundleID != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
|
||||
}
|
||||
if result.Notification.Path != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
|
||||
}
|
||||
if result.Notification.Error != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
|
||||
}
|
||||
_, _ = fmt.Fprintln(stderr)
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
59
internal/cli/output_test.go
Normal file
59
internal/cli/output_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteActionResultWritesStatusBeforeJSON(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
|
||||
err := writeActionResult(&output, &output, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||
_, _ = w.Write([]byte("status line\n"))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeActionResult() error = %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(output.String(), "status line\n") {
|
||||
t.Fatalf("output = %q, want status before JSON", output.String())
|
||||
}
|
||||
if !strings.Contains(output.String(), `"status": "succeeded"`) {
|
||||
t.Fatalf("output missing JSON result:\n%s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteActionResultQuietSuppressesOutput(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := writeActionResult(&stdout, &stderr, map[string]string{"status": "succeeded"}, outputOptions{Quiet: true}, func(w io.Writer) {
|
||||
_, _ = w.Write([]byte("status line\n"))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeActionResult() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("stdout/stderr = %q/%q, want no output", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteActionResultToleratesNilStderr(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
statusCalled := false
|
||||
|
||||
err := writeActionResult(&stdout, nil, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||
statusCalled = true
|
||||
_, _ = w.Write([]byte("status line\n"))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeActionResult() error = %v", err)
|
||||
}
|
||||
if statusCalled {
|
||||
t.Fatal("status writer was called with nil stderr")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"status": "succeeded"`) {
|
||||
t.Fatalf("stdout missing JSON result:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
162
internal/cli/result.go
Normal file
162
internal/cli/result.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
const (
|
||||
commandGenerate = "generate"
|
||||
commandRun = "run"
|
||||
|
||||
summaryStatusSucceeded = "succeeded"
|
||||
summaryStatusFailed = "failed"
|
||||
)
|
||||
|
||||
type generateSummary struct {
|
||||
Command string `json:"command"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
ReportName string `json:"reportName"`
|
||||
PromptID string `json:"promptId"`
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
ReportPath string `json:"reportPath,omitempty"`
|
||||
OutputPath string `json:"outputPath,omitempty"`
|
||||
MetadataPath string `json:"metadataPath,omitempty"`
|
||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||
PreflightPath string `json:"preflightPath,omitempty"`
|
||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
|
||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type generateNotificationSummary struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
UploadStatus string `json:"uploadStatus,omitempty"`
|
||||
StatusError string `json:"statusError,omitempty"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
PipelineID string `json:"pipelineId,omitempty"`
|
||||
BundleID string `json:"bundleId,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchSummary struct {
|
||||
Command string `json:"command"`
|
||||
Batch app.BatchKind `json:"batch"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
||||
Reports []app.BatchReportResult `json:"reports"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
|
||||
summary := generateSummary{Command: commandGenerate}
|
||||
if result == nil {
|
||||
return summary
|
||||
}
|
||||
|
||||
metadata := result.Metadata
|
||||
summary.ReportID = metadata.ReportID
|
||||
summary.ReportName = reportName(metadata.ReportID)
|
||||
summary.PromptID = metadata.PromptID
|
||||
summary.RunID = metadata.RunID
|
||||
summary.Status = summaryStatusSucceeded
|
||||
summary.GeneratedAt = metadata.GeneratedAt
|
||||
summary.ValidPeriod = metadata.ValidPeriod
|
||||
summary.ReportPath = result.ReportPath
|
||||
summary.OutputPath = result.OutputPath
|
||||
summary.MetadataPath = result.MetadataPath
|
||||
summary.DataPackagePath = result.DataPackagePath
|
||||
summary.PreflightPath = result.PreflightPath
|
||||
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
||||
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
|
||||
summary.GeneratedTextPath = result.GeneratedTextPath
|
||||
summary.RenderContextPath = result.RenderContextPath
|
||||
summary.NotificationPath = result.NotificationPath
|
||||
summary.Notification = newGenerateNotificationSummary(result.Notification)
|
||||
if err != nil {
|
||||
summary.Status = summaryStatusFailed
|
||||
summary.Error = err.Error()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func newGenerateNotificationSummary(result *app.NotificationResult) *generateNotificationSummary {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
summary := &generateNotificationSummary{
|
||||
Status: result.Status,
|
||||
UploadStatus: result.UploadStatus,
|
||||
StatusError: result.StatusError,
|
||||
RunID: result.RunID,
|
||||
PipelineID: result.PipelineID,
|
||||
BundleID: result.BundleID,
|
||||
IdempotencyKey: result.IdempotencyKey,
|
||||
StartedAt: result.StartedAt,
|
||||
FinishedAt: result.FinishedAt,
|
||||
Error: result.Error,
|
||||
}
|
||||
if !result.AcceptedAt.IsZero() {
|
||||
acceptedAt := result.AcceptedAt
|
||||
summary.AcceptedAt = &acceptedAt
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func newBatchSummary(result *app.BatchResult) batchSummary {
|
||||
summary := batchSummary{Command: commandRun}
|
||||
if result == nil {
|
||||
return summary
|
||||
}
|
||||
|
||||
summary.Batch = result.Batch
|
||||
summary.Status = batchSummaryStatus(result)
|
||||
summary.StartedAt = result.StartedAt
|
||||
summary.FinishedAt = result.FinishedAt
|
||||
summary.Total = result.Total
|
||||
summary.Succeeded = result.Succeeded
|
||||
summary.Failed = result.Failed
|
||||
summary.Notification = result.Notification
|
||||
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
||||
if summary.Status == summaryStatusFailed {
|
||||
summary.Error = app.BatchError{Result: result}.Error()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func batchSummaryStatus(result *app.BatchResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
||||
return summaryStatusFailed
|
||||
}
|
||||
return summaryStatusSucceeded
|
||||
}
|
||||
|
||||
func reportName(id report.ID) string {
|
||||
definition, err := report.DefaultRegistry().Lookup(id)
|
||||
if err != nil {
|
||||
return string(id)
|
||||
}
|
||||
return definition.Name
|
||||
}
|
||||
231
internal/cli/result_test.go
Normal file
231
internal/cli/result_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
acceptedAt := generatedAt.Add(time.Minute)
|
||||
startedAt := acceptedAt.Add(time.Minute)
|
||||
finishedAt := startedAt.Add(time.Minute)
|
||||
result := &app.ReportResult{
|
||||
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||
PreflightPath: "/runs/hourly/preflight.json",
|
||||
ReportPath: "/runs/hourly/report.md",
|
||||
OutputPath: "/copies/hourly.md",
|
||||
MetadataPath: "/runs/hourly/metadata.json",
|
||||
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||
GeneratedTextResultPath: "/runs/hourly/generated_text_result.json",
|
||||
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
||||
RenderContextPath: "/runs/hourly/render_context.json",
|
||||
NotificationPath: "/runs/hourly/notification.json",
|
||||
Metadata: state.Metadata{
|
||||
ReportID: report.Hourly,
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
RunID: "20260529T133000Z_hourly",
|
||||
GeneratedAt: generatedAt,
|
||||
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||
},
|
||||
Notification: &app.NotificationResult{
|
||||
Status: "succeeded",
|
||||
UploadStatus: "accepted",
|
||||
RunID: "distributor-run",
|
||||
PipelineID: "weatherreporter.hourly",
|
||||
BundleID: "weatherreporter.home.hourly",
|
||||
IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly",
|
||||
AcceptedAt: acceptedAt,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||
},
|
||||
}
|
||||
|
||||
summary := newGenerateSummary(result, nil)
|
||||
|
||||
if summary.Command != "generate" || summary.Status != "succeeded" {
|
||||
t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status)
|
||||
}
|
||||
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
|
||||
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
|
||||
}
|
||||
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
||||
}
|
||||
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
|
||||
t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification)
|
||||
}
|
||||
data, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
|
||||
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
result := &app.ReportResult{
|
||||
DataPackagePath: "/runs/three-day/data_package.yaml",
|
||||
PreflightPath: "/runs/three-day/preflight.json",
|
||||
ReportPath: "/runs/three-day/report.md",
|
||||
OutputPath: "/copies/three-day.md",
|
||||
MetadataPath: "/runs/three-day/metadata.json",
|
||||
Metadata: state.Metadata{
|
||||
ReportID: report.ThreeDay,
|
||||
PromptID: "weather.three_day_outlook",
|
||||
RunID: "20260529T133000Z_three_day",
|
||||
GeneratedAt: generatedAt,
|
||||
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||
},
|
||||
}
|
||||
|
||||
summary := newGenerateSummary(result, nil)
|
||||
|
||||
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
|
||||
t.Fatalf("summary = %#v, want successful 3-day summary", summary)
|
||||
}
|
||||
if summary.Notification != nil || summary.NotificationPath != "" {
|
||||
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
|
||||
}
|
||||
data, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
|
||||
if strings.Contains(string(data), omitted) {
|
||||
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
result := &app.ReportResult{
|
||||
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||
PreflightPath: "/runs/hourly/preflight.json",
|
||||
ReportPath: "/runs/hourly/report.md",
|
||||
OutputPath: "/copies/hourly.md",
|
||||
MetadataPath: "/runs/hourly/metadata.json",
|
||||
NotificationPath: "/runs/hourly/notification.json",
|
||||
Metadata: state.Metadata{
|
||||
ReportID: report.Hourly,
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
RunID: "20260529T133000Z_hourly",
|
||||
GeneratedAt: generatedAt,
|
||||
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||
},
|
||||
}
|
||||
err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`)
|
||||
|
||||
summary := newGenerateSummary(result, err)
|
||||
|
||||
if summary.Status != "failed" || summary.Error != err.Error() {
|
||||
t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error)
|
||||
}
|
||||
if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" {
|
||||
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBatchSummaryStatusDerivation(t *testing.T) {
|
||||
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
|
||||
finishedAt := startedAt.Add(2 * time.Minute)
|
||||
tests := []struct {
|
||||
name string
|
||||
result *app.BatchResult
|
||||
wantStatus string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
result: &app.BatchResult{
|
||||
Batch: app.BatchMorning,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: finishedAt,
|
||||
Total: 1,
|
||||
Succeeded: 1,
|
||||
Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}},
|
||||
},
|
||||
wantStatus: "succeeded",
|
||||
},
|
||||
{
|
||||
name: "report failure",
|
||||
result: &app.BatchResult{
|
||||
Batch: app.BatchMorning,
|
||||
Total: 2,
|
||||
Succeeded: 1,
|
||||
Failed: 1,
|
||||
Reports: []app.BatchReportResult{
|
||||
{ReportID: report.Today, Status: "succeeded"},
|
||||
{ReportID: report.Tomorrow, Status: "failed", Error: "render failed"},
|
||||
},
|
||||
},
|
||||
wantStatus: "failed",
|
||||
wantError: "batch morning failed: 1 of 2 reports failed",
|
||||
},
|
||||
{
|
||||
name: "skipped notification",
|
||||
result: &app.BatchResult{
|
||||
Batch: app.BatchEvening,
|
||||
Total: 2,
|
||||
Succeeded: 1,
|
||||
Failed: 1,
|
||||
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}},
|
||||
Notification: &app.BatchNotificationResult{
|
||||
Status: "skipped",
|
||||
Reason: "one or more reports failed",
|
||||
},
|
||||
},
|
||||
wantStatus: "failed",
|
||||
wantError: "batch evening failed: 1 of 2 reports failed",
|
||||
},
|
||||
{
|
||||
name: "failed notification",
|
||||
result: &app.BatchResult{
|
||||
Batch: app.BatchEvening,
|
||||
Total: 1,
|
||||
Succeeded: 1,
|
||||
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}},
|
||||
Notification: &app.BatchNotificationResult{
|
||||
Status: "failed",
|
||||
Error: "notify batch evening: upload rejected",
|
||||
},
|
||||
},
|
||||
wantStatus: "failed",
|
||||
wantError: "batch evening notification failed: notify batch evening: upload rejected",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
summary := newBatchSummary(tt.result)
|
||||
if summary.Command != "run" || summary.Status != tt.wantStatus {
|
||||
t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus)
|
||||
}
|
||||
if summary.Error != tt.wantError {
|
||||
t.Fatalf("error = %q, want %q", summary.Error, tt.wantError)
|
||||
}
|
||||
if len(summary.Reports) != len(tt.result.Reports) {
|
||||
t.Fatalf("reports = %#v, want copied report list", summary.Reports)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testSummaryPeriod(start time.Time) timeutil.Period {
|
||||
return timeutil.Period{
|
||||
Start: start,
|
||||
End: start.Add(6 * time.Hour),
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,15 +16,15 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
||||
|
||||
Usage:
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
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]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
@@ -40,6 +39,7 @@ Options:
|
||||
--tz NAME Override weather API timezone.
|
||||
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
||||
--quiet Suppress successful generate and run output.
|
||||
`
|
||||
|
||||
type Runner struct {
|
||||
@@ -51,7 +51,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{}
|
||||
}
|
||||
@@ -62,23 +61,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 {
|
||||
writeRunLogs(stderr, result)
|
||||
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
||||
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 +104,7 @@ type commonOptions struct {
|
||||
Timezone string
|
||||
Output string
|
||||
OutputDir string
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
type generateOptions struct {
|
||||
@@ -181,20 +190,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 +216,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 +233,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 +245,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 +292,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 +306,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 +329,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
|
||||
}
|
||||
@@ -351,58 +372,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func writeJSON(stdout io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
|
||||
if stderr == nil || result == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
notificationFields := ""
|
||||
if item.NotificationStatus != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
||||
}
|
||||
if item.NotificationRunID != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
||||
}
|
||||
if item.NotificationError != "" {
|
||||
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
||||
}
|
||||
if item.Status == "failed" {
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||
}
|
||||
if result.Notification != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
|
||||
if result.Notification.Reason != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
|
||||
}
|
||||
if result.Notification.RunID != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
|
||||
}
|
||||
if result.Notification.PipelineID != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
|
||||
}
|
||||
if result.Notification.BundleID != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
|
||||
}
|
||||
if result.Notification.Path != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
|
||||
}
|
||||
if result.Notification.Error != "" {
|
||||
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
|
||||
}
|
||||
_, _ = fmt.Fprintln(stderr)
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
|
||||
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
||||
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||
|
||||
@@ -32,6 +32,12 @@ func TestRunHelpLongFlag(t *testing.T) {
|
||||
if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
|
||||
t.Fatalf("help output missing hourly generate command:\n%s", output.stdout)
|
||||
}
|
||||
if !strings.Contains(output.stdout, "generate today") || !strings.Contains(output.stdout, "[--quiet]") {
|
||||
t.Fatalf("help output missing quiet generate usage:\n%s", output.stdout)
|
||||
}
|
||||
if !strings.Contains(output.stdout, "run morning") || !strings.Contains(output.stdout, "--quiet Suppress successful generate and run output.") {
|
||||
t.Fatalf("help output missing quiet run option:\n%s", output.stdout)
|
||||
}
|
||||
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
|
||||
if strings.Contains(output.stdout, removedGenerateCommand) {
|
||||
t.Fatalf("help output includes retired generate command:\n%s", output.stdout)
|
||||
@@ -131,7 +137,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 +149,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 +217,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)
|
||||
@@ -247,7 +267,7 @@ func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
|
||||
if err := writeJSON(&stdout, result); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
writeRunLogs(&stderr, result)
|
||||
writeBatchStatus(&stderr, result)
|
||||
|
||||
var decoded app.BatchResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
@@ -294,7 +314,7 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
||||
if err := writeJSON(&stdout, result); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
writeRunLogs(&stderr, result)
|
||||
writeBatchStatus(&stderr, result)
|
||||
|
||||
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
|
||||
@@ -306,7 +326,7 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
|
||||
func TestBatchStatusIncludesSkippedBatchNotification(t *testing.T) {
|
||||
result := &app.BatchResult{
|
||||
Batch: app.BatchMorning,
|
||||
Total: 2,
|
||||
@@ -327,7 +347,7 @@ func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
|
||||
if err := writeJSON(&stdout, result); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
writeRunLogs(&stderr, result)
|
||||
writeBatchStatus(&stderr, result)
|
||||
|
||||
var decoded app.BatchResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||
@@ -344,7 +364,7 @@ func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLogsDoNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
|
||||
func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
|
||||
result := &app.BatchResult{
|
||||
Batch: app.BatchEvening,
|
||||
Total: 1,
|
||||
@@ -360,7 +380,7 @@ func TestRunLogsDoNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
|
||||
writeRunLogs(&stderr, result)
|
||||
writeBatchStatus(&stderr, result)
|
||||
|
||||
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
|
||||
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
|
||||
@@ -387,9 +407,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 +422,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 +475,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 +527,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 +579,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 +636,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 +645,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 +668,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 +699,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 +709,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 +737,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 +913,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 +1317,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) {
|
||||
|
||||
@@ -31,7 +31,7 @@ func dailyDefinition() Definition {
|
||||
}
|
||||
|
||||
func dailyModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
items := moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.NarrativeForecast,
|
||||
@@ -40,13 +40,16 @@ func dailyModules() []module.ConfigItem {
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
)
|
||||
items = append(items, areaForecastDiscussionItem("long_term"))
|
||||
items = append(items, moduleItems(
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.DailyPlanning,
|
||||
module.HourlyForecast,
|
||||
)
|
||||
)...)
|
||||
return items
|
||||
}
|
||||
|
||||
func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
|
||||
|
||||
@@ -38,12 +38,7 @@ func hourlyModules() []module.ConfigItem {
|
||||
{ID: module.PrecipTiming},
|
||||
{ID: module.AlertDigest},
|
||||
{ID: module.SPCConvectiveOutlooks},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{
|
||||
Sections: []string{"key_messages", "short_term"},
|
||||
},
|
||||
},
|
||||
areaForecastDiscussionItem("key_messages", "short_term"),
|
||||
{ID: module.SPCConvectiveDiscussion},
|
||||
{ID: module.WeatherStory},
|
||||
}
|
||||
|
||||
@@ -57,6 +57,15 @@ func moduleItems(ids ...module.ID) []module.ConfigItem {
|
||||
return items
|
||||
}
|
||||
|
||||
func areaForecastDiscussionItem(sections ...string) module.ConfigItem {
|
||||
return module.ConfigItem{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{
|
||||
Sections: append([]string(nil), sections...),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem {
|
||||
cloned := make([]module.ConfigItem, len(items))
|
||||
copy(cloned, items)
|
||||
|
||||
Reference in New Issue
Block a user