feat: add pkg/mnt — mount operations for Core framework
core.mnt provides zero-dep mount operations:
- mnt.FS(embed, "subdir") — scoped embed.FS access (debme pattern)
- mnt.Extract(fs, targetDir, data) — template directory extraction (gosod/Install pattern)
Template extraction supports:
- Go text/template in file contents (.tmpl suffix)
- Go text/template in directory and file names ({{.Name}})
- Ignore files, rename files
- Variable substitution from any struct or map
Based on leaanthony/debme (70 lines) + leaanthony/gosod (280 lines),
rewritten as single zero-dep package. All stdlib, no transitive deps.
8 tests covering FS, Sub, ReadFile, ReadString, ReadDir, Extract.
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
7a9c9caabc
commit
21c4f718d3
8 changed files with 391 additions and 0 deletions
194
pkg/mnt/extract.go
Normal file
194
pkg/mnt/extract.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package mnt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// ExtractOptions configures template extraction.
|
||||
type ExtractOptions struct {
|
||||
// TemplateFilters identifies template files by substring match.
|
||||
// Default: [".tmpl"]
|
||||
TemplateFilters []string
|
||||
|
||||
// IgnoreFiles is a set of filenames to skip during extraction.
|
||||
IgnoreFiles map[string]struct{}
|
||||
|
||||
// RenameFiles maps original filenames to new names.
|
||||
RenameFiles map[string]string
|
||||
}
|
||||
|
||||
// Extract copies a template directory from an fs.FS to targetDir,
|
||||
// processing Go text/template in filenames and file contents.
|
||||
//
|
||||
// Files containing a template filter substring (default: ".tmpl") have
|
||||
// their contents processed through text/template with the given data.
|
||||
// The filter is stripped from the output filename.
|
||||
//
|
||||
// Directory and file names can contain Go template expressions:
|
||||
// {{.Name}}/main.go → myproject/main.go
|
||||
//
|
||||
// Data can be any struct or map[string]string for template substitution.
|
||||
func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error {
|
||||
opt := ExtractOptions{
|
||||
TemplateFilters: []string{".tmpl"},
|
||||
IgnoreFiles: make(map[string]struct{}),
|
||||
RenameFiles: make(map[string]string),
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
if len(opts[0].TemplateFilters) > 0 {
|
||||
opt.TemplateFilters = opts[0].TemplateFilters
|
||||
}
|
||||
if opts[0].IgnoreFiles != nil {
|
||||
opt.IgnoreFiles = opts[0].IgnoreFiles
|
||||
}
|
||||
if opts[0].RenameFiles != nil {
|
||||
opt.RenameFiles = opts[0].RenameFiles
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure target directory exists
|
||||
targetDir, err := filepath.Abs(targetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Categorise files
|
||||
var dirs []string
|
||||
var templateFiles []string
|
||||
var standardFiles []string
|
||||
|
||||
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
dirs = append(dirs, path)
|
||||
return nil
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if _, ignored := opt.IgnoreFiles[filename]; ignored {
|
||||
return nil
|
||||
}
|
||||
if isTemplate(filename, opt.TemplateFilters) {
|
||||
templateFiles = append(templateFiles, path)
|
||||
} else {
|
||||
standardFiles = append(standardFiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create directories (names may contain templates)
|
||||
for _, dir := range dirs {
|
||||
target := renderPath(filepath.Join(targetDir, dir), data)
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Process template files
|
||||
for _, path := range templateFiles {
|
||||
tmpl, err := template.ParseFS(fsys, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetFile := renderPath(filepath.Join(targetDir, path), data)
|
||||
|
||||
// Strip template filters from filename
|
||||
dir := filepath.Dir(targetFile)
|
||||
name := filepath.Base(targetFile)
|
||||
for _, filter := range opt.TemplateFilters {
|
||||
name = strings.ReplaceAll(name, filter, "")
|
||||
}
|
||||
if renamed := opt.RenameFiles[name]; renamed != "" {
|
||||
name = renamed
|
||||
}
|
||||
targetFile = filepath.Join(dir, name)
|
||||
|
||||
f, err := os.Create(targetFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmpl.Execute(f, data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// Copy standard files
|
||||
for _, path := range standardFiles {
|
||||
name := filepath.Base(path)
|
||||
if renamed := opt.RenameFiles[name]; renamed != "" {
|
||||
path = filepath.Join(filepath.Dir(path), renamed)
|
||||
}
|
||||
target := renderPath(filepath.Join(targetDir, path), data)
|
||||
if err := copyFile(fsys, path, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTemplate(filename string, filters []string) bool {
|
||||
for _, f := range filters {
|
||||
if strings.Contains(filename, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderPath(path string, data any) string {
|
||||
if data == nil {
|
||||
return path
|
||||
}
|
||||
tmpl, err := template.New("path").Parse(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return path
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func copyFile(fsys fs.FS, source, target string) error {
|
||||
s, err := fsys.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
_, err = io.Copy(d, s)
|
||||
return err
|
||||
}
|
||||
86
pkg/mnt/mnt.go
Normal file
86
pkg/mnt/mnt.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Package mnt provides mount operations for the Core framework.
|
||||
//
|
||||
// Mount operations attach data to/from binaries and watch live filesystems:
|
||||
//
|
||||
// - FS: mount an embed.FS subdirectory for scoped access
|
||||
// - Extract: extract a template directory with variable substitution
|
||||
// - Watch: observe filesystem changes (file watcher)
|
||||
//
|
||||
// Zero external dependencies. All operations use stdlib only.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// sub, _ := mnt.FS(myEmbed, "lib/persona")
|
||||
// content, _ := sub.ReadFile("secops/developer.md")
|
||||
//
|
||||
// mnt.Extract(sub, "/tmp/workspace", map[string]string{"Name": "myproject"})
|
||||
package mnt
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Sub wraps an embed.FS with a basedir for scoped access.
|
||||
// All paths are relative to basedir.
|
||||
type Sub struct {
|
||||
basedir string
|
||||
fs embed.FS
|
||||
}
|
||||
|
||||
// FS creates a scoped view of an embed.FS anchored at basedir.
|
||||
// Returns error if basedir doesn't exist in the embedded filesystem.
|
||||
func FS(efs embed.FS, basedir string) (*Sub, error) {
|
||||
s := &Sub{fs: efs, basedir: basedir}
|
||||
// Verify the basedir exists
|
||||
if _, err := s.ReadDir("."); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Sub) path(name string) string {
|
||||
return filepath.ToSlash(filepath.Join(s.basedir, name))
|
||||
}
|
||||
|
||||
// Open opens the named file for reading.
|
||||
func (s *Sub) Open(name string) (fs.File, error) {
|
||||
return s.fs.Open(s.path(name))
|
||||
}
|
||||
|
||||
// ReadDir reads the named directory.
|
||||
func (s *Sub) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return s.fs.ReadDir(s.path(name))
|
||||
}
|
||||
|
||||
// ReadFile reads the named file.
|
||||
func (s *Sub) ReadFile(name string) ([]byte, error) {
|
||||
return s.fs.ReadFile(s.path(name))
|
||||
}
|
||||
|
||||
// ReadString reads the named file as a string.
|
||||
func (s *Sub) ReadString(name string) (string, error) {
|
||||
data, err := s.ReadFile(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Sub returns a new Sub anchored at a subdirectory within this Sub.
|
||||
func (s *Sub) Sub(subDir string) (*Sub, error) {
|
||||
return FS(s.fs, s.path(subDir))
|
||||
}
|
||||
|
||||
// Embed returns the underlying embed.FS.
|
||||
func (s *Sub) Embed() embed.FS {
|
||||
return s.fs
|
||||
}
|
||||
|
||||
// BaseDir returns the basedir this Sub is anchored at.
|
||||
func (s *Sub) BaseDir() string {
|
||||
return s.basedir
|
||||
}
|
||||
106
pkg/mnt/mnt_test.go
Normal file
106
pkg/mnt/mnt_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package mnt_test
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/mnt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testFS embed.FS
|
||||
|
||||
func TestFS_Good(t *testing.T) {
|
||||
sub, err := mnt.FS(testFS, "testdata")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testdata", sub.BaseDir())
|
||||
}
|
||||
|
||||
func TestFS_Bad_InvalidDir(t *testing.T) {
|
||||
_, err := mnt.FS(testFS, "nonexistent")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSub_ReadFile_Good(t *testing.T) {
|
||||
sub, err := mnt.FS(testFS, "testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := sub.ReadFile("hello.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello world\n", string(data))
|
||||
}
|
||||
|
||||
func TestSub_ReadString_Good(t *testing.T) {
|
||||
sub, err := mnt.FS(testFS, "testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := sub.ReadString("hello.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello world\n", content)
|
||||
}
|
||||
|
||||
func TestSub_ReadDir_Good(t *testing.T) {
|
||||
sub, err := mnt.FS(testFS, "testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := sub.ReadDir(".")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, len(entries) >= 1)
|
||||
}
|
||||
|
||||
func TestSub_Sub_Good(t *testing.T) {
|
||||
sub, err := mnt.FS(testFS, "testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
nested, err := sub.Sub("subdir")
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := nested.ReadString("nested.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "nested content\n", content)
|
||||
}
|
||||
|
||||
func TestExtract_Good(t *testing.T) {
|
||||
sub, err := mnt.FS(testFS, "testdata/template")
|
||||
require.NoError(t, err)
|
||||
|
||||
targetDir := t.TempDir()
|
||||
err = mnt.Extract(sub, targetDir, map[string]string{
|
||||
"Name": "myproject",
|
||||
"Module": "forge.lthn.ai/core/myproject",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check template was processed
|
||||
readme, err := os.ReadFile(targetDir + "/README.md")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(readme), "myproject project")
|
||||
|
||||
// Check go.mod template was processed
|
||||
gomod, err := os.ReadFile(targetDir + "/go.mod")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(gomod), "module forge.lthn.ai/core/myproject")
|
||||
|
||||
// Check non-template was copied as-is
|
||||
main, err := os.ReadFile(targetDir + "/main.go")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "package main\n", string(main))
|
||||
}
|
||||
|
||||
func TestExtract_Good_NoData(t *testing.T) {
|
||||
sub, err := mnt.FS(testFS, "testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
targetDir := t.TempDir()
|
||||
err = mnt.Extract(sub, targetDir, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(targetDir + "/hello.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello world\n", string(data))
|
||||
}
|
||||
1
pkg/mnt/testdata/hello.txt
vendored
Normal file
1
pkg/mnt/testdata/hello.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
hello world
|
||||
1
pkg/mnt/testdata/subdir/nested.txt
vendored
Normal file
1
pkg/mnt/testdata/subdir/nested.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
nested content
|
||||
1
pkg/mnt/testdata/template/README.md.tmpl
vendored
Normal file
1
pkg/mnt/testdata/template/README.md.tmpl
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{{.Name}} project
|
||||
1
pkg/mnt/testdata/template/go.mod.tmpl
vendored
Normal file
1
pkg/mnt/testdata/template/go.mod.tmpl
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
module {{.Module}}
|
||||
1
pkg/mnt/testdata/template/main.go
vendored
Normal file
1
pkg/mnt/testdata/template/main.go
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
package main
|
||||
Loading…
Add table
Reference in a new issue