Write reports to operator-selected outputs

This commit is contained in:
2026-08-01 19:33:12 +00:00
parent ac8d618111
commit 62a12dd661
17 changed files with 393 additions and 94 deletions

View File

@@ -5,6 +5,8 @@ import (
"flag"
"fmt"
"io"
"os"
"path/filepath"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo"
@@ -37,9 +39,9 @@ Options:
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy where supported by the generate command.
--out PATH Write the generated Markdown report to PATH.
--llm-debug-dir PATH Write sensitive prompt debug artifacts outside the managed workspace.
--out-dir PATH Write extra Markdown report copies for run commands.
--out-dir PATH Write generated Markdown reports beneath PATH for run commands.
--quiet Suppress successful generate and run output.
`
@@ -47,6 +49,7 @@ type Runner struct {
Clock timeutil.Clock
ExecutorFactory ExecutorFactory
Version string
WorkingDir string
}
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
@@ -240,10 +243,20 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
return app.GenerateRequest{}, commonOptions{}, err
}
workingDir, err := r.workingDir()
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
outputPath, err := resolveOutputOverride(workingDir, opts.Output)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
req := app.GenerateRequest{
Config: cfg,
Report: reportKind,
OutputPath: opts.Output,
WorkingDir: workingDir,
OutputPath: outputPath,
LLMDebugDir: opts.LLMDebugDir,
Now: r.Clock.Now(),
Executor: executor,
@@ -304,7 +317,15 @@ func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions
if err != nil {
return app.BatchRequest{}, commonOptions{}, err
}
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
workingDir, err := r.workingDir()
if err != nil {
return app.BatchRequest{}, commonOptions{}, err
}
outputDir, err := resolveOutputOverride(workingDir, opts.OutputDir)
if err != nil {
return app.BatchRequest{}, commonOptions{}, err
}
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), WorkingDir: workingDir, OutputDir: outputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
}
func resolveRun(args []string) (app.BatchRequest, error) {
@@ -334,7 +355,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
fs.SetOutput(io.Discard)
opts := commonOptions{}
addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if err := fs.Parse(args); err != nil {
return commonOptions{}, err
@@ -384,6 +405,31 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
if includeOutput {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
fs.StringVar(&opts.Output, "out", "", "generated Markdown report path")
}
}
func (r Runner) workingDir() (string, error) {
workingDir := r.WorkingDir
if workingDir == "" {
var err error
workingDir, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
}
if !filepath.IsAbs(workingDir) {
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
}
return filepath.Clean(workingDir), nil
}
func resolveOutputOverride(workingDir, value string) (string, error) {
if value == "" {
return "", nil
}
if !filepath.IsAbs(value) {
value = filepath.Join(workingDir, value)
}
return filepath.Clean(value), nil
}

View File

@@ -101,6 +101,11 @@ func TestRunnerHelpListsOnlySupportedCommands(t *testing.T) {
t.Fatalf("help contains retired command %q:\n%s", retired, output.stdout)
}
}
for _, description := range []string{"Write the generated Markdown report to PATH.", "Write generated Markdown reports beneath PATH"} {
if !strings.Contains(output.stdout, description) {
t.Fatalf("help missing output description %q:\n%s", description, output.stdout)
}
}
}
func TestRunnerVersion(t *testing.T) {
@@ -185,6 +190,7 @@ func TestResolveSupportedCommandsAndFlags(t *testing.T) {
func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) {
configPath := writeCLIConfig(t, t.TempDir(), "")
runner, _ := countingRunner(cliExecutor{})
runner.WorkingDir = t.TempDir()
generate, generateOpts, err := runner.resolveGenerateAction([]string{
"daily", "--config", configPath, "--date", "2026-05-30", "--units", "metric", "--tz", "UTC",
@@ -193,7 +199,7 @@ func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) {
if err != nil {
t.Fatalf("resolveGenerateAction() error = %v", err)
}
if generate.Config.WeatherAPI.Units != "metric" || generate.Config.WeatherAPI.Timezone != "UTC" || generate.OutputPath != "daily.md" || generate.LLMDebugDir != "/safe/debug" || !generateOpts.Quiet {
if generate.Config.WeatherAPI.Units != "metric" || generate.Config.WeatherAPI.Timezone != "UTC" || generate.OutputPath != filepath.Join(runner.WorkingDir, "daily.md") || generate.WorkingDir != runner.WorkingDir || generate.LLMDebugDir != "/safe/debug" || !generateOpts.Quiet {
t.Fatalf("generate request/options = %#v/%#v", generate, generateOpts)
}
if got := generate.Date.Format(timeutil.DateLayout); got != "2026-05-30" {
@@ -207,7 +213,7 @@ func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) {
if err != nil {
t.Fatalf("resolveRunAction() error = %v", err)
}
if batch.Config.WeatherAPI.Units != "metric" || batch.Config.WeatherAPI.Timezone != "UTC" || batch.OutputDir != "reports" || batch.LLMDebugDir != "/safe/debug" || !batchOpts.Quiet {
if batch.Config.WeatherAPI.Units != "metric" || batch.Config.WeatherAPI.Timezone != "UTC" || batch.OutputDir != filepath.Join(runner.WorkingDir, "reports") || batch.WorkingDir != runner.WorkingDir || batch.LLMDebugDir != "/safe/debug" || !batchOpts.Quiet {
t.Fatalf("batch request/options = %#v/%#v", batch, batchOpts)
}
}
@@ -280,6 +286,7 @@ func TestRunnerSuccessfulSingleAndBatchActions(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
fixture := newCLIFixture(t)
runner, constructions := countingRunner(cliExecutor{})
runner.WorkingDir = t.TempDir()
output, err := runCLICommand(runner, tt.args(fixture.configPath, fixture.path("copies"))...)
if err != nil {
t.Fatalf("Run() error = %v", err)
@@ -314,6 +321,7 @@ func TestRunnerSuccessfulSingleAndBatchActions(t *testing.T) {
func TestRunnerPreRunFailureAndQuietMode(t *testing.T) {
runner, constructions := countingRunner(cliExecutor{})
runner.WorkingDir = t.TempDir()
output, err := runCLICommand(runner, "generate", "daily")
if err == nil || output.stdout != "" || output.stderr != "" {
t.Fatalf("pre-run output/error = %#v/%v, want error without summary", output, err)
@@ -324,6 +332,7 @@ func TestRunnerPreRunFailureAndQuietMode(t *testing.T) {
fixture := newCLIFixture(t)
runner, _ = countingRunner(cliExecutor{})
runner.WorkingDir = t.TempDir()
output, err = runCLICommand(runner, "generate", "today", "--config", fixture.configPath, "--quiet")
if err != nil || output.stdout != "" || output.stderr != "" {
t.Fatalf("quiet output/error = %#v/%v", output, err)
@@ -333,6 +342,7 @@ func TestRunnerPreRunFailureAndQuietMode(t *testing.T) {
func TestRunnerFailedActionReportsSafePartialSummary(t *testing.T) {
fixture := newCLIFixture(t)
runner, constructions := countingRunner(cliExecutor{fail: true})
runner.WorkingDir = t.TempDir()
output, err := runCLICommand(runner, "generate", "today", "--config", fixture.configPath)
if err == nil {
t.Fatal("Run() error = nil, want execution failure")
@@ -364,6 +374,7 @@ func TestRunnerFailedActionReportsSafePartialSummary(t *testing.T) {
func TestRunnerMixedBatchReportsSafePartialFailure(t *testing.T) {
fixture := newCLIFixture(t)
runner, constructions := countingRunner(cliExecutor{failPrompt: "weather.tomorrow_generated_text"})
runner.WorkingDir = t.TempDir()
output, err := runCLICommand(runner, "run", "morning", "--config", fixture.configPath)
if err == nil {
t.Fatal("Run() error = nil, want aggregate batch failure")
@@ -399,6 +410,7 @@ func TestRunnerMixedBatchReportsSafePartialFailure(t *testing.T) {
func TestRunnerInspectsReportsAndCurrentArtifacts(t *testing.T) {
fixture := newCLIFixture(t)
runner, _ := countingRunner(cliExecutor{})
runner.WorkingDir = t.TempDir()
first := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC))
second := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC))