From 62a12dd6613cdb53933823d6982ed3cd184f5fd9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 1 Aug 2026 19:33:12 +0000 Subject: [PATCH] Write reports to operator-selected outputs --- internal/app/app.go | 126 ++++++++++++++++--- internal/app/batch_execution_test.go | 2 +- internal/app/batch_notification.go | 17 +-- internal/app/batch_plan.go | 19 ++- internal/app/batch_plan_test.go | 18 ++- internal/app/batch_workflow_test.go | 28 ++--- internal/app/output_test.go | 129 ++++++++++++++++++++ internal/app/prompt_artifact_paths_test.go | 16 +-- internal/app/single_report_workflow_test.go | 29 ++--- internal/cli/root.go | 58 ++++++++- internal/cli/root_test.go | 16 ++- internal/report/daily_report.go | 2 +- internal/report/definition.go | 19 ++- internal/report/hourly_report.go | 2 +- internal/report/period_test.go | 2 +- internal/report/today_report.go | 2 +- internal/report/tomorrow_report.go | 2 +- 17 files changed, 393 insertions(+), 94 deletions(-) create mode 100644 internal/app/output_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 7314adb..9e5b144 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,7 +4,9 @@ package app import ( "context" "fmt" + "os" "path/filepath" + "strings" "time" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" @@ -42,6 +44,7 @@ const ( type GenerateRequest struct { Config config.Config Report ReportKind + WorkingDir string OutputPath string LLMDebugDir string Now time.Time @@ -56,6 +59,7 @@ type BatchRequest struct { Config config.Config Batch BatchKind Now time.Time + WorkingDir string OutputDir string LLMDebugDir string Collector Collector @@ -258,6 +262,11 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult, if err != nil { return nil, err } + outputPath, err := resolveReportOutputPath(req.WorkingDir, req.OutputPath, resolved) + if err != nil { + return nil, err + } + req.OutputPath = outputPath debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir) if err != nil { return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err) @@ -302,6 +311,11 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro if _, err := report.BatchForCommandName(string(req.Batch)); err != nil { return nil, err } + outputDir, err := resolveOutputDir(req.WorkingDir, req.OutputDir) + if err != nil { + return nil, err + } + req.OutputDir = outputDir debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir) if err != nil { return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err) @@ -340,7 +354,10 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro for _, planned := range plannedReports { resolved := planned.Resolved item := batchReportResult(planned) - outputPath := plannedBatchOutputPath(req.OutputDir, planned) + outputPath, err := plannedBatchOutputPath(req.OutputDir, planned) + if err != nil { + return nil, err + } reportResult, err := generatePromptReport(ctx, promptReportRequest{ GenerateRequest: GenerateRequest{ Config: req.Config, @@ -440,18 +457,91 @@ func batchReportResult(planned plannedBatchReport) BatchReportResult { } } -func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string { - if outputDir == "" { - return "" +func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) { + outputName, err := planned.Resolved.OutputName() + if err != nil { + return "", err } - outputCopyName := planned.OutputCopyName - if outputCopyName == "" { - outputCopyName = planned.Resolved.Definition.BatchOutputName + return validateOutputPath(filepath.Join(outputDir, outputName)) +} + +func resolveReportOutputPath(workingDir, override string, resolved report.Resolved) (string, error) { + outputName, err := resolved.OutputName() + if err != nil { + return "", err } - if outputCopyName == "" { - return "" + return resolveOutputPath(workingDir, override, outputName) +} + +func resolveOutputDir(workingDir, override string) (string, error) { + workingDir, err := validateWorkingDir(workingDir) + if err != nil { + return "", err } - return filepath.Join(outputDir, outputCopyName) + if override == "" { + return workingDir, nil + } + if strings.TrimSpace(override) == "" { + return "", fmt.Errorf("output directory is required") + } + directory := override + if !filepath.IsAbs(directory) { + directory = filepath.Join(workingDir, directory) + } + directory = filepath.Clean(directory) + if info, err := os.Stat(directory); err == nil && !info.IsDir() { + return "", fmt.Errorf("output directory %q is not a directory", directory) + } else if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("inspect output directory %q: %w", directory, err) + } + return directory, nil +} + +func resolveOutputPath(workingDir, override, defaultName string) (string, error) { + workingDir, err := validateWorkingDir(workingDir) + if err != nil { + return "", err + } + path := override + if path == "" { + path = defaultName + } + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("final output path is required") + } + if !filepath.IsAbs(path) { + path = filepath.Join(workingDir, path) + } + return validateOutputPath(path) +} + +func validateWorkingDir(workingDir string) (string, error) { + if strings.TrimSpace(workingDir) == "" { + return "", fmt.Errorf("working directory is required") + } + if !filepath.IsAbs(workingDir) { + return "", fmt.Errorf("working directory %q must be absolute", workingDir) + } + return filepath.Clean(workingDir), nil +} + +func validateOutputPath(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("final output path is required") + } + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return "", fmt.Errorf("final output path %q must be absolute", path) + } + if filepath.Dir(path) == path { + return "", fmt.Errorf("final output path %q must not be a filesystem root", path) + } + if info, err := os.Stat(path); err == nil && info.IsDir() { + return "", fmt.Errorf("final output path %q is a directory", path) + } else if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("inspect final output path %q: %w", path, err) + } + return path, nil } func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { @@ -590,7 +680,7 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque return result, nil } - notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store) + notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.OutputPath, metadata, req.Notifier, req.Store) if notificationPath != "" { result.NotificationPath = notificationPath result.Notification = notification @@ -636,7 +726,7 @@ func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolv if err != nil { return result, notificationPath, &NotificationError{ Request: notificationRequest, - Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err), + Err: fmt.Errorf("notify report %q run %q from output %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err), } } return result, notificationPath, nil @@ -655,7 +745,7 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) { } func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) { - values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName) + values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, filepath.Base(reportPath)) if err != nil { return NotificationRequest{}, err } @@ -688,16 +778,20 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor }, nil } -func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, batchOutputName string) (config.DistributorTemplateValues, error) { +func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, outputName string) (config.DistributorTemplateValues, error) { values := config.DistributorTemplateValues{ LocationID: cfg.Location.ID, ReportID: string(resolved.Definition.ID), RunID: runID, ArtifactGroup: resolved.Definition.ArtifactGroup, - BatchOutputName: batchOutputName, + BatchOutputName: outputName, } if values.BatchOutputName == "" { - values.BatchOutputName = resolved.Definition.BatchOutputName + var err error + values.BatchOutputName, err = resolved.OutputName() + if err != nil { + return config.DistributorTemplateValues{}, err + } } if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil { return config.DistributorTemplateValues{}, err diff --git a/internal/app/batch_execution_test.go b/internal/app/batch_execution_test.go index 21afb9e..39e06c0 100644 --- a/internal/app/batch_execution_test.go +++ b/internal/app/batch_execution_test.go @@ -27,7 +27,7 @@ func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) { cfg := config.Defaults() cfg.Workspace.Root = t.TempDir() now := mustParse(test.now) - req := BatchRequest{Config: cfg, Batch: test.batch, Now: now} + req := BatchRequest{Config: cfg, Batch: test.batch, Now: now, WorkingDir: t.TempDir()} candidates, err := batchInspectionCandidates(req, now) if err != nil { t.Fatalf("batchInspectionCandidates() error = %v", err) diff --git a/internal/app/batch_notification.go b/internal/app/batch_notification.go index c82ee35..b1b8335 100644 --- a/internal/app/batch_notification.go +++ b/internal/app/batch_notification.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "path/filepath" "time" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" @@ -153,15 +154,15 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str if item.ReportID != plannedReport.Resolved.Definition.ID { return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID) } - if item.ReportPath == "" { - return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID) + if item.OutputPath == "" { + return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing output path", item.ReportID, item.RunID) } - values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName) + values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, filepath.Base(item.OutputPath)) if err != nil { - return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err) + return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.OutputPath, err) } - bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values) + bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.OutputPath, values) if err != nil { return batchNotificationRequest{}, err } @@ -169,18 +170,18 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str included := BatchNotificationReport{ ReportID: item.ReportID, RunID: item.RunID, - SourcePath: item.ReportPath, + SourcePath: item.OutputPath, BundlePaths: append([]string(nil), bundlePaths...), } for _, bundlePath := range bundlePaths { file := batchNotificationFile{ ReportID: item.ReportID, RunID: item.RunID, - SourcePath: item.ReportPath, + SourcePath: item.OutputPath, BundlePath: bundlePath, } if previous, ok := seenBundlePaths[bundlePath]; ok { - return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.ReportPath, previous.ReportID, previous.RunID, previous.SourcePath) + return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.OutputPath, previous.ReportID, previous.RunID, previous.SourcePath) } seenBundlePaths[bundlePath] = file req.Files = append(req.Files, file) diff --git a/internal/app/batch_plan.go b/internal/app/batch_plan.go index 5f0ff1a..d8bc542 100644 --- a/internal/app/batch_plan.go +++ b/internal/app/batch_plan.go @@ -11,8 +11,7 @@ import ( ) type plannedBatchReport struct { - Resolved report.Resolved - OutputCopyName string + Resolved report.Resolved } func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) { @@ -36,16 +35,16 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([ var planned []plannedBatchReport switch batch { case report.Morning: - planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "") + planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq) if err != nil { return nil, err } - planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "") + planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq) if err != nil { return nil, err } case report.Evening: - planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "") + planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq) if err != nil { return nil, err } @@ -60,8 +59,7 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([ for _, date := range eligibleDailyDates(hourly, now, location) { dailyReq := resolveReq dailyReq.Date = date - outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md" - planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName) + planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq) if err != nil { return nil, err } @@ -69,15 +67,12 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([ return planned, nil } -func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) { +func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest) ([]plannedBatchReport, error) { resolved, err := registry.Resolve(id, req) if err != nil { return nil, err } - return append(planned, plannedBatchReport{ - Resolved: resolved, - OutputCopyName: outputCopyName, - }), nil + return append(planned, plannedBatchReport{Resolved: resolved}), nil } func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time { diff --git a/internal/app/batch_plan_test.go b/internal/app/batch_plan_test.go index e989d70..c33c8f0 100644 --- a/internal/app/batch_plan_test.go +++ b/internal/app/batch_plan_test.go @@ -55,7 +55,7 @@ func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) { assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00") } -func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) { +func TestPlanBatchRunUsesResolvedOutputNames(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...) @@ -68,11 +68,19 @@ func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) { if len(daily) != 1 { t.Fatalf("daily reports = %#v, want one Daily report", daily) } - if daily[0].OutputCopyName != "daily-2026-05-31.md" { - t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName) + outputName, err := daily[0].Resolved.OutputName() + if err != nil { + t.Fatalf("OutputName() error = %v", err) } - if planned[0].OutputCopyName != "" { - t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName) + if outputName != "daily-2026-05-31.md" { + t.Fatalf("Daily output name = %q, want date-qualified name", outputName) + } + outputName, err = planned[0].Resolved.OutputName() + if err != nil { + t.Fatalf("OutputName() error = %v", err) + } + if outputName != "tomorrow.md" { + t.Fatalf("Tomorrow output name = %q, want tomorrow.md", outputName) } } diff --git a/internal/app/batch_workflow_test.go b/internal/app/batch_workflow_test.go index 4ebe62d..6878b7d 100644 --- a/internal/app/batch_workflow_test.go +++ b/internal/app/batch_workflow_test.go @@ -139,7 +139,7 @@ func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) { outputDir := filepath.Join(t.TempDir(), "output") debugRoot := filepath.Join(t.TempDir(), "debug") result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: test.batch, Now: test.now, OutputDir: outputDir, LLMDebugDir: debugRoot, + Config: cfg, Batch: test.batch, Now: test.now, WorkingDir: t.TempDir(), OutputDir: outputDir, LLMDebugDir: debugRoot, Collector: collector, Executor: executor, }) if err != nil { @@ -166,7 +166,7 @@ func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) { } assertBatchItemMatchesMetadata(t, item) if filepath.Base(item.OutputPath) != test.wantCopies[index] { - t.Fatalf("output copy = %q, want %q", item.OutputPath, test.wantCopies[index]) + t.Fatalf("output = %q, want %q", item.OutputPath, test.wantCopies[index]) } assertBatchPathsExist(t, item.DataPackagePath, item.PreparationPath, item.ExecutionPath, item.LLMDebugPath, item.ReportPath, item.OutputPath, item.MetadataPath) managed, readErr := os.ReadFile(item.ReportPath) @@ -175,7 +175,7 @@ func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) { } copied, readErr := os.ReadFile(item.OutputPath) if readErr != nil || !bytes.Equal(managed, copied) { - t.Fatalf("output copy mismatch/error = %v", readErr) + t.Fatalf("output mismatch/error = %v", readErr) } } }) @@ -190,7 +190,7 @@ func TestRunBatchDetailedContinuesAfterCapacityRejection(t *testing.T) { executor.failures[1] = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil) notifier := &assembledBatchNotifier{} result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), + Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), WorkingDir: t.TempDir(), OutputDir: filepath.Join(t.TempDir(), "output"), LLMDebugDir: filepath.Join(t.TempDir(), "debug"), Collector: collector, Executor: executor, Notifier: notifier, }) @@ -226,7 +226,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) { bundle := assembledBatchBundle(t, "2026-05-31") notifier := &assembledBatchNotifier{} result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), + Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier, }) if err != nil || result.Notification != nil || result.Failed != 0 || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 { @@ -240,7 +240,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) { bundle := assembledBatchBundle(t, "2026-05-31") notifier := &assembledBatchNotifier{} result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), + Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier, }) if err != nil || result.Notification != nil || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 { @@ -257,7 +257,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) { }} outputDir := filepath.Join(t.TempDir(), "output") result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), OutputDir: outputDir, + Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir, Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier, }) if err != nil { @@ -266,9 +266,9 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) { if result.Failed != 0 || result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.Path == "" || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 1 { t.Fatalf("notification result/requests = %#v/%d/%d", result.Notification, len(notifier.reportRequests), len(notifier.batchRequests)) } - managedPaths := make(map[string]struct{}, len(result.Reports)) + outputPaths := make(map[string]struct{}, len(result.Reports)) for _, item := range result.Reports { - managedPaths[item.ReportPath] = struct{}{} + outputPaths[item.OutputPath] = struct{}{} if item.NotificationPath != "" { t.Fatalf("report item contains per-report notification path: %#v", item) } @@ -278,8 +278,8 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) { t.Fatalf("included reports = %d, want %d", len(request.IncludedReports), len(result.Reports)) } for _, file := range request.Files { - if _, ok := managedPaths[file.SourcePath]; !ok || strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" { - t.Fatalf("notification file = %#v, want managed Markdown source", file) + if _, ok := outputPaths[file.SourcePath]; !ok || !strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" { + t.Fatalf("notification file = %#v, want selected Markdown output source", file) } } artifact := readBatchNotificationArtifact(t, result.Notification.Path) @@ -293,7 +293,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) { bundle := assembledBatchBundle(t, "2026-05-31") notifier := &assembledBatchNotifier{batchErr: errors.New("batch upload rejected")} result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), + Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier, }) if err != nil { @@ -321,7 +321,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) { StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`), }} result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), + Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier, }) if err != nil || result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" { @@ -340,7 +340,7 @@ func TestRunBatchDetailedKeepsDynamicDailyArtifactsDistinct(t *testing.T) { outputDir := filepath.Join(t.TempDir(), "output") debugRoot := filepath.Join(t.TempDir(), "debug") result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, LLMDebugDir: debugRoot, + Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir, LLMDebugDir: debugRoot, Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), }) if err != nil || result.Failed != 0 || len(result.Reports) != 3 { diff --git a/internal/app/output_test.go b/internal/app/output_test.go new file mode 100644 index 0000000..f1a86e9 --- /dev/null +++ b/internal/app/output_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +func TestResolveReportOutputPath(t *testing.T) { + workingDir := t.TempDir() + cfg := config.Defaults() + now := workflowTime("2026-05-29T08:30:00-05:00") + + for _, test := range []struct { + report ReportKind + date string + name string + }{ + {report: ReportDaily, date: "2026-05-30T12:00:00-05:00", name: "daily-2026-05-30.md"}, + {report: ReportToday, date: "2026-05-29T12:00:00-05:00", name: "today.md"}, + {report: ReportTomorrow, name: "tomorrow.md"}, + {report: ReportHourly, name: "hourly.md"}, + } { + t.Run(string(test.report), func(t *testing.T) { + req := GenerateRequest{Config: cfg, Report: test.report} + if test.date != "" { + req.Date = workflowTime(test.date) + } + resolved, err := ResolveGenerate(req, now) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + path, err := resolveReportOutputPath(workingDir, "", resolved) + if err != nil { + t.Fatalf("resolveReportOutputPath() error = %v", err) + } + if path != filepath.Join(workingDir, test.name) { + t.Fatalf("path = %q, want %q", path, filepath.Join(workingDir, test.name)) + } + }) + } + + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-30T12:00:00-05:00"), + }, now) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + absoluteDir := t.TempDir() + for _, test := range []struct { + override string + want string + }{ + {override: filepath.Join("reports", "custom.md"), want: filepath.Join(workingDir, "reports", "custom.md")}, + {override: filepath.Join(absoluteDir, "custom.md"), want: filepath.Join(absoluteDir, "custom.md")}, + } { + path, err := resolveReportOutputPath(workingDir, test.override, resolved) + if err != nil { + t.Fatalf("resolveReportOutputPath(%q) error = %v", test.override, err) + } + if path != filepath.Clean(test.want) { + t.Fatalf("path = %q, want %q", path, filepath.Clean(test.want)) + } + } +} + +func TestGenerateDetailedRejectsInvalidOutputBeforeCollection(t *testing.T) { + cfg := workflowConfig(t) + collector := &workflowCollector{err: errors.New("collection must not run")} + _, err := GenerateDetailed(context.Background(), GenerateRequest{ + Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), + Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: t.TempDir(), + Collector: collector, + }) + if err == nil || collector.calls != 0 { + t.Fatalf("GenerateDetailed() error/calls = %v/%d, want invalid output before collection", err, collector.calls) + } +} + +func TestGenerateDetailedPreservesExistingOutputWhenGenerationFails(t *testing.T) { + cfg := workflowConfig(t) + cfg.Notify.Distributor.Enabled = false + workingDir := t.TempDir() + outputPath := filepath.Join(workingDir, "daily-2026-05-29.md") + if err := os.WriteFile(outputPath, []byte("existing report"), 0o600); err != nil { + t.Fatalf("write existing output: %v", err) + } + bundle := workflowBundle(t) + result, err := GenerateDetailed(context.Background(), GenerateRequest{ + Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), + Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, + Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, + Executor: &workflowExecutor{definition: report.DefaultRegistry().MustLookup(report.Daily), raw: []byte(`{}`)}, + }) + if err == nil || result == nil || result.OutputPath != "" { + t.Fatalf("result/error/output = %#v/%v/%q, want failed generation without output publication", result, err, result.OutputPath) + } + data, readErr := os.ReadFile(outputPath) + if readErr != nil || string(data) != "existing report" { + t.Fatalf("output after failure = %q, error %v, want preserved content", data, readErr) + } +} + +func TestRunBatchDetailedUsesWorkingDirectoryForOutput(t *testing.T) { + cfg := assembledBatchConfig(t, false) + workingDir := t.TempDir() + bundle := assembledBatchBundle(t, "2026-05-31") + result, err := RunBatchDetailed(context.Background(), BatchRequest{ + Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: workingDir, + Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, + Executor: newAssembledBatchExecutor(), + }) + if err != nil || result == nil || result.Failed != 0 { + t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err) + } + if len(result.Reports) != 2 { + t.Fatalf("reports = %#v, want Tomorrow and Daily", result.Reports) + } + if result.Reports[0].OutputPath != filepath.Join(workingDir, "tomorrow.md") || + result.Reports[1].OutputPath != filepath.Join(workingDir, "daily-2026-05-31.md") { + t.Fatalf("output paths = %#v, want working-directory defaults", result.Reports) + } +} diff --git a/internal/app/prompt_artifact_paths_test.go b/internal/app/prompt_artifact_paths_test.go index 52ad9ca..b8bf1b1 100644 --- a/internal/app/prompt_artifact_paths_test.go +++ b/internal/app/prompt_artifact_paths_test.go @@ -141,7 +141,7 @@ func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) { name string failOperation string failMetadataCall int - outputCopy bool + output bool notify bool want reachedPromptArtifacts }{ @@ -151,7 +151,7 @@ func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) { {name: "normalized output then metadata", failOperation: failMetadata, failMetadataCall: 3, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true}}, {name: "render context then metadata", failOperation: failMetadata, failMetadataCall: 4, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}}, {name: "managed report then metadata", failOperation: failMetadata, failMetadataCall: 5, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}}, - {name: "output copy then metadata", failOperation: failMetadata, failMetadataCall: 5, outputCopy: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}}, + {name: "output then metadata", failOperation: failMetadata, failMetadataCall: 5, output: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}}, {name: "notification then metadata", failOperation: failMetadata, failMetadataCall: 6, notify: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, notification: true}}, } @@ -160,7 +160,7 @@ func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) { req, paths := promptArtifactRequest(t, artifactPathExecutor{}) store := &failingPersistenceStore{Store: req.Store, failOperation: test.failOperation, failMetadataCall: test.failMetadataCall} req.Store = store - if test.outputCopy { + if test.output { req.OutputPath = filepath.Join(t.TempDir(), "daily.md") paths.output = req.OutputPath } @@ -276,7 +276,7 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T) failExecutionCall int failMetadataCall int requestOutput bool - failOutputCopy bool + failOutput bool notify bool notificationFailure bool wantExecution reachedExecutionArtifacts @@ -323,17 +323,17 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T) wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}, }, { - name: "output copy write", requestOutput: true, failOutputCopy: true, + name: "output write", requestOutput: true, failOutput: true, wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true}, wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}, }, { - name: "output copy checkpoint", failOperation: failPromptExecution, failExecutionCall: 5, requestOutput: true, + name: "output checkpoint", failOperation: failPromptExecution, failExecutionCall: 5, requestOutput: true, wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true}, wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}, }, { - name: "output copy metadata", failOperation: failMetadata, failMetadataCall: 5, requestOutput: true, + name: "output metadata", failOperation: failMetadata, failMetadataCall: 5, requestOutput: true, wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true}, wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}, }, @@ -374,7 +374,7 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T) req.OutputPath = filepath.Join(t.TempDir(), "daily.md") paths.output = req.OutputPath } - if test.failOutputCopy { + if test.failOutput { blocker := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil { t.Fatalf("write output blocker: %v", err) diff --git a/internal/app/single_report_workflow_test.go b/internal/app/single_report_workflow_test.go index a6b90fa..bdb907d 100644 --- a/internal/app/single_report_workflow_test.go +++ b/internal/app/single_report_workflow_test.go @@ -167,7 +167,7 @@ func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) { outputPath := filepath.Join(t.TempDir(), test.name+".md") result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"), + Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier, }) if err != nil { @@ -203,10 +203,10 @@ func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) { } copied, readErr := os.ReadFile(outputPath) if readErr != nil || !bytes.Equal(copied, managed) || result.OutputPath != outputPath { - t.Fatalf("output copy mismatch/error/path = %v/%q", readErr, result.OutputPath) + t.Fatalf("output mismatch/error/path = %v/%q", readErr, result.OutputPath) } - if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath || notifier.requests[0].ReportPath == outputPath { - t.Fatalf("notification requests = %#v, want managed report source", notifier.requests) + if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != outputPath { + t.Fatalf("notification requests = %#v, want selected output source", notifier.requests) } wantPipeline := "reports." + string(test.id) + "." + definition.ArtifactGroup if notifier.requests[0].PipelineID != wantPipeline { @@ -257,7 +257,7 @@ func TestGenerateDetailedPreservesSelectedProfileThroughExecution(t *testing.T) } bundle := workflowBundle(t) _, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: test.kind, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), + Config: cfg, Report: test.kind, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Notifier: &workflowNotifier{}, }) if err != nil { @@ -344,7 +344,7 @@ func TestGenerateDetailedStopsAtConsequentialPromptFailures(t *testing.T) { bundle := workflowBundle(t) collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}} result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), + Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: executor, }) if err == nil || result == nil || promptexec.CategoryOf(err) != test.wantCategory { @@ -384,7 +384,7 @@ func TestGenerateDetailedRejectsInspectionAndCredentialsBeforeCollection(t *test test.configure(executor) collector := &workflowCollector{err: errors.New("collector must not run")} result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), + Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: executor, }) if err == nil || result != nil || promptexec.CategoryOf(err) != test.wantCategory || collector.calls != 0 || executor.executeCalls != 0 { @@ -412,7 +412,7 @@ func TestGenerateDetailedStopsProviderWhenPreparationCannotPersist(t *testing.T) executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())} bundle := workflowBundle(t) result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), + Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Store: preparationFailingStore{Store: filesystem}, }) if err == nil || result == nil || result.PreparationPath != "" || executor.providerCalls != 0 { @@ -424,7 +424,7 @@ func TestGenerateDetailedPersistsPreparationBeforeProviderExecution(t *testing.T cfg := workflowConfig(t) cfg.Notify.Distributor.Enabled = false now := workflowTime("2026-05-29T08:30:00-05:00") - request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now} + request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now, WorkingDir: t.TempDir()} resolved, err := ResolveGenerate(request, now) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) @@ -450,7 +450,7 @@ func TestGenerateDetailedPersistsPreparationBeforeProviderExecution(t *testing.T request.Executor = executor request.Store = filesystem result, err := GenerateDetailed(context.Background(), request) - if err != nil || result == nil || !checked || result.OutputPath != "" { + if err != nil || result == nil || !checked || result.OutputPath == "" { t.Fatalf("result/error/checked/output = %#v/%v/%t/%q", result, err, checked, result.OutputPath) } } @@ -482,9 +482,6 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t * } req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderedReportPath, renderedReportPath: blocker} }, wantRaw: true, wantNormalized: true, wantContext: true}, - {name: "output copy", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) { - req.OutputPath = t.TempDir() - }, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true}, {name: "notification", raw: validDailyWorkflowJSON(), configure: func(_ *GenerateRequest, notifier *workflowNotifier) { notifier.err = errors.New("notification rejected") }, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true, wantOutput: true, wantNotify: true}, @@ -502,7 +499,7 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t * t.Fatalf("NewFilesystemStore() error = %v", err) } req := GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), + Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: filepath.Join(t.TempDir(), "daily.md"), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Notifier: notifier, Store: filesystem, } @@ -524,7 +521,7 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t * t.Fatalf("retained raw output = %q, error %v", persisted, readErr) } } - if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath) { + if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.OutputPath) { t.Fatalf("notification requests = %#v", notifier.requests) } }) @@ -560,7 +557,7 @@ func TestGenerateDetailedDebugFailuresRespectProviderBoundary(t *testing.T) { cfg := workflowConfig(t) cfg.Notify.Distributor.Enabled = false now := workflowTime("2026-05-29T08:30:00-05:00") - request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now} + request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now, WorkingDir: t.TempDir()} resolved, err := ResolveGenerate(request, now) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) diff --git a/internal/cli/root.go b/internal/cli/root.go index f964f6b..e631021 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index b551521..538ccbc 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -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)) diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go index 9732d36..20a1b6a 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily_report.go @@ -17,7 +17,7 @@ func dailyDefinition() Definition { GeneratedTextSchemaID: "daily", ComparisonStrategy: CompareSameValidDate, ArtifactGroup: "daily", - BatchOutputName: "daily.md", + OutputName: "daily.md", DistributorPathTemplates: []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", diff --git a/internal/report/definition.go b/internal/report/definition.go index 95c28ad..1a8b0f0 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -42,7 +42,7 @@ type Definition struct { GeneratedTextSchemaID string ComparisonStrategy ComparisonStrategy ArtifactGroup string - BatchOutputName string + OutputName string DistributorPathTemplates []string CompatiblePriorIDs []ID Modules []module.ConfigItem @@ -52,6 +52,23 @@ type Definition struct { runIDDisambiguator func(Resolved) string } +func (r Resolved) OutputName() (string, error) { + if r.Definition.ID == Daily { + if r.ValidPeriod.Start.IsZero() { + return "", fmt.Errorf("daily report has no valid-period start for output naming") + } + location, err := timeutil.LoadLocation(r.Timezone) + if err != nil { + return "", fmt.Errorf("load report timezone for output naming: %w", err) + } + return "daily-" + r.ValidPeriod.Start.In(location).Format(timeutil.DateLayout) + ".md", nil + } + if r.Definition.OutputName == "" { + return "", fmt.Errorf("report %q has no output name", r.Definition.ID) + } + return r.Definition.OutputName, nil +} + func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) { if d.resolve == nil { return timeutil.Period{}, fmt.Errorf("report %q has no valid-period resolver", d.ID) diff --git a/internal/report/hourly_report.go b/internal/report/hourly_report.go index ccc5c3a..58728d6 100644 --- a/internal/report/hourly_report.go +++ b/internal/report/hourly_report.go @@ -19,7 +19,7 @@ func hourlyDefinition() Definition { GeneratedTextSchemaID: "hourly", ComparisonStrategy: CompareRollingWindow, ArtifactGroup: "hourly", - BatchOutputName: "hourly.md", + OutputName: "hourly.md", DistributorPathTemplates: []string{ "hourly/index.md", }, diff --git a/internal/report/period_test.go b/internal/report/period_test.go index fdfe612..3586b46 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -103,7 +103,7 @@ func TestRegistryDefinitionsPreserveRetainedContracts(t *testing.T) { for _, tt := range tests { t.Run(string(tt.id), func(t *testing.T) { definition := registry.MustLookup(tt.id) - if definition.ComparisonStrategy != tt.comparison || definition.Morning != tt.morning || definition.Evening != tt.evening || definition.BatchOutputName != tt.outputName { + if definition.ComparisonStrategy != tt.comparison || definition.Morning != tt.morning || definition.Evening != tt.evening || definition.OutputName != tt.outputName { t.Fatalf("definition = %#v, want retained report contract", definition) } if strings.Join(definition.DistributorPathTemplates, "|") != strings.Join(tt.paths, "|") { diff --git a/internal/report/today_report.go b/internal/report/today_report.go index 3ed9aaf..e266047 100644 --- a/internal/report/today_report.go +++ b/internal/report/today_report.go @@ -15,7 +15,7 @@ func todayDefinition() Definition { GeneratedTextSchemaID: "today", ComparisonStrategy: CompareSameValidDate, ArtifactGroup: "today", - BatchOutputName: "today.md", + OutputName: "today.md", DistributorPathTemplates: []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", diff --git a/internal/report/tomorrow_report.go b/internal/report/tomorrow_report.go index 1bbb90c..b635812 100644 --- a/internal/report/tomorrow_report.go +++ b/internal/report/tomorrow_report.go @@ -15,7 +15,7 @@ func tomorrowDefinition() Definition { GeneratedTextSchemaID: "tomorrow", ComparisonStrategy: CompareSameValidDate, ArtifactGroup: "tomorrow", - BatchOutputName: "tomorrow.md", + OutputName: "tomorrow.md", DistributorPathTemplates: []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md",