summaryrefslogtreecommitdiff
path: root/internal/fsutil
diff options
context:
space:
mode:
Diffstat (limited to 'internal/fsutil')
-rw-r--r--internal/fsutil/path.go99
-rw-r--r--internal/fsutil/path_test.go112
2 files changed, 211 insertions, 0 deletions
diff --git a/internal/fsutil/path.go b/internal/fsutil/path.go
new file mode 100644
index 0000000..c872a5b
--- /dev/null
+++ b/internal/fsutil/path.go
@@ -0,0 +1,99 @@
+package fsutil
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+)
+
+// ResolveRootPath expands and validates the supplied root path. When the
+// caller did not specify a value, defaultValue is used and created on demand.
+func ResolveRootPath(input, defaultValue string) (string, error) {
+ value, isDefault := normalizeRootInput(input, defaultValue)
+ expanded, err := expandPath(value)
+ if err != nil {
+ return "", fmt.Errorf("cannot expand root path %q: %w", value, err)
+ }
+ abs, err := filepath.Abs(expanded)
+ if err != nil {
+ return "", fmt.Errorf("cannot resolve root path %q: %w", expanded, err)
+ }
+ info, err := ensureRootExists(abs, isDefault)
+ if err != nil {
+ return "", err
+ }
+ if !info.IsDir() && !info.Mode().IsRegular() {
+ return "", fmt.Errorf("root path %q is not a file or directory", abs)
+ }
+ return abs, nil
+}
+
+func normalizeRootInput(input, defaultValue string) (value string, isDefault bool) {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
+ return defaultValue, true
+ }
+ return trimmed, false
+}
+
+func ensureRootExists(path string, allowCreate bool) (fs.FileInfo, error) {
+ info, err := os.Stat(path)
+ if err == nil {
+ return info, nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return nil, fmt.Errorf("cannot access root path %q: %w", path, err)
+ }
+ if !allowCreate {
+ return nil, fmt.Errorf("root path does not exist: %s", path)
+ }
+ if mkErr := os.MkdirAll(path, 0o755); mkErr != nil {
+ return nil, fmt.Errorf("cannot create default directory %q: %w", path, mkErr)
+ }
+ info, err = os.Stat(path)
+ if err != nil {
+ return nil, fmt.Errorf("cannot stat default directory %q: %w", path, err)
+ }
+ return info, nil
+}
+
+func expandPath(p string) (string, error) {
+ if p == "" || p[0] != '~' {
+ return p, nil
+ }
+ if len(p) == 1 {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return home, nil
+ }
+ if p[1] == '/' {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, p[2:]), nil
+ }
+ username, rest := splitUserPath(p)
+ usr, err := user.Lookup(username)
+ if err != nil {
+ return "", err
+ }
+ if rest == "" {
+ return usr.HomeDir, nil
+ }
+ return filepath.Join(usr.HomeDir, rest), nil
+}
+
+func splitUserPath(p string) (string, string) {
+ sep := strings.IndexRune(p, '/')
+ if sep == -1 {
+ return p[1:], ""
+ }
+ return p[1:sep], p[sep:]
+}
diff --git a/internal/fsutil/path_test.go b/internal/fsutil/path_test.go
new file mode 100644
index 0000000..4b88573
--- /dev/null
+++ b/internal/fsutil/path_test.go
@@ -0,0 +1,112 @@
+package fsutil
+
+import (
+ "os"
+ "os/user"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveRootPathCreatesDefault(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ root, err := ResolveRootPath("", "~/Yoga")
+ if err != nil {
+ t.Fatalf("resolve root: %v", err)
+ }
+ expected := filepath.Join(home, "Yoga")
+ if root != expected {
+ t.Fatalf("expected %s, got %s", expected, root)
+ }
+ info, err := os.Stat(expected)
+ if err != nil {
+ t.Fatalf("stat default: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("expected directory at %s", expected)
+ }
+}
+
+func TestResolveRootPathRequiresExisting(t *testing.T) {
+ tmp := t.TempDir()
+ missing := filepath.Join(tmp, "missing")
+ if _, err := ResolveRootPath(missing, "~/Yoga"); err == nil {
+ t.Fatalf("expected error for %s", missing)
+ }
+}
+
+func TestResolveRootPathAllowsFile(t *testing.T) {
+ tmp := t.TempDir()
+ file := filepath.Join(tmp, "video.mp4")
+ if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ got, err := ResolveRootPath(file, "~/Yoga")
+ if err != nil {
+ t.Fatalf("resolve root: %v", err)
+ }
+ if got != file {
+ t.Fatalf("expected file path returned, got %s", got)
+ }
+}
+
+func TestExpandPathWithHome(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ custom := filepath.Join(home, "custom")
+ if err := os.MkdirAll(custom, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ got, err := ResolveRootPath("~/custom", "~/Yoga")
+ if err != nil {
+ t.Fatalf("resolve root: %v", err)
+ }
+ expected := filepath.Join(home, "custom")
+ if got != expected {
+ t.Fatalf("expected %s, got %s", expected, got)
+ }
+ if v, err := expandPath("~"); err != nil || v != home {
+ t.Fatalf("expandPath ~ failed: %v %s", err, v)
+ }
+ if _, err := expandPath("~no_such_user/foo"); err == nil {
+ t.Fatalf("expected error for unknown user")
+ }
+ if path, err := expandPath("relative/path"); err != nil || path != "relative/path" {
+ t.Fatalf("expected relative path unchanged, got %s %v", path, err)
+ }
+ if current, err := user.Current(); err == nil {
+ value := "~" + current.Username
+ if p, err := expandPath(value); err != nil || p != current.HomeDir {
+ t.Fatalf("expected home dir %s, got %s (%v)", current.HomeDir, p, err)
+ }
+ }
+}
+
+func TestSplitUserPath(t *testing.T) {
+ user, rest := splitUserPath("~alice/videos")
+ if user != "alice" || rest != "/videos" {
+ t.Fatalf("unexpected split %s %s", user, rest)
+ }
+ user, rest = splitUserPath("~bob")
+ if user != "bob" || rest != "" {
+ t.Fatalf("unexpected split %s %s", user, rest)
+ }
+}
+
+func TestNormalizeRootInput(t *testing.T) {
+ const fallback = "~/Yoga"
+ value, isDefault := normalizeRootInput("", fallback)
+ if !isDefault || value != fallback {
+ t.Fatalf("unexpected normalize result %s %v", value, isDefault)
+ }
+ value, isDefault = normalizeRootInput(" /tmp ", fallback)
+ if isDefault || value != "/tmp" {
+ t.Fatalf("unexpected normalize result %s %v", value, isDefault)
+ }
+}
+
+func TestEnsureRootExistsErrors(t *testing.T) {
+ if _, err := ensureRootExists(filepath.Join(t.TempDir(), "missing"), false); err == nil {
+ t.Fatalf("expected error when creation not allowed")
+ }
+}