1package watcher
  2
  3import (
  4	"context"
  5	"os"
  6	"path/filepath"
  7	"testing"
  8	"time"
  9
 10	"github.com/charmbracelet/crush/internal/csync"
 11	"github.com/fsnotify/fsnotify"
 12)
 13
 14func TestGlobalWatcher(t *testing.T) {
 15	t.Parallel()
 16
 17	// Test that we can get the global watcher instance
 18	gw1 := instance()
 19	if gw1 == nil {
 20		t.Fatal("Expected global watcher instance, got nil")
 21	}
 22
 23	// Test that subsequent calls return the same instance (singleton)
 24	gw2 := instance()
 25	if gw1 != gw2 {
 26		t.Fatal("Expected same global watcher instance, got different instances")
 27	}
 28
 29	// Test registration and unregistration
 30	mockWatcher := &Client{
 31		name: "test-watcher",
 32	}
 33
 34	gw1.register("test", mockWatcher)
 35
 36	// Check that it was registered
 37	registered, _ := gw1.watchers.Get("test")
 38
 39	if registered != mockWatcher {
 40		t.Fatal("Expected workspace watcher to be registered")
 41	}
 42
 43	// Test unregistration
 44	gw1.unregister("test")
 45
 46	unregistered, _ := gw1.watchers.Get("test")
 47
 48	if unregistered != nil {
 49		t.Fatal("Expected workspace watcher to be unregistered")
 50	}
 51}
 52
 53func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
 54	t.Parallel()
 55
 56	// Create a temporary directory for testing
 57	tempDir := t.TempDir()
 58
 59	// Create a new global watcher instance for this test
 60	ctx, cancel := context.WithCancel(context.Background())
 61	defer cancel()
 62
 63	// Create a real fsnotify watcher for testing
 64	watcher, err := fsnotify.NewWatcher()
 65	if err != nil {
 66		t.Fatalf("Failed to create fsnotify watcher: %v", err)
 67	}
 68	defer watcher.Close()
 69
 70	gw := &global{
 71		watcher:      watcher,
 72		watchers:     csync.NewMap[string, *Client](),
 73		debounceTime: 300 * time.Millisecond,
 74		debounceMap:  csync.NewMap[string, *time.Timer](),
 75		ctx:          ctx,
 76		cancel:       cancel,
 77	}
 78
 79	// Test that watching the same workspace multiple times is safe (idempotent)
 80	err1 := gw.addDirectoryToWatcher(tempDir)
 81	if err1 != nil {
 82		t.Fatalf("First addDirectoryToWatcher call failed: %v", err1)
 83	}
 84
 85	err2 := gw.addDirectoryToWatcher(tempDir)
 86	if err2 != nil {
 87		t.Fatalf("Second addDirectoryToWatcher call failed: %v", err2)
 88	}
 89
 90	err3 := gw.addDirectoryToWatcher(tempDir)
 91	if err3 != nil {
 92		t.Fatalf("Third addDirectoryToWatcher call failed: %v", err3)
 93	}
 94
 95	// All calls should succeed - fsnotify handles deduplication internally
 96	// This test verifies that multiple WatchWorkspace calls are safe
 97}
 98
 99func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
