8 Commits

27 changed files with 824 additions and 613 deletions

View File

@@ -4,35 +4,46 @@ This document describes the implemented workflow coordinator in `internal/app`.
## Purpose ## Purpose
`internal/app` coordinates top-level use cases: generating one report, running `internal/app` coordinates the top-level use cases after CLI parsing and config
morning or evening batches, building inspectable briefing artifacts, fetching loading are complete. It resolves report definitions, fetches weather data,
weather bundles, and reading existing artifacts for inspection. builds briefing and prompt-input artifacts, invokes Scriptorium through the
adapter boundary, persists managed state, runs batches, and reads existing
artifacts for inspection.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- app request structs containing config, report or batch selection, clock time, - `GenerateRequest` for one report command
optional report date, optional Storm Report bounds, output paths, renderer - `BatchRequest` for morning or evening batch commands
fakes, or state-store fakes - `FetchBundleRequest` for explicit bundle fetch and save workflows
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
and internal composition
- resolved report definitions from `internal/report` - resolved report definitions from `internal/report`
- forecast bundles from the Weather API adapter - forecast bundles from `internal/adapters/weatherapi`
- prior briefing snapshots from `internal/state` - prior snapshots loaded from `internal/state`
- optional renderer and state-store fakes for tests
Outputs: Outputs:
- report results with briefing, data package, preflight, report, metadata, - generated report results with briefing, data package, preflight, report,
prior snapshot, Recent Changes, and Scriptorium results metadata, prior snapshot, Recent Changes, and Scriptorium result details
- batch summaries with per-report status and artifact paths - batch summaries with per-report status, artifact paths, and error text
- saved Weather API bundle JSON for fetch workflows
- inspection JSON values for reports, metadata, briefings, data packages, prior - inspection JSON values for reports, metadata, briefings, data packages, prior
snapshots, and source provenance snapshots, and source provenance
## Boundaries ## Boundaries
- The package coordinates workflow order. `internal/app` owns workflow order and request composition. It does not parse
- It does not parse CLI flags, load YAML files directly, implement HTTP calls, CLI flags, load YAML files directly, implement HTTP transport, derive forecast
derive forecast facts, define report periods, compare Markdown, or construct facts, define report periods, compare rendered Markdown, or construct
Scriptorium argv. Scriptorium argv.
Report selection and report identity policy come from `internal/report`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
and persisted metadata stay in `internal/state`.
## Config Fields Used ## Config Fields Used
@@ -42,36 +53,57 @@ Outputs:
- `dayparts` for daily and outlook summarization - `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds - `recent_change.*` for structured Recent Changes thresholds
## External Adapters Used Output copy flags are command request fields. They are not configuration
defaults.
- `internal/adapters/weatherapi` for forecast bundle fetching ## Generation Workflow
- `internal/adapters/scriptorium` for render preflight and report generation
- `internal/state` filesystem store for persisted artifacts
## State Or Manifest Behavior Single-report generation follows this order:
Generation saves the briefing snapshot, data package, preflight result when 1. Resolve the command report to a `report.Resolved` value.
available, rendered report, and metadata. Metadata links all managed artifact 2. Create or use a filesystem store.
paths. Inspection workflows read existing state and do not fetch weather data or 3. Locate any prior compatible snapshot through `internal/state`.
invoke Scriptorium. 4. Fetch a Weather API bundle.
5. Build a report-specific briefing package.
6. Save the briefing snapshot.
7. Compute Recent Changes from structured prior and current briefings.
8. Build and save the Scriptorium `data_package`.
9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available.
11. Save metadata for inspection.
12. Run Scriptorium report generation to the managed report path.
13. Copy the managed report to the requested `--out` path when provided.
14. Save metadata with the managed report path.
## Skip And Resume Behavior If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and
metadata remain inspectable.
There is no resume workflow. Batch generation continues remaining independent ## Batch Workflow
reports after one report fails, then reports aggregate success and failure
counts. `run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
from report definitions. Batch generation continues independent reports after a
failure, records each result, writes compact status lines to stderr, emits a
JSON summary to stdout, and returns an aggregate error when any report failed.
## Inspection Workflow
Inspection workflows load existing filesystem state only. They do not fetch
weather data or invoke Scriptorium. Run-specific inspect commands share the same
store and metadata lookup path, then load the requested artifact or derived
inspection view.
## Failure Behavior ## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data. - Resolve errors stop the requested workflow before fetching weather data.
- Weather API or briefing errors stop that report before Scriptorium is called. - Weather API and briefing errors stop that report before Scriptorium runs.
- Render preflight runs before Scriptorium report generation. - Prompt input validation fails before render preflight.
- If render preflight returns a result and an error, preflight JSON and metadata - Render and run errors preserve Scriptorium stderr and exit-code context.
are persisted before the error is returned. - Metadata and artifact path errors include filesystem context.
- If Scriptorium report generation returns an error after writing output, - Batch failures are recorded per report and surfaced through an aggregate
metadata and the managed report path remain inspectable. batch error.
- Batch failures are recorded per report and surfaced through aggregate batch
failure.
## Tests ## Tests
@@ -84,6 +116,8 @@ Inspect:
## Invariants ## Invariants
- Report behavior is resolved through `internal/report`. - Report behavior is resolved through `internal/report`.
- Generated reports use the same app request and result types regardless of
report ID.
- Render preflight precedes Scriptorium report generation. - Render preflight precedes Scriptorium report generation.
- Recent Changes are computed from structured briefing snapshots. - Recent Changes are computed from structured briefing snapshots.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.

View File

