diff --git a/internal/publisher/publisher.go b/internal/publisher/publisher.go index 4b046cd..1ba464d 100644 --- a/internal/publisher/publisher.go +++ b/internal/publisher/publisher.go @@ -1,6 +1,7 @@ package publisher import ( + "context" "encoding/json" "fmt" "log/slog" @@ -30,8 +31,8 @@ type Event struct { // SessionData is the payload for cc.session.completed/failed events. type SessionData struct { SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - OwnerUUID string `json:"owner_uuid"` + TaskID string `json:"task_id,omitempty"` + OwnerUUID string `json:"owner_uuid,omitempty"` AgentType string `json:"agent_type"` TranscriptPath string `json:"transcript_path"` FilesChanged []string `json:"files_changed"` @@ -132,11 +133,15 @@ func (p *Publisher) publish(subject, eventType string, s *session.CompletedSessi return fmt.Errorf("marshal event: %w", err) } - if err := p.nc.Publish(subject, evBytes); err != nil { - return fmt.Errorf("publish: %w", err) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ack, err := p.js.Publish(ctx, subject, evBytes) + if err != nil { + return fmt.Errorf("jetstream publish: %w", err) } - p.logger.Info("published session event", "subject", subject, "session_id", s.SessionID, "task_id", taskID) + p.logger.Info("published session event", "subject", subject, "session_id", s.SessionID, "task_id", taskID, "stream", ack.Stream, "seq", ack.Sequence) return nil } diff --git a/internal/publisher/publisher_test.go b/internal/publisher/publisher_test.go new file mode 100644 index 0000000..d701d0e --- /dev/null +++ b/internal/publisher/publisher_test.go @@ -0,0 +1,208 @@ +package publisher + +import ( + "encoding/json" + "testing" + "time" +) + +func TestSessionDataJSON(t *testing.T) { + data := SessionData{ + SessionID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + AgentType: "claude-code", + TranscriptPath: "/home/mike/.claude/projects/-home-mike/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl", + FilesChanged: []string{"/home/mike/main.go"}, + ExitCode: 0, + DurationMs: 60000, + WorkingDir: "/home/mike", + Timestamp: "2026-02-14T10:00:00Z", + } + + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + if decoded["session_id"] != "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" { + t.Errorf("session_id = %v, want aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", decoded["session_id"]) + } + if decoded["agent_type"] != "claude-code" { + t.Errorf("agent_type = %v, want claude-code", decoded["agent_type"]) + } + if decoded["working_dir"] != "/home/mike" { + t.Errorf("working_dir = %v, want /home/mike", decoded["working_dir"]) + } + if int(decoded["exit_code"].(float64)) != 0 { + t.Errorf("exit_code = %v, want 0", decoded["exit_code"]) + } + if int(decoded["duration_ms"].(float64)) != 60000 { + t.Errorf("duration_ms = %v, want 60000", decoded["duration_ms"]) + } +} + +func TestSessionDataOmitempty(t *testing.T) { + // TaskID and OwnerUUID should be omitted when empty. + data := SessionData{ + SessionID: "test-session", + AgentType: "claude-code", + } + + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + if _, ok := decoded["task_id"]; ok { + t.Error("expected task_id to be omitted when empty") + } + if _, ok := decoded["owner_uuid"]; ok { + t.Error("expected owner_uuid to be omitted when empty") + } +} + +func TestSessionDataWithTaskID(t *testing.T) { + data := SessionData{ + SessionID: "test-session", + TaskID: "task-123", + OwnerUUID: "owner-456", + AgentType: "claude-code", + } + + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + if decoded["task_id"] != "task-123" { + t.Errorf("task_id = %v, want task-123", decoded["task_id"]) + } + if decoded["owner_uuid"] != "owner-456" { + t.Errorf("owner_uuid = %v, want owner-456", decoded["owner_uuid"]) + } +} + +func TestEventEnvelopeStructure(t *testing.T) { + sessionData := SessionData{ + SessionID: "test-session", + AgentType: "claude-code", + Timestamp: "2026-02-14T10:00:00Z", + } + + dataRaw, err := json.Marshal(sessionData) + if err != nil { + t.Fatalf("marshal session data failed: %v", err) + } + + ev := Event{ + ID: "evt-001", + Type: "cc.session.completed", + Source: "cc-sidecar", + Timestamp: time.Date(2026, 2, 14, 10, 0, 0, 0, time.UTC), + Data: dataRaw, + } + + raw, err := json.Marshal(ev) + if err != nil { + t.Fatalf("marshal event failed: %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + if decoded["id"] != "evt-001" { + t.Errorf("id = %v, want evt-001", decoded["id"]) + } + if decoded["type"] != "cc.session.completed" { + t.Errorf("type = %v, want cc.session.completed", decoded["type"]) + } + if decoded["source"] != "cc-sidecar" { + t.Errorf("source = %v, want cc-sidecar", decoded["source"]) + } + + // Verify nested data can be unpacked. + dataField, ok := decoded["data"].(map[string]interface{}) + if !ok { + t.Fatal("expected data field to be an object") + } + if dataField["session_id"] != "test-session" { + t.Errorf("data.session_id = %v, want test-session", dataField["session_id"]) + } +} + +func TestEventRoundTrip(t *testing.T) { + sessionData := SessionData{ + SessionID: "round-trip-session", + TaskID: "task-rt", + AgentType: "claude-code", + TranscriptPath: "/some/path.jsonl", + FilesChanged: []string{"/a.go", "/b.go"}, + ExitCode: 1, + DurationMs: 5000, + WorkingDir: "/home/mike", + Timestamp: "2026-02-14T12:00:00Z", + } + + dataRaw, _ := json.Marshal(sessionData) + + ev := Event{ + ID: "evt-rt", + Type: "cc.session.failed", + Source: "cc-sidecar", + Timestamp: time.Date(2026, 2, 14, 12, 0, 0, 0, time.UTC), + Data: dataRaw, + } + + evBytes, err := json.Marshal(ev) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded Event + if err := json.Unmarshal(evBytes, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ID != ev.ID { + t.Errorf("ID = %q, want %q", decoded.ID, ev.ID) + } + if decoded.Type != ev.Type { + t.Errorf("Type = %q, want %q", decoded.Type, ev.Type) + } + if decoded.Source != ev.Source { + t.Errorf("Source = %q, want %q", decoded.Source, ev.Source) + } + if !decoded.Timestamp.Equal(ev.Timestamp) { + t.Errorf("Timestamp = %v, want %v", decoded.Timestamp, ev.Timestamp) + } + + var decodedData SessionData + if err := json.Unmarshal(decoded.Data, &decodedData); err != nil { + t.Fatalf("unmarshal nested data: %v", err) + } + if decodedData.SessionID != "round-trip-session" { + t.Errorf("SessionID = %q, want round-trip-session", decodedData.SessionID) + } + if decodedData.ExitCode != 1 { + t.Errorf("ExitCode = %d, want 1", decodedData.ExitCode) + } + if len(decodedData.FilesChanged) != 2 { + t.Errorf("FilesChanged count = %d, want 2", len(decodedData.FilesChanged)) + } +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..f390227 --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,79 @@ +package registry + +import ( + "encoding/json" + "testing" +) + +func TestTaskMappingJSON(t *testing.T) { + mapping := TaskMapping{ + TaskID: "task-abc-123", + OwnerUUID: "owner-def-456", + } + + raw, err := json.Marshal(mapping) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded TaskMapping + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.TaskID != "task-abc-123" { + t.Errorf("TaskID = %q, want task-abc-123", decoded.TaskID) + } + if decoded.OwnerUUID != "owner-def-456" { + t.Errorf("OwnerUUID = %q, want owner-def-456", decoded.OwnerUUID) + } +} + +func TestTaskMappingJSONFields(t *testing.T) { + mapping := TaskMapping{ + TaskID: "t1", + OwnerUUID: "o1", + } + + raw, err := json.Marshal(mapping) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + // Verify the JSON field names are snake_case as expected. + if _, ok := decoded["task_id"]; !ok { + t.Error("expected JSON field 'task_id'") + } + if _, ok := decoded["owner_uuid"]; !ok { + t.Error("expected JSON field 'owner_uuid'") + } +} + +func TestTaskMappingUnmarshalFromKV(t *testing.T) { + // Simulate what a KV entry value would look like. + kvValue := []byte(`{"task_id":"task-from-kv","owner_uuid":"uuid-from-kv"}`) + + var mapping TaskMapping + if err := json.Unmarshal(kvValue, &mapping); err != nil { + t.Fatalf("unmarshal KV value failed: %v", err) + } + + if mapping.TaskID != "task-from-kv" { + t.Errorf("TaskID = %q, want task-from-kv", mapping.TaskID) + } + if mapping.OwnerUUID != "uuid-from-kv" { + t.Errorf("OwnerUUID = %q, want uuid-from-kv", mapping.OwnerUUID) + } +} + +func TestBucketName(t *testing.T) { + // Verify the bucket name constant hasn't been accidentally changed. + if bucketName != "CC_SESSION_REGISTRY" { + t.Errorf("bucketName = %q, want CC_SESSION_REGISTRY", bucketName) + } +} diff --git a/internal/session/tracker.go b/internal/session/tracker.go index 78134a4..899dfa0 100644 --- a/internal/session/tracker.go +++ b/internal/session/tracker.go @@ -21,16 +21,22 @@ type CompletedSession struct { // trackedFile tracks a JSONL transcript file being written to. type trackedFile struct { - path string - lastWrite time.Time - reported bool + path string + lastWrite time.Time + reported bool + reportedAt time.Time } +// cleanupGrace is how long a reported file stays in the map before eviction. +// This allows Touch() to reset the reported flag if the file is written again. +const cleanupGrace = 5 * time.Minute + // OnComplete is called when a session is detected as complete. type OnComplete func(s *CompletedSession) -// ProcessChecker returns true if a claude process is still running. -type ProcessChecker func() bool +// ProcessChecker returns true if a claude process is still running +// whose working directory matches the given transcript path's project. +type ProcessChecker func(transcriptPath string) bool // Tracker monitors active JSONL files and detects session completion. type Tracker struct { @@ -51,7 +57,7 @@ func NewTracker(idleThreshold, pollInterval time.Duration, logger *slog.Logger, idleThreshold: idleThreshold, pollInterval: pollInterval, onComplete: onComplete, - processCheck: isClaudeRunning, + processCheck: isClaudeRunningForTranscript, logger: logger.With("component", "tracker"), done: make(chan struct{}), } @@ -101,6 +107,16 @@ func (t *Tracker) check() { t.mu.Lock() now := time.Now() for path, tf := range t.files { + // Evict reported files after the grace period to prevent unbounded + // growth of the files map. The grace window allows Touch() to reset + // the reported flag if the file is written to again shortly after + // completion. + if tf.reported && !tf.reportedAt.IsZero() && now.Sub(tf.reportedAt) >= cleanupGrace { + t.logger.Debug("evicting completed transcript from tracker", "path", path) + delete(t.files, path) + continue + } + if tf.reported { continue } @@ -110,13 +126,14 @@ func (t *Tracker) check() { continue } - // Check if claude process is still running. - if t.processCheck() { + // Check if claude process is still running for this transcript. + if t.processCheck(path) { continue } t.logger.Info("session idle, no claude process — completing", "path", path, "idle", idle) tf.reported = true + tf.reportedAt = now readyPaths = append(readyPaths, path) } t.mu.Unlock() @@ -131,8 +148,21 @@ func (t *Tracker) check() { } } -// isClaudeRunning checks /proc for a running claude process. -func isClaudeRunning() bool { +// isClaudeRunningForTranscript checks /proc for a running claude process +// whose working directory matches the project encoded in the transcript path. +// +// Transcript paths follow the pattern: +// +// ~/.claude/projects/{project-slug}/{session-id}.jsonl +// +// The project slug encodes the absolute working directory with "/" replaced by +// "-" and leading slash dropped (e.g., "-home-mike-Warren" for "/home/mike/Warren"). +// We extract this slug, then compare it against each claude process's cwd +// (read from /proc//cwd). If the transcript path doesn't contain the +// expected structure, we fall back to the global "any claude process" check. +func isClaudeRunningForTranscript(transcriptPath string) bool { + projectDir := projectDirFromTranscript(transcriptPath) + entries, err := os.ReadDir("/proc") if err != nil { return false @@ -154,14 +184,72 @@ func isClaudeRunning() bool { } // cmdline is null-separated; check if any arg contains "claude" + isClaude := false parts := strings.Split(string(cmdline), "\x00") for _, part := range parts { base := filepath.Base(part) if base == "claude" || strings.HasPrefix(base, "claude-") { - return true + isClaude = true + break } } + if !isClaude { + continue + } + + // If we have no project dir to match against, fall back to + // "any claude process is running". + if projectDir == "" { + return true + } + + // Read the process's working directory and compare. + cwd, err := os.Readlink(filepath.Join("/proc", name, "cwd")) + if err != nil { + continue + } + if cwd == projectDir { + return true + } } return false } + +// projectDirFromTranscript extracts the working directory from a transcript +// path. Claude Code stores transcripts at: +// +// ~/.claude/projects/{project-slug}/{session-id}.jsonl +// +// The project slug is the absolute path with "/" replaced by "-" and the +// leading slash dropped. For example, "/home/mike/Warren" becomes +// "-home-mike-Warren". We reverse this encoding to recover the original path +// and verify the directory exists. If the path doesn't match the expected +// layout or the decoded directory doesn't exist, we return "" to signal that +// the caller should fall back to the global check. +func projectDirFromTranscript(transcriptPath string) string { + // Walk up to find the "projects" component. + dir := filepath.Dir(transcriptPath) // e.g., ~/.claude/projects/-home-mike-Warren + parent := filepath.Dir(dir) // e.g., ~/.claude/projects + if filepath.Base(parent) != "projects" { + return "" + } + + slug := filepath.Base(dir) // e.g., "-home-mike-Warren" + if slug == "" || slug == "." { + return "" + } + + // The slug is the absolute path with each "/" replaced by "-". + // Reconstruct by replacing "-" with "/". + // The slug starts with "-" because the absolute path starts with "/". + candidate := strings.ReplaceAll(slug, "-", "/") + + // Verify the directory actually exists to avoid false positives. + info, err := os.Stat(candidate) + if err != nil || !info.IsDir() { + return "" + } + + return candidate +} diff --git a/internal/session/tracker_test.go b/internal/session/tracker_test.go index bff4e84..db53236 100644 --- a/internal/session/tracker_test.go +++ b/internal/session/tracker_test.go @@ -2,6 +2,7 @@ package session import ( "os" + "path/filepath" "sync" "testing" "time" @@ -10,7 +11,7 @@ import ( // newTestTracker creates a tracker with process check disabled (always returns false). func newTestTracker(idleThreshold, pollInterval time.Duration, onComplete OnComplete) *Tracker { t := NewTracker(idleThreshold, pollInterval, testLogger(), onComplete) - t.processCheck = func() bool { return false } // no claude process in tests + t.processCheck = func(string) bool { return false } // no claude process in tests return t } @@ -137,7 +138,7 @@ func TestTrackerProcessRunningBlocksCompletion(t *testing.T) { }, ) // Process is "always running" — should never trigger completion. - tracker.processCheck = func() bool { return true } + tracker.processCheck = func(string) bool { return true } go tracker.Start() defer tracker.Stop() @@ -156,3 +157,55 @@ func TestTrackerProcessRunningBlocksCompletion(t *testing.T) { t.Errorf("expected 0 completions while process running, got %d", c) } } + +func TestProjectDirFromTranscript(t *testing.T) { + // Create a real directory to act as the "project dir" so that + // projectDirFromTranscript's os.Stat check passes. + realDir := t.TempDir() + + // Build a slug from the real temp dir path: replace "/" with "-". + // e.g., "/tmp/TestXyz123" -> "-tmp-TestXyz123" + slug := pathToSlug(realDir) + + // Build a fake transcript path: + // /projects//session.jsonl + fakeBase := filepath.Join(t.TempDir(), "projects", slug) + os.MkdirAll(fakeBase, 0755) + transcriptPath := filepath.Join(fakeBase, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl") + + got := projectDirFromTranscript(transcriptPath) + if got != realDir { + t.Errorf("projectDirFromTranscript(%q) = %q, want %q", transcriptPath, got, realDir) + } +} + +func TestProjectDirFromTranscript_NoProjectsParent(t *testing.T) { + // Path without "projects" parent directory should return "". + got := projectDirFromTranscript("/some/random/path/session.jsonl") + if got != "" { + t.Errorf("expected empty string for non-projects path, got %q", got) + } +} + +func TestProjectDirFromTranscript_NonexistentDir(t *testing.T) { + // Slug that decodes to a non-existent directory. + transcriptPath := "/home/mike/.claude/projects/-nonexistent-path-that-does-not-exist/session.jsonl" + got := projectDirFromTranscript(transcriptPath) + if got != "" { + t.Errorf("expected empty string for non-existent decoded dir, got %q", got) + } +} + +// pathToSlug converts an absolute path to a Claude project slug. +// e.g., "/home/mike/Warren" -> "-home-mike-Warren" +func pathToSlug(p string) string { + result := make([]byte, len(p)) + for i, c := range []byte(p) { + if c == '/' { + result[i] = '-' + } else { + result[i] = c + } + } + return string(result) +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 6b23b77..995762e 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,21 +6,25 @@ import ( "path/filepath" "strings" - "github.com/MikeSquared-Agency/cc-sidecar/internal/session" "github.com/fsnotify/fsnotify" ) +// Toucher is satisfied by session.Tracker — accepts file paths on write events. +type Toucher interface { + Touch(path string) +} + // Watcher monitors ~/.claude/projects/ for JSONL transcript changes. type Watcher struct { dir string - tracker *session.Tracker + tracker Toucher logger *slog.Logger fw *fsnotify.Watcher done chan struct{} } // New creates a new transcript watcher. -func New(dir string, tracker *session.Tracker, logger *slog.Logger) (*Watcher, error) { +func New(dir string, tracker Toucher, logger *slog.Logger) (*Watcher, error) { fw, err := fsnotify.NewWatcher() if err != nil { return nil, err diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go new file mode 100644 index 0000000..9ac1154 --- /dev/null +++ b/internal/watcher/watcher_test.go @@ -0,0 +1,161 @@ +package watcher + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fsnotify/fsnotify" +) + +func TestHandleEvent_IgnoresNonJSONL(t *testing.T) { + dir := t.TempDir() + + // Create a tracker that records touches. + var touched []string + tracker := &touchRecorder{touches: &touched} + + w := &Watcher{ + dir: dir, + tracker: tracker, + logger: testLogger(), + fw: nil, // not used by handleEvent for non-Create events + done: make(chan struct{}), + } + + // .txt file should be ignored. + w.handleEvent(fsnotify.Event{ + Name: filepath.Join(dir, "notes.txt"), + Op: fsnotify.Write, + }) + + // .json file should be ignored. + w.handleEvent(fsnotify.Event{ + Name: filepath.Join(dir, "data.json"), + Op: fsnotify.Write, + }) + + if len(touched) != 0 { + t.Errorf("expected 0 touches for non-.jsonl files, got %d: %v", len(touched), touched) + } +} + +func TestHandleEvent_TouchesJSONL(t *testing.T) { + dir := t.TempDir() + + var touched []string + tracker := &touchRecorder{touches: &touched} + + w := &Watcher{ + dir: dir, + tracker: tracker, + logger: testLogger(), + fw: nil, + done: make(chan struct{}), + } + + jsonlPath := filepath.Join(dir, "session.jsonl") + w.handleEvent(fsnotify.Event{ + Name: jsonlPath, + Op: fsnotify.Write, + }) + + if len(touched) != 1 { + t.Fatalf("expected 1 touch, got %d", len(touched)) + } + if touched[0] != jsonlPath { + t.Errorf("touched path = %q, want %q", touched[0], jsonlPath) + } +} + +func TestHandleEvent_CreateJSONL(t *testing.T) { + dir := t.TempDir() + + var touched []string + tracker := &touchRecorder{touches: &touched} + + // We need a real fsnotify.Watcher for Create events on directories, + // but for JSONL Create events the path is not a directory so it falls + // through to the suffix check and Touch. + w := &Watcher{ + dir: dir, + tracker: tracker, + logger: testLogger(), + fw: nil, + done: make(chan struct{}), + } + + jsonlPath := filepath.Join(dir, "new-session.jsonl") + // Create the file so os.Stat succeeds but returns non-dir. + os.WriteFile(jsonlPath, []byte("{}"), 0644) + + w.handleEvent(fsnotify.Event{ + Name: jsonlPath, + Op: fsnotify.Create, + }) + + if len(touched) != 1 { + t.Fatalf("expected 1 touch for created .jsonl, got %d", len(touched)) + } +} + +func TestHandleEvent_IgnoresChmodAndRemove(t *testing.T) { + dir := t.TempDir() + + var touched []string + tracker := &touchRecorder{touches: &touched} + + w := &Watcher{ + dir: dir, + tracker: tracker, + logger: testLogger(), + fw: nil, + done: make(chan struct{}), + } + + jsonlPath := filepath.Join(dir, "session.jsonl") + + w.handleEvent(fsnotify.Event{ + Name: jsonlPath, + Op: fsnotify.Chmod, + }) + + w.handleEvent(fsnotify.Event{ + Name: jsonlPath, + Op: fsnotify.Remove, + }) + + if len(touched) != 0 { + t.Errorf("expected 0 touches for Chmod/Remove, got %d", len(touched)) + } +} + +func TestAddRecursive(t *testing.T) { + root := t.TempDir() + sub1 := filepath.Join(root, "project-a") + sub2 := filepath.Join(root, "project-b") + os.MkdirAll(sub1, 0755) + os.MkdirAll(sub2, 0755) + + fw, err := fsnotify.NewWatcher() + if err != nil { + t.Fatalf("failed to create fsnotify watcher: %v", err) + } + defer fw.Close() + + w := &Watcher{ + dir: root, + logger: testLogger(), + fw: fw, + done: make(chan struct{}), + } + + if err := w.addRecursive(root); err != nil { + t.Fatalf("addRecursive failed: %v", err) + } + + // The watcher should have added root, sub1, and sub2. + // fsnotify doesn't expose a WatchList in all versions, so we verify + // by checking that we can write to a subdirectory and receive an event. + // For simplicity, just verify no error was returned. +} diff --git a/internal/watcher/watcher_test_helpers_test.go b/internal/watcher/watcher_test_helpers_test.go new file mode 100644 index 0000000..c9bc475 --- /dev/null +++ b/internal/watcher/watcher_test_helpers_test.go @@ -0,0 +1,19 @@ +package watcher + +import ( + "log/slog" + "os" +) + +// touchRecorder is a test double for Toucher that records Touch calls. +type touchRecorder struct { + touches *[]string +} + +func (r *touchRecorder) Touch(path string) { + *r.touches = append(*r.touches, path) +} + +func testLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} diff --git a/main.go b/main.go index 98f03c8..d47539c 100644 --- a/main.go +++ b/main.go @@ -95,36 +95,17 @@ func loadConfig(path string, logger *slog.Logger) Config { } cfg.NATS.URL = "nats://localhost:4222" - // Env overrides first (lowest precedence after defaults). - if v := os.Getenv("CC_SIDECAR_NATS_URL"); v != "" { - cfg.NATS.URL = v - } - if v := os.Getenv("CC_SIDECAR_NATS_TOKEN"); v != "" { - cfg.NATS.Token = v - } - if v := os.Getenv("CC_SIDECAR_WATCH_DIR"); v != "" { - cfg.WatchDir = v - } - if v := os.Getenv("CC_SIDECAR_IDLE_THRESHOLD"); v != "" { - if d, err := time.ParseDuration(v); err == nil { - cfg.IdleThreshold = d + // Load config file if provided. + if path != "" { + data, err := os.ReadFile(path) + if err != nil { + logger.Warn("could not read config file, using defaults", "path", path, "error", err) + } else if err := yaml.Unmarshal(data, &cfg); err != nil { + logger.Warn("could not parse config file, using defaults", "path", path, "error", err) } } - if path == "" { - return cfg - } - - data, err := os.ReadFile(path) - if err != nil { - logger.Warn("could not read config file, using defaults", "path", path, "error", err) - return cfg - } - if err := yaml.Unmarshal(data, &cfg); err != nil { - logger.Warn("could not parse config file, using defaults", "path", path, "error", err) - } - - // Re-apply env overrides (highest precedence). + // Env overrides (highest precedence). if v := os.Getenv("CC_SIDECAR_NATS_URL"); v != "" { cfg.NATS.URL = v } @@ -145,7 +126,10 @@ func loadConfig(path string, logger *slog.Logger) Config { func expandHome(path string) string { if strings.HasPrefix(path, "~/") { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + slog.Warn("could not determine home directory, path may be malformed", "path", path, "error", err) + } return filepath.Join(home, path[2:]) } return path