ajhahn.de
← eeco
Go 364 lines
package memory

import (
	"strings"
	"testing"
	"time"
)

func TestParseFact_HappyPath(t *testing.T) {
	src := strings.Join([]string{
		"---",
		"name: build-gate",
		"description: gate is `go vet ./...`",
		"type: reference",
		"created: 2026-05-19",
		"last_used: 2026-05-19",
		"pin: false",
		"---",
		"",
		"body line one",
		"body line two",
		"",
	}, "\n")
	f, err := ParseFact([]byte(src))
	if err != nil {
		t.Fatalf("ParseFact: %v", err)
	}
	if f.Name != "build-gate" {
		t.Errorf("name = %q", f.Name)
	}
	if f.Type != TypeReference {
		t.Errorf("type = %q", f.Type)
	}
	if f.Pin {
		t.Error("pin should be false")
	}
	if f.Body != "body line one\nbody line two\n" {
		t.Errorf("body = %q", f.Body)
	}
}

func TestParseFact_OptionalFields(t *testing.T) {
	src := strings.Join([]string{
		"---",
		"name: bug-x",
		"description: fix bug X",
		"type: finding",
		"created: 2026-05-19",
		"last_used: 2026-05-19",
		"ref: internal/config/config.go",
		"expires: 2026-12-31",
		"status: open",
		"pin: true",
		"---",
		"detail",
		"",
	}, "\n")
	f, err := ParseFact([]byte(src))
	if err != nil {
		t.Fatalf("ParseFact: %v", err)
	}
	if f.Ref != "internal/config/config.go" {
		t.Errorf("ref = %q", f.Ref)
	}
	if f.Expires == nil || !f.Expires.Equal(time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)) {
		t.Errorf("expires = %v", f.Expires)
	}
	if f.Status != "open" {
		t.Errorf("status = %q", f.Status)
	}
	if !f.Pin {
		t.Error("pin should be true")
	}
}

func TestParseFact_QuotedAndComments(t *testing.T) {
	src := strings.Join([]string{
		"---",
		`name: "quoted-name"`,
		`description: 'single quoted'`,
		"# comment line",
		"",
		"type: user",
		"created: 2026-05-19",
		"last_used: 2026-05-19",
		"pin: false",
		"unknown_key: tolerated",
		"---",
	}, "\n")
	f, err := ParseFact([]byte(src))
	if err != nil {
		t.Fatalf("ParseFact: %v", err)
	}
	if f.Name != "quoted-name" {
		t.Errorf("name = %q", f.Name)
	}
	if f.Description != "single quoted" {
		t.Errorf("description = %q", f.Description)
	}
}

func TestParseFact_ErrorCases(t *testing.T) {
	cases := map[string]string{
		"missing opening delim": "name: x\n",
		"missing closing delim": "---\nname: x\n",
		"missing colon":         "---\nthis has no colon\n---\n",
		"bad date":              "---\nname: a\ndescription: b\ntype: user\ncreated: not-a-date\nlast_used: 2026-05-19\npin: false\n---\n",
		"bad type":              "---\nname: a\ndescription: b\ntype: not-a-type\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
		"bad pin":               "---\nname: a\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: maybe\n---\n",
		"bad ref-traversal":     "---\nname: a\ndescription: b\ntype: reference\ncreated: 2026-05-19\nlast_used: 2026-05-19\nref: ../escape\npin: false\n---\n",
		"bad ref-absolute":      "---\nname: a\ndescription: b\ntype: reference\ncreated: 2026-05-19\nlast_used: 2026-05-19\nref: /etc/passwd\npin: false\n---\n",
		"missing name":          "---\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
		"bad name":              "---\nname: NotKebab\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
		"missing description":   "---\nname: a\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
	}
	for label, src := range cases {
		t.Run(label, func(t *testing.T) {
			if _, err := ParseFact([]byte(src)); err == nil {
				t.Fatalf("ParseFact(%q) succeeded; expected error", label)
			}
		})
	}
}

func TestSerialize_RoundTrip(t *testing.T) {
	exp := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
	in := &Fact{
		Name:        "round-trip",
		Description: "round trip",
		Type:        TypeFinding,
		Created:     time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
		LastUsed:    time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC),
		Ref:         "internal/config/config.go",
		Expires:     &exp,
		Status:      "open",
		Pin:         true,
		Body:        "body text\nwith two lines",
	}
	raw, err := Serialize(in)
	if err != nil {
		t.Fatal(err)
	}
	got, err := ParseFact(raw)
	if err != nil {
		t.Fatalf("re-parse:\n%s\nerr: %v", raw, err)
	}
	if got.Name != in.Name || got.Description != in.Description || got.Type != in.Type ||
		!got.Created.Equal(in.Created) || !got.LastUsed.Equal(in.LastUsed) ||
		got.Ref != in.Ref || got.Status != in.Status || got.Pin != in.Pin {
		t.Errorf("round-trip diverged.\nwant: %+v\ngot:  %+v", *in, *got)
	}
	if got.Expires == nil || !got.Expires.Equal(*in.Expires) {
		t.Errorf("expires diverged: %v", got.Expires)
	}
	if strings.TrimRight(got.Body, "\n") != strings.TrimRight(in.Body, "\n") {
		t.Errorf("body diverged:\nwant: %q\ngot:  %q", in.Body, got.Body)
	}
}

func TestSerialize_OmitsEmptyOptionals(t *testing.T) {
	in := &Fact{
		Name:        "minimal",
		Description: "minimal fact",
		Type:        TypeUser,
		Created:     time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
		LastUsed:    time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
	}
	raw, err := Serialize(in)
	if err != nil {
		t.Fatal(err)
	}
	s := string(raw)
	for _, banned := range []string{"ref:", "expires:", "status:", "source:", "agent:", "disabled:"} {
		if strings.Contains(s, banned) {
			t.Errorf("output should omit %q for empty values:\n%s", banned, s)
		}
	}
	if !strings.Contains(s, "pin: false") {
		t.Errorf("pin: false should always be emitted:\n%s", s)
	}
}

func TestParseFact_AdaptationFields(t *testing.T) {
	src := strings.Join([]string{
		"---",
		"name: terse-feedback",
		"description: user prefers terse responses",
		"type: feedback",
		"created: 2026-05-22",
		"last_used: 2026-05-22",
		"pin: false",
		"source: stop summarizing what you just did",
		"agent: claude-opus-4-7",
		"disabled: true",
		"---",
		"reasoning",
		"",
	}, "\n")
	f, err := ParseFact([]byte(src))
	if err != nil {
		t.Fatalf("ParseFact: %v", err)
	}
	if f.Source != "stop summarizing what you just did" {
		t.Errorf("source = %q", f.Source)
	}
	if f.Agent != "claude-opus-4-7" {
		t.Errorf("agent = %q", f.Agent)
	}
	if !f.Disabled {
		t.Error("disabled should be true")
	}
}

func TestParseFact_DisabledBadValue(t *testing.T) {
	src := strings.Join([]string{
		"---",
		"name: bad",
		"description: bad",
		"type: user",
		"created: 2026-05-22",
		"last_used: 2026-05-22",
		"pin: false",
		"disabled: maybe",
		"---",
	}, "\n")
	if _, err := ParseFact([]byte(src)); err == nil {
		t.Fatal("expected error on disabled: maybe")
	}
}

func TestParseFact_OldFactStillLoads(t *testing.T) {
	// A fact written before the source/agent/disabled fields existed
	// must still parse cleanly: the new fields are optional on the wire.
	src := strings.Join([]string{
		"---",
		"name: legacy",
		"description: legacy fact",
		"type: feedback",
		"created: 2026-01-01",
		"last_used: 2026-01-01",
		"pin: false",
		"---",
	}, "\n")
	f, err := ParseFact([]byte(src))
	if err != nil {
		t.Fatalf("ParseFact: %v", err)
	}
	if f.Source != "" || f.Agent != "" || f.Disabled {
		t.Errorf("new fields should be zero for legacy fact: %+v", f)
	}
}

func TestSerialize_RoundTripAdaptationFields(t *testing.T) {
	in := &Fact{
		Name:        "adapt",
		Description: "adapt to operator",
		Type:        TypeUser,
		Created:     time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
		LastUsed:    time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
		Source:      "stop using emojis",
		Agent:       "claude-opus-4-7",
		Disabled:    true,
		Body:        "body",
	}
	raw, err := Serialize(in)
	if err != nil {
		t.Fatal(err)
	}
	got, err := ParseFact(raw)
	if err != nil {
		t.Fatalf("re-parse: %v", err)
	}
	if got.Source != in.Source || got.Agent != in.Agent || got.Disabled != in.Disabled {
		t.Errorf("adaptation fields diverged: %+v", got)
	}
}

func TestValidate_RejectsOversizeSource(t *testing.T) {
	long := strings.Repeat("x", MaxSourceLen+1)
	in := &Fact{
		Name:        "oversize",
		Description: "oversize source",
		Type:        TypeFeedback,
		Created:     time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
		LastUsed:    time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
		Source:      long,
	}
	if err := in.Validate(); err == nil {
		t.Fatal("Validate: expected error on oversize source")
	}
}

func TestSerialize_RejectsInvalid(t *testing.T) {
	in := &Fact{Name: "x"} // missing required fields
	if _, err := Serialize(in); err == nil {
		t.Fatal("Serialize: expected validation error")
	}
}

// --- setField residual branches ---

func TestSetField_ExpiresEmpty_ClearsField(t *testing.T) {
	src := strings.Join([]string{
		"---",
		"name: exp-empty",
		"description: empty expires clears the field",
		"type: user",
		"created: 2026-05-19",
		"last_used: 2026-05-19",
		"expires:",
		"pin: false",
		"---",
	}, "\n")
	f, err := ParseFact([]byte(src))
	if err != nil {
		t.Fatalf("ParseFact: %v", err)
	}
	if f.Expires != nil {
		t.Errorf("expires = %v, want nil", f.Expires)
	}
}

func TestSetField_ExpiresBadDate(t *testing.T) {
	src := strings.Join([]string{
		"---",
		"name: exp-bad",
		"description: bad expires date",
		"type: user",
		"created: 2026-05-19",
		"last_used: 2026-05-19",
		"expires: not-a-date",
		"pin: false",
		"---",
	}, "\n")
	_, err := ParseFact([]byte(src))
	if err == nil {
		t.Fatal("expected bad expires date to error")
	}
	if !strings.Contains(err.Error(), "expires:") {
		t.Errorf("err = %v, want substring expires:", err)
	}
}

func TestSetField_DisabledFalseExplicit(t *testing.T) {
	src := strings.Join([]string{
		"---",
		"name: dis-false",
		"description: explicit disabled false",
		"type: user",
		"created: 2026-05-19",
		"last_used: 2026-05-19",
		"pin: false",
		"disabled: false",
		"---",
	}, "\n")
	f, err := ParseFact([]byte(src))
	if err != nil {
		t.Fatalf("ParseFact: %v", err)
	}
	if f.Disabled {
		t.Error("disabled should be false")
	}
}