diff --git a/internal/core/artifacts/artifacts.go b/internal/core/artifacts/artifacts.go new file mode 100644 index 0000000..02e115a --- /dev/null +++ b/internal/core/artifacts/artifacts.go @@ -0,0 +1,68 @@ +package artifacts + +import ( + "encoding/json" + "time" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" +) + +type Candidate struct { + Index int `json:"index"` + ExtractorKey string `json:"extractor_key"` + ArtifactType string `json:"artifact_type"` + SchemaVersion string `json:"schema_version"` + Payload json.RawMessage `json:"payload"` + SourceRefs []source.SourceRef `json:"source_refs,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type Artifact struct { + ExtractorKey string `json:"extractor_key"` + ArtifactType string `json:"artifact_type"` + SchemaVersion string `json:"schema_version"` + Payload json.RawMessage `json:"payload"` + SourceRefs []source.SourceRef `json:"source_refs,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type RejectedArtifact struct { + Candidate Candidate `json:"candidate"` + ValidatorName string `json:"validator_name"` + ReasonCode string `json:"reason_code"` + Message string `json:"message"` +} + +type RunManifest struct { + RunID string `json:"run_id,omitempty"` + InputAdapter string `json:"input_adapter,omitempty"` + SourceDigests []string `json:"source_digests,omitempty"` + Extractors []string `json:"extractors,omitempty"` + SchemaVersion string `json:"schema_version,omitempty"` + ValidationStatus string `json:"validation_status,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +func ArtifactFromCandidate(candidate Candidate) Artifact { + return Artifact{ + ExtractorKey: candidate.ExtractorKey, + ArtifactType: candidate.ArtifactType, + SchemaVersion: candidate.SchemaVersion, + Payload: append(json.RawMessage(nil), candidate.Payload...), + SourceRefs: append([]source.SourceRef(nil), candidate.SourceRefs...), + Metadata: copyMetadata(candidate.Metadata), + } +} + +func copyMetadata(metadata map[string]any) map[string]any { + if len(metadata) == 0 { + return nil + } + + copied := make(map[string]any, len(metadata)) + for key, value := range metadata { + copied[key] = value + } + return copied +} diff --git a/internal/core/artifacts/artifacts_test.go b/internal/core/artifacts/artifacts_test.go new file mode 100644 index 0000000..e3361c4 --- /dev/null +++ b/internal/core/artifacts/artifacts_test.go @@ -0,0 +1,134 @@ +package artifacts + +import ( + "encoding/json" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" +) + +func TestArtifactFromCandidatePreservesCandidateFields(t *testing.T) { + candidate := Candidate{ + Index: 7, + ExtractorKey: "generic-extractor", + ArtifactType: "generic-artifact", + SchemaVersion: "v1", + Payload: json.RawMessage(`{"name":"example"}`), + SourceRefs: []source.SourceRef{ + {SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u2"}, + }, + Metadata: map[string]any{ + "confidence": 0.75, + }, + } + + artifact := ArtifactFromCandidate(candidate) + + if artifact.ExtractorKey != candidate.ExtractorKey { + t.Fatalf("ExtractorKey = %q, want %q", artifact.ExtractorKey, candidate.ExtractorKey) + } + if artifact.ArtifactType != candidate.ArtifactType { + t.Fatalf("ArtifactType = %q, want %q", artifact.ArtifactType, candidate.ArtifactType) + } + if artifact.SchemaVersion != candidate.SchemaVersion { + t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, candidate.SchemaVersion) + } + if string(artifact.Payload) != string(candidate.Payload) { + t.Fatalf("Payload = %s, want %s", artifact.Payload, candidate.Payload) + } + if !reflect.DeepEqual(artifact.SourceRefs, candidate.SourceRefs) { + t.Fatalf("SourceRefs = %#v, want %#v", artifact.SourceRefs, candidate.SourceRefs) + } + if !reflect.DeepEqual(artifact.Metadata, candidate.Metadata) { + t.Fatalf("Metadata = %#v, want %#v", artifact.Metadata, candidate.Metadata) + } + + candidate.Payload[0] = '[' + candidate.SourceRefs[0].StartUnitID = "changed" + candidate.Metadata["confidence"] = 0.5 + + if string(artifact.Payload) != `{"name":"example"}` { + t.Fatalf("Payload changed after candidate mutation: %s", artifact.Payload) + } + if artifact.SourceRefs[0].StartUnitID != "u1" { + t.Fatalf("SourceRefs changed after candidate mutation: %#v", artifact.SourceRefs) + } + if artifact.Metadata["confidence"] != 0.75 { + t.Fatalf("Metadata changed after candidate mutation: %#v", artifact.Metadata) + } +} + +func TestJSONMarshalUsesExpectedFieldNames(t *testing.T) { + candidate := Candidate{ + Index: 1, + ExtractorKey: "generic-extractor", + ArtifactType: "generic-artifact", + SchemaVersion: "v1", + Payload: json.RawMessage(`{"value":true}`), + SourceRefs: []source.SourceRef{ + {SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u1"}, + }, + Metadata: map[string]any{ + "reviewed": true, + }, + } + rejected := RejectedArtifact{ + Candidate: candidate, + ValidatorName: "generic-validator", + ReasonCode: "invalid", + Message: "candidate was not accepted", + } + + gotJSON, err := json.Marshal(rejected) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var got map[string]any + if err := json.Unmarshal(gotJSON, &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + assertHasKeys(t, got, "candidate", "validator_name", "reason_code", "message") + + gotCandidate, ok := got["candidate"].(map[string]any) + if !ok { + t.Fatalf("candidate = %#v, want object", got["candidate"]) + } + assertHasKeys(t, gotCandidate, "index", "extractor_key", "artifact_type", "schema_version", "payload", "source_refs", "metadata") + + gotRefs, ok := gotCandidate["source_refs"].([]any) + if !ok { + t.Fatalf("source_refs = %#v, want array", gotCandidate["source_refs"]) + } + if len(gotRefs) != 1 { + t.Fatalf("len(source_refs) = %d, want 1", len(gotRefs)) + } + gotRef, ok := gotRefs[0].(map[string]any) + if !ok { + t.Fatalf("source_refs[0] = %#v, want object", gotRefs[0]) + } + assertHasKeys(t, gotRef, "source_id", "start_unit_id", "end_unit_id") +} + +func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) { + gotJSON, err := json.Marshal(RunManifest{}) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + if string(gotJSON) != "{}" { + t.Fatalf("json.Marshal(RunManifest{}) = %s, want {}", gotJSON) + } +} + +func assertHasKeys(t *testing.T, values map[string]any, keys ...string) { + t.Helper() + + for _, key := range keys { + if _, ok := values[key]; !ok { + t.Fatalf("missing key %q in %#v", key, values) + } + } +}