Skip to content
quarry Artifact [ee368d3b5f]
Login

Artifact [ee368d3b5f]

Artifact ee368d3b5f74dd63f5939acea7d76edcc16c63f2217f0a5d8d758421ce7ddf38:


package artifact

import (
	"bytes"
	"crypto/md5"
	"encoding/hex"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// zseal appends a valid Z card to a card body — test artifacts must pass
// the §6.2 self-checksum gate.
func zseal(body string) []byte {
	sum := md5.Sum([]byte(body))
	return []byte(body + "Z " + hex.EncodeToString(sum[:]) + "\n")
}

func parseOK(t *testing.T, body string) *Artifact {
	t.Helper()
	a, err := Parse(zseal(body))
	if err != nil {
		t.Fatal(err)
	}
	if a == nil {
		t.Fatal("classified as content")
	}
	return a
}

func wantReject(t *testing.T, body, msg string) {
	t.Helper()
	a, err := Parse(zseal(body))
	if err == nil {
		t.Fatalf("%s: want reject, got %+v", msg, a)
	}
}

const h40 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
const h64 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"

// --- corpus: every stored artifact of the synthetic fixture ---

func TestCorpus(t *testing.T) {
	files, err := filepath.Glob("testdata/*")
	if err != nil || len(files) == 0 {
		t.Fatalf("no corpus: %v", err)
	}
	counts := map[string]int{}
	for _, f := range files {
		if fi, err := os.Stat(f); err != nil || fi.IsDir() {
			continue
		}
		raw, err := os.ReadFile(f)
		if err != nil {
			t.Fatal(err)
		}
		// testdata is stored sterilized (§4.1.2): a '#' first line keeps
		// these artifacts from joining THIS repository's DAG when
		// committed. Strip it to recover the exact stored bytes.
		if i := bytes.IndexByte(raw, '\n'); i < 0 || raw[0] != '#' {
			t.Fatalf("%s: testdata not sterilized", f)
		} else {
			raw = raw[i+1:]
		}
		a, err := Parse(raw)
		if err != nil {
			t.Errorf("%s: %v", filepath.Base(f), err)
			continue
		}
		if a == nil {
			counts["blob"]++
			continue
		}
		counts[a.Type.String()]++
		if a.Date == "" && a.Type != Cluster {
			t.Errorf("%s: %s with no date", filepath.Base(f), a.Type)
		}
	}
	// The fixture builds 5 check-ins, 4 wiki artifacts (3 creates + 1
	// edit), 4 ticket artifacts (3 adds + 1 change), and 4 content blobs
	// (two README versions, main.go, NOTES).
	want := map[string]int{"checkin": 5, "wiki": 4, "ticket": 4, "blob": 4}
	for k, n := range want {
		if counts[k] != n {
			t.Errorf("corpus %s: got %d, want %d (all: %v)", k, counts[k], n, counts)
		}
	}
}

// --- classification ---

func TestClassifyManifest(t *testing.T) {
	a := parseOK(t, "C fix\\sbug\nD 2026-08-15T10:00:00\nF src/main.go "+h64+"\nP "+h64+"\nU dan\n")
	if a.Type != Manifest || a.Comment != "fix bug" || a.Files[0].Name != "src/main.go" {
		t.Fatalf("%+v", a)
	}
}

func TestClassifyWiki(t *testing.T) {
	a := parseOK(t, "D 2026-08-15T10:00:00\nL Home\\sPage\nU dan\nW 6\nhello\n\n")
	if a.Type != Wiki || a.WikiTitle != "Home Page" || string(a.Body) != "hello\n" {
		t.Fatalf("%+v", a)
	}
}

func TestClassifyTicket(t *testing.T) {
	a := parseOK(t, "D 2026-08-15T10:00:00\nJ status Open\nJ title crash\nK "+h40+"\nU dan\n")
	if a.Type != Ticket || a.TicketID != h40 || len(a.Fields) != 2 {
		t.Fatalf("%+v", a)
	}
}

func TestClassifyControlFallback(t *testing.T) {
	a := parseOK(t, "D 2026-08-15T10:00:00\nT +closed "+h64+"\nU dan\n")
	if a.Type != Control {
		t.Fatalf("want control, got %s", a.Type)
	}
}

func TestClassifyManifestFallbackViaComment(t *testing.T) {
	a := parseOK(t, "C empty\nD 2026-08-15T10:00:00\nT *branch * trunk\nT *sym-trunk *\nU dan\n")
	if a.Type != Manifest {
		t.Fatalf("want checkin, got %s", a.Type)
	}
}

// --- framing / opaque fallthrough ---

func TestOpaqueBlobs(t *testing.T) {
	for _, blob := range []string{
		"",
		"plain text\n",
		"no trailing newline",
		"c lowercase first\n",
		"# sterilized manifest\nD 2026-08-15T10:00:00\n",
		"X\n", // line 1 too short
	} {
		a, err := Parse([]byte(blob))
		if a != nil || err != nil {
			t.Errorf("%q: want (nil,nil), got (%v,%v)", blob, a, err)
		}
	}
}

func TestZMismatchIsContent(t *testing.T) {
	bad := "C hi\nD 2026-08-15T10:00:00\nU dan\nZ 00000000000000000000000000000000\n"
	a, err := Parse([]byte(bad))
	if a != nil || err != nil {
		t.Fatalf("Z mismatch must classify as content, got (%v,%v)", a, err)
	}
}

func TestMissingZRejected(t *testing.T) {
	// Passes framing (Z absent is tolerated there) but every type
	// requires Z (§5.2).
	a, err := Parse([]byte("C hi\nD 2026-08-15T10:00:00\nU dan\n"))
	if err == nil {
		t.Fatalf("want reject for missing Z, got %+v", a)
	}
}

// --- grammar rejections ---

func TestGrammarRejections(t *testing.T) {
	d := "D 2026-08-15T10:00:00\n"
	for name, body := range map[string]string{
		"undefined card":       d + "S what\nU dan\n",
		"card out of order":    "U dan\n" + d,
		"duplicate single":     "C a\nC b\n" + d + "U dan\n",
		"excess arity B":       "B " + h64 + " extra\n" + d + "F a " + h64 + "\n",
		"bad hash length":      "B abcdef\n" + d + "F a " + h64 + "\n",
		"F not ascending":      d + "F b.txt " + h64 + "\nF a.txt " + h64 + "\n",
		"F duplicate name":     d + "F a.txt " + h64 + "\nF a.txt " + h64 + "\n",
		"hashless F no B":      d + "F gone.txt\n",
		"bad pathname":         d + "F /abs " + h64 + "\n",
		"dotdot pathname":      d + "F a/../b " + h64 + "\n",
		"bad timestamp":        "C a\nD yesterday\nU dan\n",
		"day zero":             "C a\nD 2026-08-00T10:00:00\nU dan\n",
		"ticket id 64hex":      d + "J a b\nK " + h64 + "\nU dan\n",
		"J not ascending":      d + "J b 1\nJ a 2\nK " + h40 + "\nU dan\n",
		"forum H and I":        d + "G " + h64 + "\nH t\nI " + h64 + "\nU dan\nW 0\n\n",
		"forum merge parents":  d + "I " + h64 + "\nP " + h64 + " " + h40 + "\nU dan\nW 0\n\n",
		"card illegal in type": d + "K " + h40 + "\nJ a b\nU dan\nW 2\nhi\n",
		"tag without sign":     d + "T closed " + h64 + "\nU dan\n",
	} {
		wantReject(t, body, name)
	}
}

func TestJOrderCheckPrecedesK(t *testing.T) {
	// J cards sort before K; the ascending check must not depend on K.
	a := parseOK(t, "D 2026-08-15T10:00:00\nJ alpha 1\nJ beta 2\nK "+h40+"\nU dan\n")
	if len(a.Fields) != 2 {
		t.Fatalf("%+v", a)
	}
}

// --- decoding ---

func TestDecode(t *testing.T) {
	for in, want := range map[string]string{
		`a\sb`:   "a b",
		`a\nb`:   "a\nb",
		`a\tb`:   "a\tb",
		`a\\b`:   `a\b`,
		`a\qb`:   "aqb", // unrecognized: drop backslash
		`trail\`: `trail\`,
		`plain`:  "plain",
		`\0`:     "\x00",
		`\r\v\f`: "\r\v\f",
	} {
		if got := decode(in); got != want {
			t.Errorf("decode(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestFieldNameNotDecoded(t *testing.T) {
	a := parseOK(t, "D 2026-08-15T10:00:00\nJ has\\sslash v\\salue\nK "+h40+"\nU dan\n")
	if a.Fields[0].Name != `has\sslash` {
		t.Fatalf("field name was decoded: %q", a.Fields[0].Name)
	}
	if a.Fields[0].Value != "v alue" {
		t.Fatalf("field value not decoded: %q", a.Fields[0].Value)
	}
}

// --- timestamps ---

func TestTimestamps(t *testing.T) {
	ok := []string{
		"2026-08-15T10:00:00", "2026-08-15T10:00:00.604", "2026-08-15",
		"2026-08-15T10:00", "2026-08-15T10:00:00Z", "2026-08-15T10:00:00+05:30",
		"10:30", "10:30:00.5",
	}
	bad := []string{"now", "2454.5", "2026-8-15", "2026-08-15T25:00:00", "2026-13-01"}
	for _, s := range ok {
		if !validTimestamp(s) {
			t.Errorf("rejected valid %q", s)
		}
	}
	for _, s := range bad {
		if validTimestamp(s) {
			t.Errorf("accepted %q", s)
		}
	}
}

// --- misc semantics ---

func TestAnonymousUserDefault(t *testing.T) {
	a := parseOK(t, "C x\nD 2026-08-15T10:00:00\n")
	if a.User != "anonymous" {
		t.Fatalf("user = %q", a.User)
	}
}

func TestWBodyWithEmbeddedCards(t *testing.T) {
	// The W body may contain lines that look like cards; the size framing
	// must keep them as body bytes.
	body := "D 2026-08-15\nU dan\n"
	a := parseOK(t, fmt.Sprintf("D 2026-08-15T10:00:00\nL X\nU dan\nW %d\n%s\n", len(body), body))
	if string(a.Body) != body {
		t.Fatalf("body = %q", a.Body)
	}
}

func TestDeltaManifestDeletion(t *testing.T) {
	a := parseOK(t, "B "+h64+"\nC del\nD 2026-08-15T10:00:00\nF gone.txt\nU dan\n")
	if a.Baseline != h64 || a.Files[0].Hash != "" {
		t.Fatalf("%+v", a)
	}
}

func TestTechnoteTagRule(t *testing.T) {
	base := "C n\nD 2026-08-15T10:00:00\nE 2026-08-15T09:00:00 " + h40 + "\n"
	parseOK(t, base+"T +tagged *\nU dan\nW 2\nhi\n")
	wantReject(t, base+"T +tagged "+h64+"\nU dan\nW 2\nhi\n", "technote tag must be self-ref")
	wantReject(t, strings.Replace(base+"T -tagged *\nU dan\nW 2\nhi\n", "", "", 1), "technote tag must be +")
}

// Real Fossil writes an empty trailing value as a bare trailing space.
// Rejecting it cost sqlite every one of its 2,100 ticket changes: the
// artifact fell back to "content" and the ticket vanished from the index.
func TestEmptyTrailingValueIsAccepted(t *testing.T) {
	hash := strings.Repeat("a", 40)
	for _, tc := range []struct {
		name, body string
		check      func(*Artifact) error
	}{
		{"J card empty value", "D 2018-03-11T03:15:15.716\nJ comment \nK " + hash + "\nU dan\n",
			func(a *Artifact) error {
				if len(a.Fields) != 1 || a.Fields[0].Name != "comment" || a.Fields[0].Value != "" {
					return fmt.Errorf("fields = %+v", a.Fields)
				}
				return nil
			}},
		{"T card empty value", "D 2018-03-11T03:15:15.716\nT +date " + hash + " \nU dan\n",
			func(a *Artifact) error {
				if len(a.Tags) != 1 || a.Tags[0].Name != "+date" || a.Tags[0].Value != "" {
					return fmt.Errorf("tags = %+v", a.Tags)
				}
				return nil
			}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			a, err := Parse([]byte(zseal(tc.body)))
			if err != nil {
				t.Fatalf("rejected a real Fossil artifact: %v", err)
			}
			if err := tc.check(a); err != nil {
				t.Fatal(err)
			}
		})
	}
}

// The tolerance is trailing-only, and only for cards whose grammar ends
// in an optional value — an interior empty token is still malformed.
func TestInteriorEmptyTokenStillRejected(t *testing.T) {
	hash := strings.Repeat("a", 40)
	for _, body := range []string{
		"D 2018-03-11T03:15:15.716\nT +date  " + hash + "\nU dan\n", // interior
		"D 2018-03-11T03:15:15.716\nC hi \nU dan\n",                 // C has no optional value
	} {
		if _, err := Parse([]byte(zseal(body))); err == nil {
			t.Fatalf("accepted malformed artifact: %q", body)
		}
	}
}