@@ -1,57 +1,81 @@
# Report Registry Internals # Report Registry Internals
This document describes report identity, valid-period resolution, batch This document describes report identity, valid-period resolution, batch
membership, and comparison declarations in `internal/report`. membership, output naming, artifact grouping, and comparison declarations in
`internal/report`.
## Purpose ## Purpose
`internal/report` centralizes report definitions so report IDs, prompt IDs, `internal/report` is the canonical source for report definitions. App, state,
default output names, comparison strategies, and valid periods are declared in briefing, and CLI wiring consume resolved definitions instead of owning report
one package. identity policy themselves.
## Inputs And Outputs ## Definition Fields
Inputs: Each report definition declares:
- report ID or batch name - report ID and display name
- generation time - Scriptorium prompt ID
- timezone - valid-period resolver
- optional Daily date override - comparison strategy
- optional Storm Report start and end times - managed artifact group
- batch output copy filename
- generated-report eligibility
- prior-report compatibility list
- morning or evening batch membership
Outputs: ## Implemented Reports
- `report.Resolved` values with definition metadata and half-open valid periods | Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
- `report.Metadata` values used by briefing and persisted metadata builders | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
All implemented report definitions are eligible for generation.
## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time.
- 3-Day Outlook covers the interval from generation time through local midnight
three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
Sunday morning batch resolution.
- Storm Report covers an explicit event window supplied by the caller.
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
the configured timezone or RFC3339 timestamps with explicit offsets. End time
must be after start time.
## Boundaries ## Boundaries
- This package defines report identity and time coverage only. `internal/report` defines report metadata and time coverage. It does not fetch
- It does not fetch weather data, build briefings, compare snapshots, write weather data, build briefings, compare briefing contents, write state, parse CLI
state, parse CLI flags, or invoke Scriptorium. flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report
IDs, then uses the registry for report policy.
## Config Fields Used ## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Report The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses default output names from report definitions. output path copying uses batch output names from report definitions.
## External Adapters Used ## State And App Usage
None. - State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`.
## State Or Manifest Behavior - Generation checks `Generated`.
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
None directly. Resolved metadata contributes RunID, report ID, prompt ID, - RunIDs include the resolved report ID.
generation time, timezone, and valid period to later briefing and state
metadata.
## Skip And Resume Behavior
No resume behavior. Morning batch resolution skips Weekend Outlook on Sunday.
## Failure Behavior ## Failure Behavior
- Unknown reports and batch names return actionable errors. - Unknown report IDs and batch names return actionable errors.
- Weekend Outlook resolution returns an error when resolved directly on Sunday. - Weekend Outlook resolution returns an error when resolved directly on Sunday.
- Storm Report resolution requires start and end, with end after start. - Storm Report resolution requires start and end, with end after start.
@@ -68,4 +92,6 @@ Inspect:
- Report selection goes through the registry. - Report selection goes through the registry.
- Daily Today and Daily Tomorrow both use `weather.daily_report`. - Daily Today and Daily Tomorrow both use `weather.daily_report`.
- Valid periods are half-open intervals independent of rendered report text. - Valid periods are half-open intervals independent of rendered report text.
- Comparison strategy is declared by report definition. - Artifact grouping, batch output filenames, generated-report eligibility,
comparison compatibility, and comparison strategy are declared by report
definition.

View File

@@ -6,8 +6,9 @@ This document describes the subprocess adapter in
## Purpose ## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run` The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation while isolating subprocess details from domain for Markdown report generation. It isolates subprocess execution, argv
packages. construction, timeout handling, output capture, and exit-code interpretation
from app and domain packages.
## Inputs And Outputs ## Inputs And Outputs
@@ -17,19 +18,25 @@ Inputs:
- prompt input data package path - prompt input data package path
- report output path for `run` - report output path for `run`
- configured binary, config path, profile, timeout, and extra arguments - configured binary, config path, profile, timeout, and extra arguments
- context for cancellation
Outputs: Outputs:
- argv used for execution - argv used for execution
- captured stdout and stderr with truncation flags - captured stdout and stderr
- truncation flags for captured output
- exit code - exit code
- report output path for `run` - report output path for `run`
## Boundaries ## Boundaries
- This adapter owns Scriptorium argv construction and subprocess execution. `internal/adapters/scriptorium` owns Scriptorium command construction and
- It does not choose report types, build prompt input, fetch weather data, subprocess execution. It does not choose report types, build prompt input,
decide workflow order, or persist workflow metadata. fetch weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render and run operations.
State persistence uses a state-owned preflight artifact shape; app
orchestration converts render results before saving.
## Config Fields Used ## Config Fields Used
@@ -39,29 +46,43 @@ Outputs:
- `scriptorium.timeout` - `scriptorium.timeout`
- `scriptorium.extra_args` - `scriptorium.extra_args`
## External Adapters Used ## Commands
- external `scriptorium` CLI Render preflight argv starts with:
See [Scriptorium integration](../integrations/scriptorium.md) for the external ```text
CLI contract used by this project. scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
```
## State Or Manifest Behavior Report generation argv starts with:
`SaveRenderResult` can write render results atomically. The app and state store ```text
own managed preflight paths and metadata links. scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
```
## Skip And Resume Behavior Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments.
None. Context cancellation and configured timeout stop subprocess execution. ## Execution Behavior
The adapter runs commands without shell interpolation. The same private
execution path is used by render and run after command-specific request
validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at
1 MiB each, and marked as truncated when the cap is reached.
## Failure Behavior ## Failure Behavior
- Missing prompt ID, data package path, or run output path returns an error. - Missing prompt ID or data package path returns an error before subprocess
- Subprocess start or context errors are wrapped with operation context. execution.
- Nonzero render and run exits return captured output plus an error containing - Missing run output path returns an error before subprocess execution.
exit code and stderr. - Subprocess start errors, context cancellation, and timeouts are wrapped with
- Captured stdout and stderr are size-limited and marked when truncated. operation context by the caller-facing method.
- Nonzero render and run exits return the captured result plus an error
containing the exit code and stderr.
## Tests ## Tests
@@ -75,4 +96,5 @@ Inspect:
- No shell interpolation is used. - No shell interpolation is used.
- The Scriptorium input name is `data_package`. - The Scriptorium input name is `data_package`.
- Render and run preserve command-specific result structs.
- Scriptorium-specific flags stay inside adapter and config boundaries. - Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -4,7 +4,7 @@ This document describes filesystem state in `internal/state`.
## Purpose ## Purpose
`internal/state` owns managed artifact paths, atomic JSON writes, persisted `internal/state` owns managed workspace paths, atomic JSON writes, persisted
metadata, prior snapshot lookup, and read-only artifact inspection helpers. metadata, prior snapshot lookup, and read-only artifact inspection helpers.
## Inputs And Outputs ## Inputs And Outputs
@@ -15,7 +15,7 @@ Inputs:
- resolved report definition and valid period - resolved report definition and valid period
- briefing package - briefing package
- prompt input data package - prompt input data package
- Scriptorium render result - preflight artifact
- rendered report path preparation request - rendered report path preparation request
- RunID for inspection lookups - RunID for inspection lookups
@@ -32,10 +32,13 @@ Outputs:
## Boundaries ## Boundaries
- This package owns managed workspace layout, path validation, filesystem `internal/state` owns local filesystem layout, path validation, durable writes,
writes, and metadata reads. metadata reads, prior lookup, and report listing. It does not fetch weather
- It does not fetch weather data, derive forecasts, build prompt input content, data, derive forecasts, build prompt input content, compare briefing contents,
compare briefing contents, invoke Scriptorium, or parse CLI flags. invoke Scriptorium, import adapter result types, or parse CLI flags.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
converts adapter render results into that shape before saving.
## Config Fields Used ## Config Fields Used
@@ -48,27 +51,48 @@ Outputs:
Workspace subdirectories must be relative paths that stay under Workspace subdirectories must be relative paths that stay under
`workspace.root`. `workspace.root`.
## External Adapters Used ## Managed Layout
- local filesystem Paths are derived from the resolved report definition's artifact group, the
valid-period start date for JSON artifacts, and the RunID.
## State Or Manifest Behavior ```text
<workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
reports/<artifact_group>/<run_id>.md
```
Managed paths are grouped by report family and valid-period start date for JSON Metadata is stored beside briefing snapshots and links the briefing, data
artifacts. Reports are written under the report group. Metadata is stored beside package, preflight, and report paths. Report listing walks metadata files under
briefing snapshots and links briefing, data package, preflight, and report the snapshots directory.
paths. Report listing walks metadata files under the snapshots directory.
Prior snapshot lookup reads metadata and selects the latest earlier compatible ## Prior Lookup
snapshot. Daily Today and Daily Tomorrow are compatible with each other for the
same valid local date. 3-Day Outlook compares with prior 3-Day snapshots for
the same valid local date. Weekend Outlook compares with prior Weekend snapshots
for the same weekend window. Storm Report currently has no prior lookup.
## Skip And Resume Behavior Prior snapshot lookup reads stored metadata through the shared lookup path and
selects the latest earlier snapshot whose report ID is compatible with the
current report definition.
There is no resume workflow. Missing metadata directories return no inspection - Daily Today and Daily Tomorrow are compatible with each other for the same
records or no prior snapshot rather than creating state. valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
window.
- Storm Report currently has no prior lookup because explicit event-window
comparison is not searched by the filesystem store.
## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
prepared by creating their parent directory; Scriptorium writes the report body
to the prepared path. Extra Markdown copies are handled by app orchestration.
Inspection helpers read existing metadata, briefing, and data package files.
Missing metadata directories return no inspection records or no prior snapshot
rather than creating state.
## Failure Behavior ## Failure Behavior
@@ -88,5 +112,6 @@ Inspect:
## Invariants ## Invariants
- Managed paths stay under the configured workspace root. - Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Prior lookup is based on structured metadata, not rendered report text. - Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -12,6 +12,7 @@ Developers and LLM coding agents should use it with
- `internal/cli`: command parsing, flag handling, help text, and JSON output. - `internal/cli`: command parsing, flag handling, help text, and JSON output.
- `internal/config`: configuration structs, defaults, loading, overrides, and - `internal/config`: configuration structs, defaults, loading, overrides, and
validation. validation.
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
- `internal/adapters/weatherapi`: Weather API HTTP adapter. - `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter. - `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/forecast`: normalized bundle types and deterministic forecast - `internal/forecast`: normalized bundle types and deterministic forecast

