From afed8ab60041c636c467e6f0376179e6e2c0a095 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 08:21:38 +0200 Subject: [PATCH] feat(platform): add textblock helper for idempotent config-file blocks --- internal/platform/textblock.go | 78 +++++++++++++++++++++++++ internal/platform/textblock_test.go | 88 +++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 internal/platform/textblock.go create mode 100644 internal/platform/textblock_test.go diff --git a/internal/platform/textblock.go b/internal/platform/textblock.go new file mode 100644 index 0000000..68f85f3 --- /dev/null +++ b/internal/platform/textblock.go @@ -0,0 +1,78 @@ +package platform + +import ( + "fmt" + "os" + "strings" +) + +func blockMarkers(marker string) (string, string) { + return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker) +} + +// EnsureBlock writes a marker-delimited block into path. If the file does +// not exist it is created. If a block with the same marker already exists +// it is replaced in place. Surrounding content is preserved. +func EnsureBlock(path, marker, content string) error { + start, end := blockMarkers(marker) + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n" + + text := string(existing) + si := strings.Index(text, start) + ei := strings.Index(text, end) + if si >= 0 && ei > si { + endPos := ei + len(end) + if endPos < len(text) && text[endPos] == '\n' { + endPos++ + } + text = text[:si] + block + text[endPos:] + } else { + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + text += block + } + return os.WriteFile(path, []byte(text), 0o644) +} + +// HasBlock reports whether path contains a block with the given marker. +// A missing file yields (false, nil). +func HasBlock(path, marker string) (bool, error) { + start, _ := blockMarkers(marker) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return strings.Contains(string(data), start), nil +} + +// RemoveBlock deletes a marker-delimited block if present. +func RemoveBlock(path, marker string) error { + start, end := blockMarkers(marker) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + text := string(data) + si := strings.Index(text, start) + ei := strings.Index(text, end) + if si < 0 || ei <= si { + return nil + } + endPos := ei + len(end) + if endPos < len(text) && text[endPos] == '\n' { + endPos++ + } + text = text[:si] + text[endPos:] + return os.WriteFile(path, []byte(text), 0o644) +} diff --git a/internal/platform/textblock_test.go b/internal/platform/textblock_test.go new file mode 100644 index 0000000..73da431 --- /dev/null +++ b/internal/platform/textblock_test.go @@ -0,0 +1,88 @@ +package platform + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n" + if string(got) != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestEnsureBlockReplacesExisting(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n" + if err := os.WriteFile(p, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + if err := EnsureBlock(p, "archduino", "new content"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n" + if string(got) != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestEnsureBlockIdempotent(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + _ = EnsureBlock(p, "a", "x") + before, _ := os.ReadFile(p) + _ = EnsureBlock(p, "a", "x") + after, _ := os.ReadFile(p) + if string(before) != string(after) { + t.Fatalf("not idempotent: %q vs %q", before, after) + } +} + +func TestHasBlockTrueFalse(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + has, err := HasBlock(p, "a") + if err != nil { + t.Fatal(err) + } + if has { + t.Fatal("missing file should report has=false") + } + _ = os.WriteFile(p, []byte("no blocks here\n"), 0o644) + has, _ = HasBlock(p, "a") + if has { + t.Fatal("unblocked file should report has=false") + } + _ = EnsureBlock(p, "a", "x") + has, _ = HasBlock(p, "a") + if !has { + t.Fatal("blocked file should report has=true") + } +} + +func TestRemoveBlock(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + _ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644) + if err := RemoveBlock(p, "a"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + if strings.Contains(string(got), ">>> a >>>") { + t.Fatalf("block not removed: %q", got) + } + if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") { + t.Fatalf("surrounding content lost: %q", got) + } +}