diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-11 14:47:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-11 15:01:57 +0200 |
| commit | 42b02c47be9b285099203e44a2570636d4ca6f03 (patch) | |
| tree | 82bcb9e19bd886f36e1ca7b2d94a204c988f1fd1 /internal/config/skel_test.go | |
| download | krino-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.go | 264 |
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) + } +} |
