package cli import ( "bytes" "context" "encoding/json" "errors" "os" "path/filepath" "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 TestParseRunFlagsAcceptsPromptDebugDirectory(t *testing.T) { opts, err := parseRunFlags([]string{"--llm-debug-dir", "/tmp/prompt-debug"}) if err != nil || opts.LLMDebugDir != "/tmp/prompt-debug" { t.Fatalf("parseRunFlags() = %#v, %v", opts, err) } } func TestResolveRunActionConstructsOneExecutor(t *testing.T) { for _, command := range []string{"morning", "evening"} { t.Run(command, func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil { t.Fatal(err) } calls := 0 executor := &factoryExecutor{} runner := Runner{ Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)}, ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { calls++ return executor, nil }, } req, _, err := runner.resolveRunAction([]string{command, "--config", configPath, "--llm-debug-dir", "/tmp/debug"}) if err != nil || calls != 1 || req.Executor != executor || req.LLMDebugDir != "/tmp/debug" { t.Fatalf("resolveRunAction() request/error/calls = %#v/%v/%d", req, err, calls) } }) } } func TestResolveGenerateActionUsesInjectedWorkingDirectoryForOutputOverrides(t *testing.T) { workingDir := t.TempDir() configPath := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil { t.Fatal(err) } absoluteOutput := filepath.Join(t.TempDir(), "daily.md") for _, scenario := range []struct { name string out string want string }{ {name: "default", want: ""}, {name: "relative", out: "reports/daily.md", want: filepath.Join(workingDir, "reports", "daily.md")}, {name: "absolute", out: absoluteOutput, want: absoluteOutput}, } { t.Run(scenario.name, func(t *testing.T) { runner := Runner{ Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)}, WorkingDir: workingDir, ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { return &factoryExecutor{}, nil }, } args := []string{"daily", "--date", "2026-05-29", "--config", configPath} if scenario.out != "" { args = append(args, "--out", scenario.out) } req, _, err := runner.resolveGenerateAction(args) if err != nil || req.WorkingDir != workingDir || req.OutputPath != scenario.want { t.Fatalf("resolveGenerateAction() request/error = %#v/%v", req, err) } }) } } func TestRunActionReturnsFailureForBatchNotificationFailure(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil { t.Fatal(err) } result := &app.BatchResult{ Batch: app.BatchMorning, Total: 2, Succeeded: 2, Reports: []app.BatchReportResult{ {ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"}, {ReportID: "tomorrow", Status: "succeeded", OutputPath: "/reports/tomorrow.md"}, }, Notification: &app.BatchNotificationResult{Status: "failed", Error: "distributor unavailable"}, } var stdout, stderr bytes.Buffer runner := Runner{ Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)}, ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { return &factoryExecutor{}, nil }, runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) { return result, nil }, } err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr) var batchErr app.BatchError if !errors.As(err, &batchErr) || !strings.Contains(err.Error(), "notification failed") { t.Fatalf("Run() error = %v", err) } var summary batchSummary if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil { t.Fatalf("decode summary: %v", err) } if summary.Status != summaryStatusFailed || summary.Total != 2 || summary.Succeeded != 2 || summary.Failed != 0 || summary.Notification == nil || summary.Notification.Status != "failed" { t.Fatalf("summary = %#v", summary) } if !strings.Contains(stderr.String(), `batchNotification status="failed"`) { t.Fatalf("stdout/stderr = %q/%q", stdout.String(), stderr.String()) } for _, field := range []string{"notificationStatus", "notificationRunId", "notificationPipelineId", "notificationError"} { if strings.Contains(stdout.String(), field) || strings.Contains(stderr.String(), field) { t.Fatalf("stdout/stderr includes removed field %q: %q/%q", field, stdout.String(), stderr.String()) } } } func TestInspectCommandIsUnknownAndAbsentFromHelp(t *testing.T) { var stdout, stderr bytes.Buffer err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr) if err == nil || err.Error() != `unknown command "inspect"` { t.Fatalf("Run(inspect) error = %v", err) } if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil { t.Fatalf("Run(--help) error = %v", err) } if strings.Contains(stdout.String(), "inspect") { t.Fatalf("help contains removed inspect command:\n%s", stdout.String()) } }