git/orchard/main
updater/config.py
137 lines5 KB last change raw · history
"""User configuration.

Mirrors the shape used by the other tools in this tree: JSON under
`~/.config/<name>/config.json`, colours as ANSI slot numbers written as strings,
and a handful of UI toggles. Colour slots rather than hex, because the terminal
decides what slot 8 looks like.

This is user-editable configuration, deliberately separate from the AUR build
state in `aur.py`: one is yours to edit by hand, the other is the tool's memory.
"""

from __future__ import annotations

import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path

APP = "updater"


def config_dir() -> Path:
    base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
    return Path(base) / APP


def config_path() -> Path:
    return config_dir() / "config.json"


@dataclass
class Config:
    #: packages never updated by this tool, keyed by name
    ignored: list[str] = field(default_factory=list)

    #: advisory ids you have read and decided to live with. Acknowledge is not
    #: "fixed": most advisories on a rolling distribution have no fix available,
    #: so the useful action is to stop being told about the ones you have seen.
    acked: list[str] = field(default_factory=list)

    #: epoch of the newest news item you have read. Arch uses the news feed to
    #: announce upgrades that will not complete on their own, so this is the
    #: difference between "nothing new" and "you have not looked".
    news_seen: float = 0.0

    #: ANSI slot numbers, as strings, matching theme.py's roles
    color_accent: str = "10"  # bright green
    color_muted: str = "8"  # bright black
    color_ok: str = "2"  # green
    color_warn: str = "3"  # yellow
    color_alert: str = "1"  # red
    color_old: str = "1"
    color_new: str = "10"
    color_ver: str = "6"  # cyan

    #: UI
    cursor_icon: str = "▸"
    hide_hints: bool = False
    show_vcs: bool = True

    #: how many rows a section shows before collapsing by default
    expand_threshold: int = 8

    #: set when the file has been created or edited, so we can tell a first run
    @classmethod
    def load(cls, path: Path | None = None) -> Config:
        path = path or config_path()
        try:
            raw = json.loads(path.read_text())
        except (OSError, ValueError):
            return cls()
        # A valid JSON document is not necessarily an object. `[]` in a
        # user-editable config crashed the program at startup, before any
        # command ran.
        if not isinstance(raw, dict):
            return cls()
        known = cls.__dataclass_fields__
        return cls(**{k: v for k, v in raw.items() if k in known})

    def save(self, path: Path | None = None) -> None:
        """Write atomically: a half-written config would lose your ignore list."""
        path = path or config_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        tmp.write_text(json.dumps(asdict(self), indent=2, sort_keys=True) + "\n")
        tmp.replace(path)

    # -- ignores -----------------------------------------------------------

    def is_ignored(self, package: str) -> bool:
        return package in self.ignored

    def ignore(self, package: str) -> None:
        if package not in self.ignored:
            self.ignored.append(package)
            self.ignored.sort()

    def unignore(self, package: str) -> None:
        if package in self.ignored:
            self.ignored.remove(package)

    # -- advisory acknowledgements ----------------------------------------

    def is_acked(self, advisory: str) -> bool:
        return advisory in self.acked

    def ack(self, advisory: str) -> None:
        if advisory not in self.acked:
            self.acked.append(advisory)
            self.acked.sort()

    def unack(self, advisory: str) -> None:
        if advisory in self.acked:
            self.acked.remove(advisory)

    def toggle_acked(self, advisory: str) -> bool:
        """Flip one advisory. Returns True if it is now acknowledged."""
        if self.is_acked(advisory):
            self.unack(advisory)
            return False
        self.ack(advisory)
        return True

    def clear_acks(self) -> int:
        """Forget every acknowledgement. Returns how many there were."""
        count = len(self.acked)
        self.acked = []
        return count

    def toggle_ignored(self, package: str) -> bool:
        """Flip one package. Returns True if it is now ignored."""
        if self.is_ignored(package):
            self.unignore(package)
            return False
        self.ignore(package)
        return True