From 9431719db6262846dc225df5fc5c3aadc64431c2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 3 Jul 2026 06:03:57 +0000 Subject: [PATCH] Add core source model validation --- internal/core/source/source.go | 23 +++ internal/core/source/source_test.go | 275 ++++++++++++++++++++++++++++ internal/core/source/validation.go | 95 ++++++++++ 3 files changed, 393 insertions(+) create mode 100644 internal/core/source/source.go create mode 100644 internal/core/source/source_test.go create mode 100644 internal/core/source/validation.go diff --git a/internal/core/source/source.go b/internal/core/source/source.go new file mode 100644 index 0000000..afecaea --- /dev/null +++ b/internal/core/source/source.go @@ -0,0 +1,23 @@ +package source + +type SourceDocument struct { + ID string `json:"id"` + Kind string `json:"kind"` + Format string `json:"format"` + Digest string `json:"digest"` + Units []SourceUnit `json:"units"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type SourceUnit struct { + ID string `json:"id"` + Kind string `json:"kind"` + Text string `json:"text"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type SourceRef struct { + SourceID string `json:"source_id"` + StartUnitID string `json:"start_unit_id"` + EndUnitID string `json:"end_unit_id"` +} diff --git a/internal/core/source/source_test.go b/internal/core/source/source_test.go new file mode 100644 index 0000000..1a21698 --- /dev/null +++ b/internal/core/source/source_test.go @@ -0,0 +1,275 @@ +package source + +import ( + "strings" + "testing" +) + +func TestValidateDocumentValid(t *testing.T) { + doc := validDocument() + + if err := ValidateDocument(doc); err != nil { + t.Fatalf("ValidateDocument() error = %v, want nil", err) + } +} + +func TestValidateDocumentNil(t *testing.T) { + err := ValidateDocument(nil) + + if err == nil { + t.Fatal("ValidateDocument() error = nil, want error") + } + if err.Error() != "source document must not be nil" { + t.Fatalf("ValidateDocument() error = %q", err.Error()) + } +} + +func TestValidateDocumentMissingFields(t *testing.T) { + tests := []struct { + name string + mutate func(*SourceDocument) + wantErr string + }{ + { + name: "id", + mutate: func(doc *SourceDocument) { doc.ID = " \t" }, + wantErr: "source document id must not be empty", + }, + { + name: "kind", + mutate: func(doc *SourceDocument) { doc.Kind = "" }, + wantErr: "source document kind must not be empty", + }, + { + name: "format", + mutate: func(doc *SourceDocument) { doc.Format = "\n" }, + wantErr: "source document format must not be empty", + }, + { + name: "digest", + mutate: func(doc *SourceDocument) { doc.Digest = "" }, + wantErr: "source document digest must not be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doc := validDocument() + tt.mutate(doc) + + err := ValidateDocument(doc) + + if err == nil { + t.Fatal("ValidateDocument() error = nil, want error") + } + if err.Error() != tt.wantErr { + t.Fatalf("ValidateDocument() error = %q, want %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestValidateDocumentEmptyUnits(t *testing.T) { + doc := validDocument() + doc.Units = nil + + err := ValidateDocument(doc) + + if err == nil { + t.Fatal("ValidateDocument() error = nil, want error") + } + if err.Error() != "source document units must not be empty" { + t.Fatalf("ValidateDocument() error = %q", err.Error()) + } +} + +func TestValidateDocumentMissingUnitFields(t *testing.T) { + tests := []struct { + name string + mutate func(*SourceDocument) + wantErr string + }{ + { + name: "id", + mutate: func(doc *SourceDocument) { doc.Units[1].ID = "" }, + wantErr: "source unit[1].id must not be empty", + }, + { + name: "kind", + mutate: func(doc *SourceDocument) { doc.Units[1].Kind = " " }, + wantErr: "source unit[1].kind must not be empty", + }, + { + name: "text", + mutate: func(doc *SourceDocument) { doc.Units[1].Text = "\n\t" }, + wantErr: "source unit[1].text must not be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doc := validDocument() + tt.mutate(doc) + + err := ValidateDocument(doc) + + if err == nil { + t.Fatal("ValidateDocument() error = nil, want error") + } + if err.Error() != tt.wantErr { + t.Fatalf("ValidateDocument() error = %q, want %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestValidateDocumentDuplicateUnitIDs(t *testing.T) { + doc := validDocument() + doc.Units[1].ID = " u1 " + + err := ValidateDocument(doc) + + if err == nil { + t.Fatal("ValidateDocument() error = nil, want error") + } + if err.Error() != "source unit id \"u1\" is duplicated" { + t.Fatalf("ValidateDocument() error = %q", err.Error()) + } +} + +func TestValidateRefValid(t *testing.T) { + doc := validDocument() + ref := SourceRef{ + SourceID: "source-1", + StartUnitID: "u1", + EndUnitID: "u2", + } + + if err := ValidateRef(doc, ref); err != nil { + t.Fatalf("ValidateRef() error = %v, want nil", err) + } +} + +func TestValidateRefSourceIDMismatch(t *testing.T) { + doc := validDocument() + ref := SourceRef{ + SourceID: "source-2", + StartUnitID: "u1", + EndUnitID: "u2", + } + + err := ValidateRef(doc, ref) + + if err == nil { + t.Fatal("ValidateRef() error = nil, want error") + } + if err.Error() != "source ref source_id \"source-2\" does not match document id \"source-1\"" { + t.Fatalf("ValidateRef() error = %q", err.Error()) + } +} + +func TestValidateRefMissingUnitIDs(t *testing.T) { + tests := []struct { + name string + ref SourceRef + wantErr string + }{ + { + name: "missing source id", + ref: SourceRef{StartUnitID: "u1", EndUnitID: "u2"}, + wantErr: "source ref source_id must not be empty", + }, + { + name: "missing start id", + ref: SourceRef{SourceID: "source-1", EndUnitID: "u2"}, + wantErr: "source ref start_unit_id must not be empty", + }, + { + name: "missing end id", + ref: SourceRef{SourceID: "source-1", StartUnitID: "u1"}, + wantErr: "source ref end_unit_id must not be empty", + }, + { + name: "unknown start id", + ref: SourceRef{SourceID: "source-1", StartUnitID: "u9", EndUnitID: "u2"}, + wantErr: "source ref start_unit_id \"u9\" was not found", + }, + { + name: "unknown end id", + ref: SourceRef{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u9"}, + wantErr: "source ref end_unit_id \"u9\" was not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRef(validDocument(), tt.ref) + + if err == nil { + t.Fatal("ValidateRef() error = nil, want error") + } + if err.Error() != tt.wantErr { + t.Fatalf("ValidateRef() error = %q, want %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestValidateRefReversedUnitOrder(t *testing.T) { + doc := validDocument() + ref := SourceRef{ + SourceID: "source-1", + StartUnitID: "u2", + EndUnitID: "u1", + } + + err := ValidateRef(doc, ref) + + if err == nil { + t.Fatal("ValidateRef() error = nil, want error") + } + if !strings.Contains(err.Error(), "appears after") { + t.Fatalf("ValidateRef() error = %q, want reversed order error", err.Error()) + } +} + +func TestUnitIndex(t *testing.T) { + doc := validDocument() + + index, ok := UnitIndex(doc, "u2") + if !ok { + t.Fatal("UnitIndex() ok = false, want true") + } + if index != 1 { + t.Fatalf("UnitIndex() index = %d, want 1", index) + } + + index, ok = UnitIndex(doc, "u9") + if ok { + t.Fatal("UnitIndex() ok = true, want false") + } + if index != 0 { + t.Fatalf("UnitIndex() index = %d, want 0", index) + } +} + +func validDocument() *SourceDocument { + return &SourceDocument{ + ID: "source-1", + Kind: "document", + Format: "text/plain", + Digest: "sha256:abc123", + Units: []SourceUnit{ + { + ID: "u1", + Kind: "paragraph", + Text: "First unit.", + }, + { + ID: "u2", + Kind: "paragraph", + Text: "Second unit.", + }, + }, + } +} diff --git a/internal/core/source/validation.go b/internal/core/source/validation.go new file mode 100644 index 0000000..df38d32 --- /dev/null +++ b/internal/core/source/validation.go @@ -0,0 +1,95 @@ +package source + +import ( + "fmt" + "strings" +) + +func ValidateDocument(doc *SourceDocument) error { + if doc == nil { + return fmt.Errorf("source document must not be nil") + } + if isBlank(doc.ID) { + return fmt.Errorf("source document id must not be empty") + } + if isBlank(doc.Kind) { + return fmt.Errorf("source document kind must not be empty") + } + if isBlank(doc.Format) { + return fmt.Errorf("source document format must not be empty") + } + if isBlank(doc.Digest) { + return fmt.Errorf("source document digest must not be empty") + } + if len(doc.Units) == 0 { + return fmt.Errorf("source document units must not be empty") + } + + seenUnitIDs := make(map[string]struct{}, len(doc.Units)) + for i, unit := range doc.Units { + unitID := strings.TrimSpace(unit.ID) + if unitID == "" { + return fmt.Errorf("source unit[%d].id must not be empty", i) + } + if isBlank(unit.Kind) { + return fmt.Errorf("source unit[%d].kind must not be empty", i) + } + if isBlank(unit.Text) { + return fmt.Errorf("source unit[%d].text must not be empty", i) + } + if _, ok := seenUnitIDs[unitID]; ok { + return fmt.Errorf("source unit id %q is duplicated", unitID) + } + seenUnitIDs[unitID] = struct{}{} + } + + return nil +} + +func ValidateRef(doc *SourceDocument, ref SourceRef) error { + if doc == nil { + return fmt.Errorf("source document must not be nil") + } + if isBlank(ref.SourceID) { + return fmt.Errorf("source ref source_id must not be empty") + } + if isBlank(ref.StartUnitID) { + return fmt.Errorf("source ref start_unit_id must not be empty") + } + if isBlank(ref.EndUnitID) { + return fmt.Errorf("source ref end_unit_id must not be empty") + } + if ref.SourceID != doc.ID { + return fmt.Errorf("source ref source_id %q does not match document id %q", ref.SourceID, doc.ID) + } + + startIndex, ok := UnitIndex(doc, ref.StartUnitID) + if !ok { + return fmt.Errorf("source ref start_unit_id %q was not found", ref.StartUnitID) + } + endIndex, ok := UnitIndex(doc, ref.EndUnitID) + if !ok { + return fmt.Errorf("source ref end_unit_id %q was not found", ref.EndUnitID) + } + if startIndex > endIndex { + return fmt.Errorf("source ref start_unit_id %q appears after end_unit_id %q", ref.StartUnitID, ref.EndUnitID) + } + + return nil +} + +func UnitIndex(doc *SourceDocument, unitID string) (int, bool) { + if doc == nil { + return 0, false + } + for i, unit := range doc.Units { + if unit.ID == unitID { + return i, true + } + } + return 0, false +} + +func isBlank(value string) bool { + return strings.TrimSpace(value) == "" +}