From ccf6b66880cfda12411569d342aa43dbdf6901b6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 2 Aug 2026 06:02:24 +0000 Subject: [PATCH] Complete comparison command output --- internal/cli/comparison_test.go | 131 ++++++++++++++++++++++++++++++-- internal/cli/result.go | 102 +++++++++++++++++++++++++ internal/cli/result_test.go | 106 ++++++++++++++++++++++++++ internal/cli/root.go | 24 +++++- 4 files changed, 355 insertions(+), 8 deletions(-) diff --git a/internal/cli/comparison_test.go b/internal/cli/comparison_test.go index ece384c..8c3c3d8 100644 --- a/internal/cli/comparison_test.go +++ b/internal/cli/comparison_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) @@ -175,13 +177,110 @@ func TestExecuteComparisonUsesOneExecutorAndInjectedApplication(t *testing.T) { } } -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) +func TestCompareCommandWritesStructuredPartialFailure(t *testing.T) { + workingDir := t.TempDir() + configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n") + profileFailure := comparison.NewSafeError("generation", "execute prompt failed") + result := comparisonResult("/reports/comparison-daily", []app.ComparisonProfileResult{ + {Position: 1, ProfileID: "weather-light", BackendID: "local", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/01-weather-light.md", LLMDebugPath: "/debug/comparison-light"}, + {Position: 2, ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep", Status: comparison.StatusFailed, Error: &profileFailure}, + }) + partialErr := errors.New("comparison completed with 1 failed profiles") + factoryCalls, applicationCalls := 0, 0 + 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 &factoryExecutor{}, nil + }, + compareDetailed: func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) { + applicationCalls++ + return result, partialErr + }, } - if err := runner.Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || strings.Contains(stdout.String(), "compare") { + var stdout, stderr bytes.Buffer + err := runner.Run(context.Background(), []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath}, &stdout, &stderr) + if !errors.Is(err, partialErr) || factoryCalls != 1 || applicationCalls != 1 || stderr.Len() != 0 { + t.Fatalf("error/calls/stderr = %v/%d/%d/%q", err, factoryCalls, applicationCalls, stderr.String()) + } + var summary comparisonSummary + if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil { + t.Fatalf("decode summary: %v\n%s", err, stdout.String()) + } + if summary.Command != commandCompare || summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != partialErr.Error() || summary.OutputDirectory != result.OutputDirectory || len(summary.Results) != 2 || summary.Results[0].ReportPath != result.Results[0].ReportPath || summary.Results[0].LLMDebugPath != result.Results[0].LLMDebugPath || summary.Results[1].Error == nil { + t.Fatalf("summary = %#v", summary) + } +} + +func TestCompareCommandWritesSuccessForDefaultAndExplicitDestinations(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") + for _, test := range []struct { + name string + outputArgument []string + outputDirectory string + wantRequestPath string + }{ + {name: "configured default", outputDirectory: filepath.Join(workingDir, "configured-reports", "comparison-daily-2026-05-29")}, + {name: "explicit directory", outputArgument: []string{"--out-dir", "published"}, outputDirectory: filepath.Join(workingDir, "published"), wantRequestPath: filepath.Join(workingDir, "published")}, + } { + t.Run(test.name, func(t *testing.T) { + var received app.ComparisonRequest + runner := comparisonRunner(t, workingDir) + runner.compareDetailed = func(_ context.Context, req app.ComparisonRequest) (*app.ComparisonResult, error) { + received = req + return comparisonResult(test.outputDirectory, []app.ComparisonProfileResult{ + {Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(test.outputDirectory, "01-weather-light.md")}, + {Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(test.outputDirectory, "02-weather-deep.md")}, + }), nil + } + args := []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath} + args = append(args, test.outputArgument...) + var stdout, stderr bytes.Buffer + if err := runner.Run(context.Background(), args, &stdout, &stderr); err != nil { + t.Fatalf("Run() error = %v", err) + } + var summary comparisonSummary + if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil || summary.Status != summaryStatusSucceeded || summary.OutputDirectory != test.outputDirectory || stderr.Len() != 0 || received.OutputDir != test.wantRequestPath { + t.Fatalf("summary/error/stderr/request = %#v/%v/%q/%#v", summary, err, stderr.String(), received) + } + }) + } +} + +func TestCompareCommandQuietPreservesFailure(t *testing.T) { + configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n") + failure := errors.New("comparison completed with 2 failed profiles") + calls := 0 + runner := comparisonRunner(t, t.TempDir()) + runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) { + calls++ + return comparisonResult("/reports/comparison-daily", nil), failure + } + var stdout, stderr bytes.Buffer + err := runner.Run(context.Background(), []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--quiet", "--config", configPath}, &stdout, &stderr) + if !errors.Is(err, failure) || calls != 1 || stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("error/calls/stdout/stderr = %v/%d/%q/%q", err, calls, stdout.String(), stderr.String()) + } +} + +func TestCompareCommandLeavesPreExecutionFailuresUnstructured(t *testing.T) { + runner := comparisonRunner(t, t.TempDir()) + called := false + runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) { + called = true + return nil, nil + } + var stdout, stderr bytes.Buffer + err := runner.Run(context.Background(), []string{"compare", "today", "--profile", "only-one"}, &stdout, &stderr) + if err == nil || called || stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("error/called/stdout/stderr = %v/%t/%q/%q", err, called, stdout.String(), stderr.String()) + } +} + +func TestCompareHelpIncludesCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") { t.Fatalf("help/error = %q/%v", stdout.String(), err) } } @@ -202,3 +301,23 @@ func comparisonConfigPath(t *testing.T, contents string) string { } return path } + +func comparisonResult(outputDirectory string, results []app.ComparisonProfileResult) *app.ComparisonResult { + started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) + result := &app.ComparisonResult{ + ComparisonID: "comparison_run-123", ReportID: "daily", ReportName: "Daily Report", + PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64), + StartedAt: started, FinishedAt: started.Add(time.Minute), Timezone: "America/Chicago", + ValidPeriod: timeutil.Period{Start: started, End: started.Add(24 * time.Hour)}, OutputDirectory: outputDirectory, + ManifestPath: filepath.Join(outputDirectory, "comparison.json"), DataPackagePath: filepath.Join(outputDirectory, "data-package.yml"), + Results: append([]app.ComparisonProfileResult(nil), results...), Total: len(results), + } + for _, profile := range results { + if profile.Status == "succeeded" { + result.Succeeded++ + } else { + result.Failed++ + } + } + return result +} diff --git a/internal/cli/result.go b/internal/cli/result.go index 6fad627..2f2ee3f 100644 --- a/internal/cli/result.go +++ b/internal/cli/result.go @@ -1,9 +1,15 @@ package cli import ( + "context" + "errors" + "fmt" + "strconv" + "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" @@ -12,6 +18,7 @@ import ( const ( commandGenerate = "generate" commandRun = "run" + commandCompare = "compare" summaryStatusSucceeded = "succeeded" summaryStatusFailed = "failed" @@ -67,6 +74,41 @@ type batchSummary struct { Error string `json:"error,omitempty"` } +type comparisonSummary struct { + Command string `json:"command"` + ComparisonID string `json:"comparisonId"` + ReportID report.ID `json:"reportId"` + ReportName string `json:"reportName"` + PromptID string `json:"promptId"` + PromptVersion string `json:"promptVersion"` + PromptHash string `json:"promptHash"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"validPeriod"` + OutputDirectory string `json:"outputDirectory"` + ManifestPath string `json:"manifestPath,omitempty"` + DataPackagePath string `json:"dataPackagePath,omitempty"` + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Results []comparisonProfileSummary `json:"results"` + Error *comparison.SafeError `json:"error,omitempty"` +} + +type comparisonProfileSummary struct { + Position int `json:"position"` + ProfileID string `json:"profileId"` + BackendID string `json:"backendId,omitempty"` + ModelName string `json:"modelName"` + Status string `json:"status"` + ValidationStatus string `json:"validationStatus,omitempty"` + ReportPath string `json:"reportPath,omitempty"` + LLMDebugPath string `json:"llmDebugPath,omitempty"` + Error *comparison.SafeError `json:"error,omitempty"` +} + func newGenerateSummary(result *app.ReportResult, err error) generateSummary { summary := generateSummary{Command: commandGenerate} if result == nil { @@ -139,6 +181,66 @@ func newBatchSummary(result *app.BatchResult) batchSummary { return summary } +func newComparisonSummary(result *app.ComparisonResult, err error) comparisonSummary { + summary := comparisonSummary{Command: commandCompare, Results: []comparisonProfileSummary{}} + if result == nil { + return summary + } + summary.ComparisonID = result.ComparisonID + summary.ReportID, summary.ReportName = result.ReportID, result.ReportName + summary.PromptID, summary.PromptVersion, summary.PromptHash = result.PromptID, result.PromptVersion, result.PromptHash + summary.StartedAt, summary.FinishedAt = result.StartedAt, result.FinishedAt + summary.Timezone, summary.ValidPeriod = result.Timezone, result.ValidPeriod + summary.OutputDirectory = result.OutputDirectory + summary.ManifestPath, summary.DataPackagePath = result.ManifestPath, result.DataPackagePath + summary.Total, summary.Succeeded, summary.Failed = result.Total, result.Succeeded, result.Failed + for _, profile := range result.Results { + summary.Results = append(summary.Results, comparisonProfileSummary{ + Position: profile.Position, ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, + Status: profile.Status, ValidationStatus: string(profile.ValidationStatus), ReportPath: profile.ReportPath, + LLMDebugPath: profile.LLMDebugPath, Error: profile.Error, + }) + } + summary.Status = comparisonSummaryStatus(result, err) + if err != nil { + summary.Error = safeComparisonSummaryError(err) + } + return summary +} + +func comparisonSummaryStatus(result *app.ComparisonResult, err error) string { + if result == nil || err != nil || result.Total < 2 || result.Succeeded != result.Total || result.Failed != 0 || result.ManifestPath == "" || result.DataPackagePath == "" { + return summaryStatusFailed + } + return summaryStatusSucceeded +} + +func safeComparisonSummaryError(err error) *comparison.SafeError { + message := "comparison did not complete" + if aggregate, ok := comparisonAggregateErrorMessage(err.Error()); ok { + message = aggregate + } else if errors.Is(err, context.DeadlineExceeded) { + message = "comparison deadline exceeded" + } else if errors.Is(err, context.Canceled) { + message = "comparison canceled" + } + safe := comparison.NewSafeError("application", message) + return &safe +} + +func comparisonAggregateErrorMessage(value string) (string, bool) { + const prefix = "comparison completed with " + const suffix = " failed profiles" + if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) { + return "", false + } + count, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix)) + if err != nil || count < 1 { + return "", false + } + return fmt.Sprintf("comparison completed with %d failed profiles", count), true +} + func batchSummaryStatus(result *app.BatchResult) string { if result == nil { return "" diff --git a/internal/cli/result_test.go b/internal/cli/result_test.go index 7044b18..15b0f96 100644 --- a/internal/cli/result_test.go +++ b/internal/cli/result_test.go @@ -1,12 +1,15 @@ package cli import ( + "context" "encoding/json" + "errors" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" @@ -29,3 +32,106 @@ func TestGenerateSummaryUsesActiveResultFields(t *testing.T) { } } } + +func TestComparisonSummaryUsesLockedOrderAndSafeFields(t *testing.T) { + started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) + profileFailure := comparison.NewSafeError("generation", "execute prompt failed") + result := &app.ComparisonResult{ + ComparisonID: "comparison_run-123", ReportID: report.Daily, ReportName: "Daily Report", + PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64), + StartedAt: started, FinishedAt: started.Add(time.Minute), Timezone: "America/Chicago", + ValidPeriod: timeutil.Period{Start: started, End: started.Add(24 * time.Hour)}, OutputDirectory: "/reports/comparison-daily", + ManifestPath: "/reports/comparison-daily/comparison.json", DataPackagePath: "/reports/comparison-daily/data-package.yml", + Total: 2, Succeeded: 1, Failed: 1, + Results: []app.ComparisonProfileResult{ + {Position: 1, ProfileID: "weather-light", BackendID: "local", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/01-weather-light.md"}, + {Position: 2, ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep", Status: comparison.StatusFailed, Error: &profileFailure}, + }, + } + summary := newComparisonSummary(result, errors.New("comparison completed with 1 failed profiles")) + if summary.Command != commandCompare || summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != "comparison completed with 1 failed profiles" || len(summary.Results) != 2 || summary.Results[0].ReportPath == "" || summary.Results[1].Error == nil { + t.Fatalf("summary = %#v", summary) + } + data, err := json.Marshal(summary) + if err != nil { + t.Fatal(err) + } + previous := -1 + for _, field := range []string{"command", "comparisonId", "reportId", "reportName", "promptId", "promptVersion", "promptHash", "status", "startedAt", "finishedAt", "timezone", "validPeriod", "outputDirectory", "manifestPath", "dataPackagePath", "total", "succeeded", "failed", "results", "error"} { + position := strings.Index(string(data), `"`+field+`":`) + if field == "error" { + position = strings.LastIndex(string(data), `"`+field+`":`) + } + if position <= previous { + t.Fatalf("field order for %q in %s", field, data) + } + previous = position + } + for _, unsafe := range []string{"provider response", "rawOutput", "renderedPrompt", "endpoint"} { + if strings.Contains(string(data), unsafe) { + t.Fatalf("summary includes unsafe content %q: %s", unsafe, data) + } + } +} + +func TestComparisonSummaryOmitsUnpublishedArtifactsAndBoundsErrors(t *testing.T) { + result := &app.ComparisonResult{ComparisonID: "comparison_run-123", ReportID: report.Daily, Results: []app.ComparisonProfileResult{{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusFailed}}} + summary := newComparisonSummary(result, context.Canceled) + if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != "comparison canceled" || summary.Results == nil { + t.Fatalf("summary = %#v", summary) + } + data, err := json.Marshal(summary) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + for _, omitted := range []string{"manifestPath", "dataPackagePath"} { + if _, exists := fields[omitted]; exists { + t.Fatalf("summary includes unpublished %s: %s", omitted, data) + } + } + publicationResult := &app.ComparisonResult{ + ComparisonID: "comparison_run-123", ReportID: report.Daily, OutputDirectory: "/reports/comparison-daily", Total: 2, Succeeded: 2, + Results: []app.ComparisonProfileResult{ + {Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded}, + {Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded}, + }, + } + unsafe := errors.New("comparison completed with 1 failed profiles; provider response contains sensitive material") + publicationSummary := newComparisonSummary(publicationResult, unsafe) + if publicationSummary.Status != summaryStatusFailed || publicationSummary.Error == nil || publicationSummary.Error.Message != "comparison did not complete" || strings.Contains(publicationSummary.Error.Message, "sensitive") || publicationSummary.ManifestPath != "" || publicationSummary.DataPackagePath != "" { + safe := publicationSummary.Error + t.Fatalf("safe error = %#v", safe) + } +} + +func TestComparisonSummaryClassifiesCompleteAndAllFailedResults(t *testing.T) { + started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) + failure := comparison.NewSafeError("generation", "execute prompt failed") + complete := &app.ComparisonResult{ + ComparisonID: "comparison_run-123", ReportID: report.Daily, PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64), + StartedAt: started, FinishedAt: started, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{Start: started, End: started.Add(time.Hour)}, + OutputDirectory: "/reports/comparison-daily", ManifestPath: "/reports/comparison-daily/comparison.json", DataPackagePath: "/reports/comparison-daily/data-package.yml", + Total: 2, Succeeded: 2, + Results: []app.ComparisonProfileResult{ + {Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/01-weather-light.md"}, + {Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/02-weather-deep.md"}, + }, + } + if summary := newComparisonSummary(complete, nil); summary.Status != summaryStatusSucceeded || summary.Error != nil { + t.Fatalf("complete summary = %#v", summary) + } + allFailed := *complete + allFailed.Succeeded, allFailed.Failed = 0, 2 + allFailed.Results = []app.ComparisonProfileResult{ + {Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusFailed, Error: &failure}, + {Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusFailed, Error: &failure}, + } + summary := newComparisonSummary(&allFailed, errors.New("comparison completed with 2 failed profiles")) + if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != "comparison completed with 2 failed profiles" || summary.Results[0].ReportPath != "" || summary.Results[1].Error == nil { + t.Fatalf("all-failed summary = %#v", summary) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 328f196..8abd632 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -27,6 +27,7 @@ Usage: weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] + weatherreporter compare REPORT --profile PROFILE --profile PROFILE [--config PATH] [--units VALUE] [--tz NAME] [--date YYYY-MM-DD] [--out-dir PATH] [--replace] [--llm-debug-dir PATH] [--quiet] Options: -h, --help Show this help message. @@ -36,8 +37,10 @@ Options: --tz NAME Override weather API timezone. --out PATH Write the generated Markdown report to PATH. --llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH. - --out-dir PATH Write generated Markdown reports beneath PATH for run commands. - --quiet Suppress successful generate and run output. + --profile PROFILE Select a prompt profile for compare; repeat for every profile. + --out-dir PATH Write generated Markdown reports beneath PATH for run commands, or select the exact comparison directory. + --replace Authorize replacement of a recognized comparison bundle. + --quiet Suppress successful action output. ` type Runner struct { @@ -109,6 +112,23 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr } } return err + case "compare": + req, opts, err := r.resolveComparisonAction(args[1:]) + if err != nil { + return err + } + compareDetailed := r.compareDetailed + if compareDetailed == nil { + compareDetailed = app.CompareDetailed + } + result, err := compareDetailed(ctx, req) + if result != nil { + summary := newComparisonSummary(result, err) + if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil { + return encodeErr + } + } + return err default: return fmt.Errorf("unknown command %q", args[0]) }