diff --git a/internal/cli/run_result.go b/internal/cli/run_result.go new file mode 100644 index 0000000..5d4c185 --- /dev/null +++ b/internal/cli/run_result.go @@ -0,0 +1,111 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" +) + +const runResultSchemaVersion = "notarius.run-result.v1" + +type runResult struct { + SchemaVersion string `json:"schema_version"` + RunID string `json:"run_id"` + PipelineID string `json:"pipeline_id"` + OutputDirectory string `json:"output_directory"` + IndexFile string `json:"index_file,omitempty"` + NormalizedOutputCount int `json:"normalized_output_count"` + RejectedOutputCount int `json:"rejected_output_count"` + WarningCount int `json:"warning_count"` + ValidationStatus string `json:"validation_status"` + DebugDirectory string `json:"debug_directory,omitempty"` +} + +func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, outputDirectory, debugDirectory string) (runResult, error) { + if strings.TrimSpace(output.Manifest.RunID) == "" { + return runResult{}, fmt.Errorf("run result requires a run ID") + } + if strings.TrimSpace(resolved.ID) == "" { + return runResult{}, fmt.Errorf("run result requires a resolved pipeline ID") + } + if strings.TrimSpace(output.Manifest.PipelineID) == "" { + return runResult{}, fmt.Errorf("run result requires a manifest pipeline ID") + } + if output.Manifest.PipelineID != resolved.ID { + return runResult{}, fmt.Errorf("run result pipeline ID does not match resolved pipeline") + } + if strings.TrimSpace(output.Manifest.ValidationStatus) == "" { + return runResult{}, fmt.Errorf("run result requires a validation status") + } + if strings.TrimSpace(outputDirectory) == "" { + return runResult{}, fmt.Errorf("run result requires an output directory") + } + + absOutputDirectory, err := filepath.Abs(outputDirectory) + if err != nil { + return runResult{}, fmt.Errorf("make output directory absolute: %w", err) + } + + result := runResult{ + SchemaVersion: runResultSchemaVersion, + RunID: output.Manifest.RunID, + PipelineID: resolved.ID, + OutputDirectory: absOutputDirectory, + NormalizedOutputCount: len(output.NormalizeOutputs), + RejectedOutputCount: len(output.Rejected), + WarningCount: len(output.Warnings), + ValidationStatus: output.Manifest.ValidationStatus, + } + + if strings.TrimSpace(debugDirectory) != "" { + absDebugDirectory, err := filepath.Abs(debugDirectory) + if err != nil { + return runResult{}, fmt.Errorf("make debug directory absolute: %w", err) + } + result.DebugDirectory = absDebugDirectory + } + + if resolved.Output.Module == pipeline.DefaultOutputModule { + indexCount := 0 + for _, file := range output.OutputFiles { + if file.Name == "index.json" { + indexCount++ + } + } + if indexCount != 1 { + return runResult{}, fmt.Errorf("production JSON output must contain exactly one index.json file") + } + result.IndexFile = "index.json" + } + + return result, nil +} + +func encodeRunResult(result runResult) ([]byte, error) { + encoded, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("encode run result: %w", err) + } + return append(encoded, '\n'), nil +} + +func writeRunResult(writer io.Writer, content []byte) error { + for len(content) > 0 { + written, err := writer.Write(content) + if written < 0 || written > len(content) { + return io.ErrShortWrite + } + content = content[written:] + if err != nil { + return err + } + if written == 0 { + return io.ErrShortWrite + } + } + return nil +} diff --git a/internal/cli/run_result_test.go b/internal/cli/run_result_test.go new file mode 100644 index 0000000..6b289f7 --- /dev/null +++ b/internal/cli/run_result_test.go @@ -0,0 +1,200 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" +) + +func TestRunResultEncodesRequiredFieldsAndCounts(t *testing.T) { + result, err := newRunResult(testResolvedPipeline(pipeline.DefaultOutputModule), testRunOutput(), "relative-output", "relative-debug") + if err != nil { + t.Fatal(err) + } + + encoded, err := encodeRunResult(result) + if err != nil { + t.Fatal(err) + } + if encoded[len(encoded)-1] != '\n' || bytes.Count(encoded, []byte{'\n'}) != 1 { + t.Fatalf("encoded result is not one newline-terminated object: %q", encoded) + } + + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if got := decoded["schema_version"]; got != runResultSchemaVersion { + t.Fatalf("schema_version = %q", got) + } + if got := decoded["run_id"]; got != "run-123" { + t.Fatalf("run_id = %q", got) + } + if got := decoded["pipeline_id"]; got != "sample" { + t.Fatalf("pipeline_id = %q", got) + } + if got := decoded["validation_status"]; got != "rejected" { + t.Fatalf("validation_status = %q", got) + } + if got := decoded["index_file"]; got != "index.json" { + t.Fatalf("index_file = %q", got) + } + if got := decoded["normalized_output_count"]; got != float64(2) { + t.Fatalf("normalized_output_count = %v", got) + } + if got := decoded["rejected_output_count"]; got != float64(1) { + t.Fatalf("rejected_output_count = %v", got) + } + if got := decoded["warning_count"]; got != float64(1) { + t.Fatalf("warning_count = %v", got) + } + if got := decoded["output_directory"]; got != filepath.Join(mustWorkingDirectory(t), "relative-output") { + t.Fatalf("output_directory = %q", got) + } + if got := decoded["debug_directory"]; got != filepath.Join(mustWorkingDirectory(t), "relative-debug") { + t.Fatalf("debug_directory = %q", got) + } +} + +func TestRunResultRejectsInvalidRequiredValues(t *testing.T) { + tests := []struct { + name string + resolved pipeline.ResolvedPipeline + output pipeline.RunOutput + directory string + }{ + {name: "blank run ID", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.RunID = " " }), directory: "output"}, + {name: "blank resolved pipeline ID", resolved: pipeline.ResolvedPipeline{Output: pipeline.ModuleBinding{Module: pipeline.DefaultOutputModule}}, output: testRunOutput(), directory: "output"}, + {name: "blank manifest pipeline ID", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.PipelineID = "" }), directory: "output"}, + {name: "mismatched pipeline IDs", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.PipelineID = "other" }), directory: "output"}, + {name: "blank validation status", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.ValidationStatus = " " }), directory: "output"}, + {name: "blank output directory", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutput(), directory: " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := newRunResult(tt.resolved, tt.output, tt.directory, ""); err == nil { + t.Fatal("newRunResult() succeeded") + } + }) + } +} + +func TestRunResultOmitsIndexFileForOtherOutputModules(t *testing.T) { + result, err := newRunResult(testResolvedPipeline("test/output"), testRunOutputWithout(func(output *pipeline.RunOutput) { + output.OutputFiles = nil + }), "output", "") + if err != nil { + t.Fatal(err) + } + if result.IndexFile != "" { + t.Fatalf("index_file = %q", result.IndexFile) + } + encoded, err := encodeRunResult(result) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if _, ok := decoded["index_file"]; ok { + t.Fatalf("encoded non-JSON result contains index_file: %s", encoded) + } + if _, ok := decoded["debug_directory"]; ok { + t.Fatalf("encoded result without debug capture contains debug_directory: %s", encoded) + } +} + +func TestRunResultRequiresOneProductionIndexFile(t *testing.T) { + tests := []struct { + name string + files []contracts.OutputFile + }{ + {name: "missing", files: nil}, + {name: "duplicate", files: []contracts.OutputFile{{Name: "index.json"}, {Name: "index.json"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := testRunOutput() + output.OutputFiles = tt.files + if _, err := newRunResult(testResolvedPipeline(pipeline.DefaultOutputModule), output, "output", ""); err == nil { + t.Fatal("newRunResult() succeeded") + } + }) + } +} + +func TestWriteRunResultCompletesAndReportsWriterFailure(t *testing.T) { + content := []byte("result\n") + var target bytes.Buffer + if err := writeRunResult(partialResultWriter{writer: &target, limit: 2}, content); err != nil { + t.Fatal(err) + } + if got := target.String(); got != string(content) { + t.Fatalf("written result = %q", got) + } + + writerErr := errors.New("result writer failed") + if err := writeRunResult(failingResultWriter{err: writerErr}, content); !errors.Is(err, writerErr) { + t.Fatalf("writeRunResult() error = %v", err) + } + if err := writeRunResult(zeroResultWriter{}, content); !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("zero-progress error = %v", err) + } +} + +func testResolvedPipeline(outputModule string) pipeline.ResolvedPipeline { + return pipeline.ResolvedPipeline{ID: "sample", Output: pipeline.ModuleBinding{Module: outputModule}} +} + +func testRunOutput() pipeline.RunOutput { + return pipeline.RunOutput{ + Manifest: artifacts.RunManifest{RunID: "run-123", PipelineID: "sample", ValidationStatus: "rejected"}, + NormalizeOutputs: []contracts.SerializedOutput{{}, {}}, + Rejected: []contracts.RejectedOutput{{}}, + Warnings: []contracts.Warning{{}}, + OutputFiles: []contracts.OutputFile{{Name: "index.json"}}, + } +} + +func testRunOutputWithout(change func(*pipeline.RunOutput)) pipeline.RunOutput { + output := testRunOutput() + change(&output) + return output +} + +func mustWorkingDirectory(t *testing.T) string { + t.Helper() + workingDirectory, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + return workingDirectory +} + +type partialResultWriter struct { + writer io.Writer + limit int +} + +func (w partialResultWriter) Write(content []byte) (int, error) { + if len(content) > w.limit { + content = content[:w.limit] + } + return w.writer.Write(content) +} + +type failingResultWriter struct{ err error } + +func (w failingResultWriter) Write([]byte) (int, error) { return 0, w.err } + +type zeroResultWriter struct{} + +func (zeroResultWriter) Write([]byte) (int, error) { return 0, nil }