package claudewatcher import ( "context" "log/slog" "os" "path/filepath" "strings" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // recordingHandler captures log records so a test can assert that a file was // never even attempted — the absence of a read error is the evidence. type recordingHandler struct { mu sync.Mutex records []string } func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true } func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error { h.mu.Lock() defer h.mu.Unlock() var sb strings.Builder sb.WriteString(r.Message) r.Attrs(func(a slog.Attr) bool { sb.WriteString(" " + a.Key + "=" + a.Value.String()) return true }) h.records = append(h.records, sb.String()) return nil } func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h } func (h *recordingHandler) WithGroup(string) slog.Handler { return h } func (h *recordingHandler) contains(substr string) bool { h.mu.Lock() defer h.mu.Unlock() for _, r := range h.records { if strings.Contains(r, substr) { return true } } return false } func session(t *testing.T, root, project, id string) string { t.Helper() dir := filepath.Join(root, project) require.NoError(t, os.MkdirAll(dir, 0o755)) return writeSession(t, dir, id, []string{ `{"type":"user","sessionId":"` + id + `","message":"a prompt"}`, `{"type":"assistant","sessionId":"` + id + `","message":{"content":[{"type":"text","text":"an answer"}]}}`, }) } // A named project is not ingested, and its neighbours still are. func TestSkipProjects_skipsTheNamedProjectOnly(t *testing.T) { tmp := t.TempDir() session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "skipped") session(t, tmp, "-home-mathias-dev", "kept") sink := &memSink{} require.NoError(t, TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: []string{"-home-mathias-dev-AGENTS-cobalt-dingo"}, })) require.Len(t, sink.batches, 1) assert.Equal(t, "kept", sink.batches[0].SessionID) assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID) } // Globs are necessary, not a convenience. A project directory is derived from // the session's WORKING DIRECTORY, so a git worktree produces an ephemeral // name like "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC...". An // exact-match list could never keep up with those. func TestSkipProjects_globMatchesEphemeralWorktreeNames(t *testing.T) { tmp := t.TempDir() session(t, tmp, "-home-mathias-dev--claude-worktrees-bridge-cse-017xoCEcrc35", "worktree") session(t, tmp, "-home-mathias-dev", "kept") sink := &memSink{} require.NoError(t, TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: []string{"*claude-worktrees-bridge-cse*"}, })) require.Len(t, sink.batches, 1) assert.Equal(t, "kept", sink.batches[0].SessionID) } // The point of skipping at the directory walk: the file is never OPENED, so // its content never enters this process. Proven by making it unreadable — a // skipped file produces no read error because nothing tried to read it, while // the same file in a non-skipped project does. func TestSkipProjects_theFileIsNeverOpened(t *testing.T) { tmp := t.TempDir() path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable") require.NoError(t, os.Chmod(path, 0o000)) t.Cleanup(func() { _ = os.Chmod(path, 0o644) }) rec := &recordingHandler{} sink := &memSink{} require.NoError(t, TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: sink, Logger: slog.New(rec), SkipProjects: []string{"*cobalt-dingo*"}, })) assert.Empty(t, sink.batches) assert.False(t, rec.contains("file failed"), "a skipped file must not even be attempted") } // The control test for the one above: without the skip, the unreadable file IS // attempted and fails. Otherwise the assertion above could pass for the wrong // reason. func TestSkipProjects_withoutTheSkipTheSameFileIsAttempted(t *testing.T) { tmp := t.TempDir() path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable") require.NoError(t, os.Chmod(path, 0o000)) t.Cleanup(func() { _ = os.Chmod(path, 0o644) }) rec := &recordingHandler{} require.NoError(t, TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: &memSink{}, Logger: slog.New(rec), })) assert.True(t, rec.contains("file failed"), "the unreadable file must fail when not skipped — otherwise the skip test proves nothing") } // Patterns match the project directory NAME, not the whole path. A temp // directory or a home directory component must not accidentally match and // silently disable ingestion. func TestSkipProjects_matchesTheProjectNameNotTheFullPath(t *testing.T) { tmp := t.TempDir() session(t, tmp, "-home-mathias-dev", "kept") sink := &memSink{} require.NoError(t, TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: sink, // tmp is somewhere under /tmp; this must not match the project name. SkipProjects: []string{"*tmp*"}, })) require.Len(t, sink.batches, 1, "a path component outside the project name must not match") } // An unparseable pattern must stop the watcher rather than be ignored. A // silently dropped skip rule means ingesting what was meant to be excluded, // which is the failure that cannot be noticed after the fact. func TestSkipProjects_invalidPatternRefusesToRun(t *testing.T) { tmp := t.TempDir() session(t, tmp, "-home-mathias-dev", "kept") sink := &memSink{} err := TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: []string{"[unclosed"}, }) require.Error(t, err) assert.Contains(t, err.Error(), "[unclosed") assert.Empty(t, sink.batches, "nothing may be ingested while a skip rule is broken") } func TestSkipProjects_emptyListChangesNothing(t *testing.T) { tmp := t.TempDir() session(t, tmp, "-home-mathias-dev", "kept") sink := &memSink{} require.NoError(t, TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: nil, })) require.Len(t, sink.batches, 1) } // The difference between SkipDir and filtering after the walk, made // observable: an unreadable skipped directory must not disturb anything. // // Without SkipDir, WalkDir reads the directory's entries, gets a permission // error, and that error aborts the ENTIRE walk — so one skipped project with // awkward permissions would silently stop ingesting every other project. // Returning SkipDir means its contents are never listed at all. // // Added after a mutation test showed the earlier assertions could not tell the // two implementations apart. func TestSkipProjects_anUnreadableSkippedDirDoesNotBreakTheWalk(t *testing.T) { tmp := t.TempDir() session(t, tmp, "-home-mathias-dev", "kept") blocked := filepath.Join(tmp, "-home-mathias-dev-AGENTS-cobalt-dingo") require.NoError(t, os.MkdirAll(blocked, 0o755)) _ = writeSession(t, blocked, "secret", []string{ `{"type":"user","sessionId":"secret","message":"should never be read"}`, }) require.NoError(t, os.Chmod(blocked, 0o000)) t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) }) sink := &memSink{} require.NoError(t, TickOnce(context.Background(), Config{ SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: []string{"*cobalt-dingo*"}, })) require.Len(t, sink.batches, 1, "an unreadable skipped directory must not abort the walk") assert.Equal(t, "kept", sink.batches[0].SessionID) }