aboutsummaryrefslogtreecommitdiff
path: root/internal/config/skel_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-11 14:47:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-11 15:01:57 +0200
commit42b02c47be9b285099203e44a2570636d4ca6f03 (patch)
tree82bcb9e19bd886f36e1ca7b2d94a204c988f1fd1 /internal/config/skel_test.go
downloadkrino-42b02c47be9b285099203e44a2570636d4ca6f03.tar.gz
krino-42b02c47be9b285099203e44a2570636d4ca6f03.zip
krino: foundation — sexp reader, config language, init/new/check
Diffstat (limited to 'internal/config/skel_test.go')
-rw-r--r--internal/config/skel_test.go264
1 files changed, 264 insertions, 0 deletions
diff --git a/internal/config/skel_test.go b/internal/config/skel_test.go
new file mode 100644
index 0000000..476af65
--- /dev/null
+++ b/internal/config/skel_test.go
@@ -0,0 +1,264 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "bytes"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// uncomment turns the commented examples in a skeleton into live forms.
+func uncomment(src []byte) []byte {
+ lines := strings.Split(string(src), "\n")
+ for i, l := range lines {
+ if strings.HasPrefix(l, ";; (") || strings.HasPrefix(l, ";; ") {
+ lines[i] = l[3:]
+ }
+ }
+ return []byte(strings.Join(lines, "\n"))
+}
+
+func TestSkeletonsParse(t *testing.T) {
+ tmpl := bytes.ReplaceAll(skelTemplate, []byte("@PATH@"), []byte("/tmp"))
+ for name, src := range map[string][]byte{"plain": tmpl, "uncommented": uncomment(tmpl)} {
+ if _, errs := ParseDir("x", "template.conf", src); len(errs) > 0 {
+ t.Errorf("template.conf, %s: %v", name, errs)
+ }
+ }
+ for name, src := range map[string][]byte{"plain": skelMain, "uncommented": uncomment(skelMain)} {
+ if _, errs := ParseMain("krino.conf", src); len(errs) > 0 {
+ t.Errorf("krino.conf, %s: %v", name, errs)
+ }
+ }
+}
+
+func TestInitAndNew(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ target := filepath.Join(home, "downloads")
+ if err := os.Mkdir(target, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ main := filepath.Join(home, ".config", "krino", "krino.conf")
+ created, err := Init(main)
+ if err != nil || len(created) != 2 {
+ t.Fatalf("Init = %v, %v", created, err)
+ }
+ if _, err := Init(main); err == nil || err.Error() != main+" already exists; krino init leaves it alone" {
+ t.Fatalf("second Init: %v", err)
+ }
+ if cfg, errs := Load(main); len(errs) > 0 || len(cfg.Dirs) != 0 {
+ t.Fatalf("fresh config: %v", errs)
+ }
+ file, err := NewDir(main, "downloads", target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg, errs := Load(main)
+ if len(errs) > 0 || len(cfg.Dirs) != 1 {
+ t.Fatalf("after NewDir: %v", errs)
+ }
+ if d := cfg.Dirs[0]; d.Path != target || d.PathText != "~/downloads" || d.File != file {
+ t.Fatalf("dir = %+v", d)
+ }
+}
+
+// TestInitRefusedLeavesDirsAbsent is item D: a refused init (krino.conf
+// already exists) must not create dirs/ either.
+func TestInitRefusedLeavesDirsAbsent(t *testing.T) {
+ root := t.TempDir()
+ main := filepath.Join(root, "krino.conf")
+ if err := os.WriteFile(main, []byte("(include)\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Init(main); err == nil || err.Error() != main+" already exists; krino init leaves it alone" {
+ t.Fatalf("Init = %v, want already-exists", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "dirs")); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("dirs/ created by a refused init: %v", err)
+ }
+}
+
+func TestInitKeepsTemplate(t *testing.T) {
+ root := t.TempDir()
+ writeFiles(t, root, map[string]string{"template.conf": ";; mine\n(path \"@PATH@\")\n"})
+ created, err := Init(filepath.Join(root, "krino.conf"))
+ if err != nil || len(created) != 1 {
+ t.Fatalf("Init = %v, %v", created, err)
+ }
+ if got, _ := os.ReadFile(filepath.Join(root, "template.conf")); string(got) != ";; mine\n(path \"@PATH@\")\n" {
+ t.Fatalf("template overwritten: %q", got)
+ }
+}
+
+func TestNewDirKeepsTheRestOfTheFile(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ orig := ";; my notes\n(include \"a\" ; first\n)\n(defaults (min-age 5m)) ; tail\n"
+ writeFiles(t, home, map[string]string{"dirs/a.conf": `(path "~/a")`, "a/.keep": "", "b/.keep": ""})
+ main := filepath.Join(home, "krino.conf")
+ if err := os.WriteFile(main, []byte(orig), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewDir(main, "b", "~/b"); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(main)
+ want := ";; my notes\n(include \"a\" \"b\" ; first\n)\n(defaults (min-age 5m)) ; tail\n"
+ if string(got) != want {
+ t.Fatalf("got\n%s\nwant\n%s", got, want)
+ }
+ if fi, _ := os.Stat(main); fi.Mode().Perm() != 0o600 {
+ t.Errorf("mode = %v, want 0600 kept", fi.Mode().Perm())
+ }
+}
+
+func TestNewDirAppendsInclude(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ writeFiles(t, home, map[string]string{"krino.conf": `(log "/x.log")`, "b/.keep": ""})
+ main := filepath.Join(home, "krino.conf")
+ if _, err := NewDir(main, "b", "~/b"); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(main); string(got) != "(log \"/x.log\")\n(include \"b\")\n" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestNewDirFollowsSymlink(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ writeFiles(t, home, map[string]string{"dotfiles/krino.conf": "(include)\n", "x/.keep": ""})
+ confDir := filepath.Join(home, "conf")
+ if err := os.Mkdir(confDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ main := filepath.Join(confDir, "krino.conf")
+ if err := os.Symlink(filepath.Join(home, "dotfiles", "krino.conf"), main); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewDir(main, "x", "~/x"); err != nil {
+ t.Fatal(err)
+ }
+ if fi, _ := os.Lstat(main); fi.Mode()&os.ModeSymlink == 0 {
+ t.Fatal("krino.conf symlink was replaced by a regular file")
+ }
+ if got, _ := os.ReadFile(filepath.Join(home, "dotfiles", "krino.conf")); string(got) != "(include \"x\")\n" {
+ t.Fatalf("link target = %q", got)
+ }
+}
+
+// TestNewDirTemplateMissingPlaceholder is item A: a template.conf without
+// "@PATH@" must not silently keep its own path.
+func TestNewDirTemplateMissingPlaceholder(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ writeFiles(t, home, map[string]string{
+ "template.conf": "(path \"/somewhere/else\")\n",
+ "b/.keep": "",
+ })
+ main := filepath.Join(home, "krino.conf")
+ orig := "(include)\n"
+ if err := os.WriteFile(main, []byte(orig), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ tmplPath := filepath.Join(home, "template.conf")
+ want := tmplPath + ` must contain (path "@PATH@")`
+ if _, err := NewDir(main, "b", "~/b"); err == nil || err.Error() != want {
+ t.Fatalf("NewDir = %v, want %s", err, want)
+ }
+ if _, err := os.Stat(filepath.Join(home, "dirs", "b.conf")); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("dirs/b.conf created: %v", err)
+ }
+ if got, _ := os.ReadFile(main); string(got) != orig {
+ t.Fatalf("krino.conf changed: %q", got)
+ }
+}
+
+// TestNewDirFileAlreadyExists is item I.1: a stray dirs/NAME.conf that was
+// never included must refuse, leaving krino.conf untouched.
+func TestNewDirFileAlreadyExists(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ orig := "(include)\n"
+ writeFiles(t, home, map[string]string{"dirs/b.conf": "stale\n", "b/.keep": ""})
+ main := filepath.Join(home, "krino.conf")
+ if err := os.WriteFile(main, []byte(orig), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ want := filepath.Join(home, "dirs", "b.conf") + " already exists"
+ if _, err := NewDir(main, "b", "~/b"); err == nil || err.Error() != want {
+ t.Fatalf("NewDir = %v, want %s", err, want)
+ }
+ if got, _ := os.ReadFile(main); string(got) != orig {
+ t.Fatalf("krino.conf changed: %q", got)
+ }
+}
+
+// TestNewDirRollsBackWhenConfigDirReadOnly is item I.2: if dirs/NAME.conf
+// can be written but krino.conf cannot be replaced, the new file must be
+// rolled back and krino.conf left untouched.
+func TestNewDirRollsBackWhenConfigDirReadOnly(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root ignores read-only permissions")
+ }
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ writeFiles(t, home, map[string]string{"b/.keep": ""})
+ confDir := filepath.Join(home, "conf")
+ if err := os.Mkdir(confDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ main := filepath.Join(confDir, "krino.conf")
+ orig := "(include)\n"
+ if err := os.WriteFile(main, []byte(orig), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(confDir, "dirs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(confDir, 0o500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(confDir, 0o755) })
+ if _, err := NewDir(main, "b", "~/b"); err == nil {
+ t.Fatal("NewDir succeeded, want an error from the read-only config directory")
+ }
+ if _, err := os.Stat(filepath.Join(confDir, "dirs", "b.conf")); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("dirs/b.conf not rolled back: %v", err)
+ }
+ if got, _ := os.ReadFile(main); string(got) != orig {
+ t.Fatalf("krino.conf changed: %q", got)
+ }
+}
+
+func TestNewDirErrors(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ writeFiles(t, home, map[string]string{
+ "krino.conf": `(include "a")`, "dirs/a.conf": `(path "~")`, "x/.keep": "", "f": "",
+ })
+ main := filepath.Join(home, "krino.conf")
+ tests := []struct{ name, path, want string }{
+ {"a/b", "~/x", `bad directory name "a/b": use letters, digits, '.', '_' and '-'`},
+ {"check", "~/x", `"check" is a krino command; choose another name`},
+ {"b", "~/f", filepath.Join(home, "f") + " is not a directory"},
+ {"b", "~/missing", filepath.Join(home, "missing") + " is not a directory"},
+ {"a", "~/x", `"a" is already included`},
+ }
+ for _, tt := range tests {
+ if _, err := NewDir(main, tt.name, tt.path); err == nil || err.Error() != tt.want {
+ t.Errorf("NewDir(%q, %q) = %v, want %s", tt.name, tt.path, err, tt.want)
+ }
+ }
+ if _, err := NewDir(filepath.Join(home, "none.conf"), "b", "~/x"); err == nil ||
+ !strings.HasSuffix(err.Error(), "not found; create it with: krino init") {
+ t.Errorf("missing main file: %v", err)
+ }
+}