diff --git a/internal/framework/extractorregistry/registry.go b/internal/framework/extractorregistry/registry.go new file mode 100644 index 0000000..6e5bdfd --- /dev/null +++ b/internal/framework/extractorregistry/registry.go @@ -0,0 +1,83 @@ +package extractorregistry + +import ( + "fmt" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type Constructor func() (contracts.Extractor, error) + +type Registry struct { + constructors map[string]Constructor +} + +func New() *Registry { + return &Registry{ + constructors: make(map[string]Constructor), + } +} + +func (r *Registry) Register(key string, constructor Constructor) error { + if r == nil { + return fmt.Errorf("extractor registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return fmt.Errorf("extractor key must not be empty") + } + if constructor == nil { + return fmt.Errorf("extractor constructor for %q must not be nil", normalizedKey) + } + if _, ok := r.constructors[normalizedKey]; ok { + return fmt.Errorf("extractor %q is already registered", normalizedKey) + } + + r.constructors[normalizedKey] = constructor + return nil +} + +func (r *Registry) Build(key string) (contracts.Extractor, error) { + if r == nil { + return nil, fmt.Errorf("extractor registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return nil, fmt.Errorf("extractor key must not be empty") + } + + constructor, ok := r.constructors[normalizedKey] + if !ok { + return nil, fmt.Errorf("extractor %q is not registered", normalizedKey) + } + + extractor, err := constructor() + if err != nil { + return nil, fmt.Errorf("build extractor %q: %w", normalizedKey, err) + } + if extractor == nil { + return nil, fmt.Errorf("extractor %q constructor returned nil", normalizedKey) + } + if extractor.Key() != normalizedKey { + return nil, fmt.Errorf("extractor %q returned key %q", normalizedKey, extractor.Key()) + } + + return extractor, nil +} + +func (r *Registry) RegisteredKeys() []string { + if r == nil { + return nil + } + + keys := make([]string, 0, len(r.constructors)) + for key := range r.constructors { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/framework/extractorregistry/registry_test.go b/internal/framework/extractorregistry/registry_test.go new file mode 100644 index 0000000..ad1127e --- /dev/null +++ b/internal/framework/extractorregistry/registry_test.go @@ -0,0 +1,232 @@ +package extractorregistry + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestRegisterAndBuild(t *testing.T) { + registry := New() + + if err := registry.Register("generic-extractor", fakeConstructor("generic-extractor")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + extractor, err := registry.Build("generic-extractor") + if err != nil { + t.Fatalf("Build() error = %v, want nil", err) + } + if extractor.Key() != "generic-extractor" { + t.Fatalf("extractor.Key() = %q, want generic-extractor", extractor.Key()) + } +} + +func TestRegisterAndBuildTrimKeys(t *testing.T) { + registry := New() + + if err := registry.Register(" generic-extractor ", fakeConstructor("generic-extractor")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + extractor, err := registry.Build("\tgeneric-extractor\n") + if err != nil { + t.Fatalf("Build() error = %v, want nil", err) + } + if extractor.Key() != "generic-extractor" { + t.Fatalf("extractor.Key() = %q, want generic-extractor", extractor.Key()) + } +} + +func TestRegisterRejectsEmptyKey(t *testing.T) { + registry := New() + + err := registry.Register(" \t", fakeConstructor("generic-extractor")) + + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "key must not be empty") { + t.Fatalf("Register() error = %q, want empty key error", err.Error()) + } +} + +func TestRegisterRejectsDuplicateKey(t *testing.T) { + registry := New() + if err := registry.Register("generic-extractor", fakeConstructor("generic-extractor")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + err := registry.Register(" generic-extractor ", fakeConstructor("generic-extractor")) + + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "already registered") { + t.Fatalf("Register() error = %q, want duplicate key error", err.Error()) + } +} + +func TestRegisterRejectsNilConstructor(t *testing.T) { + registry := New() + + err := registry.Register("generic-extractor", nil) + + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "constructor") { + t.Fatalf("Register() error = %q, want constructor error", err.Error()) + } +} + +func TestBuildRejectsUnknownKey(t *testing.T) { + registry := New() + + _, err := registry.Build("missing-extractor") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "not registered") { + t.Fatalf("Build() error = %q, want unknown key error", err.Error()) + } +} + +func TestBuildWrapsConstructorError(t *testing.T) { + registry := New() + constructorErr := errors.New("constructor failed") + if err := registry.Register("generic-extractor", func() (contracts.Extractor, error) { + return nil, constructorErr + }); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := registry.Build("generic-extractor") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !errors.Is(err, constructorErr) { + t.Fatalf("Build() error = %v, want wrapped constructor error", err) + } + if !strings.Contains(err.Error(), "generic-extractor") { + t.Fatalf("Build() error = %q, want key context", err.Error()) + } +} + +func TestBuildRejectsNilExtractor(t *testing.T) { + registry := New() + if err := registry.Register("generic-extractor", func() (contracts.Extractor, error) { + return nil, nil + }); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := registry.Build("generic-extractor") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "returned nil") { + t.Fatalf("Build() error = %q, want nil extractor error", err.Error()) + } +} + +func TestBuildRejectsExtractorKeyMismatch(t *testing.T) { + registry := New() + if err := registry.Register("generic-extractor", fakeConstructor("other-extractor")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := registry.Build("generic-extractor") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "returned key") { + t.Fatalf("Build() error = %q, want key mismatch error", err.Error()) + } +} + +func TestRegisteredKeysReturnsSortedCopy(t *testing.T) { + registry := New() + for _, key := range []string{"zeta", "alpha", "middle"} { + if err := registry.Register(key, fakeConstructor(key)); err != nil { + t.Fatalf("Register(%q) error = %v, want nil", key, err) + } + } + + keys := registry.RegisteredKeys() + + want := []string{"alpha", "middle", "zeta"} + if !reflect.DeepEqual(keys, want) { + t.Fatalf("RegisteredKeys() = %#v, want %#v", keys, want) + } + + keys[0] = "changed" + if got := registry.RegisteredKeys(); !reflect.DeepEqual(got, want) { + t.Fatalf("RegisteredKeys() after caller mutation = %#v, want %#v", got, want) + } +} + +func TestNilRegistryBehavior(t *testing.T) { + var registry *Registry + + if err := registry.Register("generic-extractor", fakeConstructor("generic-extractor")); err == nil { + t.Fatal("Register() error = nil, want error") + } + if _, err := registry.Build("generic-extractor"); err == nil { + t.Fatal("Build() error = nil, want error") + } + if keys := registry.RegisteredKeys(); keys != nil { + t.Fatalf("RegisteredKeys() = %#v, want nil", keys) + } +} + +func TestBuildRejectsEmptyKey(t *testing.T) { + registry := New() + + _, err := registry.Build(" \n") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "key must not be empty") { + t.Fatalf("Build() error = %q, want empty key error", err.Error()) + } +} + +type fakeExtractor struct { + key string +} + +func fakeConstructor(key string) Constructor { + return func() (contracts.Extractor, error) { + return fakeExtractor{key: key}, nil + } +} + +func (extractor fakeExtractor) Key() string { + return extractor.key +} + +func (extractor fakeExtractor) ArtifactType() string { + return "generic-artifact" +} + +func (extractor fakeExtractor) SchemaVersion() string { + return "v1" +} + +func (extractor fakeExtractor) Validators() []contracts.Validator { + return nil +} + +func (extractor fakeExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { + return contracts.ExtractionResult{}, nil +} diff --git a/internal/framework/inputregistry/registry.go b/internal/framework/inputregistry/registry.go new file mode 100644 index 0000000..48aefab --- /dev/null +++ b/internal/framework/inputregistry/registry.go @@ -0,0 +1,83 @@ +package inputregistry + +import ( + "fmt" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type Constructor func() (contracts.InputAdapter, error) + +type Registry struct { + constructors map[string]Constructor +} + +func New() *Registry { + return &Registry{ + constructors: make(map[string]Constructor), + } +} + +func (r *Registry) Register(key string, constructor Constructor) error { + if r == nil { + return fmt.Errorf("input adapter registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return fmt.Errorf("input adapter key must not be empty") + } + if constructor == nil { + return fmt.Errorf("input adapter constructor for %q must not be nil", normalizedKey) + } + if _, ok := r.constructors[normalizedKey]; ok { + return fmt.Errorf("input adapter %q is already registered", normalizedKey) + } + + r.constructors[normalizedKey] = constructor + return nil +} + +func (r *Registry) Build(key string) (contracts.InputAdapter, error) { + if r == nil { + return nil, fmt.Errorf("input adapter registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return nil, fmt.Errorf("input adapter key must not be empty") + } + + constructor, ok := r.constructors[normalizedKey] + if !ok { + return nil, fmt.Errorf("input adapter %q is not registered", normalizedKey) + } + + adapter, err := constructor() + if err != nil { + return nil, fmt.Errorf("build input adapter %q: %w", normalizedKey, err) + } + if adapter == nil { + return nil, fmt.Errorf("input adapter %q constructor returned nil", normalizedKey) + } + if adapter.Key() != normalizedKey { + return nil, fmt.Errorf("input adapter %q returned key %q", normalizedKey, adapter.Key()) + } + + return adapter, nil +} + +func (r *Registry) RegisteredKeys() []string { + if r == nil { + return nil + } + + keys := make([]string, 0, len(r.constructors)) + for key := range r.constructors { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/framework/inputregistry/registry_test.go b/internal/framework/inputregistry/registry_test.go new file mode 100644 index 0000000..bea0462 --- /dev/null +++ b/internal/framework/inputregistry/registry_test.go @@ -0,0 +1,229 @@ +package inputregistry + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestRegisterAndBuild(t *testing.T) { + registry := New() + + if err := registry.Register("generic-input", fakeConstructor("generic-input")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + adapter, err := registry.Build("generic-input") + if err != nil { + t.Fatalf("Build() error = %v, want nil", err) + } + if adapter.Key() != "generic-input" { + t.Fatalf("adapter.Key() = %q, want generic-input", adapter.Key()) + } +} + +func TestRegisterAndBuildTrimKeys(t *testing.T) { + registry := New() + + if err := registry.Register(" generic-input ", fakeConstructor("generic-input")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + adapter, err := registry.Build("\tgeneric-input\n") + if err != nil { + t.Fatalf("Build() error = %v, want nil", err) + } + if adapter.Key() != "generic-input" { + t.Fatalf("adapter.Key() = %q, want generic-input", adapter.Key()) + } +} + +func TestRegisterRejectsEmptyKey(t *testing.T) { + registry := New() + + err := registry.Register(" \t", fakeConstructor("generic-input")) + + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "key must not be empty") { + t.Fatalf("Register() error = %q, want empty key error", err.Error()) + } +} + +func TestRegisterRejectsDuplicateKey(t *testing.T) { + registry := New() + if err := registry.Register("generic-input", fakeConstructor("generic-input")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + err := registry.Register(" generic-input ", fakeConstructor("generic-input")) + + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "already registered") { + t.Fatalf("Register() error = %q, want duplicate key error", err.Error()) + } +} + +func TestRegisterRejectsNilConstructor(t *testing.T) { + registry := New() + + err := registry.Register("generic-input", nil) + + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "constructor") { + t.Fatalf("Register() error = %q, want constructor error", err.Error()) + } +} + +func TestBuildRejectsUnknownKey(t *testing.T) { + registry := New() + + _, err := registry.Build("missing-input") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "not registered") { + t.Fatalf("Build() error = %q, want unknown key error", err.Error()) + } +} + +func TestBuildWrapsConstructorError(t *testing.T) { + registry := New() + constructorErr := errors.New("constructor failed") + if err := registry.Register("generic-input", func() (contracts.InputAdapter, error) { + return nil, constructorErr + }); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := registry.Build("generic-input") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !errors.Is(err, constructorErr) { + t.Fatalf("Build() error = %v, want wrapped constructor error", err) + } + if !strings.Contains(err.Error(), "generic-input") { + t.Fatalf("Build() error = %q, want key context", err.Error()) + } +} + +func TestBuildRejectsNilAdapter(t *testing.T) { + registry := New() + if err := registry.Register("generic-input", func() (contracts.InputAdapter, error) { + return nil, nil + }); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := registry.Build("generic-input") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "returned nil") { + t.Fatalf("Build() error = %q, want nil adapter error", err.Error()) + } +} + +func TestBuildRejectsAdapterKeyMismatch(t *testing.T) { + registry := New() + if err := registry.Register("generic-input", fakeConstructor("other-input")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := registry.Build("generic-input") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "returned key") { + t.Fatalf("Build() error = %q, want key mismatch error", err.Error()) + } +} + +func TestRegisteredKeysReturnsSortedCopy(t *testing.T) { + registry := New() + for _, key := range []string{"zeta", "alpha", "middle"} { + if err := registry.Register(key, fakeConstructor(key)); err != nil { + t.Fatalf("Register(%q) error = %v, want nil", key, err) + } + } + + keys := registry.RegisteredKeys() + + want := []string{"alpha", "middle", "zeta"} + if !reflect.DeepEqual(keys, want) { + t.Fatalf("RegisteredKeys() = %#v, want %#v", keys, want) + } + + keys[0] = "changed" + if got := registry.RegisteredKeys(); !reflect.DeepEqual(got, want) { + t.Fatalf("RegisteredKeys() after caller mutation = %#v, want %#v", got, want) + } +} + +func TestNilRegistryBehavior(t *testing.T) { + var registry *Registry + + if err := registry.Register("generic-input", fakeConstructor("generic-input")); err == nil { + t.Fatal("Register() error = nil, want error") + } + if _, err := registry.Build("generic-input"); err == nil { + t.Fatal("Build() error = nil, want error") + } + if keys := registry.RegisteredKeys(); keys != nil { + t.Fatalf("RegisteredKeys() = %#v, want nil", keys) + } +} + +func TestBuildRejectsEmptyKey(t *testing.T) { + registry := New() + + _, err := registry.Build(" \n") + + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "key must not be empty") { + t.Fatalf("Build() error = %q, want empty key error", err.Error()) + } +} + +type fakeAdapter struct { + key string +} + +func fakeConstructor(key string) Constructor { + return func() (contracts.InputAdapter, error) { + return fakeAdapter{key: key}, nil + } +} + +func (adapter fakeAdapter) Key() string { + return adapter.key +} + +func (adapter fakeAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { + return &source.SourceDocument{ + ID: req.SourceID, + Kind: "document", + Format: "text/plain", + Digest: "sha256:abc123", + Units: []source.SourceUnit{ + {ID: "u1", Kind: "unit", Text: "Source unit."}, + }, + }, nil +} diff --git a/internal/framework/runner/registry_integration_test.go b/internal/framework/runner/registry_integration_test.go new file mode 100644 index 0000000..a17db12 --- /dev/null +++ b/internal/framework/runner/registry_integration_test.go @@ -0,0 +1,141 @@ +package runner + +import ( + "context" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/extractorregistry" + validationhelpers "gitea.maximumdirect.net/eric/notarius/internal/framework/validators" +) + +func TestRunnerUsesExtractorRegistry(t *testing.T) { + var builtKeys []string + var executedKeys []string + registry := extractorregistry.New() + + registerIntegrationExtractor(t, registry, "second", &builtKeys, &executedKeys, []contracts.Validator{ + integrationValidator{name: "reject-second", approve: false}, + }) + registerIntegrationExtractor(t, registry, "first", &builtKeys, &executedKeys, []contracts.Validator{ + integrationValidator{name: "approve-first", approve: true}, + }) + + output, err := New(registry).Run(context.Background(), RunInput{ + Source: integrationSourceDocument(), + ExtractorKeys: []string{"second", "first"}, + }) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + if !reflect.DeepEqual(builtKeys, []string{"second", "first"}) { + t.Fatalf("built keys = %#v, want configured order", builtKeys) + } + if !reflect.DeepEqual(executedKeys, []string{"second", "first"}) { + t.Fatalf("executed keys = %#v, want configured order", executedKeys) + } + if got := artifactKeys(output.Approved); !reflect.DeepEqual(got, []string{"first"}) { + t.Fatalf("approved keys = %#v, want [first]", got) + } + if got := rejectedKeys(output.Rejected); !reflect.DeepEqual(got, []string{"second"}) { + t.Fatalf("rejected keys = %#v, want [second]", got) + } +} + +func registerIntegrationExtractor(t *testing.T, registry *extractorregistry.Registry, key string, builtKeys *[]string, executedKeys *[]string, validators []contracts.Validator) { + t.Helper() + + if err := registry.Register(key, func() (contracts.Extractor, error) { + *builtKeys = append(*builtKeys, key) + return integrationExtractor{key: key, executedKeys: executedKeys, validators: validators}, nil + }); err != nil { + t.Fatalf("Register(%q) error = %v, want nil", key, err) + } +} + +type integrationExtractor struct { + key string + executedKeys *[]string + validators []contracts.Validator +} + +func (extractor integrationExtractor) Key() string { + return extractor.key +} + +func (extractor integrationExtractor) ArtifactType() string { + return "generic-artifact" +} + +func (extractor integrationExtractor) SchemaVersion() string { + return "v1" +} + +func (extractor integrationExtractor) Validators() []contracts.Validator { + return extractor.validators +} + +func (extractor integrationExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { + *extractor.executedKeys = append(*extractor.executedKeys, extractor.key) + return contracts.ExtractionResult{ + Candidates: []artifacts.Candidate{ + {Payload: []byte(`{"value":true}`)}, + }, + }, nil +} + +type integrationValidator struct { + name string + approve bool +} + +func (validator integrationValidator) Name() string { + return validator.name +} + +func (validator integrationValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + decisions := make([]contracts.ValidationDecision, 0, len(req.Candidates)) + for _, candidate := range req.Candidates { + if validator.approve { + decisions = append(decisions, validationhelpers.Approved(candidate.Index)) + } else { + decisions = append(decisions, validationhelpers.Rejected(candidate.Index, "invalid", "not accepted")) + } + } + return contracts.ValidationResult{ + ValidatorName: validator.name, + Decisions: decisions, + }, nil +} + +func integrationSourceDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "source-1", + Kind: "document", + Format: "text/plain", + Digest: "sha256:abc123", + Units: []source.SourceUnit{ + {ID: "u1", Kind: "unit", Text: "Source unit."}, + }, + } +} + +func artifactKeys(approved []artifacts.Artifact) []string { + keys := make([]string, 0, len(approved)) + for _, artifact := range approved { + keys = append(keys, artifact.ExtractorKey) + } + return keys +} + +func rejectedKeys(rejected []artifacts.RejectedArtifact) []string { + keys := make([]string, 0, len(rejected)) + for _, artifact := range rejected { + keys = append(keys, artifact.Candidate.ExtractorKey) + } + return keys +} diff --git a/internal/framework/runner/runner.go b/internal/framework/runner/runner.go new file mode 100644 index 0000000..3f1412a --- /dev/null +++ b/internal/framework/runner/runner.go @@ -0,0 +1,164 @@ +package runner + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/validators" +) + +type ExtractorFactory interface { + Build(key string) (contracts.Extractor, error) +} + +type Runner struct { + extractors ExtractorFactory +} + +func New(extractors ExtractorFactory) *Runner { + return &Runner{extractors: extractors} +} + +type RunInput struct { + Source *source.SourceDocument + ExtractorKeys []string + LLMClient contracts.StructuredLLMClient + Metadata map[string]any +} + +type RunOutput struct { + Approved []artifacts.Artifact `json:"approved,omitempty"` + Rejected []artifacts.RejectedArtifact `json:"rejected,omitempty"` + Warnings []contracts.Warning `json:"warnings,omitempty"` +} + +func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) { + var output RunOutput + if r == nil { + return output, fmt.Errorf("runner must not be nil") + } + if r.extractors == nil { + return output, fmt.Errorf("runner extractor factory must not be nil") + } + if err := source.ValidateDocument(input.Source); err != nil { + return output, fmt.Errorf("validate source document: %w", err) + } + if len(input.ExtractorKeys) == 0 { + return output, fmt.Errorf("extractor keys must not be empty") + } + + nextCandidateIndex := 0 + for _, extractorKey := range input.ExtractorKeys { + extractor, err := r.extractors.Build(extractorKey) + if err != nil { + return output, fmt.Errorf("build extractor %q: %w", extractorKey, err) + } + + result, err := extractor.Extract(ctx, contracts.ExtractionRequest{ + Source: input.Source, + LLMClient: input.LLMClient, + Metadata: input.Metadata, + }) + output.Warnings = append(output.Warnings, result.Warnings...) + if err != nil { + return output, fmt.Errorf("extract with extractor %q: %w", extractor.Key(), err) + } + + candidates, err := normalizeCandidates(extractor, result.Candidates, &nextCandidateIndex) + if err != nil { + return output, err + } + + approved, rejected, warnings, err := runValidators(ctx, extractor, input.Source, candidates, input.Metadata) + output.Warnings = append(output.Warnings, warnings...) + output.Rejected = append(output.Rejected, rejected...) + if err != nil { + return output, err + } + + for _, candidate := range approved { + output.Approved = append(output.Approved, artifacts.ArtifactFromCandidate(candidate)) + } + } + + return output, nil +} + +func normalizeCandidates(extractor contracts.Extractor, candidates []artifacts.Candidate, nextIndex *int) ([]artifacts.Candidate, error) { + normalized := make([]artifacts.Candidate, 0, len(candidates)) + for _, candidate := range candidates { + candidate.Index = *nextIndex + *nextIndex = *nextIndex + 1 + + if candidate.ExtractorKey == "" { + candidate.ExtractorKey = extractor.Key() + } else if candidate.ExtractorKey != extractor.Key() { + return nil, fmt.Errorf("candidate extractor_key %q does not match extractor %q", candidate.ExtractorKey, extractor.Key()) + } + + if candidate.ArtifactType == "" { + candidate.ArtifactType = extractor.ArtifactType() + } else if candidate.ArtifactType != extractor.ArtifactType() { + return nil, fmt.Errorf("candidate artifact_type %q does not match extractor %q artifact type %q", candidate.ArtifactType, extractor.Key(), extractor.ArtifactType()) + } + + if candidate.SchemaVersion == "" { + candidate.SchemaVersion = extractor.SchemaVersion() + } else if candidate.SchemaVersion != extractor.SchemaVersion() { + return nil, fmt.Errorf("candidate schema_version %q does not match extractor %q schema version %q", candidate.SchemaVersion, extractor.Key(), extractor.SchemaVersion()) + } + + normalized = append(normalized, candidate) + } + return normalized, nil +} + +func runValidators(ctx context.Context, extractor contracts.Extractor, doc *source.SourceDocument, candidates []artifacts.Candidate, metadata map[string]any) ([]artifacts.Candidate, []artifacts.RejectedArtifact, []contracts.Warning, error) { + eligible := candidates + var rejected []artifacts.RejectedArtifact + var warnings []contracts.Warning + + for _, validator := range extractor.Validators() { + result, err := validator.Validate(ctx, contracts.ValidationRequest{ + Source: doc, + Candidates: eligible, + Metadata: metadata, + }) + warnings = append(warnings, result.Warnings...) + if err != nil { + return nil, rejected, warnings, fmt.Errorf("validate extractor %q with validator %q: %w", extractor.Key(), validator.Name(), err) + } + if result.ValidatorName != validator.Name() { + return nil, rejected, warnings, fmt.Errorf("validator %q returned result for %q", validator.Name(), result.ValidatorName) + } + if err := validators.EnforceDecisionCardinality(eligible, result.Decisions); err != nil { + return nil, rejected, warnings, fmt.Errorf("validate extractor %q with validator %q: %w", extractor.Key(), validator.Name(), err) + } + + decisions := make(map[int]contracts.ValidationDecision, len(result.Decisions)) + for _, decision := range result.Decisions { + decisions[decision.CandidateIndex] = decision + } + + nextEligible := make([]artifacts.Candidate, 0, len(eligible)) + for _, candidate := range eligible { + decision := decisions[candidate.Index] + if decision.Approved { + nextEligible = append(nextEligible, candidate) + continue + } + rejected = append(rejected, artifacts.RejectedArtifact{ + Candidate: candidate, + ValidatorName: result.ValidatorName, + ReasonCode: decision.ReasonCode, + Message: decision.Message, + }) + } + eligible = nextEligible + } + + return eligible, rejected, warnings, nil +} diff --git a/internal/framework/runner/runner_test.go b/internal/framework/runner/runner_test.go new file mode 100644 index 0000000..4ea009f --- /dev/null +++ b/internal/framework/runner/runner_test.go @@ -0,0 +1,444 @@ +package runner + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + validationhelpers "gitea.maximumdirect.net/eric/notarius/internal/framework/validators" +) + +func TestNewAndDataTypes(t *testing.T) { + r := New(fakeFactory{}) + if r == nil { + t.Fatal("New() = nil, want runner") + } + + input := RunInput{ + Source: validSourceDocument(), + ExtractorKeys: []string{"generic-extractor"}, + Metadata: map[string]any{"request": "test"}, + } + output := RunOutput{ + Approved: []artifacts.Artifact{{ExtractorKey: "generic-extractor"}}, + Rejected: []artifacts.RejectedArtifact{{ValidatorName: "generic-validator"}}, + Warnings: []contracts.Warning{{ReasonCode: "note", Message: "message"}}, + } + + if input.Source.ID != "source-1" { + t.Fatalf("RunInput.Source.ID = %q, want source-1", input.Source.ID) + } + if len(output.Approved) != 1 || len(output.Rejected) != 1 || len(output.Warnings) != 1 { + t.Fatalf("RunOutput = %#v, want constructed fields", output) + } +} + +func TestRunRejectsInvalidSetup(t *testing.T) { + tests := []struct { + name string + run func() (RunOutput, error) + error string + }{ + { + name: "nil runner", + run: func() (RunOutput, error) { return (*Runner)(nil).Run(context.Background(), RunInput{}) }, + error: "runner must not be nil", + }, + { + name: "nil factory", + run: func() (RunOutput, error) { return New(nil).Run(context.Background(), RunInput{}) }, + error: "factory", + }, + { + name: "invalid source", + run: func() (RunOutput, error) { + return New(fakeFactory{}).Run(context.Background(), RunInput{Source: &source.SourceDocument{}, ExtractorKeys: []string{"generic-extractor"}}) + }, + error: "validate source document", + }, + { + name: "empty extractors", + run: func() (RunOutput, error) { + return New(fakeFactory{}).Run(context.Background(), RunInput{Source: validSourceDocument()}) + }, + error: "extractor keys must not be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.run() + if err == nil { + t.Fatal("Run() error = nil, want error") + } + if !strings.Contains(err.Error(), tt.error) { + t.Fatalf("Run() error = %q, want substring %q", err.Error(), tt.error) + } + }) + } +} + +func TestRunUsesConfiguredExtractorOrderAndAssignsGlobalIndices(t *testing.T) { + var order []string + var seenIndices []int + recordIndices := func(candidates []artifacts.Candidate) []contracts.ValidationDecision { + decisions := make([]contracts.ValidationDecision, 0, len(candidates)) + for _, candidate := range candidates { + seenIndices = append(seenIndices, candidate.Index) + decisions = append(decisions, validationhelpers.Approved(candidate.Index)) + } + return decisions + } + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "second": fakeExtractor{key: "second", artifactType: "artifact", schemaVersion: "v1", candidateCount: 1, validators: []contracts.Validator{fakeValidator{name: "recorder-second", decisions: recordIndices}}, order: &order}, + "first": fakeExtractor{key: "first", artifactType: "artifact", schemaVersion: "v1", candidateCount: 2, validators: []contracts.Validator{fakeValidator{name: "recorder-first", decisions: recordIndices}}, order: &order}, + }} + + output, err := New(factory).Run(context.Background(), RunInput{ + Source: validSourceDocument(), + ExtractorKeys: []string{"second", "first"}, + }) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + if !reflect.DeepEqual(order, []string{"second", "first"}) { + t.Fatalf("order = %#v, want configured order", order) + } + if !reflect.DeepEqual(seenIndices, []int{0, 1, 2}) { + t.Fatalf("seen indices = %#v, want [0 1 2]", seenIndices) + } + if len(output.Approved) != 3 { + t.Fatalf("len(Approved) = %d, want 3", len(output.Approved)) + } +} + +func TestRunFillsEmptyCandidateExtractorMetadata(t *testing.T) { + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "generic-extractor": fakeExtractor{key: "generic-extractor", artifactType: "generic-artifact", schemaVersion: "v1", candidates: []artifacts.Candidate{{Payload: []byte(`{"value":true}`)}}}, + }} + + output, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + artifact := output.Approved[0] + if artifact.ExtractorKey != "generic-extractor" || artifact.ArtifactType != "generic-artifact" || artifact.SchemaVersion != "v1" { + t.Fatalf("approved artifact metadata = %#v, want extractor metadata", artifact) + } +} + +func TestRunRejectsCandidateMetadataMismatches(t *testing.T) { + tests := []struct { + name string + candidate artifacts.Candidate + error string + }{ + {name: "extractor key", candidate: artifacts.Candidate{ExtractorKey: "other"}, error: "extractor_key"}, + {name: "artifact type", candidate: artifacts.Candidate{ArtifactType: "other"}, error: "artifact_type"}, + {name: "schema version", candidate: artifacts.Candidate{SchemaVersion: "other"}, error: "schema_version"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "generic-extractor": fakeExtractor{key: "generic-extractor", artifactType: "generic-artifact", schemaVersion: "v1", candidates: []artifacts.Candidate{tt.candidate}}, + }} + + _, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + + if err == nil { + t.Fatal("Run() error = nil, want error") + } + if !strings.Contains(err.Error(), tt.error) { + t.Fatalf("Run() error = %q, want substring %q", err.Error(), tt.error) + } + }) + } +} + +func TestRunApprovesCandidatesWithoutValidators(t *testing.T) { + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "generic-extractor": fakeExtractor{key: "generic-extractor", artifactType: "generic-artifact", schemaVersion: "v1", candidateCount: 2}, + }} + + output, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + if len(output.Approved) != 2 { + t.Fatalf("len(Approved) = %d, want 2", len(output.Approved)) + } + if len(output.Rejected) != 0 { + t.Fatalf("len(Rejected) = %d, want 0", len(output.Rejected)) + } +} + +func TestRunValidatorApprovalProducesApprovedArtifacts(t *testing.T) { + validator := fakeValidator{name: "generic-validator", decisions: func(candidates []artifacts.Candidate) []contracts.ValidationDecision { + return []contracts.ValidationDecision{validationhelpers.Approved(candidates[0].Index)} + }} + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "generic-extractor": fakeExtractor{key: "generic-extractor", artifactType: "generic-artifact", schemaVersion: "v1", candidateCount: 1, validators: []contracts.Validator{validator}}, + }} + + output, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + if len(output.Approved) != 1 { + t.Fatalf("len(Approved) = %d, want 1", len(output.Approved)) + } +} + +func TestRunValidatorRejectionRemovesCandidateFromLaterValidators(t *testing.T) { + var laterSeen int + rejectFirst := fakeValidator{name: "reject-first", decisions: func(candidates []artifacts.Candidate) []contracts.ValidationDecision { + return []contracts.ValidationDecision{ + validationhelpers.Rejected(candidates[0].Index, "invalid", "not accepted"), + validationhelpers.Approved(candidates[1].Index), + } + }} + approveRemaining := fakeValidator{name: "approve-remaining", decisions: func(candidates []artifacts.Candidate) []contracts.ValidationDecision { + laterSeen = len(candidates) + return []contracts.ValidationDecision{validationhelpers.Approved(candidates[0].Index)} + }} + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "generic-extractor": fakeExtractor{key: "generic-extractor", artifactType: "generic-artifact", schemaVersion: "v1", candidateCount: 2, validators: []contracts.Validator{rejectFirst, approveRemaining}}, + }} + + output, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + if laterSeen != 1 { + t.Fatalf("later validator saw %d candidates, want 1", laterSeen) + } + if len(output.Rejected) != 1 { + t.Fatalf("len(Rejected) = %d, want 1", len(output.Rejected)) + } + if output.Rejected[0].ValidatorName != "reject-first" || output.Rejected[0].ReasonCode != "invalid" { + t.Fatalf("Rejected[0] = %#v, want rejection details", output.Rejected[0]) + } + if len(output.Approved) != 1 { + t.Fatalf("len(Approved) = %d, want 1", len(output.Approved)) + } +} + +func TestRunSurfacesValidatorNameMismatch(t *testing.T) { + validator := fakeValidator{name: "generic-validator", resultName: "other-validator", decisions: approveAll} + factory := factoryWithValidator(validator) + + _, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + + assertRunError(t, err, "returned result") +} + +func TestRunSurfacesValidatorCardinalityError(t *testing.T) { + validator := fakeValidator{name: "generic-validator", decisions: func(candidates []artifacts.Candidate) []contracts.ValidationDecision { + return nil + }} + factory := factoryWithValidator(validator) + + _, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + + assertRunError(t, err, "0 decisions for 1 candidates") +} + +func TestRunCollectsExtractorAndValidatorWarnings(t *testing.T) { + validator := fakeValidator{ + name: "generic-validator", + decisions: approveAll, + warnings: []contracts.Warning{{ReasonCode: "validator-warning", Message: "validator warning"}}, + } + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "generic-extractor": fakeExtractor{ + key: "generic-extractor", + artifactType: "generic-artifact", + schemaVersion: "v1", + candidateCount: 1, + validators: []contracts.Validator{validator}, + warnings: []contracts.Warning{{ReasonCode: "extractor-warning", Message: "extractor warning"}}, + }, + }} + + output, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"generic-extractor"}}) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + if got := warningReasons(output.Warnings); !reflect.DeepEqual(got, []string{"extractor-warning", "validator-warning"}) { + t.Fatalf("warning reasons = %#v, want extractor and validator warnings", got) + } +} + +func TestRunReturnsPartialOutputWhenLaterExtractorFails(t *testing.T) { + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "ok": fakeExtractor{key: "ok", artifactType: "artifact", schemaVersion: "v1", candidateCount: 1}, + "fail": fakeExtractor{key: "fail", artifactType: "artifact", schemaVersion: "v1", err: errors.New("extract failed")}, + }} + + output, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"ok", "fail"}}) + + assertRunError(t, err, "extract with extractor") + if len(output.Approved) != 1 { + t.Fatalf("len(Approved) = %d, want partial approved output", len(output.Approved)) + } +} + +func TestRunReturnsPartialOutputWhenLaterValidatorFails(t *testing.T) { + validatorErr := errors.New("validator failed") + factory := fakeFactory{extractors: map[string]contracts.Extractor{ + "ok": fakeExtractor{key: "ok", artifactType: "artifact", schemaVersion: "v1", candidateCount: 1}, + "fail": fakeExtractor{key: "fail", artifactType: "artifact", schemaVersion: "v1", candidateCount: 1, validators: []contracts.Validator{fakeValidator{name: "failing-validator", err: validatorErr}}}, + }} + + output, err := New(factory).Run(context.Background(), RunInput{Source: validSourceDocument(), ExtractorKeys: []string{"ok", "fail"}}) + + assertRunError(t, err, "failing-validator") + if len(output.Approved) != 1 { + t.Fatalf("len(Approved) = %d, want partial approved output", len(output.Approved)) + } +} + +type fakeFactory struct { + extractors map[string]contracts.Extractor + err error +} + +func (factory fakeFactory) Build(key string) (contracts.Extractor, error) { + if factory.err != nil { + return nil, factory.err + } + extractor, ok := factory.extractors[key] + if !ok { + return nil, errors.New("missing extractor") + } + return extractor, nil +} + +type fakeExtractor struct { + key string + artifactType string + schemaVersion string + candidateCount int + candidates []artifacts.Candidate + validators []contracts.Validator + warnings []contracts.Warning + err error + order *[]string +} + +func (extractor fakeExtractor) Key() string { + return extractor.key +} + +func (extractor fakeExtractor) ArtifactType() string { + return extractor.artifactType +} + +func (extractor fakeExtractor) SchemaVersion() string { + return extractor.schemaVersion +} + +func (extractor fakeExtractor) Validators() []contracts.Validator { + return extractor.validators +} + +func (extractor fakeExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { + if extractor.order != nil { + *extractor.order = append(*extractor.order, extractor.key) + } + candidates := append([]artifacts.Candidate(nil), extractor.candidates...) + for len(candidates) < extractor.candidateCount { + candidates = append(candidates, artifacts.Candidate{Payload: []byte(`{"value":true}`)}) + } + return contracts.ExtractionResult{ + Candidates: candidates, + Warnings: extractor.warnings, + }, extractor.err +} + +type fakeValidator struct { + name string + resultName string + decisions func([]artifacts.Candidate) []contracts.ValidationDecision + warnings []contracts.Warning + err error +} + +func (validator fakeValidator) Name() string { + return validator.name +} + +func (validator fakeValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + resultName := validator.resultName + if resultName == "" { + resultName = validator.name + } + var decisions []contracts.ValidationDecision + if validator.decisions != nil { + decisions = validator.decisions(req.Candidates) + } + return contracts.ValidationResult{ + ValidatorName: resultName, + Decisions: decisions, + Warnings: validator.warnings, + }, validator.err +} + +func factoryWithValidator(validator contracts.Validator) fakeFactory { + return fakeFactory{extractors: map[string]contracts.Extractor{ + "generic-extractor": fakeExtractor{ + key: "generic-extractor", + artifactType: "generic-artifact", + schemaVersion: "v1", + candidateCount: 1, + validators: []contracts.Validator{validator}, + }, + }} +} + +func approveAll(candidates []artifacts.Candidate) []contracts.ValidationDecision { + decisions := make([]contracts.ValidationDecision, 0, len(candidates)) + for _, candidate := range candidates { + decisions = append(decisions, validationhelpers.Approved(candidate.Index)) + } + return decisions +} + +func validSourceDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "source-1", + Kind: "document", + Format: "text/plain", + Digest: "sha256:abc123", + Units: []source.SourceUnit{ + {ID: "u1", Kind: "unit", Text: "Source unit."}, + }, + } +} + +func warningReasons(warnings []contracts.Warning) []string { + reasons := make([]string, 0, len(warnings)) + for _, warning := range warnings { + reasons = append(reasons, warning.ReasonCode) + } + return reasons +} + +func assertRunError(t *testing.T, err error, want string) { + t.Helper() + + if err == nil { + t.Fatal("Run() error = nil, want error") + } + if !strings.Contains(err.Error(), want) { + t.Fatalf("Run() error = %q, want substring %q", err.Error(), want) + } +} diff --git a/internal/framework/validators/validators.go b/internal/framework/validators/validators.go new file mode 100644 index 0000000..5129129 --- /dev/null +++ b/internal/framework/validators/validators.go @@ -0,0 +1,64 @@ +package validators + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +const ( + ReasonApproved = "approved" +) + +func Approved(candidateIndex int) contracts.ValidationDecision { + return contracts.ValidationDecision{ + CandidateIndex: candidateIndex, + Approved: true, + ReasonCode: ReasonApproved, + Message: ReasonApproved, + } +} + +func Rejected(candidateIndex int, reasonCode string, message string) contracts.ValidationDecision { + return contracts.ValidationDecision{ + CandidateIndex: candidateIndex, + Approved: false, + ReasonCode: strings.TrimSpace(reasonCode), + Message: strings.TrimSpace(message), + } +} + +func EnforceDecisionCardinality(candidates []artifacts.Candidate, decisions []contracts.ValidationDecision) error { + if len(candidates) != len(decisions) { + return fmt.Errorf("validator returned %d decisions for %d candidates", len(decisions), len(candidates)) + } + + expected := make(map[int]struct{}, len(candidates)) + for _, candidate := range candidates { + if _, ok := expected[candidate.Index]; ok { + return fmt.Errorf("candidate index %d is duplicated", candidate.Index) + } + expected[candidate.Index] = struct{}{} + } + + seen := make(map[int]struct{}, len(decisions)) + for _, decision := range decisions { + if _, ok := expected[decision.CandidateIndex]; !ok { + return fmt.Errorf("validator returned decision for unknown candidate index %d", decision.CandidateIndex) + } + if _, ok := seen[decision.CandidateIndex]; ok { + return fmt.Errorf("validator returned duplicate decision for candidate index %d", decision.CandidateIndex) + } + seen[decision.CandidateIndex] = struct{}{} + } + + for candidateIndex := range expected { + if _, ok := seen[candidateIndex]; !ok { + return fmt.Errorf("validator did not return decision for candidate index %d", candidateIndex) + } + } + + return nil +} diff --git a/internal/framework/validators/validators_test.go b/internal/framework/validators/validators_test.go new file mode 100644 index 0000000..5cab651 --- /dev/null +++ b/internal/framework/validators/validators_test.go @@ -0,0 +1,105 @@ +package validators + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestApproved(t *testing.T) { + decision := Approved(7) + + if decision.CandidateIndex != 7 { + t.Fatalf("CandidateIndex = %d, want 7", decision.CandidateIndex) + } + if !decision.Approved { + t.Fatal("Approved = false, want true") + } + if decision.ReasonCode != ReasonApproved { + t.Fatalf("ReasonCode = %q, want %q", decision.ReasonCode, ReasonApproved) + } + if decision.Message != "approved" { + t.Fatalf("Message = %q, want approved", decision.Message) + } +} + +func TestRejectedTrimsReasonAndMessage(t *testing.T) { + decision := Rejected(3, " invalid ", "\tmessage\n") + + if decision.CandidateIndex != 3 { + t.Fatalf("CandidateIndex = %d, want 3", decision.CandidateIndex) + } + if decision.Approved { + t.Fatal("Approved = true, want false") + } + if decision.ReasonCode != "invalid" { + t.Fatalf("ReasonCode = %q, want invalid", decision.ReasonCode) + } + if decision.Message != "message" { + t.Fatalf("Message = %q, want message", decision.Message) + } +} + +func TestEnforceDecisionCardinalityAllowsNonZeroCandidateIndices(t *testing.T) { + candidates := []artifacts.Candidate{{Index: 4}, {Index: 8}} + decisions := []contracts.ValidationDecision{Approved(8), Approved(4)} + + if err := EnforceDecisionCardinality(candidates, decisions); err != nil { + t.Fatalf("EnforceDecisionCardinality() error = %v, want nil", err) + } +} + +func TestEnforceDecisionCardinalityAllowsEmptyInputs(t *testing.T) { + if err := EnforceDecisionCardinality(nil, nil); err != nil { + t.Fatalf("EnforceDecisionCardinality() error = %v, want nil", err) + } +} + +func TestEnforceDecisionCardinalityRejectsUnknownDecisionIndex(t *testing.T) { + err := EnforceDecisionCardinality( + []artifacts.Candidate{{Index: 1}}, + []contracts.ValidationDecision{Approved(2)}, + ) + + assertCardinalityError(t, err, "unknown candidate index 2") +} + +func TestEnforceDecisionCardinalityRejectsDuplicateDecisionIndex(t *testing.T) { + err := EnforceDecisionCardinality( + []artifacts.Candidate{{Index: 1}, {Index: 2}}, + []contracts.ValidationDecision{Approved(1), Approved(1)}, + ) + + assertCardinalityError(t, err, "duplicate decision") +} + +func TestEnforceDecisionCardinalityRejectsMissingDecisionIndex(t *testing.T) { + err := EnforceDecisionCardinality( + []artifacts.Candidate{{Index: 1}, {Index: 2}}, + []contracts.ValidationDecision{Approved(1)}, + ) + + assertCardinalityError(t, err, "1 decisions for 2 candidates") +} + +func TestEnforceDecisionCardinalityRejectsDuplicateCandidateIndex(t *testing.T) { + err := EnforceDecisionCardinality( + []artifacts.Candidate{{Index: 1}, {Index: 1}}, + []contracts.ValidationDecision{Approved(1), Approved(1)}, + ) + + assertCardinalityError(t, err, "candidate index 1 is duplicated") +} + +func assertCardinalityError(t *testing.T, err error, want string) { + t.Helper() + + if err == nil { + t.Fatal("EnforceDecisionCardinality() error = nil, want error") + } + if !strings.Contains(err.Error(), want) { + t.Fatalf("EnforceDecisionCardinality() error = %q, want substring %q", err.Error(), want) + } +}