View File

@@ -3,12 +3,9 @@ package scriptorium
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os"
"os/exec" "os/exec"
"path/filepath"
"time" "time"
) )
@@ -108,29 +105,20 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
if req.DataPackagePath == "" { if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required") return nil, fmt.Errorf("data package path is required")
} }
binary := r.Binary execution, err := r.execute(ctx, r.renderArgs(req))
if binary == "" {
binary = "scriptorium"
}
commands := r.Commands
if commands == nil {
commands = ExecRunner{}
}
args := r.renderArgs(req)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("run scriptorium render: %w", err) return nil, fmt.Errorf("run scriptorium render: %w", err)
} }
result := &RenderResult{ result := &RenderResult{
Command: append([]string{binary}, args...), Command: execution.argv(),
Stdout: string(commandResult.Stdout), Stdout: string(execution.result.Stdout),
Stderr: string(commandResult.Stderr), Stderr: string(execution.result.Stderr),
StdoutTruncated: commandResult.StdoutTruncated, StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: commandResult.StderrTruncated, StderrTruncated: execution.result.StderrTruncated,
ExitCode: commandResult.ExitCode, ExitCode: execution.result.ExitCode,
} }
if commandResult.ExitCode != 0 { if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr) return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
} }
return result, nil return result, nil
} }
@@ -145,6 +133,32 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if req.OutputPath == "" { if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required") return nil, fmt.Errorf("output path is required")
} }
execution, err := r.execute(ctx, r.runArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
}
result := &RunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
type execution struct {
binary string
args []string
result CommandResult
}
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary binary := r.Binary
if binary == "" { if binary == "" {
binary = "scriptorium" binary = "scriptorium"
@@ -153,24 +167,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if commands == nil { if commands == nil {
commands = ExecRunner{} commands = ExecRunner{}
} }
args := r.runArgs(req) result, err := commands.Run(ctx, binary, args, r.Timeout)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err) return execution{}, err
} }
result := &RunResult{ return execution{binary: binary, args: args, result: result}, nil
Command: append([]string{binary}, args...), }
Stdout: string(commandResult.Stdout),
Stderr: string(commandResult.Stderr), func (e execution) argv() []string {
StdoutTruncated: commandResult.StdoutTruncated, return append([]string{e.binary}, e.args...)
StderrTruncated: commandResult.StderrTruncated,
ExitCode: commandResult.ExitCode,
OutputPath: req.OutputPath,
}
if commandResult.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
}
return result, nil
} }
func (r Runner) renderArgs(req RenderRequest) []string { func (r Runner) renderArgs(req RenderRequest) []string {
@@ -207,37 +212,6 @@ func (r Runner) runArgs(req RunRequest) []string {
return args return args
} }
func SaveRenderResult(path string, result *RenderResult) error {
if result == nil {
return fmt.Errorf("render result is required")
}
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("marshal render result: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary preflight file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary preflight file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary preflight file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save preflight %q: %w", path, err)
}
return nil
}
type limitedBuffer struct { type limitedBuffer struct {
data []byte data []byte
limit int limit int

View File

@@ -11,14 +11,13 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
) )
@@ -398,29 +397,8 @@ func sourceHash(raw json.RawMessage) (string, error) {
} }
func SaveBundle(path string, bundle *forecast.Bundle) error { func SaveBundle(path string, bundle *forecast.Bundle) error {
data, err := json.MarshalIndent(bundle, "", " ") if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
if err != nil { return fmt.Errorf("save bundle: %w", err)
return fmt.Errorf("marshal forecast bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary bundle file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary bundle file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary bundle file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save bundle %q: %w", path, err)
} }
return nil return nil
} }

