From 21c4f718d35220a044a0ddd77cf6b24253b24d5f Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 23:32:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20pkg/mnt=20=E2=80=94=20mount=20ope?= =?UTF-8?q?rations=20for=20Core=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/mnt/extract.go | 194 +++++++++++++++++++++++ pkg/mnt/mnt.go | 86 ++++++++++ pkg/mnt/mnt_test.go | 106 +++++++++++++ pkg/mnt/testdata/hello.txt | 1 + pkg/mnt/testdata/subdir/nested.txt | 1 + pkg/mnt/testdata/template/README.md.tmpl | 1 + pkg/mnt/testdata/template/go.mod.tmpl | 1 + pkg/mnt/testdata/template/main.go | 1 + 8 files changed, 391 insertions(+) create mode 100644 pkg/mnt/extract.go create mode 100644 pkg/mnt/mnt.go create mode 100644 pkg/mnt/mnt_test.go create mode 100644 pkg/mnt/testdata/hello.txt create mode 100644 pkg/mnt/testdata/subdir/nested.txt create mode 100644 pkg/mnt/testdata/template/README.md.tmpl create mode 100644 pkg/mnt/testdata/template/go.mod.tmpl create mode 100644 pkg/mnt/testdata/template/main.go diff --git a/pkg/mnt/extract.go b/pkg/mnt/extract.go new file mode 100644 index 0000000..797f983 --- /dev/null +++ b/pkg/mnt/extract.go @@ -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 +} diff --git a/pkg/mnt/mnt.go b/pkg/mnt/mnt.go new file mode 100644 index 0000000..5acb4f6 --- /dev/null +++ b/pkg/mnt/mnt.go @@ -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 +} diff --git a/pkg/mnt/mnt_test.go b/pkg/mnt/mnt_test.go new file mode 100644 index 0000000..0d65e38 --- /dev/null +++ b/pkg/mnt/mnt_test.go @@ -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)) +} diff --git a/pkg/mnt/testdata/hello.txt b/pkg/mnt/testdata/hello.txt new file mode 100644 index 0000000..3b18e51 --- /dev/null +++ b/pkg/mnt/testdata/hello.txt @@ -0,0 +1 @@ +hello world diff --git a/pkg/mnt/testdata/subdir/nested.txt b/pkg/mnt/testdata/subdir/nested.txt new file mode 100644 index 0000000..ca281f5 --- /dev/null +++ b/pkg/mnt/testdata/subdir/nested.txt @@ -0,0 +1 @@ +nested content diff --git a/pkg/mnt/testdata/template/README.md.tmpl b/pkg/mnt/testdata/template/README.md.tmpl new file mode 100644 index 0000000..fdc89c8 --- /dev/null +++ b/pkg/mnt/testdata/template/README.md.tmpl @@ -0,0 +1 @@ +{{.Name}} project diff --git a/pkg/mnt/testdata/template/go.mod.tmpl b/pkg/mnt/testdata/template/go.mod.tmpl new file mode 100644 index 0000000..9f840df --- /dev/null +++ b/pkg/mnt/testdata/template/go.mod.tmpl @@ -0,0 +1 @@ +module {{.Module}} diff --git a/pkg/mnt/testdata/template/main.go b/pkg/mnt/testdata/template/main.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/pkg/mnt/testdata/template/main.go @@ -0,0 +1 @@ +package main