// Package config reads and writes ~/.config/updater/config.json. // // The file is shared with the Python side, which owns the schema. So this reads // the whole document into a map and edits only the keys it understands, rather // than unmarshalling into a struct: a struct would silently drop any field the // Go side has not been taught about the moment it saved. package config import ( "encoding/json" "os" "path/filepath" "sort" "time" ) const app = "updater" // Config is a live view of the config file. type Config struct { path string raw map[string]any Ignored []string acked []string dirty bool } // Path resolves the config location, honouring XDG_CONFIG_HOME. func Path() string { base := os.Getenv("XDG_CONFIG_HOME") if base == "" { home, err := os.UserHomeDir() if err != nil { return "" } base = filepath.Join(home, ".config") } return filepath.Join(base, app, "config.json") } // Load reads the config, falling back to defaults rather than failing. func Load() *Config { path := Path() c := &Config{path: path, raw: map[string]any{}} if body, err := os.ReadFile(path); err == nil { _ = json.Unmarshal(body, &c.raw) } if v, ok := c.raw["ignored"].([]any); ok { for _, item := range v { if name, ok := item.(string); ok { c.Ignored = append(c.Ignored, name) } } } sort.Strings(c.Ignored) c.acked = c.AckedList() sort.Strings(c.acked) return c } // Exists reports whether a config file is present at all. func (c *Config) Exists() bool { _, err := os.Stat(c.path) return err == nil } // PathOf exposes the resolved path for display. func (c *Config) PathOf() string { return c.path } // Dirty reports whether anything has changed since load. func (c *Config) Dirty() bool { return c.dirty } // Acked lists acknowledged advisory ids, so a status line can count them. func (c *Config) AckedList() []string { raw, ok := c.raw["acked"].([]any) if !ok { return nil } out := make([]string, 0, len(raw)) for _, item := range raw { if name, ok := item.(string); ok { out = append(out, name) } } return out } // Ack records an advisory as read and done with. func (c *Config) Ack(advisory string) { existing := c.AckedList() for _, a := range existing { if a == advisory { return } } existing = append(existing, advisory) sort.Strings(existing) items := make([]any, 0, len(existing)) for _, a := range existing { items = append(items, a) } c.raw["acked"] = items c.acked = existing c.dirty = true } // ClearAcks forgets every acknowledgement and reports how many there were. func (c *Config) ClearAcks() int { count := len(c.AckedList()) c.raw["acked"] = []any{} c.acked = nil c.dirty = true return count } // MarkNewsSeen records that the news feed has been read up to now. func (c *Config) MarkNewsSeen() { c.raw["news_seen"] = float64(time.Now().Unix()) c.dirty = true } // HasSeenNews reports whether the news has ever been marked read. func (c *Config) HasSeenNews() bool { v, ok := c.raw["news_seen"].(float64) return ok && v > 0 } // IsIgnored reports whether a package is held back. func (c *Config) IsIgnored(name string) bool { for _, n := range c.Ignored { if n == name { return true } } return false } // Toggle flips a package's ignored state and reports the new value. func (c *Config) Toggle(name string) bool { if c.IsIgnored(name) { c.remove(name) return false } c.Ignored = append(c.Ignored, name) sort.Strings(c.Ignored) c.dirty = true return true } func (c *Config) remove(name string) { out := c.Ignored[:0] for _, n := range c.Ignored { if n != name { out = append(out, n) } } c.Ignored = out c.dirty = true } // Save writes the file atomically, preserving keys this side does not model. func (c *Config) Save() error { if c.path == "" { return os.ErrInvalid } items := make([]any, 0, len(c.Ignored)) for _, n := range c.Ignored { items = append(items, n) } c.raw["ignored"] = items body, err := json.MarshalIndent(c.raw, "", " ") if err != nil { return err } body = append(body, '\n') if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { return err } // a half-written config would lose the ignore list tmp := c.path + ".tmp" if err := os.WriteFile(tmp, body, 0o644); err != nil { return err } if err := os.Rename(tmp, c.path); err != nil { return err } c.dirty = false return nil } func (c *Config) slot(key, fallback string) string { if v, ok := c.raw[key].(string); ok && v != "" { return v } return fallback } // AccentSlot is the configured accent, as an ANSI slot number. func (c *Config) AccentSlot() string { return c.slot("color_accent", "10") } // MutedSlot is the configured muted colour. func (c *Config) MutedSlot() string { return c.slot("color_muted", "8") } // CursorIcon is the marker drawn next to the selected row. func (c *Config) CursorIcon() string { if v, ok := c.raw["cursor_icon"].(string); ok && v != "" { return v } return "▸" } // HideHints suppresses the keybinding footer. func (c *Config) HideHints() bool { v, _ := c.raw["hide_hints"].(bool) return v } // ExpandThreshold is how many children a section may have before it starts // collapsed. func (c *Config) ExpandThreshold() int { if v, ok := c.raw["expand_threshold"].(float64); ok && v > 0 { return int(v) } return 8 }