diff --git a/internal/app/comparison_test.go b/internal/app/comparison_test.go index e1784a0..731ce20 100644 --- a/internal/app/comparison_test.go +++ b/internal/app/comparison_test.go @@ -13,6 +13,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestCompareDetailedPublishesOneCoherentBundle(t *testing.T) { @@ -102,6 +103,33 @@ func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) { } } +func TestCompareDetailedLeavesDestinationWhenCollectionOrPreparationFails(t *testing.T) { + collectionErr := errors.New("weather collection failed") + for _, test := range []struct { + name string + collector *generationCollector + }{ + {name: "collection", collector: &generationCollector{err: collectionErr}}, + {name: "preparation", collector: &generationCollector{bundle: &weatherdata.Bundle{}}}, + } { + t.Run(test.name, func(t *testing.T) { + workingDir := t.TempDir() + executor := &generationExecutor{} + result, err := CompareDetailed(context.Background(), ComparisonRequest{ + Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"}, + WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"), + Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: test.collector, Executor: executor, + }) + if err == nil || result == nil || executor.executeCalls != 0 || result.ManifestPath != "" || result.DataPackagePath != "" { + t.Fatalf("CompareDetailed() result/error/executor = %#v/%v/%#v", result, err, executor) + } + if _, statErr := os.Stat(filepath.Join(workingDir, "comparison-daily-2026-05-29")); !os.IsNotExist(statErr) { + t.Fatalf("comparison destination stat error = %v", statErr) + } + }) + } +} + func TestCompareDetailedPublishesManifestWhenEveryProfileFails(t *testing.T) { bundle := generationBundle(t) executor := &generationExecutor{executeErrors: map[string]error{ diff --git a/internal/cli/comparison_test.go b/internal/cli/comparison_test.go new file mode 100644 index 0000000..ece384c --- /dev/null +++ b/internal/cli/comparison_test.go @@ -0,0 +1,204 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestParseComparisonFlagsPreservesProfileOrder(t *testing.T) { + opts, err := parseComparisonFlags(app.ReportDaily, []string{ + "--profile", "weather-light", "--profile=weather-balanced", "--profile", "weather-deep", + "--date", "2026-05-29", "--out-dir", "reports", "--replace", "--quiet", + }) + if err != nil { + t.Fatalf("parseComparisonFlags() error = %v", err) + } + wantProfiles := []string{"weather-light", "weather-balanced", "weather-deep"} + if !reflect.DeepEqual([]string(opts.ProfileIDs), wantProfiles) || opts.Date != "2026-05-29" || opts.OutputDir != "reports" || !opts.Replace || !opts.Quiet { + t.Fatalf("options = %#v", opts) + } +} + +func TestResolveComparisonActionBuildsExplicitRequest(t *testing.T) { + workingDir := t.TempDir() + configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n units: metric\n timezone: America/Chicago\noutput:\n directory: configured-reports\npromptkit:\n profile: configured-default\n") + clock := timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)} + var factoryConfig PromptExecutorConfig + factoryCalls := 0 + executor := &factoryExecutor{} + runner := Runner{ + Clock: clock, WorkingDir: workingDir, + ExecutorFactory: func(value PromptExecutorConfig) (promptexec.Executor, error) { + factoryCalls++ + factoryConfig = value + return executor, nil + }, + } + req, opts, err := runner.resolveComparisonAction([]string{ + "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", + "--out-dir", "bundles/../comparison", "--replace", "--llm-debug-dir", "/tmp/debug", "--units", "imperial", "--tz", "America/New_York", "--config", configPath, + }) + if err != nil { + t.Fatalf("resolveComparisonAction() error = %v", err) + } + if factoryCalls != 1 || req.Executor != executor || req.Clock != clock || req.WorkingDir != workingDir || req.OutputDir != filepath.Join(workingDir, "comparison") || !req.Replace || req.LLMDebugDir != "/tmp/debug" { + t.Fatalf("request/factory calls = %#v/%#v/%d", req, factoryConfig, factoryCalls) + } + if got, want := req.ProfileIDs, []string{"weather-light", "weather-deep"}; !reflect.DeepEqual(got, want) { + t.Fatalf("profile IDs = %#v, want %#v", got, want) + } + if req.Config.WeatherAPI.Units != "imperial" || req.Config.WeatherAPI.Timezone != "America/New_York" || req.Config.Output.Directory != "configured-reports" || factoryConfig.Profile != "" || opts.Quiet { + t.Fatalf("configuration/options/factory config = %#v/%#v/%#v", req.Config, opts, factoryConfig) + } + location, loadErr := time.LoadLocation("America/New_York") + if loadErr != nil || req.Date.Location().String() != location.String() || req.Date.Format("2006-01-02") != "2026-05-29" { + t.Fatalf("date/location = %v/%v", req.Date, loadErr) + } +} + +func TestResolveComparisonActionMatchesReportDatePolicies(t *testing.T) { + configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n timezone: America/Chicago\n") + runner := comparisonRunner(t, t.TempDir()) + for _, test := range []struct { + name string + args []string + wantDay string + wantErr bool + }{ + {name: "daily requires date", args: []string{"daily", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true}, + {name: "today uses current local date", args: []string{"today", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-29"}, + {name: "today accepts date", args: []string{"today", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-30"}, + {name: "tomorrow rejects date", args: []string{"tomorrow", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true}, + {name: "hourly rejects date", args: []string{"hourly", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + req, _, err := runner.resolveComparisonAction(test.args) + if test.wantErr { + if err == nil { + t.Fatal("resolveComparisonAction() error = nil") + } + return + } + if err != nil || req.Date.Format("2006-01-02") != test.wantDay { + t.Fatalf("request/error = %#v/%v", req, err) + } + }) + } +} + +func TestResolveComparisonActionLeavesConfiguredOutputWithoutOverride(t *testing.T) { + workingDir := t.TempDir() + configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: configured/../reports\n") + req, _, err := comparisonRunner(t, workingDir).resolveComparisonAction([]string{ + "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath, + }) + if err != nil || req.OutputDir != "" || req.Config.Output.Directory != "configured/../reports" { + t.Fatalf("request/error = %#v/%v", req, err) + } +} + +func TestComparisonInputFailuresDoNotConstructOrExecute(t *testing.T) { + for _, test := range []struct { + name string + args []string + }{ + {name: "missing report", args: nil}, + {name: "unknown report", args: []string{"unknown", "--profile", "one", "--profile", "two"}}, + {name: "single profile", args: []string{"today", "--profile", "one"}}, + {name: "blank profile", args: []string{"today", "--profile", "one", "--profile", " \t"}}, + {name: "duplicate profile", args: []string{"today", "--profile", "one", "--profile", "one"}}, + {name: "unexpected argument", args: []string{"today", "extra", "--profile", "one", "--profile", "two"}}, + {name: "unsupported output flag", args: []string{"today", "--out", "report.md", "--profile", "one", "--profile", "two"}}, + {name: "configuration failure", args: []string{"today", "--profile", "one", "--profile", "two", "--config", filepath.Join(t.TempDir(), "missing.yml")}}, + } { + t.Run(test.name, func(t *testing.T) { + factoryCalls, applicationCalls := 0, 0 + runner := Runner{ + Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)}, + ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { + factoryCalls++ + return &factoryExecutor{}, nil + }, + compareDetailed: func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) { + applicationCalls++ + return nil, nil + }, + } + if _, err := runner.executeComparison(context.Background(), test.args); err == nil { + t.Fatal("executeComparison() error = nil") + } + if factoryCalls != 0 || applicationCalls != 0 { + t.Fatalf("factory/application calls = %d/%d", factoryCalls, applicationCalls) + } + }) + } +} + +func TestExecuteComparisonUsesOneExecutorAndInjectedApplication(t *testing.T) { + workingDir := t.TempDir() + configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n") + executor := &factoryExecutor{} + factoryCalls, applicationCalls := 0, 0 + var received app.ComparisonRequest + wantResult := &app.ComparisonResult{ComparisonID: "comparison_test"} + runner := Runner{ + Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)}, WorkingDir: workingDir, + ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { + factoryCalls++ + return executor, nil + }, + compareDetailed: func(_ context.Context, req app.ComparisonRequest) (*app.ComparisonResult, error) { + applicationCalls++ + received = req + return wantResult, errors.New("comparison completed with 1 failed profiles") + }, + } + result, err := runner.executeComparison(context.Background(), []string{ + "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--out-dir", "comparison", "--replace", "--config", configPath, + }) + if result != wantResult || err == nil || factoryCalls != 1 || applicationCalls != 1 || received.Executor != executor || received.OutputDir != filepath.Join(workingDir, "comparison") || !received.Replace { + t.Fatalf("result/error/calls/request = %#v/%v/%d/%d/%#v", result, err, factoryCalls, applicationCalls, received) + } + if !reflect.DeepEqual(received.ProfileIDs, []string{"weather-light", "weather-deep"}) { + t.Fatalf("profile IDs = %#v", received.ProfileIDs) + } +} + +func TestCompareRemainsAbsentFromRootDispatchAndHelp(t *testing.T) { + runner := comparisonRunner(t, t.TempDir()) + var stdout, stderr bytes.Buffer + if err := runner.Run(context.Background(), []string{"compare", "today"}, &stdout, &stderr); err == nil || err.Error() != `unknown command "compare"` { + t.Fatalf("Run(compare) error = %v", err) + } + if err := runner.Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || strings.Contains(stdout.String(), "compare") { + t.Fatalf("help/error = %q/%v", stdout.String(), err) + } +} + +func comparisonRunner(t *testing.T, workingDir string) Runner { + t.Helper() + return Runner{ + Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)}, WorkingDir: workingDir, + ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { return &factoryExecutor{}, nil }, + } +} + +func comparisonConfigPath(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b02b274..328f196 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo" + "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" @@ -45,6 +46,7 @@ type Runner struct { Version string WorkingDir string runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error) + compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) } func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { @@ -127,6 +129,24 @@ type generateOptions struct { Date string } +type comparisonOptions struct { + commonOptions + Date string + ProfileIDs profileValues + Replace bool +} + +type profileValues []string + +func (values *profileValues) String() string { + return "" +} + +func (values *profileValues) Set(value string) error { + *values = append(*values, value) + return nil +} + func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { req, _, err := r.resolveGenerateAction(args) return req, err @@ -212,6 +232,93 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { return req, err } +func (r Runner) resolveComparison(args []string) (app.ComparisonRequest, error) { + req, _, err := r.resolveComparisonAction(args) + return req, err +} + +func (r Runner) executeComparison(ctx context.Context, args []string) (*app.ComparisonResult, error) { + req, _, err := r.resolveComparisonAction(args) + if err != nil { + return nil, err + } + compareDetailed := r.compareDetailed + if compareDetailed == nil { + compareDetailed = app.CompareDetailed + } + return compareDetailed(ctx, req) +} + +func (r Runner) resolveComparisonAction(args []string) (app.ComparisonRequest, commonOptions, error) { + if r.Clock == nil { + r.Clock = timeutil.SystemClock{} + } + if len(args) == 0 { + return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare requires a report name") + } + if _, err := report.IDForCommandName(args[0]); err != nil { + return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("unknown compare report %q", args[0]) + } + reportKind := app.ReportKind(args[0]) + opts, err := parseComparisonFlags(reportKind, args[1:]) + if err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + if err := comparison.ValidateProfileIDs(opts.ProfileIDs); err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + cfg, err := config.Load(config.LoadOptions{ + Path: opts.ConfigPath, + Units: opts.Units, + Timezone: opts.Timezone, + }) + if err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) + if err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + workingDir, err := r.workingDir() + if err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + outputDir, err := resolveOutputOverride(workingDir, opts.OutputDir) + if err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + + req := app.ComparisonRequest{ + Config: cfg, Report: reportKind, ProfileIDs: append([]string(nil), opts.ProfileIDs...), + WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock, + } + switch reportKind { + case app.ReportDaily: + if opts.Date == "" { + return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare daily requires --date YYYY-MM-DD") + } + req.Date, err = timeutil.ParseLocalDate(opts.Date, location) + case app.ReportToday: + if opts.Date == "" { + req.Date = timeutil.LocalDate(r.Clock.Now(), location) + } else { + req.Date, err = timeutil.ParseLocalDate(opts.Date, location) + } + } + if err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + + promptkitConfig := cfg.Promptkit + promptkitConfig.Profile = "" + executor, err := r.promptExecutor(promptkitConfig) + if err != nil { + return app.ComparisonRequest{}, commonOptions{}, err + } + req.Executor = executor + return req, opts.commonOptions, nil +} + func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} @@ -288,6 +395,27 @@ func parseRunFlags(args []string) (commonOptions, error) { return opts, nil } +func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptions, error) { + fs := flag.NewFlagSet("compare "+string(report), flag.ContinueOnError) + fs.SetOutput(io.Discard) + opts := comparisonOptions{} + addCommonFlags(fs, &opts.commonOptions, false) + fs.StringVar(&opts.OutputDir, "out-dir", "", "comparison bundle directory") + fs.BoolVar(&opts.Replace, "replace", false, "replace a recognized comparison bundle") + fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output") + fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID") + if report == app.ReportDaily || report == app.ReportToday { + fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") + } + if err := fs.Parse(args); err != nil { + return comparisonOptions{}, err + } + if fs.NArg() > 0 { + return comparisonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + return opts, nil +} + func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.Units, "units", "", "weather API units")