Pad/internal/io/pool/mock/filesystem.go
Greg Pomerantz 06b1444207 gofmt: format all remaining files with the go1.27 toolchain
The tree was formatted with an older gofmt; go1.27's gofmt additionally
wants: EOF exactly one newline (no trailing blank lines), imports sorted
alphabetically within a block, mixed-precedence binary expressions
re-spaced for grouping ((a+b)/c), single-field composite literals
un-aligned, adjacent one-line method signatures aligned, and one-line
bodies containing a compound statement expanded. Applied repo-wide
(31 files under internal/); pure formatting, no semantic changes —
build and the full test suite pass.
2026-08-23 10:03:27 -04:00

559 lines
12 KiB
Go

// Package mock provides a configurable mock filesystem for testing
// the IO worker pool without touching the real disk.
package mock
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"pad/internal/io/pool/types"
)
// 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
}
// 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
}
// 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
}
// ReadFileAt reads a specific range of bytes from a file at the given path.
func (fs *FileSystem) ReadFileAt(path string, offset, size int) ([]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)
}
if offset >= len(f.Content) {
fs.mu.Unlock()
return []byte{}, nil
}
end := offset + size
if end > len(f.Content) {
end = len(f.Content)
}
content := append([]byte(nil), f.Content[offset:end]...)
fs.mu.Unlock()
return content, nil
}
// WriteFile writes or replaces the content of a file at the given path.
// This is the non-atomic path — use WriteFileAtomic for safety.
func (fs *FileSystem) WriteFile(path string, content []byte) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
if fs.writeError {
fs.mu.Unlock()
return fmt.Errorf("simulated write error")
}
// Determine if this is a create or modify for notification purposes
isNew := !fs.fileExists(path)
name := filepath.Base(path)
now := time.Now()
fs.files[path] = &File{
Path: path,
Name: name,
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, ifElse(isNew, "Created", "Modified")})
}
fs.mu.Unlock()
fs.sendNotifications(events)
return nil
}
// fileExists checks if a file (not directory) exists at the given path.
// Caller must hold fs.mu.
func (fs *FileSystem) fileExists(path string) bool {
f, ok := fs.files[path]
return ok && !f.IsDir
}
func ifElse(cond bool, a, b string) string {
if cond {
return a
}
return b
}
// WriteFileAtomic replaces the content of a file atomically via a temp file + rename.
// Temp files live in a top-level .tmp/ directory.
func (fs *FileSystem) WriteFileAtomic(path string, content []byte) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
if fs.writeError {
fs.mu.Unlock()
return fmt.Errorf("simulated write error")
}
// Determine if this is a create or modify for notification purposes
isNew := !fs.fileExists(path)
// Temp file path: .tmp/<basename>
tempPath := ".tmp/" + filepath.Base(path)
// Ensure .tmp/ directory exists
if _, ok := fs.files[".tmp"]; !ok {
fs.files[".tmp"] = &File{
Path: ".tmp",
Name: ".tmp",
ModTime: time.Now(),
IsDir: true,
}
}
name := filepath.Base(path)
now := time.Now()
// 1. Write to temp location
fs.files[tempPath] = &File{
Path: tempPath,
Name: name + ".tmp",
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
// 2. Atomic rename: insert target + delete temp in one locked operation
fs.files[path] = &File{
Path: path,
Name: name,
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
delete(fs.files, tempPath)
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, ifElse(isNew, "Created", "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) ([]types.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 []types.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 == "" || strings.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
}