go-io/node/node.go

617 lines
16 KiB
Go
Raw Normal View History

2026-03-30 21:29:35 +00:00
// Package node keeps io.Medium data in memory.
//
// nodeTree := node.New()
// nodeTree.AddData("config/app.yaml", []byte("port: 8080"))
// snapshot, _ := nodeTree.ToTar()
// restored, _ := node.FromTar(snapshot)
package node
import (
"archive/tar"
"bytes"
"cmp"
goio "io"
"io/fs"
"path"
"slices"
"time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
)
// Example: nodeTree := node.New()
// nodeTree.AddData("config/app.yaml", []byte("port: 8080"))
// snapshot, _ := nodeTree.ToTar()
// restored, _ := node.FromTar(snapshot)
type Node struct {
files map[string]*dataFile
}
2026-03-30 21:29:35 +00:00
// Compile-time interface checks.
var _ coreio.Medium = (*Node)(nil)
var _ fs.ReadFileFS = (*Node)(nil)
func New() *Node {
return &Node{files: make(map[string]*dataFile)}
}
// ---------- Node-specific methods ----------
// AddData stages content in the in-memory filesystem.
func (n *Node) AddData(name string, content []byte) {
name = core.TrimPrefix(name, "/")
if name == "" {
return
}
// Directories are implicit, so we don't store them.
if core.HasSuffix(name, "/") {
return
}
n.files[name] = &dataFile{
name: name,
content: content,
modTime: time.Now(),
}
}
// ToTar serialises the entire in-memory tree to a tar archive.
func (n *Node) ToTar() ([]byte, error) {
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
for _, file := range n.files {
hdr := &tar.Header{
Name: file.name,
Mode: 0600,
Size: int64(len(file.content)),
ModTime: file.modTime,
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write(file.content); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
2026-03-30 21:29:35 +00:00
// Example: restored, _ := node.FromTar(snapshot)
func FromTar(data []byte) (*Node, error) {
n := New()
if err := n.LoadTar(data); err != nil {
return nil, err
}
return n, nil
}
// LoadTar replaces the in-memory tree with the contents of a tar archive.
func (n *Node) LoadTar(data []byte) error {
newFiles := make(map[string]*dataFile)
tr := tar.NewReader(bytes.NewReader(data))
for {
header, err := tr.Next()
if err == goio.EOF {
break
}
if err != nil {
return err
}
if header.Typeflag == tar.TypeReg {
content, err := goio.ReadAll(tr)
if err != nil {
return core.E("node.LoadTar", "read tar entry", err)
}
name := core.TrimPrefix(header.Name, "/")
if name == "" || core.HasSuffix(name, "/") {
continue
}
newFiles[name] = &dataFile{
name: name,
content: content,
modTime: header.ModTime,
}
}
}
n.files = newFiles
return nil
}
func (n *Node) WalkNode(root string, fn fs.WalkDirFunc) error {
return fs.WalkDir(n, root, fn)
}
// Example: options := node.WalkOptions{MaxDepth: 1, SkipErrors: true}
type WalkOptions struct {
// MaxDepth limits how many directory levels to descend. 0 means unlimited.
MaxDepth int
// Filter, if set, is called for each entry. Return true to include the
// entry (and descend into it if it is a directory).
Filter func(entryPath string, entry fs.DirEntry) bool
// SkipErrors suppresses errors (e.g. nonexistent root) instead of
// propagating them through the callback.
SkipErrors bool
}
// WalkWithOptions walks the in-memory tree with an explicit configuration.
//
// nodeTree := New()
// options := WalkOptions{MaxDepth: 1, SkipErrors: true}
// _ = nodeTree.WalkWithOptions(".", func(path string, entry fs.DirEntry, err error) error { return nil }, options)
func (n *Node) WalkWithOptions(root string, fn fs.WalkDirFunc, options WalkOptions) error {
if options.SkipErrors {
// If root doesn't exist, silently return nil.
if _, err := n.Stat(root); err != nil {
return nil
}
}
return fs.WalkDir(n, root, func(entryPath string, entry fs.DirEntry, err error) error {
if options.Filter != nil && err == nil {
if !options.Filter(entryPath, entry) {
if entry != nil && entry.IsDir() {
return fs.SkipDir
}
return nil
}
}
// Call the user's function first so the entry is visited.
result := fn(entryPath, entry, err)
// After visiting a directory at MaxDepth, prevent descending further.
if result == nil && options.MaxDepth > 0 && entry != nil && entry.IsDir() && entryPath != root {
rel := core.TrimPrefix(entryPath, root)
rel = core.TrimPrefix(rel, "/")
depth := len(core.Split(rel, "/"))
if depth >= options.MaxDepth {
return fs.SkipDir
}
}
return result
})
}
func (n *Node) ReadFile(name string) ([]byte, error) {
name = core.TrimPrefix(name, "/")
f, ok := n.files[name]
if !ok {
return nil, core.E("node.ReadFile", core.Concat("path not found: ", name), fs.ErrNotExist)
}
// Return a copy to prevent callers from mutating internal state.
result := make([]byte, len(f.content))
copy(result, f.content)
return result, nil
}
// CopyFile copies a file from the in-memory tree to the local filesystem.
func (n *Node) CopyFile(sourcePath, destinationPath string, perm fs.FileMode) error {
sourcePath = core.TrimPrefix(sourcePath, "/")
f, ok := n.files[sourcePath]
if !ok {
// Check if it's a directory — can't copy directories this way.
info, err := n.Stat(sourcePath)
if err != nil {
return core.E("node.CopyFile", core.Concat("source not found: ", sourcePath), fs.ErrNotExist)
}
if info.IsDir() {
return core.E("node.CopyFile", core.Concat("source is a directory: ", sourcePath), fs.ErrInvalid)
}
return core.E("node.CopyFile", core.Concat("source not found: ", sourcePath), fs.ErrNotExist)
}
parent := core.PathDir(destinationPath)
if parent != "." && parent != "" && parent != destinationPath && !coreio.Local.IsDir(parent) {
return &fs.PathError{Op: "copyfile", Path: destinationPath, Err: fs.ErrNotExist}
}
return coreio.Local.WriteMode(destinationPath, string(f.content), perm)
}
// CopyTo copies a file (or directory tree) from the node to any Medium.
//
// Example usage:
//
// dst := io.NewMockMedium()
// _ = n.CopyTo(dst, "config", "backup/config")
func (n *Node) CopyTo(target coreio.Medium, sourcePath, destPath string) error {
sourcePath = core.TrimPrefix(sourcePath, "/")
info, err := n.Stat(sourcePath)
if err != nil {
return err
}
if !info.IsDir() {
// Single file copy
f, ok := n.files[sourcePath]
if !ok {
return core.E("node.CopyTo", core.Concat("path not found: ", sourcePath), fs.ErrNotExist)
}
return target.Write(destPath, string(f.content))
}
// Directory: walk and copy all files underneath
prefix := sourcePath
if prefix != "" && !core.HasSuffix(prefix, "/") {
prefix += "/"
}
for filePath, f := range n.files {
if !core.HasPrefix(filePath, prefix) && filePath != sourcePath {
continue
}
rel := core.TrimPrefix(filePath, prefix)
dest := destPath
if rel != "" {
dest = core.Concat(destPath, "/", rel)
}
if err := target.Write(dest, string(f.content)); err != nil {
return err
}
}
return nil
}
// ---------- Medium interface: fs.FS methods ----------
func (n *Node) Open(name string) (fs.File, error) {
name = core.TrimPrefix(name, "/")
if file, ok := n.files[name]; ok {
return &dataFileReader{file: file}, nil
}
// Check if it's a directory
prefix := name + "/"
if name == "." || name == "" {
prefix = ""
}
for filePath := range n.files {
if core.HasPrefix(filePath, prefix) {
return &dirFile{path: name, modTime: time.Now()}, nil
}
}
return nil, core.E("node.Open", core.Concat("path not found: ", name), fs.ErrNotExist)
}
func (n *Node) Stat(name string) (fs.FileInfo, error) {
name = core.TrimPrefix(name, "/")
if file, ok := n.files[name]; ok {
return file.Stat()
}
// Check if it's a directory
prefix := name + "/"
if name == "." || name == "" {
prefix = ""
}
for filePath := range n.files {
if core.HasPrefix(filePath, prefix) {
return &dirInfo{name: path.Base(name), modTime: time.Now()}, nil
}
}
return nil, core.E("node.Stat", core.Concat("path not found: ", name), fs.ErrNotExist)
}
func (n *Node) ReadDir(name string) ([]fs.DirEntry, error) {
name = core.TrimPrefix(name, "/")
if name == "." {
name = ""
}
// Disallow reading a file as a directory.
if info, err := n.Stat(name); err == nil && !info.IsDir() {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
}
entries := []fs.DirEntry{}
seen := make(map[string]bool)
prefix := ""
if name != "" {
prefix = name + "/"
}
for filePath := range n.files {
if !core.HasPrefix(filePath, prefix) {
continue
}
relPath := core.TrimPrefix(filePath, prefix)
firstComponent := core.SplitN(relPath, "/", 2)[0]
if seen[firstComponent] {
continue
}
seen[firstComponent] = true
if core.Contains(relPath, "/") {
dir := &dirInfo{name: firstComponent, modTime: time.Now()}
entries = append(entries, fs.FileInfoToDirEntry(dir))
} else {
file := n.files[filePath]
info, _ := file.Stat()
entries = append(entries, fs.FileInfoToDirEntry(info))
}
}
slices.SortFunc(entries, func(a, b fs.DirEntry) int {
return cmp.Compare(a.Name(), b.Name())
})
return entries, nil
}
// ---------- Medium interface: read/write ----------
func (n *Node) Read(filePath string) (string, error) {
filePath = core.TrimPrefix(filePath, "/")
f, ok := n.files[filePath]
if !ok {
return "", core.E("node.Read", core.Concat("path not found: ", filePath), fs.ErrNotExist)
}
return string(f.content), nil
}
func (n *Node) Write(filePath, content string) error {
n.AddData(filePath, []byte(content))
return nil
}
func (n *Node) WriteMode(filePath, content string, mode fs.FileMode) error {
return n.Write(filePath, content)
}
func (n *Node) FileGet(filePath string) (string, error) {
return n.Read(filePath)
}
func (n *Node) FileSet(filePath, content string) error {
return n.Write(filePath, content)
}
// Example: _ = nodeTree.EnsureDir("config")
func (n *Node) EnsureDir(_ string) error {
return nil
}
// ---------- Medium interface: existence checks ----------
func (n *Node) Exists(filePath string) bool {
_, err := n.Stat(filePath)
return err == nil
}
func (n *Node) IsFile(filePath string) bool {
filePath = core.TrimPrefix(filePath, "/")
_, ok := n.files[filePath]
return ok
}
func (n *Node) IsDir(filePath string) bool {
info, err := n.Stat(filePath)
if err != nil {
return false
}
return info.IsDir()
}
// ---------- Medium interface: mutations ----------
func (n *Node) Delete(filePath string) error {
filePath = core.TrimPrefix(filePath, "/")
if _, ok := n.files[filePath]; ok {
delete(n.files, filePath)
return nil
}
return core.E("node.Delete", core.Concat("path not found: ", filePath), fs.ErrNotExist)
}
func (n *Node) DeleteAll(filePath string) error {
filePath = core.TrimPrefix(filePath, "/")
found := false
if _, ok := n.files[filePath]; ok {
delete(n.files, filePath)
found = true
}
prefix := filePath + "/"
for entryPath := range n.files {
if core.HasPrefix(entryPath, prefix) {
delete(n.files, entryPath)
found = true
}
}
if !found {
return core.E("node.DeleteAll", core.Concat("path not found: ", filePath), fs.ErrNotExist)
}
return nil
}
func (n *Node) Rename(oldPath, newPath string) error {
oldPath = core.TrimPrefix(oldPath, "/")
newPath = core.TrimPrefix(newPath, "/")
f, ok := n.files[oldPath]
if !ok {
return core.E("node.Rename", core.Concat("path not found: ", oldPath), fs.ErrNotExist)
}
f.name = newPath
n.files[newPath] = f
delete(n.files, oldPath)
return nil
}
func (n *Node) List(filePath string) ([]fs.DirEntry, error) {
filePath = core.TrimPrefix(filePath, "/")
if filePath == "" || filePath == "." {
return n.ReadDir(".")
}
return n.ReadDir(filePath)
}
// ---------- Medium interface: streams ----------
func (n *Node) Create(filePath string) (goio.WriteCloser, error) {
filePath = core.TrimPrefix(filePath, "/")
return &nodeWriter{node: n, path: filePath}, nil
}
func (n *Node) Append(filePath string) (goio.WriteCloser, error) {
filePath = core.TrimPrefix(filePath, "/")
var existing []byte
if f, ok := n.files[filePath]; ok {
existing = make([]byte, len(f.content))
copy(existing, f.content)
}
return &nodeWriter{node: n, path: filePath, buf: existing}, nil
}
func (n *Node) ReadStream(filePath string) (goio.ReadCloser, error) {
f, err := n.Open(filePath)
if err != nil {
return nil, err
}
return goio.NopCloser(f), nil
}
func (n *Node) WriteStream(filePath string) (goio.WriteCloser, error) {
return n.Create(filePath)
}
// ---------- Internal types ----------
// nodeWriter buffers writes and commits them to the Node on Close.
type nodeWriter struct {
node *Node
path string
buf []byte
}
func (w *nodeWriter) Write(p []byte) (int, error) {
w.buf = append(w.buf, p...)
return len(p), nil
}
func (w *nodeWriter) Close() error {
w.node.files[w.path] = &dataFile{
name: w.path,
content: w.buf,
modTime: time.Now(),
}
return nil
}
// dataFile represents a file in the Node.
type dataFile struct {
name string
content []byte
modTime time.Time
}
func (d *dataFile) Stat() (fs.FileInfo, error) { return &dataFileInfo{file: d}, nil }
func (d *dataFile) Read(_ []byte) (int, error) { return 0, goio.EOF }
func (d *dataFile) Close() error { return nil }
// dataFileInfo implements fs.FileInfo for a dataFile.
type dataFileInfo struct{ file *dataFile }
func (d *dataFileInfo) Name() string { return path.Base(d.file.name) }
func (d *dataFileInfo) Size() int64 { return int64(len(d.file.content)) }
func (d *dataFileInfo) Mode() fs.FileMode { return 0444 }
func (d *dataFileInfo) ModTime() time.Time { return d.file.modTime }
func (d *dataFileInfo) IsDir() bool { return false }
func (d *dataFileInfo) Sys() any { return nil }
// dataFileReader implements fs.File for reading a dataFile.
type dataFileReader struct {
file *dataFile
reader *bytes.Reader
}
func (d *dataFileReader) Stat() (fs.FileInfo, error) { return d.file.Stat() }
func (d *dataFileReader) Read(p []byte) (int, error) {
if d.reader == nil {
d.reader = bytes.NewReader(d.file.content)
}
return d.reader.Read(p)
}
func (d *dataFileReader) Close() error { return nil }
// dirInfo implements fs.FileInfo for an implicit directory.
type dirInfo struct {
name string
modTime time.Time
}
func (d *dirInfo) Name() string { return d.name }
func (d *dirInfo) Size() int64 { return 0 }
func (d *dirInfo) Mode() fs.FileMode { return fs.ModeDir | 0555 }
func (d *dirInfo) ModTime() time.Time { return d.modTime }
func (d *dirInfo) IsDir() bool { return true }
func (d *dirInfo) Sys() any { return nil }
// dirFile implements fs.File for a directory.
type dirFile struct {
path string
modTime time.Time
}
func (d *dirFile) Stat() (fs.FileInfo, error) {
return &dirInfo{name: path.Base(d.path), modTime: d.modTime}, nil
}
func (d *dirFile) Read([]byte) (int, error) {
return 0, core.E("node.dirFile.Read", core.Concat("cannot read directory: ", d.path), &fs.PathError{Op: "read", Path: d.path, Err: fs.ErrInvalid})
}
func (d *dirFile) Close() error { return nil }
// Ensure Node implements fs.FS so WalkDir works.
var _ fs.FS = (*Node)(nil)
// Ensure Node also satisfies fs.StatFS and fs.ReadDirFS for WalkDir.
var _ fs.StatFS = (*Node)(nil)
var _ fs.ReadDirFS = (*Node)(nil)
// Unexported helper: ensure ReadStream result also satisfies fs.File
// (for cases where callers do a type assertion).
var _ goio.ReadCloser = goio.NopCloser(nil)
// Ensure nodeWriter satisfies goio.WriteCloser.
var _ goio.WriteCloser = (*nodeWriter)(nil)
// Ensure dirFile satisfies fs.File.
var _ fs.File = (*dirFile)(nil)
// Ensure dataFileReader satisfies fs.File.
var _ fs.File = (*dataFileReader)(nil)
// ReadDirFile is not needed since fs.WalkDir works via ReadDirFS on the FS itself,
// but we need the Node to satisfy fs.ReadDirFS.
// ensure all internal compile-time checks are grouped above
// no further type assertions needed