v3.42.34: Add spf13/afero for filesystem abstraction
- New internal/fs package for testable filesystem operations - In-memory filesystem support for unit testing without disk I/O - Swappable global FS: SetFS(afero.NewMemMapFs()) - Wrapper functions: ReadFile, WriteFile, Mkdir, Walk, Glob, etc. - Testing helpers: WithMemFs(), SetupTestDir() - Comprehensive test suite demonstrating usage - Upgraded afero from v1.10.0 to v1.15.0
This commit is contained in:
223
internal/fs/fs.go
Normal file
223
internal/fs/fs.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Package fs provides filesystem abstraction using spf13/afero for testability.
|
||||
// It allows swapping the real filesystem with an in-memory mock for unit tests.
|
||||
package fs
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// FS is the global filesystem interface used throughout the application.
|
||||
// By default, it uses the real OS filesystem.
|
||||
// For testing, use SetFS(afero.NewMemMapFs()) to use an in-memory filesystem.
|
||||
var FS afero.Fs = afero.NewOsFs()
|
||||
|
||||
// SetFS sets the global filesystem (useful for testing)
|
||||
func SetFS(fs afero.Fs) {
|
||||
FS = fs
|
||||
}
|
||||
|
||||
// ResetFS resets to the real OS filesystem
|
||||
func ResetFS() {
|
||||
FS = afero.NewOsFs()
|
||||
}
|
||||
|
||||
// NewMemMapFs creates a new in-memory filesystem for testing
|
||||
func NewMemMapFs() afero.Fs {
|
||||
return afero.NewMemMapFs()
|
||||
}
|
||||
|
||||
// NewReadOnlyFs wraps a filesystem to make it read-only
|
||||
func NewReadOnlyFs(base afero.Fs) afero.Fs {
|
||||
return afero.NewReadOnlyFs(base)
|
||||
}
|
||||
|
||||
// NewBasePathFs creates a filesystem rooted at a specific path
|
||||
func NewBasePathFs(base afero.Fs, path string) afero.Fs {
|
||||
return afero.NewBasePathFs(base, path)
|
||||
}
|
||||
|
||||
// --- File Operations (use global FS) ---
|
||||
|
||||
// Create creates a file
|
||||
func Create(name string) (afero.File, error) {
|
||||
return FS.Create(name)
|
||||
}
|
||||
|
||||
// Open opens a file for reading
|
||||
func Open(name string) (afero.File, error) {
|
||||
return FS.Open(name)
|
||||
}
|
||||
|
||||
// OpenFile opens a file with specified flags and permissions
|
||||
func OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
|
||||
return FS.OpenFile(name, flag, perm)
|
||||
}
|
||||
|
||||
// Remove removes a file or empty directory
|
||||
func Remove(name string) error {
|
||||
return FS.Remove(name)
|
||||
}
|
||||
|
||||
// RemoveAll removes a path and any children it contains
|
||||
func RemoveAll(path string) error {
|
||||
return FS.RemoveAll(path)
|
||||
}
|
||||
|
||||
// Rename renames (moves) a file
|
||||
func Rename(oldname, newname string) error {
|
||||
return FS.Rename(oldname, newname)
|
||||
}
|
||||
|
||||
// Stat returns file info
|
||||
func Stat(name string) (os.FileInfo, error) {
|
||||
return FS.Stat(name)
|
||||
}
|
||||
|
||||
// Chmod changes file mode
|
||||
func Chmod(name string, mode os.FileMode) error {
|
||||
return FS.Chmod(name, mode)
|
||||
}
|
||||
|
||||
// Chown changes file ownership (may not work on all filesystems)
|
||||
func Chown(name string, uid, gid int) error {
|
||||
return FS.Chown(name, uid, gid)
|
||||
}
|
||||
|
||||
// Chtimes changes file access and modification times
|
||||
func Chtimes(name string, atime, mtime time.Time) error {
|
||||
return FS.Chtimes(name, atime, mtime)
|
||||
}
|
||||
|
||||
// --- Directory Operations ---
|
||||
|
||||
// Mkdir creates a directory
|
||||
func Mkdir(name string, perm os.FileMode) error {
|
||||
return FS.Mkdir(name, perm)
|
||||
}
|
||||
|
||||
// MkdirAll creates a directory and all parents
|
||||
func MkdirAll(path string, perm os.FileMode) error {
|
||||
return FS.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
// ReadDir reads a directory
|
||||
func ReadDir(dirname string) ([]os.FileInfo, error) {
|
||||
return afero.ReadDir(FS, dirname)
|
||||
}
|
||||
|
||||
// --- File Content Operations ---
|
||||
|
||||
// ReadFile reads an entire file
|
||||
func ReadFile(filename string) ([]byte, error) {
|
||||
return afero.ReadFile(FS, filename)
|
||||
}
|
||||
|
||||
// WriteFile writes data to a file
|
||||
func WriteFile(filename string, data []byte, perm os.FileMode) error {
|
||||
return afero.WriteFile(FS, filename, data, perm)
|
||||
}
|
||||
|
||||
// --- Existence Checks ---
|
||||
|
||||
// Exists checks if a file or directory exists
|
||||
func Exists(path string) (bool, error) {
|
||||
return afero.Exists(FS, path)
|
||||
}
|
||||
|
||||
// DirExists checks if a directory exists
|
||||
func DirExists(path string) (bool, error) {
|
||||
return afero.DirExists(FS, path)
|
||||
}
|
||||
|
||||
// IsDir checks if path is a directory
|
||||
func IsDir(path string) (bool, error) {
|
||||
return afero.IsDir(FS, path)
|
||||
}
|
||||
|
||||
// IsEmpty checks if a directory is empty
|
||||
func IsEmpty(path string) (bool, error) {
|
||||
return afero.IsEmpty(FS, path)
|
||||
}
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
// Walk walks a directory tree
|
||||
func Walk(root string, walkFn filepath.WalkFunc) error {
|
||||
return afero.Walk(FS, root, walkFn)
|
||||
}
|
||||
|
||||
// Glob returns the names of all files matching pattern
|
||||
func Glob(pattern string) ([]string, error) {
|
||||
return afero.Glob(FS, pattern)
|
||||
}
|
||||
|
||||
// TempDir creates a temporary directory
|
||||
func TempDir(dir, prefix string) (string, error) {
|
||||
return afero.TempDir(FS, dir, prefix)
|
||||
}
|
||||
|
||||
// TempFile creates a temporary file
|
||||
func TempFile(dir, pattern string) (afero.File, error) {
|
||||
return afero.TempFile(FS, dir, pattern)
|
||||
}
|
||||
|
||||
// CopyFile copies a file from src to dst
|
||||
func CopyFile(src, dst string) error {
|
||||
srcFile, err := FS.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
srcInfo, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := FS.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
|
||||
// FileSize returns the size of a file
|
||||
func FileSize(path string) (int64, error) {
|
||||
info, err := FS.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// --- Testing Helpers ---
|
||||
|
||||
// WithMemFs executes a function with an in-memory filesystem, then restores the original
|
||||
func WithMemFs(fn func(fs afero.Fs)) {
|
||||
original := FS
|
||||
memFs := afero.NewMemMapFs()
|
||||
FS = memFs
|
||||
defer func() { FS = original }()
|
||||
fn(memFs)
|
||||
}
|
||||
|
||||
// SetupTestDir creates a test directory structure in-memory
|
||||
func SetupTestDir(files map[string]string) afero.Fs {
|
||||
memFs := afero.NewMemMapFs()
|
||||
for path, content := range files {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "/" {
|
||||
_ = memFs.MkdirAll(dir, 0755)
|
||||
}
|
||||
_ = afero.WriteFile(memFs, path, []byte(content), 0644)
|
||||
}
|
||||
return memFs
|
||||
}
|
||||
191
internal/fs/fs_test.go
Normal file
191
internal/fs/fs_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestMemMapFs(t *testing.T) {
|
||||
// Use in-memory filesystem for testing
|
||||
WithMemFs(func(memFs afero.Fs) {
|
||||
// Create a file
|
||||
err := WriteFile("/test/file.txt", []byte("hello world"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Read it back
|
||||
content, err := ReadFile("/test/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "hello world" {
|
||||
t.Errorf("expected 'hello world', got '%s'", string(content))
|
||||
}
|
||||
|
||||
// Check existence
|
||||
exists, err := Exists("/test/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists failed: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("file should exist")
|
||||
}
|
||||
|
||||
// Check non-existent file
|
||||
exists, err = Exists("/nonexistent.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists failed: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("file should not exist")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupTestDir(t *testing.T) {
|
||||
// Create test directory structure
|
||||
testFs := SetupTestDir(map[string]string{
|
||||
"/backups/db1.dump": "database 1 content",
|
||||
"/backups/db2.dump": "database 2 content",
|
||||
"/config/settings.json": `{"key": "value"}`,
|
||||
})
|
||||
|
||||
// Verify files exist
|
||||
content, err := afero.ReadFile(testFs, "/backups/db1.dump")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if string(content) != "database 1 content" {
|
||||
t.Errorf("unexpected content: %s", string(content))
|
||||
}
|
||||
|
||||
// Verify directory structure
|
||||
files, err := afero.ReadDir(testFs, "/backups")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir failed: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Errorf("expected 2 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
WithMemFs(func(memFs afero.Fs) {
|
||||
// Create source file
|
||||
err := WriteFile("/source.txt", []byte("copy me"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Copy file
|
||||
err = CopyFile("/source.txt", "/dest.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify copy
|
||||
content, err := ReadFile("/dest.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if string(content) != "copy me" {
|
||||
t.Errorf("unexpected content: %s", string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileSize(t *testing.T) {
|
||||
WithMemFs(func(memFs afero.Fs) {
|
||||
data := []byte("12345678901234567890") // 20 bytes
|
||||
err := WriteFile("/sized.txt", data, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
size, err := FileSize("/sized.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("FileSize failed: %v", err)
|
||||
}
|
||||
if size != 20 {
|
||||
t.Errorf("expected size 20, got %d", size)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTempDir(t *testing.T) {
|
||||
WithMemFs(func(memFs afero.Fs) {
|
||||
// Create temp dir
|
||||
dir, err := TempDir("", "test-")
|
||||
if err != nil {
|
||||
t.Fatalf("TempDir failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it exists
|
||||
isDir, err := IsDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("IsDir failed: %v", err)
|
||||
}
|
||||
if !isDir {
|
||||
t.Error("temp dir should be a directory")
|
||||
}
|
||||
|
||||
// Verify it's empty
|
||||
isEmpty, err := IsEmpty(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("IsEmpty failed: %v", err)
|
||||
}
|
||||
if !isEmpty {
|
||||
t.Error("temp dir should be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalk(t *testing.T) {
|
||||
WithMemFs(func(memFs afero.Fs) {
|
||||
// Create directory structure
|
||||
_ = MkdirAll("/root/a/b", 0755)
|
||||
_ = WriteFile("/root/file1.txt", []byte("1"), 0644)
|
||||
_ = WriteFile("/root/a/file2.txt", []byte("2"), 0644)
|
||||
_ = WriteFile("/root/a/b/file3.txt", []byte("3"), 0644)
|
||||
|
||||
var files []string
|
||||
err := Walk("/root", func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Walk failed: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 3 {
|
||||
t.Errorf("expected 3 files, got %d: %v", len(files), files)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlob(t *testing.T) {
|
||||
WithMemFs(func(memFs afero.Fs) {
|
||||
_ = WriteFile("/data/backup1.dump", []byte("1"), 0644)
|
||||
_ = WriteFile("/data/backup2.dump", []byte("2"), 0644)
|
||||
_ = WriteFile("/data/config.json", []byte("{}"), 0644)
|
||||
|
||||
matches, err := Glob("/data/*.dump")
|
||||
if err != nil {
|
||||
t.Fatalf("Glob failed: %v", err)
|
||||
}
|
||||
|
||||
if len(matches) != 2 {
|
||||
t.Errorf("expected 2 matches, got %d: %v", len(matches), matches)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user