Artifact bb7358a36beb67c5f91c4d6c605390261ddde8bf4da6c9daf8ee8d183ac986ed:
- File
internal/search/search_test.go
— part of check-in
[6273a0750d]
at
2026-07-22 23:03:24
on branch trunk
— fix: correct go.mod module path to go-libfossil (#76) (#117)
* fix: correct go.mod module path to go-libfossil (#76)
The module directive still declared github.com/danmestas/libfossil after the repository was renamed to go-libfossil. pkg.go.dev/godoc scraping matches the declared module path against its hosting path, so the stale path broke SDK doc generation.
Rename the module path across all four in-repo modules (root, the two db/driver submodules, observer/otel, and docs/site) and every internal import, plus the release workflow, the doc-generation script, and the docs.
No tag has been published under the go-libfossil path yet, so the cross-module requires cannot resolve from the proxy. Add replace directives pointing the in-repo requires at their local directories so GOWORK=off builds and the CI matrix resolve without a published version. These replaces are ignored by downstream consumers and can be dropped once new-path tags are cut.
* Guard releases against replace directives; regenerate SDK docs
The go-libfossil rename bridges the in-repo modules with `replace` directives. Go refuses a main module that carries them, so a tag cut while they are present breaks `go install .../cmd/libfossil@<tag>` and every `@version` fetch of the driver submodules. Add a Release workflow step that fails the job before anything is published if any tracked go.mod still contains a replace directive, so the bridge is a CI-enforced TODO rather than a silent landmine. The step comment records what unblocks a release: re-tag the submodules with the corrected module paths, bump the root requires, then drop the replace blocks.
Regenerate the SDK reference docs. The previous pass only substituted URLs over the pre-existing generated files, leaving stale signatures (Repo.Timeline was still documented as taking LogOpts rather than TimelineOpts). Note that gomarkdoc must be run outside a git worktree — inside one it silently strips every source link.
Also reword the README rename note, which read as though the import path had not changed, and re-sort the ncruces require block. (user: [email protected], size: 15520) [annotate] [blame] [check-ins using]
package search_test import ( "fmt" "strings" "testing" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/search" "github.com/danmestas/go-libfossil/simio" _ "github.com/danmestas/go-libfossil/db/driver/ncruces" ) func newTestRepo(t *testing.T) *repo.Repo { t.Helper() dir := t.TempDir() r, err := repo.Create(dir+"/test.fossil", "test", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) return r } func TestFTS5Available(t *testing.T) { r := newTestRepo(t) rows, err := r.DB().Query("PRAGMA compile_options") if err != nil { t.Fatal(err) } defer rows.Close() found := false for rows.Next() { var opt string if err := rows.Scan(&opt); err != nil { t.Fatal(err) } if opt == "ENABLE_FTS5" { found = true break } } if !found { t.Fatal("FTS5 not available in SQLite driver — search package requires it") } } func TestOpenCreatesSchema(t *testing.T) { r := newTestRepo(t) idx, err := search.Open(r) if err != nil { t.Fatal(err) } // Verify FTS table exists by attempting a query _, err = idx.Search(search.Query{Term: "test"}) if err != nil { t.Fatal("search after open failed:", err) } } func TestDrop(t *testing.T) { r := newTestRepo(t) idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.Drop(); err != nil { t.Fatal(err) } // After drop, Open should recreate tables idx2, err := search.Open(r) if err != nil { t.Fatal(err) } _, err = idx2.Search(search.Query{Term: "test"}) if err != nil { t.Fatal("search after drop+reopen failed:", err) } } func TestNeedsReindex_EmptyRepo(t *testing.T) { r := newTestRepo(t) idx, err := search.Open(r) if err != nil { t.Fatal(err) } needs, err := idx.NeedsReindex() if err != nil { t.Fatal(err) } // Empty repo has no trunk tip — nothing to index if needs { t.Fatal("expected NeedsReindex=false for empty repo") } } func TestNeedsReindex_AfterCheckin(t *testing.T) { r := newTestRepo(t) // manifest.Checkin creates repos programmatically — no fossil binary needed. // manifest.File fields: Name string, Content []byte, Perm string _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "hello.txt", Content: []byte("hello world")}}, Comment: "initial", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } needs, err := idx.NeedsReindex() if err != nil { t.Fatal(err) } if !needs { t.Fatal("expected NeedsReindex=true after checkin") } } func TestRebuildIndex_IndexesFiles(t *testing.T) { r := newTestRepo(t) _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "main.go", Content: []byte("package main\n\nfunc handleSync() {}\n")}, {Name: "README.md", Content: []byte("# Hello World\n")}, }, Comment: "initial", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } // After rebuild, NeedsReindex should be false needs, err := idx.NeedsReindex() if err != nil { t.Fatal(err) } if needs { t.Fatal("expected NeedsReindex=false after rebuild") } } func TestRebuildIndex_SkipsBinaries(t *testing.T) { if testing.Short() { t.Skip("requires Search implementation (Task 4)") } r := newTestRepo(t) binaryContent := make([]byte, 100) binaryContent[50] = 0x00 // null byte copy(binaryContent, []byte("not really text")) _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "text.txt", Content: []byte("searchable content here")}, {Name: "image.bin", Content: binaryContent}, }, Comment: "with binary", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } // Verify text file IS searchable results, err := idx.Search(search.Query{Term: "searchable"}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } if results[0].Path != "text.txt" { t.Fatalf("expected text.txt, got %s", results[0].Path) } // Verify binary file is NOT searchable binaryResults, err := idx.Search(search.Query{Term: "not really text"}) if err != nil { t.Fatal(err) } if len(binaryResults) != 0 { t.Fatalf("expected 0 results for binary content, got %d", len(binaryResults)) } } func TestRebuildIndex_HandlesDeltaChains(t *testing.T) { if testing.Short() { t.Skip("requires Search implementation (Task 4)") } r := newTestRepo(t) // First checkin _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "data.txt", Content: []byte("original content")}}, Comment: "first", User: "test", }) if err != nil { t.Fatal(err) } // Second checkin with Delta=true — blob stored as delta, not full content _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "data.txt", Content: []byte("modified content")}}, Comment: "second", User: "test", Delta: true, }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } // Should find the expanded (modified) content, not the delta bytes results, err := idx.Search(search.Query{Term: "modified"}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result for delta-expanded content, got %d", len(results)) } } func TestRebuildIndex_Idempotent(t *testing.T) { if testing.Short() { t.Skip("requires Search implementation (Task 4)") } r := newTestRepo(t) _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "a.txt", Content: []byte("hello world")}}, Comment: "initial", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } // Rebuild twice — second should no-op if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } results, err := idx.Search(search.Query{Term: "hello"}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result after double rebuild, got %d", len(results)) } } func TestSearch_SubstringMatch(t *testing.T) { r := newTestRepo(t) _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "sync/client.go", Content: []byte("package sync\n\nfunc handleSync() {\n\tlog.Println(\"syncing\")\n}\n")}, {Name: "sync/server.go", Content: []byte("package sync\n\nfunc ServeHTTP() {\n\tlog.Println(\"serving\")\n}\n")}, }, Comment: "sync code", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } results, err := idx.Search(search.Query{Term: "handleSync"}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result for 'handleSync', got %d", len(results)) } if results[0].Path != "sync/client.go" { t.Fatalf("expected sync/client.go, got %s", results[0].Path) } if results[0].Line != 3 { t.Fatalf("expected line 3, got %d", results[0].Line) } if results[0].MatchLen != 10 { t.Fatalf("expected MatchLen=10, got %d", results[0].MatchLen) } } func TestSearch_CaseInsensitive(t *testing.T) { r := newTestRepo(t) _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "a.go", Content: []byte("func HandleSync() {}\n")}}, Comment: "case test", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } // Search with different case — should still match results, err := idx.Search(search.Query{Term: "handlesync"}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 case-insensitive result, got %d", len(results)) } } func TestSearch_MinTermLength(t *testing.T) { r := newTestRepo(t) idx, err := search.Open(r) if err != nil { t.Fatal(err) } // Sub-3-char query returns empty results, err := idx.Search(search.Query{Term: "ab"}) if err != nil { t.Fatal(err) } if len(results) != 0 { t.Fatalf("expected 0 results for 2-char query, got %d", len(results)) } } func TestSearch_WithContext(t *testing.T) { r := newTestRepo(t) fileContent := "line1\nline2\nline3 target\nline4\nline5\n" _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "f.txt", Content: []byte(fileContent)}}, Comment: "context test", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } results, err := idx.Search(search.Query{Term: "target", ContextLines: 1}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } if results[0].Line != 3 { t.Fatalf("expected line 3, got %d", results[0].Line) } ctx := results[0].Context if !strings.Contains(ctx, "line2") || !strings.Contains(ctx, "line3 target") || !strings.Contains(ctx, "line4") { t.Fatalf("context missing expected lines: %q", ctx) } } func TestSearch_EscapesFTS5SpecialChars(t *testing.T) { r := newTestRepo(t) _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "q.txt", Content: []byte(`she said "hello" to the world`)}}, Comment: "quotes", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } // Search for a term containing a double quote — should not crash results, err := idx.Search(search.Query{Term: `"hello"`}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result for quoted search, got %d", len(results)) } } func TestSearch_MaxResults(t *testing.T) { r := newTestRepo(t) var files []manifest.File for i := 0; i < 10; i++ { files = append(files, manifest.File{ Name: fmt.Sprintf("file%d.txt", i), Content: []byte("common search term here"), }) } _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: files, Comment: "many files", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } results, err := idx.Search(search.Query{Term: "common search", MaxResults: 3}) if err != nil { t.Fatal(err) } if len(results) > 3 { t.Fatalf("expected at most 3 results, got %d", len(results)) } } func TestStaleAfterNewCheckin(t *testing.T) { if testing.Short() { t.Skip("requires Search implementation (Task 4)") } r := newTestRepo(t) // First checkin rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha content")}}, Comment: "first", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } // Verify "alpha" is searchable results, err := idx.Search(search.Query{Term: "alpha"}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result for alpha, got %d", len(results)) } // Second checkin adds new file with sym-trunk tag to advance the trunk pointer _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "a.txt", Content: []byte("alpha content")}, {Name: "b.txt", Content: []byte("bravo content")}, }, Comment: "second", User: "test", Parent: rid1, Tags: []deck.TagCard{ {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, }, }) if err != nil { t.Fatal(err) } // Should be stale now needs, err := idx.NeedsReindex() if err != nil { t.Fatal(err) } if !needs { t.Fatal("expected NeedsReindex=true after second checkin") } // Reindex if err := idx.RebuildIndex(); err != nil { t.Fatal(err) } // Now "bravo" should be searchable results, err = idx.Search(search.Query{Term: "bravo"}) if err != nil { t.Fatal(err) } if len(results) != 1 { t.Fatalf("expected 1 result for bravo, got %d", len(results)) } // And NeedsReindex is false again needs, err = idx.NeedsReindex() if err != nil { t.Fatal(err) } if needs { t.Fatal("expected NeedsReindex=false after reindex") } } // TestRebuildIndex_BuggifyResilience verifies that RebuildIndex behaves // correctly when content.Expand's BUGGIFY site is active (1% chance of // flipping byte 0 in expanded content). Two outcomes are acceptable: // // 1. Manifest corrupted → RebuildIndex returns error (can't read file list). // 2. File blob corrupted → RebuildIndex succeeds with wrong content indexed. // The search index is a derived cache — corrupted content doesn't compromise // the repo, just means one file's results are wrong until next reindex. // // The key property: RebuildIndex never panics, and if it succeeds, the index // is structurally valid and searchable. func TestRebuildIndex_BuggifyResilience(t *testing.T) { r := newTestRepo(t) // Create files BEFORE enabling BUGGIFY so checkin itself isn't corrupted. var files []manifest.File for i := 0; i < 50; i++ { files = append(files, manifest.File{ Name: fmt.Sprintf("src/file%d.go", i), Content: []byte(fmt.Sprintf("package src\n\nfunc Function%d() { /* unique content %d */ }\n", i, i)), }) } _, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: files, Comment: "buggify resilience test", User: "test", }) if err != nil { t.Fatal(err) } idx, err := search.Open(r) if err != nil { t.Fatal(err) } // Enable BUGGIFY AFTER checkin so only RebuildIndex is affected. // Seed 42 gives deterministic fault injection. simio.EnableBuggify(42) defer simio.DisableBuggify() rebuildErr := idx.RebuildIndex() if rebuildErr != nil { // Outcome 1: BUGGIFY corrupted the manifest blob itself. // RebuildIndex correctly returns an error. This is acceptable — // a corrupted manifest means we can't enumerate files. t.Logf("BUGGIFY corrupted manifest (expected): %v", rebuildErr) return } // Outcome 2: manifest was fine, but some file blobs may be corrupted. // Verify the index is structurally valid. needs, err := idx.NeedsReindex() if err != nil { t.Fatal(err) } if needs { t.Fatal("expected NeedsReindex=false after successful rebuild under BUGGIFY") } // Index must be searchable — queries must not error. results, err := idx.Search(search.Query{Term: "Function"}) if err != nil { t.Fatal("Search failed after BUGGIFY rebuild:", err) } // We expect results for most files. Some may have corrupted content // (byte 0 flipped by BUGGIFY), but the FTS index should still work. if len(results) == 0 { t.Fatal("expected at least some results after BUGGIFY rebuild, got 0") } t.Logf("BUGGIFY resilience: %d/%d files matched 'Function'", len(results), len(files)) }