Add the Config view: edit and save the settings, applied without a restart

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:31:13 +02:00
co-authored by Claude Sonnet 5.5
parent 0ccd02b3c5
commit a4ec9ad71e
25 changed files with 1168 additions and 54 deletions
+101 -1
View File
@@ -103,14 +103,54 @@ func Load(path string) (*Config, []string, error) {
// 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) {
raw, err := parseRaw(data)
if err != nil {
return nil, err
}
return raw.Effective(os.Getenv)
}
// parseRaw decodes config JSON as it stands in the file: no environment
// overrides, no defaults, no validation.
func parseRaw(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)
}
return &cfg, nil
}
cfg.applyEnv(os.Getenv)
// LoadFile reads the config at path exactly as written: no environment
// overrides, no defaults, no validation. It is what an editor of the file must
// start from, so values that only came from the environment are not written
// back to disk.
func LoadFile(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
cfg, err := parseRaw(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return cfg, nil
}
// Effective returns the configuration the program runs with: a copy with the
// environment overrides and defaults applied, validated.
func (c *Config) Effective(env func(string) string) (*Config, error) {
cfg := *c
if c.UI.Covers != nil {
v := *c.UI.Covers
cfg.UI.Covers = &v
}
if c.UI.NotifyDownloads != nil {
v := *c.UI.NotifyDownloads
cfg.UI.NotifyDownloads = &v
}
cfg.applyEnv(env)
cfg.applyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
@@ -118,6 +158,66 @@ func Parse(data []byte) (*Config, error) {
return &cfg, nil
}
// envVars maps the settings that can be overridden by the environment to the
// variable that does it.
var envVars = map[string]string{
"radarr.url": "ARR_TUI_RADARR_URL",
"radarr.apikey": "ARR_TUI_RADARR_APIKEY",
"sonarr.url": "ARR_TUI_SONARR_URL",
"sonarr.apikey": "ARR_TUI_SONARR_APIKEY",
"sabnzbd.url": "ARR_TUI_SABNZBD_URL",
"sabnzbd.apikey": "ARR_TUI_SABNZBD_APIKEY",
}
// Overrides returns, for every setting that the environment currently
// overrides, the name of the variable. Keys look like "radarr.url".
func Overrides(env func(string) string) map[string]string {
out := map[string]string{}
for key, name := range envVars {
if env(name) != "" {
out[key] = name
}
}
return out
}
// Save writes the config to path as indented JSON. The file is replaced
// atomically (a temporary file in the same directory, then a rename) and keeps
// the permissions of the existing file, or is private (0600) when new: it
// holds API keys.
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
mode := os.FileMode(0o600)
if info, err := os.Stat(path); err == nil {
mode = info.Mode().Perm()
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp")
if err != nil {
return fmt.Errorf("saving config: %w", err)
}
defer os.Remove(tmp.Name()) // no-op after a successful rename
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return fmt.Errorf("saving config: %w", err)
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("saving config: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("saving config: %w", err)
}
return nil
}
func (c *Config) applyEnv(get func(string) string) {
override := func(dst *string, key string) {
if v := get(key); v != "" {
+79
View File
@@ -109,3 +109,82 @@ func TestCoverStyleValidation(t *testing.T) {
t.Error("unknown style accepted")
}
}
func TestSaveRoundTripAndPermissions(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
covers := false
cfg := &Config{
Radarr: Arr{Service: Service{URL: "http://r:7878", APIKey: "rk"}, DefaultQualityProfile: "HD-1080p"},
Sonarr: Arr{Service: Service{URL: "http://s:8989", APIKey: "sk"}},
SABnzbd: Service{URL: "http://a:8080", APIKey: "ak"},
UI: UI{RefreshSeconds: 5, Covers: &covers, CoverStyle: "sextant", Bell: true},
}
if err := Save(path, cfg); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("new file mode = %v, want 0600 (it holds API keys): %v", info.Mode().Perm(), err)
}
got, err := LoadFile(path)
if err != nil {
t.Fatal(err)
}
if got.Radarr.DefaultQualityProfile != "HD-1080p" || got.UI.RefreshSeconds != 5 || got.UI.Covers == nil || *got.UI.Covers || got.UI.CoverStyle != "sextant" || !got.UI.Bell {
t.Errorf("round trip lost values: %+v", got)
}
// Unset optional settings stay out of the file.
if data, _ := os.ReadFile(path); strings.Contains(string(data), "notify_downloads") || strings.Contains(string(data), "default_root_folder") {
t.Errorf("unset settings written:\n%s", data)
}
// An existing file keeps its permissions and no temporary file is left behind.
if err := os.Chmod(path, 0o640); err != nil {
t.Fatal(err)
}
if err := Save(path, cfg); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Errorf("existing mode changed to %v", info.Mode().Perm())
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("leftover files in the config directory: %v", entries)
}
}
func TestLoadFileIgnoresEnvironmentAndDefaults(t *testing.T) {
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
path := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(path, []byte(`{"radarr":{"url":"http://r/","apikey":"from-file"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}}`), 0o600)
raw, err := LoadFile(path)
if err != nil {
t.Fatal(err)
}
if raw.Radarr.APIKey != "from-file" || raw.Radarr.URL != "http://r/" || raw.UI.RefreshSeconds != 0 {
t.Errorf("raw = %+v: environment, trailing slash trimming and defaults must not be applied", raw)
}
eff, err := raw.Effective(os.Getenv)
if err != nil {
t.Fatal(err)
}
if eff.Radarr.APIKey != "from-env" || eff.Radarr.URL != "http://r" || eff.UI.RefreshSeconds != 3 {
t.Errorf("effective = %+v", eff)
}
if raw.Radarr.APIKey != "from-file" {
t.Error("Effective must not modify the raw config")
}
if got := Overrides(os.Getenv); len(got) != 1 || got["radarr.apikey"] != "ARR_TUI_RADARR_APIKEY" {
t.Errorf("overrides = %v", got)
}
}
func TestEffectiveValidates(t *testing.T) {
raw := &Config{Radarr: Arr{Service: Service{URL: "ftp://x", APIKey: "k"}}}
if _, err := raw.Effective(func(string) string { return "" }); err == nil {
t.Error("an invalid config must not become effective")
}
}