feat: initial commit — unarr CLI
Search, inspect, stream, and download torrents from the terminal. Replaces the entire *arr stack with a single binary.
This commit is contained in:
commit
29cf0a0126
85 changed files with 10178 additions and 0 deletions
288
internal/config/config.go
Normal file
288
internal/config/config.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// Config holds all persistent CLI configuration.
|
||||
type Config struct {
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
Agent AgentConfig `toml:"agent"`
|
||||
Download DownloadConfig `toml:"downloads"`
|
||||
Organize OrganizeConfig `toml:"organize"`
|
||||
Daemon DaemonConfig `toml:"daemon"`
|
||||
Notifications NotificationsConfig `toml:"notifications"`
|
||||
General GeneralConfig `toml:"general"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
APIURL string `toml:"api_url"`
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
ID string `toml:"id"`
|
||||
Name string `toml:"name"`
|
||||
}
|
||||
|
||||
type DownloadConfig struct {
|
||||
Dir string `toml:"dir"`
|
||||
PreferredMethod string `toml:"preferred_method"`
|
||||
MaxConcurrent int `toml:"max_concurrent"`
|
||||
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
|
||||
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
|
||||
}
|
||||
|
||||
type OrganizeConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
MoviesDir string `toml:"movies_dir"`
|
||||
TVShowsDir string `toml:"tv_shows_dir"`
|
||||
}
|
||||
|
||||
type DaemonConfig struct {
|
||||
PollInterval string `toml:"poll_interval"`
|
||||
HeartbeatInterval string `toml:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
type NotificationsConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
}
|
||||
|
||||
type GeneralConfig struct {
|
||||
Country string `toml:"country"`
|
||||
Locale string `toml:"locale"`
|
||||
NoColor bool `toml:"no_color"`
|
||||
}
|
||||
|
||||
// Default returns a Config with sensible defaults.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Auth: AuthConfig{
|
||||
APIURL: "https://torrentclaw.com",
|
||||
},
|
||||
Download: DownloadConfig{
|
||||
PreferredMethod: "auto",
|
||||
MaxConcurrent: 3,
|
||||
},
|
||||
Organize: OrganizeConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Daemon: DaemonConfig{
|
||||
PollInterval: "30s",
|
||||
HeartbeatInterval: "30s",
|
||||
},
|
||||
Notifications: NotificationsConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
General: GeneralConfig{
|
||||
Country: "US",
|
||||
Locale: "en",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads config from the default or specified path.
|
||||
// Falls back to defaults for any missing values.
|
||||
// If the file does not exist, returns defaults without error.
|
||||
func Load(path string) (Config, error) {
|
||||
if path == "" {
|
||||
path = FilePath()
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
// Re-apply defaults for zero values that should have defaults
|
||||
if cfg.Auth.APIURL == "" {
|
||||
cfg.Auth.APIURL = "https://torrentclaw.com"
|
||||
}
|
||||
if cfg.Download.PreferredMethod == "" {
|
||||
cfg.Download.PreferredMethod = "auto"
|
||||
}
|
||||
if cfg.Download.MaxConcurrent == 0 {
|
||||
cfg.Download.MaxConcurrent = 3
|
||||
}
|
||||
if cfg.General.Country == "" {
|
||||
cfg.General.Country = "US"
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes config to the default or specified path using atomic write.
|
||||
func Save(cfg Config, path string) error {
|
||||
if path == "" {
|
||||
path = FilePath()
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
encoder := toml.NewEncoder(&buf)
|
||||
if err := encoder.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("encode config: %w", err)
|
||||
}
|
||||
|
||||
// Atomic write: write to temp, then rename
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(buf.String()), 0o600); err != nil {
|
||||
return fmt.Errorf("write temp config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("rename config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseSpeed parses a human-readable speed string into bytes/s.
|
||||
// Supports: "10MB", "500KB", "1GB", "1024", "0" (unlimited).
|
||||
func ParseSpeed(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "0" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
s = strings.ToUpper(s)
|
||||
multiplier := int64(1)
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(s, "GB"):
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
s = strings.TrimSuffix(s, "GB")
|
||||
case strings.HasSuffix(s, "MB"):
|
||||
multiplier = 1024 * 1024
|
||||
s = strings.TrimSuffix(s, "MB")
|
||||
case strings.HasSuffix(s, "KB"):
|
||||
multiplier = 1024
|
||||
s = strings.TrimSuffix(s, "KB")
|
||||
}
|
||||
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid speed %q: %w", s, err)
|
||||
}
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("speed cannot be negative: %s", s)
|
||||
}
|
||||
|
||||
return int64(n * float64(multiplier)), nil
|
||||
}
|
||||
|
||||
// ApplyEnvOverrides applies UNARR_* environment variable overrides.
|
||||
func (c *Config) ApplyEnvOverrides() {
|
||||
if v := os.Getenv("UNARR_API_KEY"); v != "" {
|
||||
c.Auth.APIKey = v
|
||||
}
|
||||
if v := os.Getenv("UNARR_API_URL"); v != "" {
|
||||
c.Auth.APIURL = v
|
||||
}
|
||||
if v := os.Getenv("UNARR_COUNTRY"); v != "" {
|
||||
c.General.Country = v
|
||||
}
|
||||
if v := os.Getenv("UNARR_DOWNLOAD_DIR"); v != "" {
|
||||
c.Download.Dir = v
|
||||
}
|
||||
}
|
||||
|
||||
// dangerousPaths are system-critical directories that should never be used as
|
||||
// download or organize targets (per platform).
|
||||
var dangerousPaths = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
// Unix
|
||||
for _, p := range []string{
|
||||
"/", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/boot", "/dev", "/proc", "/sys",
|
||||
"/etc", "/var", "/tmp", "/root",
|
||||
// macOS
|
||||
"/System", "/Library", "/private", "/private/etc", "/private/tmp", "/private/var",
|
||||
} {
|
||||
m[p] = true
|
||||
}
|
||||
// Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, drive := range []string{"C", "D"} {
|
||||
for _, p := range []string{
|
||||
drive + `:\`,
|
||||
drive + `:\Windows`,
|
||||
drive + `:\Windows\System32`,
|
||||
drive + `:\Program Files`,
|
||||
drive + `:\Program Files (x86)`,
|
||||
} {
|
||||
m[filepath.Clean(p)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// ValidatePaths checks that configured directories are safe to write to.
|
||||
// Returns an error if any path points to a system directory or the user's
|
||||
// home directory root (must use a subdirectory).
|
||||
func (c *Config) ValidatePaths() error {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
check := func(label, dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: invalid path %q: %w", label, dir, err)
|
||||
}
|
||||
clean := filepath.Clean(abs)
|
||||
|
||||
if dangerousPaths[clean] {
|
||||
return fmt.Errorf("%s: refusing to use system directory %q", label, clean)
|
||||
}
|
||||
|
||||
// Block home root — require a subdirectory
|
||||
if home != "" && clean == filepath.Clean(home) {
|
||||
return fmt.Errorf("%s: use a subdirectory of your home, not %q itself", label, clean)
|
||||
}
|
||||
|
||||
// Block hidden dirs under home (e.g. ~/.ssh, ~/.gnupg)
|
||||
if home != "" && strings.HasPrefix(clean, filepath.Clean(home)+string(filepath.Separator)) {
|
||||
rel, _ := filepath.Rel(home, clean)
|
||||
first := strings.SplitN(rel, string(filepath.Separator), 2)[0]
|
||||
if strings.HasPrefix(first, ".") && first != ".local" && first != ".config" {
|
||||
return fmt.Errorf("%s: refusing to use hidden directory %q", label, clean)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := check("downloads.dir", c.Download.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := check("organize.movies_dir", c.Organize.MoviesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := check("organize.tv_shows_dir", c.Organize.TVShowsDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
202
internal/config/config_test.go
Normal file
202
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
if cfg.Auth.APIURL != "https://torrentclaw.com" {
|
||||
t.Errorf("default APIURL = %q, want https://torrentclaw.com", cfg.Auth.APIURL)
|
||||
}
|
||||
if cfg.Download.PreferredMethod != "auto" {
|
||||
t.Errorf("default PreferredMethod = %q, want auto", cfg.Download.PreferredMethod)
|
||||
}
|
||||
if cfg.Download.MaxConcurrent != 3 {
|
||||
t.Errorf("default MaxConcurrent = %d, want 3", cfg.Download.MaxConcurrent)
|
||||
}
|
||||
if cfg.General.Country != "US" {
|
||||
t.Errorf("default Country = %q, want US", cfg.General.Country)
|
||||
}
|
||||
if cfg.Daemon.HeartbeatInterval != "30s" {
|
||||
t.Errorf("default HeartbeatInterval = %q, want 30s", cfg.Daemon.HeartbeatInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
cfg, err := Load("/nonexistent/path/config.toml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load nonexistent should return defaults, got err: %v", err)
|
||||
}
|
||||
if cfg.Auth.APIURL != "https://torrentclaw.com" {
|
||||
t.Errorf("missing file should return default APIURL, got %q", cfg.Auth.APIURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "config.toml")
|
||||
|
||||
cfg := Default()
|
||||
cfg.Auth.APIKey = "tc_test123"
|
||||
cfg.Auth.APIURL = "https://custom.example.com"
|
||||
cfg.General.Country = "ES"
|
||||
cfg.Download.Dir = "/media/downloads"
|
||||
cfg.Agent.ID = "agent-uuid-123"
|
||||
cfg.Agent.Name = "Test Machine"
|
||||
|
||||
if err := Save(cfg, path); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
// File should exist
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Fatal("config file was not created")
|
||||
}
|
||||
|
||||
// No .tmp file left behind
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Error("temp file was not cleaned up")
|
||||
}
|
||||
|
||||
// Load it back
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded.Auth.APIKey != "tc_test123" {
|
||||
t.Errorf("APIKey = %q, want tc_test123", loaded.Auth.APIKey)
|
||||
}
|
||||
if loaded.Auth.APIURL != "https://custom.example.com" {
|
||||
t.Errorf("APIURL = %q, want https://custom.example.com", loaded.Auth.APIURL)
|
||||
}
|
||||
if loaded.General.Country != "ES" {
|
||||
t.Errorf("Country = %q, want ES", loaded.General.Country)
|
||||
}
|
||||
if loaded.Download.Dir != "/media/downloads" {
|
||||
t.Errorf("Dir = %q, want /media/downloads", loaded.Download.Dir)
|
||||
}
|
||||
if loaded.Agent.ID != "agent-uuid-123" {
|
||||
t.Errorf("AgentID = %q, want agent-uuid-123", loaded.Agent.ID)
|
||||
}
|
||||
if loaded.Agent.Name != "Test Machine" {
|
||||
t.Errorf("AgentName = %q, want Test Machine", loaded.Agent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPreservesDefaults(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "config.toml")
|
||||
|
||||
// Write partial config (only auth section)
|
||||
os.WriteFile(path, []byte(`[auth]
|
||||
api_key = "tc_partial"
|
||||
`), 0o644)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Auth.APIKey != "tc_partial" {
|
||||
t.Errorf("APIKey = %q, want tc_partial", cfg.Auth.APIKey)
|
||||
}
|
||||
// Defaults should be preserved for missing sections
|
||||
if cfg.Auth.APIURL != "https://torrentclaw.com" {
|
||||
t.Errorf("APIURL should default, got %q", cfg.Auth.APIURL)
|
||||
}
|
||||
if cfg.Download.MaxConcurrent != 3 {
|
||||
t.Errorf("MaxConcurrent should default to 3, got %d", cfg.Download.MaxConcurrent)
|
||||
}
|
||||
if cfg.General.Country != "US" {
|
||||
t.Errorf("Country should default to US, got %q", cfg.General.Country)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvOverrides(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
t.Setenv("UNARR_API_KEY", "tc_env_key")
|
||||
t.Setenv("UNARR_API_URL", "https://env.example.com")
|
||||
t.Setenv("UNARR_COUNTRY", "DE")
|
||||
t.Setenv("UNARR_DOWNLOAD_DIR", "/env/downloads")
|
||||
|
||||
cfg.ApplyEnvOverrides()
|
||||
|
||||
if cfg.Auth.APIKey != "tc_env_key" {
|
||||
t.Errorf("APIKey = %q, want tc_env_key", cfg.Auth.APIKey)
|
||||
}
|
||||
if cfg.Auth.APIURL != "https://env.example.com" {
|
||||
t.Errorf("APIURL = %q, want https://env.example.com", cfg.Auth.APIURL)
|
||||
}
|
||||
if cfg.General.Country != "DE" {
|
||||
t.Errorf("Country = %q, want DE", cfg.General.Country)
|
||||
}
|
||||
if cfg.Download.Dir != "/env/downloads" {
|
||||
t.Errorf("Dir = %q, want /env/downloads", cfg.Download.Dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCreatesDirectory(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "nested", "deep", "config.toml")
|
||||
|
||||
cfg := Default()
|
||||
if err := Save(cfg, path); err != nil {
|
||||
t.Fatalf("Save with nested dir failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Error("config file was not created in nested dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpeed(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
}{
|
||||
{"0", 0},
|
||||
{"", 0},
|
||||
{"10MB", 10 * 1024 * 1024},
|
||||
{"500KB", 500 * 1024},
|
||||
{"1GB", 1024 * 1024 * 1024},
|
||||
{"1.5MB", int64(1.5 * 1024 * 1024)},
|
||||
{"10mb", 10 * 1024 * 1024},
|
||||
{"1024", 1024},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := ParseSpeed(tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("ParseSpeed(%q) error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ParseSpeed(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Error cases
|
||||
if _, err := ParseSpeed("abc"); err == nil {
|
||||
t.Error("ParseSpeed(\"abc\") should error")
|
||||
}
|
||||
if _, err := ParseSpeed("-5MB"); err == nil {
|
||||
t.Error("ParseSpeed(\"-5MB\") should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidTOML(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "config.toml")
|
||||
os.WriteFile(path, []byte(`not valid toml [[[`), 0o644)
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid TOML, got nil")
|
||||
}
|
||||
}
|
||||
100
internal/config/config_validate_test.go
Normal file
100
internal/config/config_validate_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePaths_Dangerous(t *testing.T) {
|
||||
dangerous := []string{"/", "/etc", "/bin", "/sbin", "/usr", "/lib", "/lib64",
|
||||
"/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "/root",
|
||||
"/System", "/Library", "/private"}
|
||||
|
||||
for _, d := range dangerous {
|
||||
// Test all three path fields
|
||||
for _, field := range []string{"download", "movies", "tvshows"} {
|
||||
cfg := Default()
|
||||
switch field {
|
||||
case "download":
|
||||
cfg.Download.Dir = d
|
||||
case "movies":
|
||||
cfg.Organize.MoviesDir = d
|
||||
case "tvshows":
|
||||
cfg.Organize.TVShowsDir = d
|
||||
}
|
||||
if err := cfg.ValidatePaths(); err == nil {
|
||||
t.Errorf("ValidatePaths() should reject %s=%q", field, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePaths_HomeRoot(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
cfg.Download.Dir = home
|
||||
if err := cfg.ValidatePaths(); err == nil {
|
||||
t.Errorf("ValidatePaths() should reject home root %q", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePaths_HiddenDir(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
cfg.Download.Dir = filepath.Join(home, ".ssh")
|
||||
if err := cfg.ValidatePaths(); err == nil {
|
||||
t.Error("ValidatePaths() should reject ~/.ssh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePaths_Valid(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
|
||||
valid := []string{
|
||||
filepath.Join(home, "Downloads"),
|
||||
filepath.Join(home, "Media"),
|
||||
filepath.Join(home, "Media", "Movies"),
|
||||
"/mnt/storage/downloads",
|
||||
}
|
||||
|
||||
for _, d := range valid {
|
||||
cfg := Default()
|
||||
cfg.Download.Dir = d
|
||||
if err := cfg.ValidatePaths(); err != nil {
|
||||
t.Errorf("ValidatePaths() should accept %q, got: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePaths_AllowedHiddenDirs(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
|
||||
// .local and .config are whitelisted
|
||||
allowed := []string{
|
||||
filepath.Join(home, ".local", "share", "unarr"),
|
||||
filepath.Join(home, ".config", "unarr"),
|
||||
}
|
||||
|
||||
for _, d := range allowed {
|
||||
cfg := Default()
|
||||
cfg.Download.Dir = d
|
||||
if err := cfg.ValidatePaths(); err != nil {
|
||||
t.Errorf("ValidatePaths() should allow %q, got: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
internal/config/paths.go
Normal file
58
internal/config/paths.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const appName = "unarr"
|
||||
|
||||
// Dir returns the configuration directory following XDG conventions.
|
||||
// - Linux: ~/.config/unarr
|
||||
// - macOS: ~/Library/Application Support/unarr
|
||||
// - Windows: %APPDATA%/unarr
|
||||
//
|
||||
// Overridable via UNARR_CONFIG_DIR env var.
|
||||
func Dir() string {
|
||||
if d := os.Getenv("UNARR_CONFIG_DIR"); d != "" {
|
||||
return d
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support", appName)
|
||||
case "windows":
|
||||
return filepath.Join(os.Getenv("APPDATA"), appName)
|
||||
default: // linux, freebsd, etc.
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, appName)
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", appName)
|
||||
}
|
||||
}
|
||||
|
||||
// FilePath returns the full path to the config file.
|
||||
func FilePath() string {
|
||||
return filepath.Join(Dir(), "config.toml")
|
||||
}
|
||||
|
||||
// DataDir returns the data directory for logs, cache, etc.
|
||||
// - Linux: ~/.local/share/unarr
|
||||
// - macOS: ~/Library/Application Support/unarr
|
||||
// - Windows: %LOCALAPPDATA%/unarr
|
||||
func DataDir() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return Dir() // macOS uses same dir for config and data
|
||||
case "windows":
|
||||
return filepath.Join(os.Getenv("LOCALAPPDATA"), appName)
|
||||
default:
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, appName)
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".local", "share", appName)
|
||||
}
|
||||
}
|
||||
53
internal/config/paths_test.go
Normal file
53
internal/config/paths_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDir(t *testing.T) {
|
||||
dir := Dir()
|
||||
if dir == "" {
|
||||
t.Error("Dir() returned empty string")
|
||||
}
|
||||
if !strings.Contains(dir, "unarr") {
|
||||
t.Errorf("Dir() = %q, should contain 'unarr'", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePath(t *testing.T) {
|
||||
path := FilePath()
|
||||
if !strings.HasSuffix(path, "config.toml") {
|
||||
t.Errorf("FilePath() = %q, should end with config.toml", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataDir(t *testing.T) {
|
||||
dir := DataDir()
|
||||
if dir == "" {
|
||||
t.Error("DataDir() returned empty string")
|
||||
}
|
||||
if !strings.Contains(dir, "unarr") {
|
||||
t.Errorf("DataDir() = %q, should contain 'unarr'", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirOverrideEnv(t *testing.T) {
|
||||
t.Setenv("UNARR_CONFIG_DIR", "/custom/path")
|
||||
dir := Dir()
|
||||
if dir != "/custom/path" {
|
||||
t.Errorf("Dir() with env = %q, want /custom/path", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirXDGOverride(t *testing.T) {
|
||||
// Clear the custom env so XDG takes effect
|
||||
os.Unsetenv("UNARR_CONFIG_DIR")
|
||||
t.Setenv("XDG_CONFIG_HOME", "/xdg/config")
|
||||
|
||||
dir := Dir()
|
||||
if dir != "/xdg/config/unarr" {
|
||||
t.Errorf("Dir() with XDG = %q, want /xdg/config/unarr", dir)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue