// 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
}