diff --git a/cmd/remindb/serve_test.go b/cmd/remindb/serve_test.go index 7ed7d48..0c0bbec 100644 --- a/cmd/remindb/serve_test.go +++ b/cmd/remindb/serve_test.go @@ -171,6 +171,7 @@ func TestNewServeLogger_JsonFileOutput(t *testing.T) { } if file == nil { t.Fatal("output_path set, file handle should be returned for cleanup") + return } defer func() { _ = file.Close() }() @@ -204,6 +205,7 @@ func TestNewServeLogger_ConfiguredBufferCaptures(t *testing.T) { } if buf == nil { t.Fatal("buffer should be returned for the logs resource") + return } lg.Info("a") diff --git a/internal/pathmatch/ignore_test.go b/internal/pathmatch/ignore_test.go index be7fd8d..b82cb14 100644 --- a/internal/pathmatch/ignore_test.go +++ b/internal/pathmatch/ignore_test.go @@ -41,6 +41,7 @@ func TestLoadIgnore_EmptyFile(t *testing.T) { } if m == nil { t.Fatal("expected non-nil matcher for empty file") + return } if m.Match("anything.md", false) { t.Error("empty matcher should not match") @@ -57,6 +58,7 @@ func TestLoadIgnore_CommentsAndBlanks(t *testing.T) { } if m == nil { t.Fatal("expected non-nil matcher") + return } if len(m.patterns) != 0 { t.Errorf("expected 0 patterns, got %d", len(m.patterns)) diff --git a/mcp_integration_test.go b/mcp_integration_test.go index fe76f6a..5a54ba9 100644 --- a/mcp_integration_test.go +++ b/mcp_integration_test.go @@ -944,6 +944,7 @@ func TestMcp_OverviewResource(t *testing.T) { } if overview == nil { t.Fatalf("resources/list missing remindb://overview, got %d resources", len(listed.Resources)) + return } if overview.MIMEType != "application/json" { t.Errorf("overview MIME type = %q, want application/json", overview.MIMEType) @@ -1032,6 +1033,7 @@ func TestMcp_FilesResource(t *testing.T) { } if files == nil { t.Fatalf("resources/list missing remindb://files, got %d resources", len(listed.Resources)) + return } if files.MIMEType != "application/json" { t.Errorf("files MIME type = %q, want application/json", files.MIMEType) @@ -1140,6 +1142,7 @@ func TestMcp_TreeResource(t *testing.T) { } if tree == nil { t.Fatalf("resources/list missing remindb://tree, got %d resources", len(listed.Resources)) + return } if tree.MIMEType != "application/json" { t.Errorf("tree MIME type = %q, want application/json", tree.MIMEType) @@ -1189,6 +1192,7 @@ func TestMcp_TreeResource(t *testing.T) { } if pivot == nil { t.Fatalf("no node with a grandchild found; cannot assert depth bounding") + return } // Shape: every node carries the full field set. @@ -1288,6 +1292,7 @@ func TestMcp_SnapshotsResource(t *testing.T) { } if snapshots == nil { t.Fatalf("resources/list missing remindb://snapshots, got %d resources", len(listed.Resources)) + return } if snapshots.MIMEType != "application/json" { t.Errorf("snapshots MIME type = %q, want application/json", snapshots.MIMEType) @@ -1488,6 +1493,7 @@ func TestMcp_TemperatureResource(t *testing.T) { } if heat == nil { t.Fatalf("resources/list missing remindb://temperature, got %d resources", len(listed.Resources)) + return } if heat.MIMEType != "application/json" { t.Errorf("temperature MIME type = %q, want application/json", heat.MIMEType) @@ -1576,6 +1582,7 @@ func TestMcp_DoctorResource(t *testing.T) { } if doctor == nil { t.Fatalf("resources/list missing remindb://doctor, got %d resources", len(listed.Resources)) + return } if doctor.MIMEType != "application/json" { t.Errorf("doctor MIME type = %q, want application/json", doctor.MIMEType) @@ -1646,6 +1653,7 @@ func TestMcp_LogsResource(t *testing.T) { } if logs == nil { t.Fatalf("resources/list missing remindb://logs, got %d resources", len(listed.Resources)) + return } if logs.MIMEType != "application/json" { t.Errorf("logs MIME type = %q, want application/json", logs.MIMEType) @@ -2100,6 +2108,7 @@ func TestMcp_RescanResource(t *testing.T) { } if rescan == nil { t.Fatalf("resources/list missing remindb://rescan, got %d resources", len(listed.Resources)) + return } if rescan.MIMEType != "application/json" { t.Errorf("rescan MIME type = %q, want application/json", rescan.MIMEType) diff --git a/pkg/mcp/rescan/rescan.go b/pkg/mcp/rescan/rescan.go index d622875..75aa8ef 100644 --- a/pkg/mcp/rescan/rescan.go +++ b/pkg/mcp/rescan/rescan.go @@ -285,27 +285,34 @@ func (r *Loop) scan(ctx context.Context) { return } - var deleted []string + var deletedAbs []string + var deletedRel []string for path := range r.modTimes { if seen[path] { continue } - delete(r.modTimes, path) rel, err := filepath.Rel(r.dir, path) if err != nil { rel = path } - deleted = append(deleted, rel) + deletedAbs = append(deletedAbs, path) + deletedRel = append(deletedRel, rel) } r.store.OpMu.Lock() defer r.store.OpMu.Unlock() - snap.PurgedFiles = r.reconcileDeleted(ctx, deleted) + purged, ok := r.reconcileDeleted(ctx, deletedRel) + snap.PurgedFiles = purged + if ok { + for _, abs := range deletedAbs { + delete(r.modTimes, abs) + } + } if len(changed) == 0 { - if len(deleted) > 0 { + if ok && len(deletedRel) > 0 { r.notifyChange() } @@ -344,18 +351,18 @@ func (r *Loop) scan(ctx context.Context) { r.notifyChange() } -func (r *Loop) reconcileDeleted(ctx context.Context, deleted []string) []rescanstat.PurgedFile { +func (r *Loop) reconcileDeleted(ctx context.Context, deleted []string) ([]rescanstat.PurgedFile, bool) { if len(deleted) == 0 { - return nil + return nil, true } nodes, err := r.store.GetNodesByFiles(ctx, deleted) if err != nil { r.logger.Error("rescan: load deleted nodes failed", "err", err) - return nil + return nil, false } if len(nodes) == 0 { - return nil + return nil, true } deltas := make([]diff.Delta, 0, len(nodes)) @@ -380,7 +387,7 @@ func (r *Loop) reconcileDeleted(ctx context.Context, deleted []string) []rescans emitter.WithMessage(msg), ); err != nil { r.logger.Error("rescan: purge emit failed", "err", err) - return nil + return nil, false } r.logger.Info("rescan: purged deleted files", @@ -395,5 +402,5 @@ func (r *Loop) reconcileDeleted(ctx context.Context, deleted []string) []rescans } sort.Slice(purged, func(i, j int) bool { return purged[i].Path < purged[j].Path }) - return purged + return purged, true } diff --git a/pkg/mcp/rescan/rescan_test.go b/pkg/mcp/rescan/rescan_test.go index 67008b0..79c9754 100644 --- a/pkg/mcp/rescan/rescan_test.go +++ b/pkg/mcp/rescan/rescan_test.go @@ -438,6 +438,77 @@ func TestRescanLoop_SkipsPurgeOnWalkError(t *testing.T) { } } +func TestRescanLoop_RetainsModTimesOnPurgeEmitFailure(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "keep.md", "# Keep\n") + writeFile(t, dir, "gone.md", "# Gone\n\nBody.\n") + + st := testutil.OpenTestDB(t) + r := mustRescan(t, st, dir, time.Minute, nil) + r.now = func() time.Time { return time.Now().Add(time.Hour) } + + ctx := context.Background() + r.scan(ctx) + + before, err := st.GetNodesByFile(ctx, "gone.md") + if err != nil { + t.Fatalf("GetNodesByFile: %v", err) + } + if len(before) == 0 { + t.Fatal("expected gone.md nodes after initial scan") + } + + if err := os.Remove(filepath.Join(dir, "gone.md")); err != nil { + t.Fatal(err) + } + + failCtx, cancel := context.WithCancel(ctx) + originalWalk := r.walkFn + r.walkFn = func(root string, fn fs.WalkDirFunc) error { + if err := originalWalk(root, fn); err != nil { + return err + } + cancel() + return nil + } + + snapsBefore, _ := st.ListSnapshots(ctx, 10) + r.scan(failCtx) + + if _, ok := r.modTimes[filepath.Join(dir, "gone.md")]; !ok { + t.Error("modTimes entry for gone.md dropped despite failed purge emit") + } + + stillThere, err := st.GetNodesByFile(ctx, "gone.md") + if err != nil { + t.Fatalf("GetNodesByFile (after failed emit): %v", err) + } + if len(stillThere) == 0 { + t.Error("gone.md nodes purged from DB even though emit failed") + } + + snapsAfter, _ := st.ListSnapshots(ctx, 10) + if len(snapsAfter) != len(snapsBefore) { + t.Errorf("snapshots changed: before=%d after=%d (emit failed, must not commit purge snapshot)", + len(snapsBefore), len(snapsAfter)) + } + + r.walkFn = originalWalk + r.scan(ctx) + + if _, ok := r.modTimes[filepath.Join(dir, "gone.md")]; ok { + t.Error("modTimes entry for gone.md should be cleared after successful retry") + } + + after, err := st.GetNodesByFile(ctx, "gone.md") + if err != nil { + t.Fatalf("GetNodesByFile (retry): %v", err) + } + if len(after) != 0 { + t.Errorf("orphan nodes after successful retry = %d, want 0", len(after)) + } +} + func TestRescanLoop_NewFile(t *testing.T) { dir := t.TempDir()