View File

@@ -4,9 +4,7 @@ package app
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
@@ -14,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -68,8 +67,6 @@ type BriefingRequest struct {
OutputPath string OutputPath string
} }
type DailyBriefingRequest = BriefingRequest
type ReportRequest struct { type ReportRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
@@ -78,15 +75,11 @@ type ReportRequest struct {
Store state.Store Store state.Store
} }
type DailyReportRequest = ReportRequest
type BriefingResult struct { type BriefingResult struct {
Package briefing.Package Package briefing.Package
OutputPath string OutputPath string
} }
type DailyBriefingResult = BriefingResult
type ReportResult struct { type ReportResult struct {
Briefing briefing.Package Briefing briefing.Package
BriefingPath string BriefingPath string
@@ -103,8 +96,6 @@ type ReportResult struct {
RunResult *scriptorium.RunResult RunResult *scriptorium.RunResult
} }
type DailyReportResult = ReportResult
type BatchResult struct { type BatchResult struct {
Batch BatchKind `json:"batch"` Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
@@ -157,7 +148,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
if err != nil { if err != nil {
return err return err
} }
if isGeneratedReport(resolved.Definition.ID) { if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{ _, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
@@ -200,7 +191,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
startedAt := now startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports { for _, resolved := range resolvedReports {
if !isGeneratedReport(resolved.Definition.ID) { if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
} }
@@ -257,19 +248,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
} }
func batchOutputPath(outputDir string, definition report.Definition) string { func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" { if outputDir == "" || definition.BatchOutputName == "" {
return "" return ""
} }
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-") return filepath.Join(outputDir, definition.BatchOutputName)
return filepath.Join(outputDir, name)
}
func isGeneratedReport(id report.ID) bool {
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
}
func isDailyReport(id report.ID) bool {
return id == report.DailyToday || id == report.DailyTomorrow
} }
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
@@ -359,10 +341,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
return bundle, nil return bundle, nil
} }
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
return GenerateBriefing(ctx, req)
}
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) { func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil { if err != nil {
@@ -390,10 +368,6 @@ func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
} }
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
return GenerateReport(ctx, req)
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
store := req.Store store := req.Store
if store == nil { if store == nil {
@@ -460,7 +434,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
preflightPath := paths.Preflight preflightPath := paths.Preflight
if renderResult != nil { if renderResult != nil {
var err error var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult) preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -490,7 +464,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
OutputPath: reportPath, OutputPath: reportPath,
}) })
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath { if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil { if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err return nil, err
} }
} }
@@ -524,10 +498,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}, nil }, nil
} }
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
return BuildBriefing(req, bundle)
}
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil { if err != nil {
@@ -588,10 +558,6 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace) return state.NewFilesystemStore(cfg.Workspace)
} }
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
return recentChanges(ctx, store, priorSnapshot, current, cfg)
}
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
if priorSnapshot == nil { if priorSnapshot == nil {
return nil, nil return nil, nil
@@ -618,29 +584,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
} }
} }
func copyFileAtomic(source string, target string) error { func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
data, err := os.ReadFile(source) if result == nil {
if err != nil { return state.PreflightArtifact{}
return fmt.Errorf("read rendered report %q: %w", source, err)
} }
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return state.PreflightArtifact{
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err) Command: append([]string(nil), result.Command...),
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
} }
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary report output file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary report output file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary report output file: %w", err)
}
if err := os.Rename(tmpName, target); err != nil {
return fmt.Errorf("save report output %q: %w", target, err)
}
return nil
} }

View File

