Pad/internal/io/pool/task.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

784 lines
22 KiB
Go

package pool
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"pad/internal/io/pool/types"
)
// taskIDCounter provides unique IDs for tasks.
var taskIDCounter atomic.Int64
// Task defines the interface for a worker task.
type Task interface {
Execute() Result
Priority() Priority
TaskType() TaskType
TaskID() string
DirPath() string
Context() context.Context
Timeout() time.Duration
Cancel()
}
// Priority defines task priority.
type Priority int
const (
LowPriority Priority = iota
MediumPriority
HighPriority
)
func (p Priority) String() string {
switch p {
case LowPriority:
return "low"
case MediumPriority:
return "medium"
case HighPriority:
return "high"
default:
return "unknown"
}
}
// TaskType defines the type of task.
type TaskType int
const (
TypeUnknown TaskType = iota
TypeReadFile
TypeReadChunk
TypeStatFile
TypeBuildLineIndex
TypeWriteFile
TypeBuildIndex
// Browser task types
TypeReadDir
TypeLoadIndex
TypeLoadPages
TypeStatDir
// Cache task types
TypeWriteCache
TypeReadCache
TypeInvalidate
// State persistence types
TypeSaveState
TypeSaveUndo
// In-file search (scans an in-memory content snapshot on a worker)
TypeSearch
)
func (t TaskType) String() string {
switch t {
case TypeReadFile:
return "read_file"
case TypeReadChunk:
return "read_chunk"
case TypeStatFile:
return "stat_file"
case TypeBuildLineIndex:
return "build_line_index"
case TypeWriteFile:
return "write_file"
case TypeBuildIndex:
return "build_index"
case TypeReadDir:
return "read_dir"
case TypeLoadIndex:
return "load_index"
case TypeLoadPages:
return "load_pages"
case TypeStatDir:
return "stat_dir"
case TypeWriteCache:
return "write_cache"
case TypeReadCache:
return "read_cache"
case TypeInvalidate:
return "invalidate_cache"
case TypeSaveState:
return "save_state"
case TypeSaveUndo:
return "save_undo"
case TypeSearch:
return "search"
default:
return "unknown"
}
}
// --- Specific Task Implementations ---
// ReadChunkTask reads a specific chunk of a file.
type ReadChunkTask struct {
taskID string
Path string
ChunkIdx int
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewReadChunkTask creates a new ReadChunkTask.
func NewReadChunkTask(path string, chunkIdx int, fs FileSystem) *ReadChunkTask {
ctx, cancel := context.WithCancel(context.Background())
return &ReadChunkTask{
taskID: fmt.Sprintf("readchunk-%d-%s", chunkIdx, filepath.Base(path)),
Path: path,
ChunkIdx: chunkIdx,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *ReadChunkTask) Execute() Result {
// Use ReadFileAt to read only the specific chunk range (not the entire file)
chunkSize := 64 * 1024 // Must match the chunk size used in ChunkedBuffer
start := t.ChunkIdx * chunkSize
chunk, err := t.FS.ReadFileAt(t.Path, start, chunkSize)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeReadChunk, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read chunk %d: %w", t.ChunkIdx, err)}
}
return Result{
TaskID: t.taskID,
TaskType: TypeReadChunk,
FilePath: t.Path,
Success: true,
Data: chunk,
ChunkIdx: t.ChunkIdx, // Explicitly pass the chunk index
Timestamp: time.Now(),
}
}
func (t *ReadChunkTask) Priority() Priority { return HighPriority }
func (t *ReadChunkTask) TaskType() TaskType { return TypeReadChunk }
func (t *ReadChunkTask) TaskID() string { return t.taskID }
func (t *ReadChunkTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadChunkTask) Context() context.Context { return t.ctx }
func (t *ReadChunkTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadChunkTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// StatFileTask retrieves file metadata (like size and modification time).
// The plan indicates this replaces the full-file ReadFileTask for opening.
type StatFileTask struct {
taskID string
Path string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewStatFileTask creates a new StatFileTask.
func NewStatFileTask(path string, fs FileSystem) *StatFileTask {
ctx, cancel := context.WithCancel(context.Background())
return &StatFileTask{
taskID: fmt.Sprintf("statfile-%s", filepath.Base(path)),
Path: path,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *StatFileTask) Execute() Result {
// In the current plan, this reads the full file content to get size.
// A more optimized version for large files would use os.Stat or similar,
// which returns size directly without reading content.
content, err := t.FS.ReadFile(t.Path)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeStatFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file for stat: %w", err)}
}
// We also need mtime for invalidation, but FileInfo might not be directly returned as Data.
// For now, just returning size.
return Result{
TaskID: t.taskID, TaskType: TypeStatFile, FilePath: t.Path, Success: true,
Data: &FileStat{Path: t.Path, Size: int64(len(content))},
}
}
func (t *StatFileTask) Priority() Priority { return MediumPriority }
func (t *StatFileTask) TaskType() TaskType { return TypeStatFile }
func (t *StatFileTask) TaskID() string { return t.taskID }
func (t *StatFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *StatFileTask) Context() context.Context { return t.ctx }
func (t *StatFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *StatFileTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// FileStat holds file metadata.
type FileStat struct {
Path string
Size int64
// MTime time.Time // Add this if FileSystem.Stat returns it and we need it
}
// BuildLineIndexTask builds the line index for a file.
type BuildLineIndexTask struct {
taskID string
Path string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewBuildLineIndexTask creates a new BuildLineIndexTask.
func NewBuildLineIndexTask(path string, fs FileSystem) *BuildLineIndexTask {
ctx, cancel := context.WithCancel(context.Background())
return &BuildLineIndexTask{
taskID: fmt.Sprintf("buildindex-%s", filepath.Base(path)),
Path: path,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *BuildLineIndexTask) Execute() Result {
content, err := t.FS.ReadFile(t.Path)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeBuildLineIndex, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file for index build: %w", err)}
}
offsets := []int32{0} // Line 0 starts at byte offset 0
for i := 0; i < len(content); i++ {
if content[i] == '\n' {
offsets = append(offsets, int32(i+1))
}
}
lineIndex := types.NewLineIndex(offsets, 0, int64(len(content)))
return Result{
TaskID: t.taskID, TaskType: TypeBuildLineIndex, FilePath: t.Path, Success: true,
Data: lineIndex,
}
}
func (t *BuildLineIndexTask) Priority() Priority { return LowPriority }
func (t *BuildLineIndexTask) TaskType() TaskType { return TypeBuildLineIndex }
func (t *BuildLineIndexTask) TaskID() string { return t.taskID }
func (t *BuildLineIndexTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *BuildLineIndexTask) Context() context.Context { return t.ctx }
func (t *BuildLineIndexTask) Timeout() time.Duration { return 30 * time.Second }
func (t *BuildLineIndexTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// BuildIndexTask builds the directory index for the browser.
type BuildIndexTask struct {
taskID string
Dir string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewBuildIndexTask creates a new BuildIndexTask.
func NewBuildIndexTask(dir string, fs FileSystem) *BuildIndexTask {
ctx, cancel := context.WithCancel(context.Background())
return &BuildIndexTask{
taskID: fmt.Sprintf("buildindex-%s", filepath.Base(dir)),
Dir: dir,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *BuildIndexTask) Execute() Result {
entries, err := t.FS.ReadDir(t.Dir)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeBuildIndex, Success: false, Error: fmt.Errorf("failed to read directory: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeBuildIndex, Success: true, Data: entries}
}
func (t *BuildIndexTask) Priority() Priority { return HighPriority }
func (t *BuildIndexTask) TaskType() TaskType { return TypeBuildIndex }
func (t *BuildIndexTask) TaskID() string { return t.taskID }
func (t *BuildIndexTask) DirPath() string { return t.Dir }
func (t *BuildIndexTask) Context() context.Context { return t.ctx }
func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second }
func (t *BuildIndexTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// LoadPagesTask loads specific pages from a directory index.
type LoadPagesTask struct {
taskID string
Dir string
PageIdxs []int
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewLoadPagesTask creates a new LoadPagesTask.
func NewLoadPagesTask(dir string, pageIdxs []int, fs FileSystem) *LoadPagesTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &LoadPagesTask{
taskID: fmt.Sprintf("loadpages-%s-%d", filepath.Base(dir), id),
Dir: dir,
PageIdxs: pageIdxs,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *LoadPagesTask) Execute() Result {
// In a real implementation, this would read the actual entry data for the pages.
// For now, just return the page indices as success — the browser manager
// will load the actual data from the SortIndex.
return Result{TaskID: t.taskID, TaskType: TypeLoadPages, Success: true, Data: t.PageIdxs}
}
func (t *LoadPagesTask) Priority() Priority { return HighPriority }
func (t *LoadPagesTask) TaskType() TaskType { return TypeLoadPages }
func (t *LoadPagesTask) TaskID() string { return t.taskID }
func (t *LoadPagesTask) DirPath() string { return t.Dir }
func (t *LoadPagesTask) Context() context.Context { return t.ctx }
func (t *LoadPagesTask) Timeout() time.Duration { return 5 * time.Second }
func (t *LoadPagesTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// ReadDirTask reads directory entries.
type ReadDirTask struct {
taskID string
Dir string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewReadDirTask creates a new ReadDirTask.
func NewReadDirTask(dir string, fs FileSystem) *ReadDirTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &ReadDirTask{
taskID: fmt.Sprintf("readdir-%s-%d", filepath.Base(dir), id),
Dir: dir,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *ReadDirTask) Execute() Result {
entries, err := t.FS.ReadDir(t.Dir)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeReadDir, Success: false, Error: fmt.Errorf("failed to read directory: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeReadDir, Success: true, Data: entries}
}
func (t *ReadDirTask) Priority() Priority { return HighPriority }
func (t *ReadDirTask) TaskType() TaskType { return TypeReadDir }
func (t *ReadDirTask) TaskID() string { return t.taskID }
func (t *ReadDirTask) DirPath() string { return t.Dir }
func (t *ReadDirTask) Context() context.Context { return t.ctx }
func (t *ReadDirTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadDirTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// SaveStateTask persists application state.
type SaveStateTask struct {
taskID string
Path string
Content []byte
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewSaveStateTask creates a new SaveStateTask.
func NewSaveStateTask(path string, content []byte, fs FileSystem) *SaveStateTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &SaveStateTask{
taskID: fmt.Sprintf("savestate-%s-%d", filepath.Base(path), id),
Path: path,
Content: content,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *SaveStateTask) Execute() Result {
err := t.FS.WriteFileAtomic(t.Path, t.Content)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeSaveState, Success: false, Error: fmt.Errorf("failed to save state: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeSaveState, Success: true}
}
func (t *SaveStateTask) Priority() Priority { return LowPriority }
func (t *SaveStateTask) TaskType() TaskType { return TypeSaveState }
func (t *SaveStateTask) TaskID() string { return t.taskID }
func (t *SaveStateTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveStateTask) Context() context.Context { return t.ctx }
func (t *SaveStateTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveStateTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// SaveUndoTask persists undo stack.
type SaveUndoTask struct {
taskID string
Path string
Content []byte
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewSaveUndoTask creates a new SaveUndoTask.
func NewSaveUndoTask(path string, content []byte, fs FileSystem) *SaveUndoTask {
ctx, cancel := context.WithCancel(context.Background())
id := taskIDCounter.Add(1)
return &SaveUndoTask{
taskID: fmt.Sprintf("saveundo-%s-%d", filepath.Base(path), id),
Path: path,
Content: content,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *SaveUndoTask) Execute() Result {
err := t.FS.WriteFileAtomic(t.Path, t.Content)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeSaveUndo, Success: false, Error: fmt.Errorf("failed to save undo: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeSaveUndo, Success: true}
}
func (t *SaveUndoTask) Priority() Priority { return LowPriority }
func (t *SaveUndoTask) TaskType() TaskType { return TypeSaveUndo }
func (t *SaveUndoTask) TaskID() string { return t.taskID }
func (t *SaveUndoTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveUndoTask) Context() context.Context { return t.ctx }
func (t *SaveUndoTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveUndoTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// ReadFileTask reads the full content of a file.
// Used as a fallback for small files or initial load before chunking is set up.
type ReadFileTask struct {
taskID string
Path string
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewReadFileTask creates a new ReadFileTask.
func NewReadFileTask(path string, fs FileSystem) *ReadFileTask {
ctx, cancel := context.WithCancel(context.Background())
return &ReadFileTask{
taskID: fmt.Sprintf("readfile-%s", filepath.Base(path)),
Path: path,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *ReadFileTask) Execute() Result {
content, err := t.FS.ReadFile(t.Path)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeReadFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeReadFile, FilePath: t.Path, Success: true, Data: content}
}
func (t *ReadFileTask) Priority() Priority { return HighPriority }
func (t *ReadFileTask) TaskType() TaskType { return TypeReadFile }
func (t *ReadFileTask) TaskID() string { return t.taskID }
func (t *ReadFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadFileTask) Context() context.Context { return t.ctx }
func (t *ReadFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadFileTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// SearchData is the payload of a completed SearchTask: the byte ranges
// [start, end) of every match of Query in the scanned Text, ascending and
// non-overlapping. Gen is the query generation the scan was dispatched for,
// so the caller can drop stale results.
type SearchData struct {
Gen uint64
Matches [][2]int
}
// SearchTask scans an in-memory content snapshot for a plain, case-
// insensitive substring. The caller snapshots the content (the editor's
// in-range files are fully resident) so the scan never races edits or chunk
// state; a superseding query gets a higher Gen and its result is dropped by
// the caller. Matching is plain substring (no regex), case-insensitive via
// unicode lower-casing of both sides, matching the browser's name filter.
type SearchTask struct {
taskID string
Text string
Query string
Gen uint64
ctx context.Context
cancel context.CancelFunc
}
// NewSearchTask creates a new SearchTask.
func NewSearchTask(text, query string, gen uint64) *SearchTask {
ctx, cancel := context.WithCancel(context.Background())
return &SearchTask{
taskID: fmt.Sprintf("search-%d", taskIDCounter.Add(1)),
Text: text,
Query: query,
Gen: gen,
ctx: ctx,
cancel: cancel,
}
}
func (t *SearchTask) Execute() Result {
matches := FindSubstring(t.Text, t.Query)
return Result{TaskID: t.taskID, TaskType: TypeSearch, Success: true, Data: SearchData{Gen: t.Gen, Matches: matches}}
}
// FindSubstring returns the byte ranges [start, end) of every occurrence of
// query in text, case-insensitively. Occurrences are ascending and non-
// overlapping (a match consumes its range, so "aa" finds one match in
// "aaa"). An empty query matches nothing.
func FindSubstring(text, query string) [][2]int {
if query == "" {
return nil
}
q := toLowerFold(query)
t := toLowerFold(text)
var matches [][2]int
from := 0
for {
i := indexOf(t[from:], q)
if i < 0 {
return matches
}
abs := from + i
matches = append(matches, [2]int{abs, abs + len(q)})
from = abs + len(q)
}
}
// toLowerFold lower-cases s without allocating when s is already
// lower-case ASCII (the common case for both query and content).
func toLowerFold(s string) string {
for i := 0; i < len(s); i++ {
if b := s[i]; b >= 'A' && b <= 'Z' {
return strings.ToLower(s)
}
}
return s
}
// indexOf is strings.Index but kept local so the scan above reads as one
// unit; it is the standard library's two-way search.
func indexOf(s, sub string) int { return strings.Index(s, sub) }
func (t *SearchTask) Priority() Priority { return MediumPriority }
func (t *SearchTask) TaskType() TaskType { return TypeSearch }
func (t *SearchTask) TaskID() string { return t.taskID }
func (t *SearchTask) DirPath() string { return "" }
func (t *SearchTask) Context() context.Context { return t.ctx }
func (t *SearchTask) Timeout() time.Duration { return 10 * time.Second }
func (t *SearchTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// WriteFileTask writes content to a file.
type WriteFileTask struct {
taskID string
Path string
Content []byte
FS FileSystem
ctx context.Context
cancel context.CancelFunc
}
// NewWriteFileTask creates a new WriteFileTask.
func NewWriteFileTask(path string, content []byte, fs FileSystem) *WriteFileTask {
ctx, cancel := context.WithCancel(context.Background())
return &WriteFileTask{
taskID: fmt.Sprintf("writefile-%s", filepath.Base(path)),
Path: path,
Content: content,
FS: fs,
ctx: ctx,
cancel: cancel,
}
}
func (t *WriteFileTask) Execute() Result {
err := t.FS.WriteFile(t.Path, t.Content)
if err != nil {
return Result{TaskID: t.taskID, TaskType: TypeWriteFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to write file: %w", err)}
}
return Result{TaskID: t.taskID, TaskType: TypeWriteFile, FilePath: t.Path, Success: true}
}
func (t *WriteFileTask) Priority() Priority { return LowPriority }
func (t *WriteFileTask) TaskType() TaskType { return TypeWriteFile }
func (t *WriteFileTask) TaskID() string { return t.taskID }
func (t *WriteFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *WriteFileTask) Context() context.Context { return t.ctx }
func (t *WriteFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *WriteFileTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// --- Mock File System (for testing/development) ---
// MockFS implements the pool.FileSystem interface for testing.
type MockFS struct {
files map[string][]byte
}
func NewMockFS() *MockFS {
return &MockFS{
files: make(map[string][]byte),
}
}
func (m *MockFS) ReadFile(path string) ([]byte, error) {
content, ok := m.files[path]
if !ok {
return nil, fmt.Errorf("file not found: %s", path)
}
return content, nil
}
func (m *MockFS) ReadFileAt(path string, offset, size int) ([]byte, error) {
content, ok := m.files[path]
if !ok {
return nil, fmt.Errorf("file not found: %s", path)
}
if offset >= len(content) {
return []byte{}, nil
}
end := offset + size
if end > len(content) {
end = len(content)
}
result := make([]byte, len(content[offset:end]))
copy(result, content[offset:end])
return result, nil
}
func (m *MockFS) WriteFile(path string, content []byte) error {
m.files[path] = content
return nil
}
func (m *MockFS) WriteFileAtomic(path string, content []byte) error {
m.files[path] = content
return nil
}
func (m *MockFS) DeleteFile(path string) error {
delete(m.files, path)
return nil
}
func (m *MockFS) CreateDir(path string) error {
return nil
}
func (m *MockFS) DirExists(path string) bool {
_, ok := m.files[path]
return ok
}
func (m *MockFS) FileExists(path string) bool {
_, ok := m.files[path]
return ok
}
// mockFileInfo implements io.FileInfo for MockFS.
type mockFileInfo struct {
name string
size int64
mode uint32
modTime time.Time
}
func (f *mockFileInfo) Name() string { return f.name }
func (f *mockFileInfo) Size() int64 { return f.size }
func (f *mockFileInfo) Mode() os.FileMode { return os.FileMode(f.mode) }
func (f *mockFileInfo) ModTime() time.Time { return f.modTime }
func (f *mockFileInfo) IsDir() bool { return false }
func (f *mockFileInfo) Sys() any { return nil }
// mockDirEntry implements types.DirEntry for MockFS ReadDir.
type mockDirEntry struct {
name string
isDir bool
}
func (e *mockDirEntry) Name() string { return e.name }
func (e *mockDirEntry) IsDir() bool { return e.isDir }
func (e *mockDirEntry) Info() (os.FileInfo, error) {
return &mockFileInfo{name: e.name, size: 0}, nil
}
func (m *MockFS) ReadDir(path string) ([]types.DirEntry, error) {
var entries []types.DirEntry
for p := range m.files {
if filepath.Dir(p) == path {
entries = append(entries, &mockDirEntry{name: filepath.Base(p), isDir: false})
}
}
return entries, nil
}
var _ FileSystem = (*MockFS)(nil) // Compile-time interface check