diff --git a/internal/app/app.go b/internal/app/app.go index 77c10f3..d8f9bed 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -53,6 +53,7 @@ type GenerateRequest struct { Date time.Time StormStart time.Time StormEnd time.Time + Collector Collector Notifier Notifier } @@ -61,6 +62,7 @@ type BatchRequest struct { Batch BatchKind Now time.Time OutputDir string + Collector Collector Renderer Renderer Store state.Store Notifier Notifier @@ -85,6 +87,7 @@ type ReportRequest struct { Config config.Config Resolved report.Resolved OutputPath string + Collector Collector Renderer Renderer Store state.Store Notifier Notifier @@ -161,6 +164,16 @@ type Renderer interface { StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) } +type Collector interface { + Run(context.Context, collect.Request) (*collect.Result, error) +} + +type defaultCollector struct{} + +func (defaultCollector) Run(ctx context.Context, req collect.Request) (*collect.Result, error) { + return collect.Run(ctx, req) +} + type Notifier interface { Notify(context.Context, NotificationRequest) (*NotificationResult, error) } @@ -224,6 +237,7 @@ func Generate(ctx context.Context, req GenerateRequest) error { Config: req.Config, Resolved: resolved, OutputPath: req.OutputPath, + Collector: req.Collector, Notifier: req.Notifier, }) return err @@ -280,6 +294,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro Config: req.Config, Resolved: resolved, OutputPath: outputPath, + Collector: req.Collector, Renderer: req.Renderer, Store: store, Notifier: req.Notifier, @@ -394,10 +409,23 @@ func reportRegistry(cfg config.Config) (report.Registry, error) { } func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { - result, err := collect.Run(ctx, collect.Request{Config: req.Config}) + return collectBundle(ctx, req.Config, nil) +} + +func collectBundle(ctx context.Context, cfg config.Config, collector Collector) (*weatherdata.Bundle, error) { + if collector == nil { + collector = defaultCollector{} + } + result, err := collector.Run(ctx, collect.Request{Config: cfg}) if err != nil { return nil, err } + if result == nil { + return nil, fmt.Errorf("collect weather bundle: collector returned nil result") + } + if result.Bundle == nil { + return nil, fmt.Errorf("collect weather bundle: collector returned nil bundle") + } return result.Bundle, nil } @@ -433,7 +461,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro return nil, err } - bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) + bundle, err := collectBundle(ctx, req.Config, req.Collector) if err != nil { return nil, err } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index b3e4748..4ecb7a0 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -15,6 +15,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" @@ -81,6 +82,61 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) { } } +func TestGenerateUsesProvidedCollector(t *testing.T) { + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = "" + cfg.Workspace.Root = t.TempDir() + collector := &recordingCollector{err: errors.New("provided collector failed")} + + err := Generate(context.Background(), GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + Now: mustParse("2026-05-29T05:00:00-05:00"), + Collector: collector, + }) + if err == nil { + t.Fatal("Generate() error = nil, want collector error") + } + if !strings.Contains(err.Error(), "provided collector failed") { + t.Fatalf("Generate() error = %q, want provided collector error", err.Error()) + } + if len(collector.requests) != 1 { + t.Fatalf("collector requests = %d, want 1", len(collector.requests)) + } + if collector.requests[0].Config.WeatherAPI.BaseURL != "" { + t.Fatalf("collector base URL = %q, want request config", collector.requests[0].Config.WeatherAPI.BaseURL) + } +} + +func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) { + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = "" + cfg.Workspace.Root = t.TempDir() + markerPath := filepath.Join(t.TempDir(), "scriptorium-called") + binaryPath := filepath.Join(t.TempDir(), "scriptorium") + script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nexit 0\n", markerPath) + if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil { + t.Fatalf("write scriptorium marker script: %v", err) + } + cfg.Scriptorium.Binary = binaryPath + collector := &recordingCollector{err: errors.New("collection unavailable")} + + err := Generate(context.Background(), GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + Now: mustParse("2026-05-29T05:00:00-05:00"), + Collector: collector, + }) + if err == nil { + t.Fatal("Generate() error = nil, want collector error") + } + if _, statErr := os.Stat(markerPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("scriptorium marker stat error = %v, want marker absent after collection failure", statErr) + } +} + func TestGenerateReportWritesReportAndPreflight(t *testing.T) { server := dailyBundleServer(t) cfg := dailyWorkspaceConfig(t, server) @@ -2318,6 +2374,39 @@ func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) { } } +func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) { + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = "" + cfg.Workspace.Root = t.TempDir() + collector := &recordingCollector{err: errors.New("batch collector failed")} + renderer := &recordingRenderer{} + + result, err := RunBatchDetailed(context.Background(), BatchRequest{ + Config: cfg, + Batch: BatchMorning, + Now: mustParse("2026-05-29T05:00:00-05:00"), + Collector: collector, + Renderer: renderer, + }) + if err != nil { + t.Fatalf("RunBatchDetailed() error = %v", err) + } + if result.Total == 0 || result.Failed != result.Total { + t.Fatalf("batch result = %#v, want every report failed by collector", result) + } + if len(collector.requests) != result.Total { + t.Fatalf("collector requests = %d, want %d", len(collector.requests), result.Total) + } + for _, reportResult := range result.Reports { + if !strings.Contains(reportResult.Error, "batch collector failed") { + t.Fatalf("report error = %q, want fake collector error", reportResult.Error) + } + } + if renderer.renderCalls != 0 || renderer.runCalls != 0 || renderer.structuredRunCalls != 0 { + t.Fatalf("renderer calls render=%d run=%d structured=%d, want none after collection failure", renderer.renderCalls, renderer.runCalls, renderer.structuredRunCalls) + } +} + func mustParse(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { @@ -2790,6 +2879,20 @@ type recordingRenderer struct { structuredRunBody string } +type recordingCollector struct { + result *collect.Result + err error + requests []collect.Request +} + +func (c *recordingCollector) Run(_ context.Context, req collect.Request) (*collect.Result, error) { + c.requests = append(c.requests, req) + if c.err != nil { + return nil, c.err + } + return c.result, nil +} + type recordingStore struct { state.Store calls []string