@@ -70,11 +70,9 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
} }
} }
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) { func TestGenerateBriefingWritesArtifact(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := dailyTestConfig(t, server)
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportDaily, Report: ReportDaily,
@@ -85,13 +83,13 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
} }
path := filepath.Join(t.TempDir(), "daily.briefing.json") path := filepath.Join(t.TempDir(), "daily.briefing.json")
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
OutputPath: path, OutputPath: path,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyBriefing() error = %v", err) t.Fatalf("GenerateBriefing() error = %v", err)
} }
if result.OutputPath != path { if result.OutputPath != path {
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path) t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
@@ -108,11 +106,9 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
} }
} }
func TestGenerateDailyBriefingDefaultPath(t *testing.T) { func TestGenerateBriefingDefaultPath(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := dailyTestConfig(t, server)
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -123,23 +119,21 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyBriefing() error = %v", err) t.Fatalf("GenerateBriefing() error = %v", err)
} }
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) { if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath) t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
} }
} }
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) { func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := dailyTestConfig(t, server)
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -165,14 +159,14 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
} }
outputPath := filepath.Join(t.TempDir(), "daily.md") outputPath := filepath.Join(t.TempDir(), "daily.md")
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if renderer.renderCalls != 1 { if renderer.renderCalls != 1 {
@@ -193,14 +187,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
if renderer.runRequest.OutputPath != result.ReportPath { if renderer.runRequest.OutputPath != result.ReportPath {
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath) t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
} }
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} { assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("expected requested report output %q: %v", outputPath, err)
}
data, err := os.ReadFile(result.DataPackagePath) data, err := os.ReadFile(result.DataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
@@ -236,7 +223,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
} }
} }
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) { func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -259,13 +246,13 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"), err: errors.New("scriptorium render exited with code 1: render failed"),
} }
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{ _, err = GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateDailyReport() error = nil, want render error") t.Fatal("GenerateReport() error = nil, want render error")
} }
store, err := state.NewFilesystemStore(cfg.Workspace) store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil { if err != nil {
@@ -291,7 +278,7 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
} }
} }
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) { func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -315,13 +302,13 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
runBody: "# Daily Report\n", runBody: "# Daily Report\n",
} }
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{ _, err = GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateDailyReport() error = nil, want run error") t.Fatal("GenerateReport() error = nil, want run error")
} }
if renderer.renderCalls != 1 || renderer.runCalls != 1 { if renderer.renderCalls != 1 || renderer.runCalls != 1 {
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls) t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
@@ -342,7 +329,7 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
} }
} }
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -393,14 +380,14 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
runBody: "# Daily Report\n", runBody: "# Daily Report\n",
} }
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Resolved: currentResolved,
Renderer: renderer, Renderer: renderer,
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if len(result.RecentChanges) == 0 { if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior snapshot") t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
@@ -433,13 +420,13 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
runBody: "# Tomorrow Planning Brief\n", runBody: "# Tomorrow Planning Brief\n",
} }
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" { if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
@@ -506,14 +493,14 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
runBody: "# Tomorrow Planning Brief\n", runBody: "# Tomorrow Planning Brief\n",
} }
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Resolved: currentResolved,
Renderer: renderer, Renderer: renderer,
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.PriorSnapshot == nil { if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot") t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
@@ -1021,6 +1008,23 @@ func mustParse(value string) time.Time {
return parsed return parsed
} }
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
return cfg
}
func assertPathsExist(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
}
func priorDailyBriefing(resolved report.Resolved) briefing.Package { func priorDailyBriefing(resolved report.Resolved) briefing.Package {
low := 50.0 low := 50.0
high := 58.0 high := 58.0

View File

@@ -40,59 +40,44 @@ func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.Rep
} }
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) { func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { return inspection.metadata, err
return state.Metadata{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
return metadata, err
} }
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) { func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return briefing.Package{}, err return briefing.Package{}, err
} }
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
if err != nil {
return briefing.Package{}, err
}
return store.LoadBriefing(ctx, metadata.BriefingPath)
} }
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) { func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return promptinput.Package{}, err return promptinput.Package{}, err
} }
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
if err != nil {
return promptinput.Package{}, err
}
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
} }
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) { func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) resolved, err := resolvedFromMetadata(inspection.metadata)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resolved, err := resolvedFromMetadata(metadata) return inspection.store.FindPriorSnapshot(ctx, resolved)
if err != nil {
return nil, err
}
return store.FindPriorSnapshot(ctx, resolved)
} }
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) { func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
metadata, err := InspectMetadata(ctx, req) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return SourceInspection{}, err return SourceInspection{}, err
} }
metadata := inspection.metadata
return SourceInspection{ return SourceInspection{
RunID: metadata.RunID, RunID: metadata.RunID,
ReportID: metadata.ReportID, ReportID: metadata.ReportID,
@@ -102,6 +87,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
}, nil }, nil
} }
type runInspection struct {
store *state.FilesystemStore
metadata state.Metadata
}
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
store, err := defaultStore(req.Config)
if err != nil {
return runInspection{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return runInspection{}, err
}
return runInspection{store: store, metadata: metadata}, nil
}
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) { func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID) definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
if err != nil { if err != nil {

View File

@@ -2,12 +2,10 @@
package briefing package briefing
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -78,29 +76,8 @@ func BuildMetadata(ctx BuildContext) Metadata {
} }
func Save(path string, pkg Package) error { func Save(path string, pkg Package) error {
data, err := json.MarshalIndent(pkg, "", " ") if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
if err != nil { return fmt.Errorf("save briefing package: %w", err)
return fmt.Errorf("marshal briefing package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary briefing file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary briefing file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary briefing file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save briefing %q: %w", path, err)
} }
return nil return nil
} }

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -72,7 +73,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
result, err := app.RunBatchDetailed(ctx, req) result, err := app.RunBatchDetailed(ctx, req)
if result != nil { if result != nil {
writeRunLogs(stderr, result) writeRunLogs(stderr, result)
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil { if encodeErr := writeJSON(stdout, result); encodeErr != nil {
return encodeErr return encodeErr
} }
if result.Failed > 0 { if result.Failed > 0 {
@@ -108,6 +109,29 @@ type inspectOptions struct {
RunID string RunID string
} }
type inspectRunCommand struct {
Name string
Inspect func(context.Context, app.InspectRunRequest) (any, error)
}
var inspectRunCommands = []inspectRunCommand{
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectMetadata(ctx, req)
}},
{Name: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectBriefing(ctx, req)
}},
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectDataPackage(ctx, req)
}},
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectPriorSnapshot(ctx, req)
}},
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectSources(ctx, req)
}},
}
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error { func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("inspect requires a command") return fmt.Errorf("inspect requires a command")
@@ -128,81 +152,32 @@ func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer)
return err return err
} }
return writeJSON(stdout, records) return writeJSON(stdout, records)
case "metadata":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, metadata)
case "briefing":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "data-package":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "prior":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, prior)
case "sources":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, sources)
default: default:
for _, candidate := range inspectRunCommands {
if candidate.Name == command {
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
}
}
return fmt.Errorf("unknown inspect command %q", command) return fmt.Errorf("unknown inspect command %q", command)
} }
} }
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
opts, err := parseInspectRunFlags(command.Name, args)
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, value)
}
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if r.Clock == nil { if r.Clock == nil {
r.Clock = timeutil.SystemClock{} r.Clock = timeutil.SystemClock{}
@@ -210,12 +185,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if len(args) == 0 { if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
} }
report, ok := reportKind(args[0]) reportKind, ok := reportKind(args[0])
if !ok { if !ok {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0]) return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
} }
opts, err := parseGenerateFlags(report, args[1:]) opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, err
} }
@@ -223,7 +198,6 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
Path: opts.ConfigPath, Path: opts.ConfigPath,
Units: opts.Units, Units: opts.Units,
Timezone: opts.Timezone, Timezone: opts.Timezone,
Output: opts.Output,
}) })
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, err
@@ -235,12 +209,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
req := app.GenerateRequest{ req := app.GenerateRequest{
Config: cfg, Config: cfg,
Report: report, Report: reportKind,
OutputPath: opts.Output, OutputPath: opts.Output,
Now: r.Clock.Now(), Now: r.Clock.Now(),
} }
switch report { switch reportKind {
case app.ReportDaily: case app.ReportDaily:
if opts.Date == "" { if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location) req.Date = timeutil.LocalDate(r.Clock.Now(), location)
@@ -257,17 +231,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if opts.End == "" { if opts.End == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end") return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
} }
req.StormStart, err = timeutil.ParseStormTime(opts.Start, location) period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, err
} }
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location) req.StormStart = period.Start
if err != nil { req.StormEnd = period.End
return app.GenerateRequest{}, err
}
if !req.StormEnd.After(req.StormStart) {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
}
} }
return req, nil return req, nil
@@ -372,12 +341,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
return opts, nil return opts, nil
} }
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func writeJSON(stdout io.Writer, value any) error { func writeJSON(stdout io.Writer, value any) error {
encoder := json.NewEncoder(stdout) encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ") encoder.SetIndent("", " ")

View File

@@ -62,12 +62,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace") workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
outPath := filepath.Join(tempDir, "storm.md") outPath := filepath.Join(tempDir, "storm.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -90,14 +86,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")) dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
if err != nil { data, err := os.ReadFile(dataPackagePath)
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
@@ -110,12 +100,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace") workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
outPath := filepath.Join(tempDir, "tomorrow.md") outPath := filepath.Join(tempDir, "tomorrow.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -136,14 +122,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")) dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
if err != nil { data, err := os.ReadFile(dataPackagePath)
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
@@ -581,6 +561,45 @@ func TestRunInspectMissingMetadata(t *testing.T) {
} }
} }
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yml")
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"}
for _, command := range commands {
t.Run(command+" requires run id", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing run id error")
}
if !strings.Contains(err.Error(), "requires a run id") {
t.Fatalf("error = %q, want missing run id context", err.Error())
}
})
t.Run(command+" accepts config", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing metadata error")
}
if !strings.Contains(err.Error(), "metadata for run id") {
t.Fatalf("error = %q, want missing metadata context", err.Error())
}
})
}
}
func TestResolveGenerateCommands(t *testing.T) { func TestResolveGenerateCommands(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
tests := []struct { tests := []struct {
@@ -643,7 +662,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) { func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"}) _, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing start error")
}
if !strings.Contains(err.Error(), "requires --start") {
t.Fatalf("error = %q, want missing start", err.Error())
}
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
if err == nil { if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing end error") t.Fatal("resolveGenerate() error = nil, want missing end error")
} }
@@ -652,6 +679,26 @@ func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
} }
} }
func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{
"storm",
"--tz", "America/Chicago",
"--start", "2026-05-29T18:00",
"--end", "2026-05-30T06:00",
})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" {
t.Fatalf("StormStart = %q, want local Chicago time", got)
}
if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" {
t.Fatalf("StormEnd = %q, want local Chicago time", got)
}
}
func TestResolveGenerateStormParsesRFC3339(t *testing.T) { func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -668,6 +715,22 @@ func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
} }
} }
func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{
"storm",
"--start", "2026-05-30T06:00",
"--end", "2026-05-29T18:00",
})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want invalid bounds error")
}
if !strings.Contains(err.Error(), "end time after start time") {
t.Fatalf("error = %q, want invalid bounds context", err.Error())
}
}
func TestResolveRunCommands(t *testing.T) { func TestResolveRunCommands(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -739,6 +802,28 @@ func dailyServer(t *testing.T) *httptest.Server {
return server return server
} }
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return configPath
}
func oneArtifact(t *testing.T, root string, parts ...string) string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...))
if err != nil {
t.Fatalf("glob artifact: %v", err)
}
if len(matches) != 1 {
t.Fatalf("artifact matches = %#v, want one", matches)
}
return matches[0]
}
func writeFakeScriptorium(t *testing.T, dir string) string { func writeFakeScriptorium(t *testing.T, dir string) string {
t.Helper() t.Helper()
path := filepath.Join(dir, "scriptorium") path := filepath.Join(dir, "scriptorium")

View File

@@ -17,7 +17,6 @@ type Config struct {
MissingSource MissingSourceConfig `yaml:"missing_source"` MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"` Workspace WorkspaceConfig `yaml:"workspace"`
Reports ReportOutputConfig `yaml:"reports"`
Dayparts []DaypartConfig `yaml:"dayparts"` Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"` RecentChange RecentChangeConfig `yaml:"recent_change"`
} }
@@ -52,11 +51,6 @@ type WorkspaceConfig struct {
PreflightDir string `yaml:"preflight_dir"` PreflightDir string `yaml:"preflight_dir"`
} }
type ReportOutputConfig struct {
OutputDir string `yaml:"output_dir"`
Paths map[string]string `yaml:"paths"`
}
type DaypartConfig struct { type DaypartConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Start string `yaml:"start"` Start string `yaml:"start"`

View File

@@ -92,7 +92,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
} }
func TestLoadAppliesOverrides(t *testing.T) { func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"}) cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("Load() error = %v", err)
} }
@@ -102,7 +102,4 @@ func TestLoadAppliesOverrides(t *testing.T) {
if cfg.WeatherAPI.Timezone != "+09:30" { if cfg.WeatherAPI.Timezone != "+09:30" {
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone) t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
} }
if cfg.Reports.OutputDir != "./out" {
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
}
} }

View File

@@ -28,10 +28,6 @@ func Defaults() Config {
DataPackagesDir: "data-packages", DataPackagesDir: "data-packages",
PreflightDir: "preflight", PreflightDir: "preflight",
}, },
Reports: ReportOutputConfig{
OutputDir: "reports",
Paths: map[string]string{},
},
Dayparts: []DaypartConfig{ Dayparts: []DaypartConfig{
{Name: "overnight", Start: "00:00", End: "06:00"}, {Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"}, {Name: "morning", Start: "06:00", End: "12:00"},

View File

@@ -12,7 +12,6 @@ type LoadOptions struct {
Path string Path string
Units string Units string
Timezone string Timezone string
Output string
} }
func Load(opts LoadOptions) (Config, error) { func Load(opts LoadOptions) (Config, error) {
@@ -35,9 +34,6 @@ func Load(opts LoadOptions) (Config, error) {
if opts.Timezone != "" { if opts.Timezone != "" {
cfg.WeatherAPI.Timezone = opts.Timezone cfg.WeatherAPI.Timezone = opts.Timezone
} }
if opts.Output != "" {
cfg.Reports.OutputDir = opts.Output
}
if err := Validate(cfg); err != nil { if err := Validate(cfg); err != nil {
return Config{}, err return Config{}, err
@@ -61,8 +57,5 @@ func mergeFile(cfg *Config, path string) error {
if cfg.MissingSource.Sources == nil { if cfg.MissingSource.Sources == nil {
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{} cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
} }
if cfg.Reports.Paths == nil {
cfg.Reports.Paths = map[string]string{}
}
return nil return nil
} }

View File

@@ -58,9 +58,6 @@ func Validate(cfg Config) error {
if cfg.Workspace.Root == "" { if cfg.Workspace.Root == "" {
return fmt.Errorf("workspace.root is required") return fmt.Errorf("workspace.root is required")
} }
if cfg.Reports.OutputDir == "" {
return fmt.Errorf("reports.output_dir is required")
}
if len(cfg.Dayparts) == 0 { if len(cfg.Dayparts) == 0 {
return fmt.Errorf("dayparts must contain at least one entry") return fmt.Errorf("dayparts must contain at least one entry")
} }

View File

@@ -0,0 +1,48 @@
// Package fileutil provides narrow filesystem helpers for durable artifacts.
package fileutil
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func WriteFileAtomic(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func WriteJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
return WriteFileAtomic(path, data)
}
func CopyFileAtomic(source string, target string) error {
data, err := os.ReadFile(source)
if err != nil {
return fmt.Errorf("read %q: %w", source, err)
}
return WriteFileAtomic(target, data)
}

View File

@@ -0,0 +1,103 @@
package fileutil
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteFileAtomicCreatesParentDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "artifact.txt")
if err := WriteFileAtomic(path, []byte("artifact")); err != nil {
t.Fatalf("WriteFileAtomic() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "artifact" {
t.Fatalf("data = %q, want artifact", data)
}
}
func TestWriteFileAtomicOverwritesTarget(t *testing.T) {
path := filepath.Join(t.TempDir(), "artifact.txt")
if err := WriteFileAtomic(path, []byte("old")); err != nil {
t.Fatalf("WriteFileAtomic() initial error = %v", err)
}
if err := WriteFileAtomic(path, []byte("new")); err != nil {
t.Fatalf("WriteFileAtomic() overwrite error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "new" {
t.Fatalf("data = %q, want new", data)
}
}
func TestWriteFileAtomicCleansTemporaryFileAfterRenameError(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
err := WriteFileAtomic(target, []byte("data"))
if err == nil {
t.Fatal("WriteFileAtomic() error = nil, want rename error")
}
if !strings.Contains(err.Error(), "save") {
t.Fatalf("error = %q, want save context", err.Error())
}
matches, err := filepath.Glob(filepath.Join(dir, ".target.*.tmp"))
if err != nil {
t.Fatalf("Glob() error = %v", err)
}
if len(matches) != 0 {
t.Fatalf("temporary files = %v, want none", matches)
}
}
func TestWriteJSONAtomic(t *testing.T) {
path := filepath.Join(t.TempDir(), "artifact.json")
if err := WriteJSONAtomic(path, map[string]string{"status": "ok"}); err != nil {
t.Fatalf("WriteJSONAtomic() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "{\n \"status\": \"ok\"\n}" {
t.Fatalf("json = %q, want indented object", data)
}
}
func TestCopyFileAtomic(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source.txt")
target := filepath.Join(dir, "nested", "target.txt")
if err := os.WriteFile(source, []byte("copied"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := CopyFileAtomic(source, target); err != nil {
t.Fatalf("CopyFileAtomic() error = %v", err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "copied" {
t.Fatalf("data = %q, want copied", data)
}
}

View File

@@ -2,14 +2,12 @@
package promptinput package promptinput
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -117,29 +115,8 @@ func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil { if err := Validate(pkg); err != nil {
return err return err
} }
data, err := json.MarshalIndent(pkg, "", " ") if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
if err != nil { return fmt.Errorf("save data package: %w", err)
return fmt.Errorf("marshal data package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary data package file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary data package file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary data package file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save data package %q: %w", path, err)
} }
return nil return nil
} }

View File

@@ -39,6 +39,10 @@ type Definition struct {
PromptID string PromptID string
ComparisonStrategy ComparisonStrategy ComparisonStrategy ComparisonStrategy
DefaultOutputName string DefaultOutputName string
ArtifactGroup string
BatchOutputName string
Generated bool
CompatiblePriorIDs []ID
Morning bool Morning bool
Evening bool Evening bool
resolve func(ResolveRequest) (timeutil.Period, error) resolve func(ResolveRequest) (timeutil.Period, error)
@@ -51,6 +55,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
return d.resolve(req) return d.resolve(req)
} }
func (d Definition) CompatibleWithPrior(id ID) bool {
for _, compatibleID := range d.CompatiblePriorIDs {
if id == compatibleID {
return true
}
}
return false
}
type ResolveRequest struct { type ResolveRequest struct {
Now time.Time Now time.Time
Location *time.Location Location *time.Location

View File

@@ -1,6 +1,7 @@
package report package report
import ( import (
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -177,6 +178,79 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
} }
} }
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
tests := []struct {
id ID
artifactGroup string
batchOutputName string
generated bool
compatiblePriorIDs []ID
}{
{
id: DailyToday,
artifactGroup: "daily",
batchOutputName: "daily.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: DailyTomorrow,
artifactGroup: "daily",
batchOutputName: "tomorrow.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: ThreeDay,
artifactGroup: "three-day",
batchOutputName: "three-day.md",
generated: true,
compatiblePriorIDs: []ID{ThreeDay},
},
{
id: Weekend,
artifactGroup: "weekend",
batchOutputName: "weekend.md",
generated: true,
compatiblePriorIDs: []ID{Weekend},
},
{
id: Storm,
artifactGroup: "storm",
batchOutputName: "storm.md",
generated: true,
compatiblePriorIDs: []ID{Storm},
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if definition.ArtifactGroup != tt.artifactGroup {
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
}
if definition.BatchOutputName != tt.batchOutputName {
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
}
if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
}
for _, id := range tt.compatiblePriorIDs {
if !definition.CompatibleWithPrior(id) {
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
}
}
})
}
}
func TestResolvedMetadata(t *testing.T) { func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})

View File

@@ -14,6 +14,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "daily.md", DefaultOutputName: "daily.md",
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Morning: true, Morning: true,
resolve: resolveDailyToday, resolve: resolveDailyToday,
}, },
@@ -23,6 +27,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "tomorrow.md", DefaultOutputName: "tomorrow.md",
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Evening: true, Evening: true,
resolve: resolveDailyTomorrow, resolve: resolveDailyTomorrow,
}, },
@@ -32,6 +40,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.three_day_outlook", PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "three_day.md", DefaultOutputName: "three_day.md",
ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
Generated: true,
CompatiblePriorIDs: []ID{ThreeDay},
Morning: true, Morning: true,
resolve: resolveThreeDay, resolve: resolveThreeDay,
}, },
@@ -41,6 +53,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.weekend_outlook", PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow, ComparisonStrategy: CompareWeekendWindow,
DefaultOutputName: "weekend.md", DefaultOutputName: "weekend.md",
ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
Generated: true,
CompatiblePriorIDs: []ID{Weekend},
Morning: true, Morning: true,
resolve: resolveWeekend, resolve: resolveWeekend,
}, },
@@ -50,6 +66,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.storm_report", PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow, ComparisonStrategy: CompareExplicitWindow,
DefaultOutputName: "storm.md", DefaultOutputName: "storm.md",
ArtifactGroup: "storm",
BatchOutputName: "storm.md",
Generated: true,
CompatiblePriorIDs: []ID{Storm},
resolve: resolveStorm, resolve: resolveStorm,
}, },
} }

