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.
559 lines
12 KiB
Go
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
|
|
}
|