From 2381c8712ebafa8060f0d2feacadd85bef280eb0 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 4 Jul 2026 22:14:16 +0300 Subject: - Delete internal/file/content.go (duplicate implementation with different semantics). - Refactor internal/file/file.go to use a File struct with options (WithContent, WithSource, WithMode, etc.). - Update Have signature to accept variadic options instead of (path, param). - Update tests to use new API. - Update TODO.md to remove resolved blocker and reflect remaining features. --- .gitignore | 1 + AGENTS.md | 4 + TODO.md | 23 ------ assets/testfiles/test.tmpl | 2 + assets/testfiles/test.txt | 1 + examples/examples.go | 19 +++++ internal/file/content.go | 52 ------------ internal/file/file.go | 197 ++++++++++++++++++++++++++++++++++++++++----- internal/file/file_test.go | 29 +++++-- 9 files changed, 226 insertions(+), 102 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 assets/testfiles/test.tmpl create mode 100644 assets/testfiles/test.txt create mode 100644 examples/examples.go delete mode 100644 internal/file/content.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..175f5a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/gonf diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3d3cd6d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +# Agent Guidelines + +## Resource Management +All public functions which start with `Have` should be responsible for registering the resource they create. diff --git a/TODO.md b/TODO.md index 58b4527..6f42ecf 100644 --- a/TODO.md +++ b/TODO.md @@ -5,34 +5,11 @@ Perl [Rex](https://www.rexify.org/) `Rexfile` used to install `~/git/dotfiles`. It is derived from an audit of that Rexfile (`~/git/dotfiles/Rexfile`). -## 0. Fix the current build (blocker) - -`gonf` does not compile today: - -- `internal/file/content.go` and `internal/file/file.go` both declare - `resolveContent` and `applyTemplate` → duplicate declarations. -- Decide on one implementation and delete the other. Note the two copies - have **different signatures / semantics**: - - `file.go`: `resolveContent(param, targetPath)` — templating triggered by - the *target* path ending in `.tmpl`, plus a `source://` check on param. - - `content.go`: `resolveContent(path, param)` — templating triggered by the - *source* path. -- The `File.Have(path, param)` API conflates the trigger and the target - (see the `TestHaveSourceFile` comment in `file_test.go`). A source file and - its install destination must be **separate arguments**, e.g. - `file.Have(dst, source://src, mode)`. - -Until this is fixed nothing else can be built or tested. - ## 1. File resource — missing capabilities The Rexfile uses `file` for far more than "write these bytes". gonf's `file.Have` currently only manages content + checksum idempotency. Missing: -- **Permissions / mode.** Every Rexfile `file` call sets a mode - (`0600`, `0640`, `0700`, `0750`). gonf ignores mode entirely. Need to accept - and enforce a file mode, and only chmod when it differs (stay idempotent). -- **Separate source vs. destination path** (see section 0). - **`ensure => 'absent'`.** Remove a file if present. Used by `prune_dir`. - **`ensure => 'directory'`.** See section 2. diff --git a/assets/testfiles/test.tmpl b/assets/testfiles/test.tmpl new file mode 100644 index 0000000..073e946 --- /dev/null +++ b/assets/testfiles/test.tmpl @@ -0,0 +1,2 @@ +Hello, {{.Param}}! +Welcome to {{.USER}}. diff --git a/assets/testfiles/test.txt b/assets/testfiles/test.txt new file mode 100644 index 0000000..88d12a3 --- /dev/null +++ b/assets/testfiles/test.txt @@ -0,0 +1 @@ +Simple source content. diff --git a/examples/examples.go b/examples/examples.go new file mode 100644 index 0000000..ecc5036 --- /dev/null +++ b/examples/examples.go @@ -0,0 +1,19 @@ +package examples + +import ( + "fmt" + + "codeberg.org/snonux/gonf/internal/file" +) + +func Run() error { + if err := file.Have("/tmp/gonf_example.conf", file.WithSource("source://assets/testfiles/test.tmpl")); err != nil { + return fmt.Errorf("failed to create example conf: %w", err) + } + + if err := file.Have("/tmp/foo.txt", file.WithContent("hi")); err != nil { + return fmt.Errorf("failed to create foo.txt: %w", err) + } + + return nil +} diff --git a/internal/file/content.go b/internal/file/content.go deleted file mode 100644 index 2bb787d..0000000 --- a/internal/file/content.go +++ /dev/null @@ -1,52 +0,0 @@ -package file - -import ( - "bytes" - "fmt" - "os" - "strings" - "text/template" -) - -func resolveContent(param, targetPath string) ([]byte, error) { - var content []byte - var err error - - if strings.HasPrefix(param, "source://") { - sourcePath := strings.TrimPrefix(param, "source://") - content, err = os.ReadFile(sourcePath) - if err != nil { - return nil, fmt.Errorf("failed to read source file %s: %w", sourcePath, err) - } - } else { - content = []byte(param) - } - - if strings.HasSuffix(targetPath, ".tmpl") || (strings.HasPrefix(param, "source://") && strings.HasSuffix(strings.TrimPrefix(param, "source://"), ".tmpl")) { - return applyTemplate(content, param) - } - - return content, nil -} - -func applyTemplate(content []byte, param string) ([]byte, error) { - data := make(map[string]string) - for _, env := range os.Environ() { - pair := strings.SplitN(env, "=", 2) - if len(pair) == 2 { - data[pair[0]] = pair[1] - } - } - data["Param"] = param - - tmpl, err := template.New("resource").Parse(string(content)) - if err != nil { - return nil, fmt.Errorf("template parse error: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("template execute error: %w", err) - } - return buf.Bytes(), nil -} diff --git a/internal/file/file.go b/internal/file/file.go index aed7b13..cf46f19 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -1,22 +1,189 @@ package file import ( + "bytes" "crypto/sha256" + "fmt" "log" "os" + "os/user" + "strconv" + "strings" + "text/template" "codeberg.org/snonux/gonf/internal/resource" ) -func Have(path, param string) error { - _ = resource.Register("File", path) +type File struct { + path string + param string + source string + user string + group string + mode os.FileMode +} + +type Option func(*File) + +func WithContent(content string) Option { + return func(f *File) { + f.param = content + f.source = "" + } +} + +func WithSource(source string) Option { + return func(f *File) { + if !strings.HasPrefix(source, "source://") { + source = "source://" + source + } + f.source = source + f.param = source + } +} + +func WithUser(user string) Option { + return func(f *File) { + f.user = user + } +} + +func WithGroup(group string) Option { + return func(f *File) { + f.group = group + } +} + +func WithMode(mode os.FileMode) Option { + return func(f *File) { + f.mode = mode + } +} + +func Have(path string, opts ...Option) error { + curr, err := user.Current() + if err != nil { + log.Fatalf("failed to get current user for default: %v", err) + } + + f := &File{ + path: path, + mode: 0o640, + user: curr.Username, + group: curr.Gid, + } + + for _, opt := range opts { + opt(f) + } + + return f.Apply() +} + +func (f *File) Apply() error { + _ = resource.Register("File", f.path) + + content, err := f.resolveContent() + if err != nil { + log.Fatalf("failed to resolve content for %s: %v", f.path, err) + } + + return f.have(content) +} + +func (f *File) resolveContent() ([]byte, error) { + var content []byte + var err error + + if strings.HasPrefix(f.param, "source://") { + sourcePath := strings.TrimPrefix(f.param, "source://") + content, err = os.ReadFile(sourcePath) + if err != nil { + return nil, fmt.Errorf("failed to read source file %s: %w", sourcePath, err) + } + } else { + content = []byte(f.param) + } + + if strings.HasSuffix(f.path, ".tmpl") || (strings.HasPrefix(f.param, "source://") && strings.HasSuffix(strings.TrimPrefix(f.param, "source://"), ".tmpl")) { + return f.applyTemplate(content) + } - content, err := resolveContent(param, path) + return content, nil +} + +func (f *File) applyTemplate(content []byte) ([]byte, error) { + data := make(map[string]string) + for _, env := range os.Environ() { + pair := strings.SplitN(env, "=", 2) + if len(pair) == 2 { + data[pair[0]] = pair[1] + } + } + data["Param"] = f.param + + tmpl, err := template.New("resource").Parse(string(content)) if err != nil { - log.Fatalf("failed to resolve content for %s: %v", path, err) + return nil, fmt.Errorf("template parse error: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("template execute error: %w", err) + } + return buf.Bytes(), nil +} + +func (f *File) have(content []byte) error { + log.Printf("processing file: %s", f.path) + existingChecksum := getChecksum(f.path) + newChecksum := sha256.Sum256(content) + log.Printf("computed checksum for new content: %x", newChecksum) + + tmpPath := f.path + ".tmp" + if err := writeTmpFile(tmpPath, content, f.mode); err != nil { + return err + } + + if err := updateFromTmp(tmpPath, f.path, existingChecksum != newChecksum); err != nil { + return err } - return have(path, content) + return f.applyAttributes() +} + +func (f *File) applyAttributes() error { + // Apply Mode + if err := os.Chmod(f.path, f.mode); err != nil { + return fmt.Errorf("failed to chmod %s to %v: %w", f.path, f.mode, err) + } + log.Printf("set mode %v for %s", f.mode, f.path) + + // Apply User and Group + uid, gid := -1, -1 + + if f.user != "" { + u, err := user.Lookup(f.user) + if err != nil { + return fmt.Errorf("failed to lookup user %s: %w", f.user, err) + } + uid, _ = strconv.Atoi(u.Uid) + } + + if f.group != "" { + gidInt, err := strconv.Atoi(f.group) + if err != nil { + return fmt.Errorf("group must be numeric for now: %s", f.group) + } + gid = gidInt + } + + if err := os.Chown(f.path, uid, gid); err != nil { + return fmt.Errorf("failed to chown %s to %s:%s: %w", f.path, f.user, f.group, err) + } + log.Printf("set owner %s:%s for %s", f.user, f.group, f.path) + + return nil } func getChecksum(path string) [32]byte { @@ -31,9 +198,9 @@ func getChecksum(path string) [32]byte { return checksum } -func writeTmpFile(tmpPath string, content []byte) error { - log.Printf("writing %d bytes to temporary file %s", len(content), tmpPath) - if err := os.WriteFile(tmpPath, content, 0o644); err != nil { +func writeTmpFile(tmpPath string, content []byte, mode os.FileMode) error { + log.Printf("writing %d bytes to temporary file %s with mode %v", len(content), tmpPath, mode) + if err := os.WriteFile(tmpPath, content, mode); err != nil { log.Printf("failed to write temporary file %s: %v", tmpPath, err) return err } @@ -61,17 +228,3 @@ func updateFromTmp(tmpPath, path string, checksumChanged bool) error { log.Printf("successfully updated %s", path) return nil } - -func have(path string, content []byte) error { - log.Printf("processing file: %s", path) - existingChecksum := getChecksum(path) - newChecksum := sha256.Sum256(content) - log.Printf("computed checksum for new content: %x", newChecksum) - - tmpPath := path + ".tmp" - if err := writeTmpFile(tmpPath, content); err != nil { - return err - } - - return updateFromTmp(tmpPath, path, existingChecksum != newChecksum) -} diff --git a/internal/file/file_test.go b/internal/file/file_test.go index e41d7dc..a9397ba 100644 --- a/internal/file/file_test.go +++ b/internal/file/file_test.go @@ -31,7 +31,7 @@ func TestWriteTmpFile(t *testing.T) { tmpPath := filepath.Join(dir, "test.tmp") content := []byte("temp content") - if err := writeTmpFile(tmpPath, content); err != nil { + if err := writeTmpFile(tmpPath, content, 0o644); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -101,7 +101,7 @@ func TestHaveStringCreateNewFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "new.txt") - if err := Have(path, "hello world"); err != nil { + if err := Have(path, WithContent("hello world")); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -114,12 +114,31 @@ func TestHaveStringCreateNewFile(t *testing.T) { } } +func TestHaveMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mode.txt") + mode := os.FileMode(0o600) + + if err := Have(path, WithContent("mode test"), WithMode(mode)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + // Mask to check only permission bits + if info.Mode().Perm() != mode { + t.Errorf("expected mode %v, got %v", mode, info.Mode().Perm()) + } +} + func TestHaveSourceFile(t *testing.T) { dir := t.TempDir() sourcePath := filepath.Join("..", "..", "assets", "testfiles", "test.txt") targetPath := filepath.Join(dir, "target.txt") - if err := Have(targetPath, "source://"+sourcePath); err != nil { + if err := Have(targetPath, WithSource(sourcePath)); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -138,7 +157,7 @@ func TestHaveTemplateFile(t *testing.T) { sourcePath := filepath.Join("..", "..", "assets", "testfiles", "test.tmpl") targetPath := filepath.Join(dir, "target.conf") - if err := Have(targetPath, "source://"+sourcePath); err != nil { + if err := Have(targetPath, WithSource(sourcePath)); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -147,7 +166,7 @@ func TestHaveTemplateFile(t *testing.T) { t.Fatalf("reading file: %v", err) } - expectedParam := "source://" + sourcePath + expectedParam := sourcePath if !strings.Contains(string(got), expectedParam) { t.Errorf("expected content to contain Param %q, got %q", expectedParam, string(got)) } -- cgit v1.2.3