100	t.Parallel()
101
102	// Create a temporary directory structure for testing
103	tempDir := t.TempDir()
104	subDir := filepath.Join(tempDir, "subdir")
105	if err := os.Mkdir(subDir, 0o755); err != nil {
106		t.Fatalf("Failed to create subdirectory: %v", err)
107	}
108
109	// Create some files
110	file1 := filepath.Join(tempDir, "file1.txt")
111	file2 := filepath.Join(subDir, "file2.txt")
112	if err := os.WriteFile(file1, []byte("content1"), 0o644); err != nil {
113		t.Fatalf("Failed to create file1: %v", err)
114	}
115	if err := os.WriteFile(file2, []byte("content2"), 0o644); err != nil {
116		t.Fatalf("Failed to create file2: %v", err)
117	}
118
119	// Create a new global watcher instance for this test
120	ctx, cancel := context.WithCancel(context.Background())
121	defer cancel()
122
123	// Create a real fsnotify watcher for testing
124	watcher, err := fsnotify.NewWatcher()
125	if err != nil {
126		t.Fatalf("Failed to create fsnotify watcher: %v", err)
127	}
128	defer watcher.Close()
129
130	gw := &global{
131		watcher:      watcher,
132		watchers:     csync.NewMap[string, *Client](),
133		debounceTime: 300 * time.Millisecond,
134		debounceMap:  csync.NewMap[string, *time.Timer](),
135		ctx:          ctx,
136		cancel:       cancel,
137	}
138
139	// Watch the workspace
140	err = gw.addDirectoryToWatcher(tempDir)
141	if err != nil {
142		t.Fatalf("addDirectoryToWatcher failed: %v", err)
143	}
144
145	// Verify that our expected directories exist and can be watched
146	expectedDirs := []string{tempDir, subDir}
147
148	for _, expectedDir := range expectedDirs {
149		info, err := os.Stat(expectedDir)
150		if err != nil {
151			t.Fatalf("Expected directory %s doesn't exist: %v", expectedDir, err)
152		}
153		if !info.IsDir() {
154			t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
155		}
156
157		// Try to add it again - fsnotify should handle this gracefully
158		err = gw.addDirectoryToWatcher(expectedDir)
159		if err != nil {
160			t.Fatalf("Failed to add directory %s to watcher: %v", expectedDir, err)
161		}
162	}
163
164	// Verify that files exist but we don't try to watch them directly
165	testFiles := []string{file1, file2}
166	for _, file := range testFiles {
167		info, err := os.Stat(file)
168		if err != nil {
169			t.Fatalf("Test file %s doesn't exist: %v", file, err)
170		}
171		if info.IsDir() {
172			t.Fatalf("Expected %s to be a file, but it's a directory", file)
173		}
174	}
175}
176
177func TestFsnotifyDeduplication(t *testing.T) {
178	t.Parallel()
179
180	// Create a temporary directory for testing
181	tempDir := t.TempDir()
182
183	// Create a real fsnotify watcher
184	watcher, err := fsnotify.NewWatcher()
185	if err != nil {
186		t.Fatalf("Failed to create fsnotify watcher: %v", err)
187	}
188	defer watcher.Close()
189
190	// Add the same directory multiple times
191	err1 := watcher.Add(tempDir)
192	if err1 != nil {
193		t.Fatalf("First Add failed: %v", err1)
194	}
195
196	err2 := watcher.Add(tempDir)
197	if err2 != nil {
198		t.Fatalf("Second Add failed: %v", err2)
199	}
200
201	err3 := watcher.Add(tempDir)
202	if err3 != nil {
203		t.Fatalf("Third Add failed: %v", err3)
204	}
205
206	// All should succeed - fsnotify handles deduplication internally
207	// This test verifies the fsnotify behavior we're relying on
208}
209
210func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
211	t.Parallel()
212
213	// Create a temporary directory structure for testing
214	tempDir := t.TempDir()
215
216	// Create directories that should be ignored
217	nodeModules := filepath.Join(tempDir, "node_modules")
218	target := filepath.Join(tempDir, "target")
219	customIgnored := filepath.Join(tempDir, "custom_ignored")
220	normalDir := filepath.Join(tempDir, "src")
221
222	for _, dir := range []string{nodeModules, target, customIgnored, normalDir} {
223		if err := os.MkdirAll(dir, 0o755); err != nil {
224			t.Fatalf("Failed to create directory %s: %v", dir, err)
225		}
226	}
227
228	// Create .gitignore file
229	gitignoreContent := "node_modules/\ntarget/\n"
230	if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
231		t.Fatalf("Failed to create .gitignore: %v", err)
232	}
233
234	// Create .crushignore file
235	crushignoreContent := "custom_ignored/\n"
236	if err := os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644); err != nil {
237		t.Fatalf("Failed to create .crushignore: %v", err)
238	}
239
240	// Create a new global watcher instance for this test
241	ctx, cancel := context.WithCancel(context.Background())
242	defer cancel()
243
244	// Create a real fsnotify watcher for testing
245	watcher, err := fsnotify.NewWatcher()
246	if err != nil {
247		t.Fatalf("Failed to create fsnotify watcher: %v", err)
248	}
249	defer watcher.Close()
250
251	gw := &global{
252		watcher:      watcher,
253		watchers:     csync.NewMap[string, *Client](),
254		debounceTime: 300 * time.Millisecond,
255		debounceMap:  csync.NewMap[string, *time.Timer](),
256		ctx:          ctx,
257		cancel:       cancel,
258	}
259
260	// Watch the workspace
261	err = gw.addDirectoryToWatcher(tempDir)
262	if err != nil {
263		t.Fatalf("addDirectoryToWatcher failed: %v", err)
264	}
265
266	// This test verifies that the watcher can successfully add directories to fsnotify
267	// The actual ignore logic is tested in the fsext package
268	// Here we just verify that the watcher integration works
269}
270
271func TestGlobalWatcherShutdown(t *testing.T) {
272	t.Parallel()
273
274	// Create a new context for this test
275	ctx, cancel := context.WithCancel(context.Background())
276	defer cancel()
277
278	// Create a temporary global watcher for testing
279	gw := &global{
280		watchers:     csync.NewMap[string, *Client](),
281		debounceTime: 300 * time.Millisecond,
282		debounceMap:  csync.NewMap[string, *time.Timer](),
283		ctx:          ctx,
284		cancel:       cancel,
285	}
286
287	// Test shutdown doesn't panic
288	gw.shutdown()
289
290	// Verify context was cancelled
291	select {
292	case <-gw.ctx.Done():
293		// Expected
294	case <-time.After(100 * time.Millisecond):
295		t.Fatal("Expected context to be cancelled after shutdown")
296	}
297}