Initial project skeleton (M0)

Add SPECS.md, JSON config loader with env overrides and validation,
and an empty tview shell with tabs and status bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:38:15 +02:00
co-authored by Claude Sonnet 5.5
commit 9f05523c02
10 changed files with 693 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
// Package config loads and validates the arr-tui configuration file.
package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
// Service holds the connection settings of a single backend.
type Service struct {
URL string `json:"url"`
APIKey string `json:"apikey"`
}
// Arr is a Radarr or Sonarr instance, with optional defaults for the add form.
type Arr struct {
Service
DefaultQualityProfile string `json:"default_quality_profile,omitempty"`
DefaultRootFolder string `json:"default_root_folder,omitempty"`
}
// UI holds interface settings.
type UI struct {
RefreshSeconds int `json:"refresh_seconds,omitempty"`
}
// Config is the full application configuration.
type Config struct {
Radarr Arr `json:"radarr"`
Sonarr Arr `json:"sonarr"`
SABnzbd Service `json:"sabnzbd"`
UI UI `json:"ui"`
}
const defaultRefreshSeconds = 3
// DefaultPath returns $XDG_CONFIG_HOME/arr-tui/config.json, falling back to
// ~/.config/arr-tui/config.json.
func DefaultPath() (string, error) {
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir = filepath.Join(home, ".config")
}
return filepath.Join(dir, "arr-tui", "config.json"), nil
}
// Load reads the config at path, applies environment overrides and defaults,
// and validates the result. The returned warnings are non-fatal.
func Load(path string) (*Config, []string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("reading config: %w", err)
}
var warnings []string
if info, err := os.Stat(path); err == nil && info.Mode().Perm()&0o077 != 0 {
warnings = append(warnings, fmt.Sprintf(
"%s is accessible by other users (mode %04o); it contains API keys, consider chmod 600",
path, info.Mode().Perm()))
}
cfg, err := Parse(data)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", path, err)
}
return cfg, warnings, nil
}
// Parse decodes config JSON, applies environment overrides and defaults, and
// validates the result. Unknown fields are rejected to catch typos.
func Parse(data []byte) (*Config, error) {
var cfg Config
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
cfg.applyEnv(os.Getenv)
cfg.applyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) applyEnv(get func(string) string) {
override := func(dst *string, key string) {
if v := get(key); v != "" {
*dst = v
}
}
override(&c.Radarr.URL, "ARR_TUI_RADARR_URL")
override(&c.Radarr.APIKey, "ARR_TUI_RADARR_APIKEY")
override(&c.Sonarr.URL, "ARR_TUI_SONARR_URL")
override(&c.Sonarr.APIKey, "ARR_TUI_SONARR_APIKEY")
override(&c.SABnzbd.URL, "ARR_TUI_SABNZBD_URL")
override(&c.SABnzbd.APIKey, "ARR_TUI_SABNZBD_APIKEY")
}
func (c *Config) applyDefaults() {
if c.UI.RefreshSeconds <= 0 {
c.UI.RefreshSeconds = defaultRefreshSeconds
}
c.Radarr.URL = strings.TrimRight(c.Radarr.URL, "/")
c.Sonarr.URL = strings.TrimRight(c.Sonarr.URL, "/")
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
}
// Validate checks that every service has a usable URL and API key.
func (c *Config) Validate() error {
var errs []error
for _, s := range []struct {
name string
svc Service
}{
{"radarr", c.Radarr.Service},
{"sonarr", c.Sonarr.Service},
{"sabnzbd", c.SABnzbd},
} {
if s.svc.APIKey == "" {
errs = append(errs, fmt.Errorf("%s: apikey is required", s.name))
}
u, err := url.Parse(s.svc.URL)
switch {
case s.svc.URL == "":
errs = append(errs, fmt.Errorf("%s: url is required", s.name))
case err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "":
errs = append(errs, fmt.Errorf("%s: url %q must be an http(s) URL", s.name, s.svc.URL))
}
}
return errors.Join(errs...)
}
+78
View File
@@ -0,0 +1,78 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
const valid = `{
"radarr": {"url": "http://localhost:7878/", "apikey": "r"},
"sonarr": {"url": "http://localhost:8989", "apikey": "s"},
"sabnzbd": {"url": "https://sab.example.com", "apikey": "n"}
}`
func TestParseValid(t *testing.T) {
cfg, err := Parse([]byte(valid))
if err != nil {
t.Fatal(err)
}
if cfg.Radarr.URL != "http://localhost:7878" {
t.Errorf("trailing slash not trimmed: %q", cfg.Radarr.URL)
}
if cfg.UI.RefreshSeconds != defaultRefreshSeconds {
t.Errorf("refresh default = %d", cfg.UI.RefreshSeconds)
}
}
func TestParseErrors(t *testing.T) {
tests := map[string]struct {
json string
want string
}{
"unknown field": {`{"radar": {}}`, "unknown field"},
"missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"},
"bad url": {`{"radarr":{"url":"localhost","apikey":"x"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "must be an http(s) URL"},
"invalid json": {`{`, "parsing config"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
_, err := Parse([]byte(tc.json))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want containing %q", err, tc.want)
}
})
}
}
func TestEnvOverride(t *testing.T) {
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
cfg, err := Parse([]byte(valid))
if err != nil {
t.Fatal(err)
}
if cfg.Radarr.APIKey != "from-env" {
t.Errorf("apikey = %q", cfg.Radarr.APIKey)
}
}
func TestLoadPermissionWarning(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
t.Fatal(err)
}
_, warnings, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(warnings) != 1 {
t.Fatalf("warnings = %v", warnings)
}
if err := os.Chmod(path, 0o600); err != nil {
t.Fatal(err)
}
if _, warnings, _ = Load(path); len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
}