package indexer import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" ) // TestIncrementalReindexPaths_EmptyPathsFallsBackToWholeRoot verifies // that passing no paths makes IncrementalReindexPaths behave exactly // like a whole-root IncrementalReindex: a changed file anywhere in the // tree is picked up. func TestIncrementalReindexPaths_EmptyPathsFallsBackToWholeRoot(t *testing.T) { dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "pkg"), 0o755)) writeFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Main() {}\n") writeFile(t, filepath.Join(dir, "pkg", "util.go"), "package pkg\n\nfunc Util() {}\n") g := graph.New() idx := newTestIndexer(g) _, err := idx.Index(dir) require.NoError(t, err) bumpMtime(t, filepath.Join(dir, "pkg", "util.go"), "package pkg\n\nfunc Util() {}\n\nfunc UtilTwo() {}\n") res, err := idx.IncrementalReindexPaths(dir, nil) require.NoError(t, err) require.NotNil(t, res) assert.Equal(t, 1, res.StaleFileCount, "empty paths must scan the whole root and pick up the changed file") assert.NotEmpty(t, g.FindNodesByName("UtilTwo"), "the whole-root pass must re-index the changed file") } // TestIncrementalReindexPaths_ScopesToDirectory checks that a directory // path scopes the pass: a stale file inside the scoped directory is // re-indexed, while a stale file outside it is left untouched. func TestIncrementalReindexPaths_ScopesToDirectory(t *testing.T) { dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "in"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(dir, "out"), 0o755)) writeFile(t, filepath.Join(dir, "in", "a.go"), "package in\n\nfunc A() {}\n") writeFile(t, filepath.Join(dir, "out", "b.go"), "package out\n\nfunc B() {}\n") g := graph.New() idx := newTestIndexer(g) _, err := idx.Index(dir) require.NoError(t, err) // Make BOTH files stale, then scope the reindex to the "in" dir. bumpMtime(t, filepath.Join(dir, "in", "a.go"), "package in\n\nfunc A() {}\n\nfunc AScoped() {}\n") bumpMtime(t, filepath.Join(dir, "out", "b.go"), "package out\n\nfunc B() {}\n\nfunc BUnscoped() {}\n") res, err := idx.IncrementalReindexPaths(dir, []string{filepath.Join(dir, "in")}) require.NoError(t, err) assert.Equal(t, 1, res.StaleFileCount, "only the file inside the scoped directory should be re-indexed") assert.NotEmpty(t, g.FindNodesByName("AScoped"), "the scoped file's new symbol must be in the graph") assert.Empty(t, g.FindNodesByName("BUnscoped"), "a file outside the scoped directory must NOT be re-indexed") } // TestIncrementalReindexPaths_RelativePathScope verifies that scoped // paths may be supplied relative to the repository root, not just // absolute. func TestIncrementalReindexPaths_RelativePathScope(t *testing.T) { dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755)) writeFile(t, filepath.Join(dir, "sub", "c.go"), "package sub\n\nfunc C() {}\n") writeFile(t, filepath.Join(dir, "root.go"), "package main\n\nfunc Root() {}\n") g := graph.New() idx := newTestIndexer(g) _, err := idx.Index(dir) require.NoError(t, err) bumpMtime(t, filepath.Join(dir, "sub", "c.go"), "package sub\n\nfunc C() {}\n\nfunc CRel() {}\n") bumpMtime(t, filepath.Join(dir, "root.go"), "package main\n\nfunc Root() {}\n\nfunc RootRel() {}\n") // "sub" is repo-root-relative. res, err := idx.IncrementalReindexPaths(dir, []string{"sub"}) require.NoError(t, err) assert.Equal(t, 1, res.StaleFileCount) assert.NotEmpty(t, g.FindNodesByName("CRel")) assert.Empty(t, g.FindNodesByName("RootRel"), "the root-level file is outside the scoped relative path") } // TestIncrementalReindexPaths_ScopesToSingleFile checks that a single // file path scopes the pass to exactly that file. func TestIncrementalReindexPaths_ScopesToSingleFile(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "one.go"), "package main\n\nfunc One() {}\n") writeFile(t, filepath.Join(dir, "two.go"), "package main\n\nfunc Two() {}\n") g := graph.New() idx := newTestIndexer(g) _, err := idx.Index(dir) require.NoError(t, err) bumpMtime(t, filepath.Join(dir, "one.go"), "package main\n\nfunc One() {}\n\nfunc OneEdited() {}\n") bumpMtime(t, filepath.Join(dir, "two.go"), "package main\n\nfunc Two() {}\n\nfunc TwoEdited() {}\n") res, err := idx.IncrementalReindexPaths(dir, []string{filepath.Join(dir, "one.go")}) require.NoError(t, err) assert.Equal(t, 1, res.StaleFileCount) assert.Equal(t, 1, res.FileCount, "FileCount for a single-file scope is the one in-scope file") assert.NotEmpty(t, g.FindNodesByName("OneEdited")) assert.Empty(t, g.FindNodesByName("TwoEdited"), "the unscoped file must not be re-indexed") } // TestIncrementalReindexPaths_EvictsDeletedFileInScope verifies that a // file deleted from disk under a scoped path is evicted, while a // deletion outside the scope is left alone. func TestIncrementalReindexPaths_EvictsDeletedFileInScope(t *testing.T) { dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "in"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(dir, "out"), 0o755)) inGone := filepath.Join(dir, "in", "gone.go") outGone := filepath.Join(dir, "out", "gone.go") writeFile(t, inGone, "package in\n\nfunc InGone() {}\n") writeFile(t, outGone, "package out\n\nfunc OutGone() {}\n") g := graph.New() idx := newTestIndexer(g) _, err := idx.Index(dir) require.NoError(t, err) require.NotEmpty(t, g.FindNodesByName("InGone")) require.NotEmpty(t, g.FindNodesByName("OutGone")) require.NoError(t, os.Remove(inGone)) require.NoError(t, os.Remove(outGone)) _, err = idx.IncrementalReindexPaths(dir, []string{filepath.Join(dir, "in")}) require.NoError(t, err) assert.Empty(t, g.FindNodesByName("InGone"), "a file deleted under the scoped path must be evicted") assert.NotEmpty(t, g.FindNodesByName("OutGone"), "a deletion outside the scoped path must NOT be evicted by a scoped pass") } // TestIncrementalReindexPaths_RejectsPathOutsideRoot verifies that a // scoped path escaping the repository root is rejected — scoping is a // narrowing operation, never an escape hatch. func TestIncrementalReindexPaths_RejectsPathOutsideRoot(t *testing.T) { root := t.TempDir() outside := t.TempDir() writeFile(t, filepath.Join(root, "main.go"), "package main\n\nfunc Main() {}\n") g := graph.New() idx := newTestIndexer(g) _, err := idx.Index(root) require.NoError(t, err) _, err = idx.IncrementalReindexPaths(root, []string{outside}) require.Error(t, err, "a path outside the repo root must be rejected") assert.Contains(t, err.Error(), "outside repository root") } // TestIncrementalReindexPaths_ConvergesForScopedFile is the consistency // invariant for the scoped path: editing a file and reconciling it with // a file-scoped IncrementalReindexPaths must leave that file's symbols // identical to a full index of the same disk state. func TestIncrementalReindexPaths_ConvergesForScopedFile(t *testing.T) { build := func(dir string) { writeFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() { helper() }\n\nfunc helper() {}\n") writeFile(t, filepath.Join(dir, "other.go"), "package main\n\nfunc Other() {}\n") } dirA := t.TempDir() build(dirA) gA := graph.New() idxA := newTestIndexer(gA) _, err := idxA.Index(dirA) require.NoError(t, err) bumpMtime(t, filepath.Join(dirA, "main.go"), "package main\n\nfunc main() { helper(); helper() }\n\nfunc helper() {}\n") _, err = idxA.IncrementalReindexPaths(dirA, []string{filepath.Join(dirA, "main.go")}) require.NoError(t, err) dirB := t.TempDir() build(dirB) writeFile(t, filepath.Join(dirB, "main.go"), "package main\n\nfunc main() { helper(); helper() }\n\nfunc helper() {}\n") gB := graph.New() idxB := newTestIndexer(gB) _, err = idxB.Index(dirB) require.NoError(t, err) // main.go's symbols must match between the scoped-incremental and // the full-index graphs. assert.Len(t, gA.FindNodesByName("main"), len(gB.FindNodesByName("main"))) assert.Len(t, gA.FindNodesByName("helper"), len(gB.FindNodesByName("helper"))) assert.NotEmpty(t, gA.FindNodesByName("Other"), "a scoped reindex must leave the untouched file's nodes intact") }