// Package mock provides a configurable mock filesystem for testing // the IO worker pool without touching the real disk. package mock import ( "fmt" "log" "os" "path/filepath" "sync" "time" ) // File represents a file or directory in the mock filesystem. type File struct { Path string Name string Content []byte ModTime time.Time Size int64 IsDir bool } // DirEntry mimics fs.DirEntry behavior for the mock. type DirEntry struct { name string isDir bool modTime time.Time size int64 } func (d DirEntry) Name() string { return d.name } func (d DirEntry) IsDir() bool { return d.isDir } func (d DirEntry) Info() (os.FileInfo, error) { return &mockFileInfo{name: d.name, isDir: d.isDir, modTime: d.modTime, size: d.size}, nil } // mockFileInfo implements os.FileInfo for the mock. type mockFileInfo struct { name string isDir bool modTime time.Time size int64 } func (fi *mockFileInfo) Name() string { return fi.name } func (fi *mockFileInfo) Size() int64 { return fi.size } func (fi *mockFileInfo) Mode() os.FileMode { return 0644 } func (fi *mockFileInfo) ModTime() time.Time { return fi.modTime } func (fi *mockFileInfo) IsDir() bool { return fi.isDir } func (fi *mockFileInfo) Sys() any { return nil } // FileChangeEvent represents an external filesystem change. type FileChangeEvent struct { Path string EventType string // "Created", "Modified", "Deleted" Time time.Time } // notifyEvent is a pending notification collected while holding the lock. type notifyEvent struct { path string eventType string } // FileSystem is a thread-safe in-memory filesystem for testing. type FileSystem struct { mu sync.RWMutex files map[string]*File // path -> file delay time.Duration // uniform delay applied to all operations changes chan FileChangeEvent // async change notifications (nil = no notifications) writeError bool // Added to simulate write errors } // NewFileSystem creates an empty mock filesystem. func NewFileSystem() *FileSystem { return &FileSystem{ files: make(map[string]*File), } } // SetWriteError simulates I/O errors for Write operations. func (fs *FileSystem) SetWriteError(err bool) { fs.mu.Lock() defer fs.mu.Unlock() fs.writeError = err } // SetDelay sets a uniform delay applied to all IO operations. func (fs *FileSystem) SetDelay(d time.Duration) { fs.mu.Lock() defer fs.mu.Unlock() fs.delay = d } // SetChangeEvents sets the channel for async change notifications. // Pass nil to disable notifications. func (fs *FileSystem) SetChangeEvents(ch chan FileChangeEvent) { fs.mu.Lock() defer fs.mu.Unlock() fs.changes = ch } // sendNotifications sends any pending notification events. // Caller must NOT hold the mutex. func (fs *FileSystem) sendNotifications(events []notifyEvent) { fs.mu.RLock() ch := fs.changes fs.mu.RUnlock() if ch == nil { return } for _, e := range events { select { case ch <- FileChangeEvent{ Path: e.path, EventType: e.eventType, Time: time.Now(), }: default: // Drop event if channel is full (non-blocking) } } } // CreateFile creates a file with the given content at the specified path. func (fs *FileSystem) CreateFile(path string, content []byte) error { fs.mu.Lock() if fs.delay > 0 { fs.mu.Unlock() time.Sleep(fs.delay) fs.mu.Lock() } name := filepath.Base(path) now := time.Now() f := &File{ Path: path, Name: name, Content: content, ModTime: now, Size: int64(len(content)), IsDir: false, } fs.files[path] = f // Collect notification while holding the lock var events []notifyEvent if fs.changes != nil { events = append(events, notifyEvent{path, "Created"}) } fs.mu.Unlock() // Send notifications outside the lock (avoids deadlock) fs.sendNotifications(events) return nil } // ReadFile returns the content of a file at the given path. func (fs *FileSystem) ReadFile(path string) ([]byte, error) { fs.mu.Lock() if fs.delay > 0 { fs.mu.Unlock() time.Sleep(fs.delay) fs.mu.Lock() } f, ok := fs.files[path] if !ok { fs.mu.Unlock() return nil, fmt.Errorf("file not found: %s", path) } content := append([]byte(nil), f.Content...) fs.mu.Unlock() return content, nil } // WriteFile replaces the content of a file at the given path. func (fs *FileSystem) WriteFile(path string, content []byte) error { fs.mu.Lock() log.Printf("FileSystem: WriteFile to %s, writeError=%v", path, fs.writeError) if fs.writeError { fs.mu.Unlock() return fmt.Errorf("simulated write error") } if fs.delay > 0 { fs.mu.Unlock() time.Sleep(fs.delay) fs.mu.Lock() } f, ok := fs.files[path] if !ok { fs.mu.Unlock() return fmt.Errorf("file not found: %s", path) } f.Content = content f.Size = int64(len(content)) f.ModTime = time.Now() var events []notifyEvent if fs.changes != nil { events = append(events, notifyEvent{path, "Modified"}) } fs.mu.Unlock() fs.sendNotifications(events) return nil } // DeleteFile removes a file at the given path. func (fs *FileSystem) DeleteFile(path string) error { fs.mu.Lock() if fs.delay > 0 { fs.mu.Unlock() time.Sleep(fs.delay) fs.mu.Lock() } if _, ok := fs.files[path]; !ok { fs.mu.Unlock() return fmt.Errorf("file not found: %s", path) } delete(fs.files, path) var events []notifyEvent if fs.changes != nil { events = append(events, notifyEvent{path, "Deleted"}) } fs.mu.Unlock() fs.sendNotifications(events) return nil } // CreateDir creates a directory at the given path. func (fs *FileSystem) CreateDir(path string) error { fs.mu.Lock() if fs.delay > 0 { fs.mu.Unlock() time.Sleep(fs.delay) fs.mu.Lock() } name := filepath.Base(path) f := &File{ Path: path, Name: name, ModTime: time.Now(), IsDir: true, } fs.files[path] = f var events []notifyEvent if fs.changes != nil { events = append(events, notifyEvent{path, "Created"}) } fs.mu.Unlock() fs.sendNotifications(events) return nil } // ReadDir returns a list of entries in the given directory. func (fs *FileSystem) ReadDir(path string) ([]DirEntry, error) { fs.mu.Lock() if fs.delay > 0 { fs.mu.Unlock() time.Sleep(fs.delay) fs.mu.Lock() } dirPath := filepath.Clean(path) if _, ok := fs.files[dirPath]; !ok { fs.mu.Unlock() return nil, fmt.Errorf("directory not found: %s", path) } var entries []DirEntry for _, f := range fs.files { if f.Path == dirPath { continue } parent := filepath.Dir(f.Path) if parent == dirPath { entries = append(entries, DirEntry{ name: f.Name, isDir: f.IsDir, modTime: f.ModTime, size: f.Size, }) } } // Sort by name for i := 0; i < len(entries); i++ { for j := i + 1; j < len(entries); j++ { if entries[i].name > entries[j].name { entries[i], entries[j] = entries[j], entries[i] } } } fs.mu.Unlock() return entries, nil } // DirExists returns true if the path exists and is a directory. func (fs *FileSystem) DirExists(path string) bool { fs.mu.RLock() defer fs.mu.RUnlock() f, ok := fs.files[path] return ok && f.IsDir } // FileExists returns true if the path exists and is a file. func (fs *FileSystem) FileExists(path string) bool { fs.mu.RLock() defer fs.mu.RUnlock() f, ok := fs.files[path] return ok && !f.IsDir } // GetFile returns the file at the given path, or nil if not found. func (fs *FileSystem) GetFile(path string) *File { fs.mu.RLock() defer fs.mu.RUnlock() return fs.files[path] } // SetFileContent atomically replaces the content of an existing file. func (fs *FileSystem) SetFileContent(path string, content []byte) { fs.mu.Lock() if fs.delay > 0 { fs.mu.Unlock() time.Sleep(fs.delay) fs.mu.Lock() } f, ok := fs.files[path] if !ok { fs.mu.Unlock() return } f.Content = content f.Size = int64(len(content)) f.ModTime = time.Now() var events []notifyEvent if fs.changes != nil { events = append(events, notifyEvent{path, "Modified"}) } fs.mu.Unlock() fs.sendNotifications(events) } // AddFile adds a file without triggering change notifications. // Useful for pre-populating the filesystem before tests. func (fs *FileSystem) AddFile(path string, content []byte, modTime time.Time) { fs.mu.Lock() defer fs.mu.Unlock() name := filepath.Base(path) f := &File{ Path: path, Name: name, Content: content, ModTime: modTime, Size: int64(len(content)), IsDir: false, } fs.files[path] = f } // AddDir adds a directory without triggering change notifications. func (fs *FileSystem) AddDir(path string, modTime time.Time) { fs.mu.Lock() defer fs.mu.Unlock() name := filepath.Base(path) f := &File{ Path: path, Name: name, ModTime: modTime, IsDir: true, } fs.files[path] = f } // RemoveFile removes a file without triggering change notifications. func (fs *FileSystem) RemoveFile(path string) { fs.mu.Lock() defer fs.mu.Unlock() delete(fs.files, path) } // ListPaths returns all file paths in the filesystem, optionally filtered by prefix. func (fs *FileSystem) ListPaths(prefix string) []string { fs.mu.RLock() defer fs.mu.RUnlock() var paths []string for path := range fs.files { if prefix == "" || filepath.HasPrefix(path, prefix) { paths = append(paths, path) } } return paths } // Count returns the number of files and directories in the mock filesystem. func (fs *FileSystem) Count() (files int, dirs int) { fs.mu.RLock() defer fs.mu.RUnlock() for _, f := range fs.files { if f.IsDir { dirs++ } else { files++ } } return }