git/orchard/main
tui/internal/config/config.go
228 lines5 KB last change raw · history
// 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
}