View File

@@ -10,9 +10,9 @@ import (
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
) )
@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
if metadata.RunID == "" { if metadata.RunID == "" {
return ArtifactPaths{}, fmt.Errorf("run id is required") return ArtifactPaths{}, fmt.Errorf("run id is required")
} }
group, err := reportGroup(resolved.Definition.ID) group := resolved.Definition.ArtifactGroup
if err != nil { if group == "" {
return ArtifactPaths{}, err return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
validDate := resolved.ValidPeriod.Start.Format("2006-01-02") validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID filenameBase := metadata.RunID
@@ -99,7 +99,7 @@ func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolv
if err != nil { if err != nil {
return "", err return "", err
} }
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil { if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
return "", err return "", err
} }
return paths.Briefing, nil return paths.Briefing, nil
@@ -113,21 +113,18 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
if err := promptinput.Validate(pkg); err != nil { if err := promptinput.Validate(pkg); err != nil {
return "", err return "", err
} }
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil { if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
return "", err return "", err
} }
return paths.DataPackage, nil return paths.DataPackage, nil
} }
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) { func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
if result == nil {
return "", fmt.Errorf("render result is required")
}
paths, err := s.Paths(resolved) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := writeJSONAtomic(paths.Preflight, result); err != nil { if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
return "", err return "", err
} }
return paths.Preflight, nil return paths.Preflight, nil
@@ -161,23 +158,19 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
if path == "" { if path == "" {
return "", fmt.Errorf("metadata path cannot be resolved") return "", fmt.Errorf("metadata path cannot be resolved")
} }
if err := writeJSONAtomic(path, metadata); err != nil { if err := fileutil.WriteJSONAtomic(path, metadata); err != nil {
return "", err return "", err
} }
return path, nil return path, nil
} }
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
return s.FindPriorSnapshot(ctx, resolved)
}
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) { func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow { if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
return nil, nil return nil, nil
} }
group, err := reportGroup(resolved.Definition.ID) group := resolved.Definition.ArtifactGroup
if err != nil { if group == "" {
return nil, err return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
dirs, err := s.metadataDirectories(resolved, group) dirs, err := s.metadataDirectories(resolved, group)
if err != nil { if err != nil {
@@ -205,7 +198,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
if metadata.RunID == resolved.Metadata().RunID { if metadata.RunID == resolved.Metadata().RunID {
continue continue
} }
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) { if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
continue continue
} }
if !comparablePeriod(metadata, resolved) { if !comparablePeriod(metadata, resolved) {
@@ -343,19 +336,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
return dirs, nil return dirs, nil
} }
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
switch group {
case "daily":
return prior == report.DailyToday || prior == report.DailyTomorrow
case "three-day":
return prior == report.ThreeDay && current == report.ThreeDay
case "weekend":
return prior == report.Weekend && current == report.Weekend
default:
return false
}
}
func (s *FilesystemStore) join(parts ...string) string { func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...) all := append([]string{s.root}, parts...)
return filepath.Join(all...) return filepath.Join(all...)
@@ -375,48 +355,6 @@ func validateRelativeDir(name string, value string) error {
return nil return nil
} }
func reportGroup(id report.ID) (string, error) {
switch id {
case report.DailyToday, report.DailyTomorrow:
return "daily", nil
case report.ThreeDay:
return "three-day", nil
case report.Weekend:
return "weekend", nil
case report.Storm:
return "storm", nil
default:
return "", fmt.Errorf("unknown report %q", id)
}
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func readJSON(path string, target any) error { func readJSON(path string, target any) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {

View File

@@ -9,7 +9,6 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
@@ -56,7 +55,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SaveDataPackage() error = %v", err) t.Fatalf("SaveDataPackage() error = %v", err)
} }
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`}) preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
if err != nil { if err != nil {
t.Fatalf("SavePreflight() error = %v", err) t.Fatalf("SavePreflight() error = %v", err)
} }
@@ -67,6 +66,17 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil { if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
t.Fatalf("write rendered report: %v", err) t.Fatalf("write rendered report: %v", err)
} }
var preflight PreflightArtifact
preflightData, err := os.ReadFile(preflightPath)
if err != nil {
t.Fatalf("read preflight: %v", err)
}
if err := json.Unmarshal(preflightData, &preflight); err != nil {
t.Fatalf("decode preflight: %v", err)
}
if preflight.Stdout != `{"ok":true}` {
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
}
paths, err := store.Paths(resolved) paths, err := store.Paths(resolved)
if err != nil { if err != nil {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
@@ -114,7 +124,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
} }
} }
func TestFindPriorDailySnapshot(t *testing.T) { func TestFindPriorSnapshot(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00") second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
@@ -138,12 +148,12 @@ func TestFindPriorDailySnapshot(t *testing.T) {
t.Fatalf("SaveMetadata() error = %v", err) t.Fatalf("SaveMetadata() error = %v", err)
} }
prior, err := store.FindPriorDailySnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err) t.Fatalf("FindPriorSnapshot() error = %v", err)
} }
if prior == nil { if prior == nil {
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot") t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
} }
if prior.Metadata.RunID != first.Metadata().RunID { if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
@@ -153,7 +163,7 @@ func TestFindPriorDailySnapshot(t *testing.T) {
} }
} }
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) { func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
@@ -177,12 +187,12 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
t.Fatalf("SaveMetadata() error = %v", err) t.Fatalf("SaveMetadata() error = %v", err)
} }
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate) prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
if err != nil { if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err) t.Fatalf("FindPriorSnapshot() error = %v", err)
} }
if prior != nil { if prior != nil {
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior) t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior)
} }
} }

View File

@@ -4,7 +4,6 @@ package state
import ( import (
"context" "context"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -14,11 +13,10 @@ type Store interface {
Paths(report.Resolved) (ArtifactPaths, error) Paths(report.Resolved) (ArtifactPaths, error)
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error) SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
PrepareRenderedReport(context.Context, report.Resolved) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error)
SaveMetadata(context.Context, Metadata) (string, error) SaveMetadata(context.Context, Metadata) (string, error)
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
LoadBriefing(context.Context, string) (briefing.Package, error) LoadBriefing(context.Context, string) (briefing.Package, error)
} }
@@ -26,3 +24,12 @@ type PriorSnapshot struct {
Metadata Metadata Metadata Metadata
BriefingPath string BriefingPath string
} }
type PreflightArtifact struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
}