diff --git a/CHANGELOG.md b/CHANGELOG.md index 859a3a1..cec8684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ ### Fixed +- Bat caches rebuild after all theme writes and recover missing or stale compiled themes, including custom theme directories. - OpenCode surfaces follow the active mode with readable syntax and interface states. - Managed OpenCode agent colors follow the active mode while preserving unrelated configuration. - Dark Starship diagnostic styles resolve through the palette. diff --git a/docs/configuration/theme-system.md b/docs/configuration/theme-system.md index 82c9c37..b2da552 100644 --- a/docs/configuration/theme-system.md +++ b/docs/configuration/theme-system.md @@ -52,6 +52,18 @@ python scripts/generate-palette-tokens.py --check python scripts/generate-dark-css.py --check ``` +## Bat compiled cache + +`dreamcoder sync` checks Bat's compiled cache after all theme source and variant +writes. Missing `themes.bin` or `syntaxes.bin`, or theme sources newer than either +binary, trigger `bat cache --build`; an up-to-date cache is left unchanged. +`BAT_THEME_DIR` selects the sources (default: `$XDG_CONFIG_HOME/bat/themes`, or +`~/.config/bat/themes`). `BAT_CACHE_PATH` selects the compiled cache (default: +`$XDG_CACHE_HOME/bat`, or `~/.cache/bat`). Builds ignore user Bat options by setting +`BAT_CONFIG_PATH` to the null device. Bat is optional: when it is absent, sync +continues without compiling. A build failure stops sync with a diagnostic and +paths to check before retrying; generated theme sources remain available. + ## Pi and Gentle Shell `dreamcoder light`, `dreamcoder dark`, and `dreamcoder sync` generate the same diff --git a/src/dreamcoder_theme/bat_cache.py b/src/dreamcoder_theme/bat_cache.py new file mode 100644 index 0000000..77a3e37 --- /dev/null +++ b/src/dreamcoder_theme/bat_cache.py @@ -0,0 +1,64 @@ +"""Maintain Bat's compiled cache after theme sources have been written.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from contextlib import ExitStack +from pathlib import Path + +from .settings import ThemePaths + + +class BatCacheError(RuntimeError): + """A present Bat installation could not compile its theme sources.""" + + +def ensure_bat_cache(paths: ThemePaths) -> str: + """Repair missing/stale binaries without rebuilding an up-to-date cache.""" + bat = shutil.which("bat") + if bat is None: + return "skipped (bat not installed)" + cache = Path( + os.environ.get( + "BAT_CACHE_PATH", + Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "bat", + ) + ) + command = [bat, "cache", "--build"] + try: + binaries = [cache / name for name in ("themes.bin", "syntaxes.bin")] + sources = [path for path in paths.bat_theme_dir.rglob("*") if path.is_file()] + if all(path.is_file() for path in binaries): + oldest_cache = min(path.stat().st_mtime_ns for path in binaries) + if all(path.stat().st_mtime_ns <= oldest_cache for path in sources): + return "unchanged" + with ExitStack() as stack: + source_root = paths.bat_theme_dir.parent + if paths.bat_theme_dir.name != "themes": + # Bat requires a themes/ child of --source, even for custom destinations. + source_root = Path(stack.enter_context(tempfile.TemporaryDirectory())) + (source_root / "themes").symlink_to(paths.bat_theme_dir.resolve()) + command += ["--source", str(source_root), "--target", str(cache)] + result = subprocess.run( + command, + env=dict(os.environ, BAT_CONFIG_PATH=os.devnull), + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() or "no diagnostic output" + raise BatCacheError( + f"bat cache build failed (exit {result.returncode}): {detail}. " + f"Check themes in {paths.bat_theme_dir} and cache permissions at {cache}, " + "then rerun dreamcoder sync." + ) + except OSError as exc: + raise BatCacheError( + f"bat cache build failed: {exc}. Check themes in {paths.bat_theme_dir} " + f"and cache permissions at {cache}, then rerun dreamcoder sync." + ) from exc + return "rebuilt" diff --git a/src/dreamcoder_theme/sync.py b/src/dreamcoder_theme/sync.py index f85ad66..770c9f5 100644 --- a/src/dreamcoder_theme/sync.py +++ b/src/dreamcoder_theme/sync.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, NamedTuple +from .bat_cache import BatCacheError, ensure_bat_cache from .herdr_contract import SUPPORTED_PROFILES, HerdrProfile from .opencode_colors import update_opencode_agent_colors from .palette import ( @@ -1269,6 +1270,13 @@ def main(argv: Sequence[str] | None = None) -> None: ) changed["bat_theme_variants"] = any(bat_variant_changes) + # Repository mirrors can share the live Bat sources through symlinks. + # Compile only after every source/variant write, even when nothing changed. + try: + ensure_bat_cache(paths) + except BatCacheError as exc: + raise SystemExit(str(exc)) from None + if not valid_starship(paths.starship): raise SystemExit(f"Generated Starship config is invalid: {paths.starship}") print_summary(prepared.mode, paths, changed, repo_changes) diff --git a/tests/test_bat_cache.py b/tests/test_bat_cache.py new file mode 100644 index 0000000..e5509b2 --- /dev/null +++ b/tests/test_bat_cache.py @@ -0,0 +1,148 @@ +"""Public sync/cache lifecycle checks; every subprocess uses isolated roots.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from unittest import mock + +import pytest +from test_pi_theme_generation import run_sync + +from dreamcoder_theme.bat_cache import BatCacheError, ensure_bat_cache +from dreamcoder_theme.palette_tokens import VARIANTS +from dreamcoder_theme.renderers import codex_tmtheme_content +from dreamcoder_theme.settings import theme_paths + + +@pytest.fixture +def bat_env(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.setenv("BAT_CACHE_PATH", str(tmp_path / "cache/bat")) + monkeypatch.setenv("BAT_THEME_DIR", str(tmp_path / "config/bat/themes")) + return tmp_path + + +@pytest.mark.skipif(shutil.which("bat") is None, reason="optional bat not installed") +@pytest.mark.parametrize("existing_sources", [False, True]) +def test_public_sync_builds_after_all_writes_and_repairs_missing_cache(tmp_path, existing_sources): + if existing_sources: + assert run_sync(tmp_path, "light").returncode == 0 + for _ in range(2): + result = run_sync(tmp_path, "light") + assert result.returncode == 0, result.stdout + result.stderr + assert result.stderr == "" + cache = tmp_path / "cache/bat/themes.bin" + assert cache.is_file() + env = dict(os.environ, BAT_CONFIG_PATH=os.devnull, BAT_CACHE_PATH=str(cache.parent)) + themes = subprocess.run( + [shutil.which("bat"), "--list-themes"], + env=env, + text=True, + capture_output=True, + timeout=10, + check=False, + ) + assert themes.returncode == 0 + assert themes.stderr == "" + assert {"Dreamcoder", "Dreamcoder-Dark", "Dreamcoder-Light"} <= set( + themes.stdout.splitlines() + ) + before = {path.name: path.read_bytes() for path in cache.parent.iterdir()} + modified = cache.stat().st_mtime_ns + unchanged = run_sync(tmp_path, "light") + assert unchanged.returncode == 0 + assert unchanged.stderr == "" + assert cache.stat().st_mtime_ns == modified + assert {path.name: path.read_bytes() for path in cache.parent.iterdir()} == before + cache.unlink() + + +@pytest.mark.parametrize("xdg_default", [False, True]) +def test_cache_fresh_stale_and_unchanged(bat_env, monkeypatch, xdg_default): + if xdg_default: + monkeypatch.delenv("BAT_CACHE_PATH") + monkeypatch.delenv("BAT_THEME_DIR") + paths = theme_paths() + paths.bat_theme_dir.mkdir(parents=True) + source = paths.bat_theme_dir / "Dreamcoder.tmTheme" + source.write_text("theme") + cache = bat_env / "cache/bat" + + def build(*args, **kwargs): + assert args[0][-4:] == ["--source", str(paths.bat_theme_dir.parent), "--target", str(cache)] + assert kwargs["env"]["BAT_CONFIG_PATH"] == os.devnull + cache.mkdir(parents=True, exist_ok=True) + for name in ("themes.bin", "syntaxes.bin"): + (cache / name).write_text("compiled") + return subprocess.CompletedProcess(args[0], 0, "", "") + + with ( + mock.patch("dreamcoder_theme.bat_cache.shutil.which", return_value="bat"), + mock.patch("dreamcoder_theme.bat_cache.subprocess.run", side_effect=build) as runner, + ): + assert ensure_bat_cache(paths) == "rebuilt" + assert ensure_bat_cache(paths) == "unchanged" + assert runner.call_count == 1 + os.utime(source, ns=(1, (cache / "themes.bin").stat().st_mtime_ns + 1000000)) + assert ensure_bat_cache(paths) == "rebuilt" + assert runner.call_count == 2 + (cache / "syntaxes.bin").unlink() + assert ensure_bat_cache(paths) == "rebuilt" + assert runner.call_count == 3 + + +def test_optional_absence_and_build_failure(bat_env): + with mock.patch("dreamcoder_theme.bat_cache.shutil.which", return_value=None): + assert ensure_bat_cache(theme_paths()) == "skipped (bat not installed)" + with ( + mock.patch("dreamcoder_theme.bat_cache.shutil.which", return_value="bat"), + mock.patch( + "dreamcoder_theme.bat_cache.subprocess.run", + return_value=subprocess.CompletedProcess([], 7, "", "broken syntax"), + ), + pytest.raises(BatCacheError, match=r"bat cache build failed.*broken syntax"), + ): + ensure_bat_cache(theme_paths()) + + +@pytest.mark.skipif(shutil.which("bat") is None, reason="optional bat not installed") +def test_custom_theme_directory(bat_env, monkeypatch): + monkeypatch.setenv("BAT_THEME_DIR", str(bat_env / "custom-colors")) + paths = theme_paths() + paths.bat_theme_dir.mkdir() + (paths.bat_theme_dir / "Dreamcoder.tmTheme").write_text(codex_tmtheme_content(VARIANTS["dark"])) + assert ensure_bat_cache(paths) == "rebuilt" + assert ensure_bat_cache(paths) == "unchanged" + + +def test_public_build_failure_is_actionable(tmp_path, monkeypatch): + binary_dir = tmp_path / "bin" + binary_dir.mkdir() + bat = binary_dir / "bat" + bat.write_text( + f"#!{sys.executable}\nimport sys\nsys.stderr.write('broken syntax')\nsys.exit(7)\n" + ) + bat.chmod(0o755) + monkeypatch.setenv("PATH", str(binary_dir) + os.pathsep + os.environ["PATH"]) + result = run_sync(tmp_path, "light") + assert result.returncode == 1 + assert result.stdout == "✓ Generated src/dreamcoder_theme/palette_tokens.py\n" + assert result.stderr == ( + "bat cache build failed (exit 7): broken syntax. " + f"Check themes in {tmp_path / 'config/bat/themes'} and cache permissions at " + f"{tmp_path / 'cache/bat'}, then rerun dreamcoder sync.\n" + ) + assert not (tmp_path / "cache/bat/themes.bin").exists() + before = {p.name: p.read_bytes() for p in (tmp_path / "config/bat/themes").iterdir()} + repeated = run_sync(tmp_path, "light") + assert (repeated.returncode, repeated.stdout, repeated.stderr) == ( + result.returncode, + result.stdout, + result.stderr, + ) + assert {p.name: p.read_bytes() for p in (tmp_path / "config/bat/themes").iterdir()} == before diff --git a/tests/test_pi_theme_generation.py b/tests/test_pi_theme_generation.py index 2cc0aa6..0571a14 100644 --- a/tests/test_pi_theme_generation.py +++ b/tests/test_pi_theme_generation.py @@ -76,6 +76,9 @@ def run_sync(tmp: Path, mode: str = "dark") -> subprocess.CompletedProcess[str]: { "HOME": str(tmp / "home"), "XDG_CACHE_HOME": str(tmp / "cache"), + "BAT_CACHE_PATH": str(tmp / "cache/bat"), + "BAT_CONFIG_PATH": os.devnull, + "BAT_THEME_DIR": str(tmp / "config/bat/themes"), "GENTLE_SHELL_HOME": str(tmp / "gentle-agent"), "PI_CODING_AGENT_DIR": str(pi_agent), "PI_THEME": str(pi_agent / "themes/dreamcoder.json"),