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:
Snider 2026-03-17 23:32:53 +00:00
parent 7a9c9caabc
commit 21c4f718d3
8 changed files with 391 additions and 0 deletions

194
pkg/mnt/extract.go Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
hello world

1
pkg/mnt/testdata/subdir/nested.txt vendored Normal file
View file

@ -0,0 +1 @@
nested content

View file

@ -0,0 +1 @@
{{.Name}} project

1
pkg/mnt/testdata/template/go.mod.tmpl vendored Normal file
View file

@ -0,0 +1 @@
module {{.Module}}

1
pkg/mnt/testdata/template/main.go vendored Normal file
View file

@ -0,0 +1 @@
package main