diff --git a/README.md b/README.md index ea49014..79b05c2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - Split view with post body / repo stats and threaded comments or README side by side - Filter feed by source, refresh on demand, paginate with `m` - Keyword search with `/`, copy URL with `y`, mark seen posts dimmed automatically -- Config file at `~/.devskim/config.toml` — created automatically on first run +- Config file at `${XDG_CONFIG_HOME:-~/.config}/devskim/config.toml` — created automatically on first run ## Install @@ -57,10 +57,10 @@ pip install -e . --- -On first run, a config file is created at `~/.devskim/config.toml`. Edit it to change subreddits: +On first run, a config file is created at `$XDG_CONFIG_HOME/devskim/config.toml` (defaults to `~/.config/devskim/config.toml`). Edit it to change subreddits: ```bash -nano ~/.devskim/config.toml +nano "${XDG_CONFIG_HOME:-~/.config}/devskim/config.toml" ``` ```toml @@ -108,7 +108,7 @@ Run `devskim` — changes take effect on next launch or press `r` to refresh. ## Config -`~/.devskim/config.toml` — created on first run with defaults. +`$XDG_CONFIG_HOME/devskim/config.toml` (default: `~/.config/devskim/config.toml`) — created on first run with defaults. If `~/.devskim` exists and the XDG path does not, the legacy location is used automatically. | Key | Default | Description | |-----|---------|-------------| diff --git a/devskim/config.py b/devskim/config.py index cb92c50..32ba09a 100644 --- a/devskim/config.py +++ b/devskim/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import time try: @@ -10,9 +11,38 @@ from dataclasses import dataclass, field from pathlib import Path -CONFIG_DIR = Path.home() / ".devskim" + +def _resolve_config_dir() -> Path: + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg and Path(xdg).is_absolute(): + return Path(xdg) / "devskim" + xdg_default = Path.home() / ".config" / "devskim" + legacy = Path.home() / ".devskim" + # Keep existing installs working if they have ~/.devskim and no XDG dir yet. + if legacy.is_dir() and not xdg_default.exists(): + return legacy + return xdg_default + + +def _resolve_cache_dir() -> Path: + xdg = os.environ.get("XDG_CACHE_HOME") + if xdg and Path(xdg).is_absolute(): + return Path(xdg) / "devskim" + return Path.home() / ".cache" / "devskim" + + +def _resolve_data_dir() -> Path: + xdg = os.environ.get("XDG_DATA_HOME") + if xdg and Path(xdg).is_absolute(): + return Path(xdg) / "devskim" + return Path.home() / ".local" / "share" / "devskim" + + +CONFIG_DIR = _resolve_config_dir() CONFIG_PATH = CONFIG_DIR / "config.toml" -CACHE_PATH = CONFIG_DIR / "cache.json" +CACHE_DIR = _resolve_cache_dir() +CACHE_PATH = CACHE_DIR / "cache.json" +DATA_DIR = _resolve_data_dir() DEFAULT_CONFIG = """\ subreddits = ["programming", "ClaudeAI", "machinelearning"] @@ -28,7 +58,7 @@ @dataclass class Config: - """Runtime settings loaded from ~/.devskim/config.toml.""" + """Runtime settings loaded from $XDG_CONFIG_HOME/devskim/config.toml.""" subreddits: list[str] = field( default_factory=lambda: ["programming", "python", "machinelearning"] @@ -79,7 +109,7 @@ def load_cache(ttl_minutes: int) -> list[dict] | None: def save_cache(items: list[dict]) -> None: """Write feed items to disk with a current timestamp.""" try: - CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_PATH.write_text(json.dumps({"ts": time.time(), "items": items})) except Exception: pass diff --git a/devskim/seen.py b/devskim/seen.py index 6e2a129..1cdd41b 100644 --- a/devskim/seen.py +++ b/devskim/seen.py @@ -3,9 +3,9 @@ import json import time -from .config import CONFIG_DIR +from .config import DATA_DIR -SEEN_PATH = CONFIG_DIR / "seen.json" +SEEN_PATH = DATA_DIR / "seen.json" SEEN_TTL_SECONDS = 86_400 # 1 day @@ -44,7 +44,7 @@ def load_seen() -> set[str]: def mark_seen(post_id: str) -> bool: """Record post_id as seen, pruning entries older than the TTL. Returns False on write failure.""" try: - CONFIG_DIR.mkdir(parents=True, exist_ok=True) + DATA_DIR.mkdir(parents=True, exist_ok=True) cutoff = time.time() - SEEN_TTL_SECONDS data = {pid: ts for pid, ts in _load_raw().items() if ts >= cutoff} data[post_id] = time.time() diff --git a/tests/test_config.py b/tests/test_config.py index 9b9bdc4..2b93e26 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,61 @@ import json +import os import time from unittest.mock import patch -from devskim.config import Config, load_cache, load_config, save_cache +from devskim.config import ( + Config, + _resolve_cache_dir, + _resolve_config_dir, + _resolve_data_dir, + load_cache, + load_config, + save_cache, +) + + +def test_resolve_config_dir_xdg_env(tmp_path): + with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}, clear=False): + result = _resolve_config_dir() + assert result == tmp_path / "devskim" + + +def test_resolve_config_dir_xdg_relative_ignored(): + with patch.dict(os.environ, {"XDG_CONFIG_HOME": "relative/path"}, clear=False): + result = _resolve_config_dir() + assert result.is_absolute() + + +def test_resolve_config_dir_xdg_default(tmp_path): + env = {k: v for k, v in os.environ.items() if k != "XDG_CONFIG_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_home = tmp_path + mock_path.home.return_value = mock_home + result = _resolve_config_dir() + assert result == mock_home / ".config" / "devskim" + + +def test_resolve_config_dir_legacy_fallback(tmp_path): + legacy = tmp_path / ".devskim" + legacy.mkdir() + env = {k: v for k, v in os.environ.items() if k != "XDG_CONFIG_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_path.home.return_value = tmp_path + result = _resolve_config_dir() + assert result == legacy + + +def test_resolve_config_dir_xdg_takes_priority_over_legacy(tmp_path): + legacy = tmp_path / ".devskim" + legacy.mkdir() + xdg_dir = tmp_path / "xdg" + xdg_dir.mkdir() + (xdg_dir / "devskim").mkdir() + with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg_dir)}, clear=False): + result = _resolve_config_dir() + assert result == xdg_dir / "devskim" def test_config_defaults(): @@ -69,7 +122,7 @@ def test_save_and_reload_cache(tmp_path): items = [{"title": "test", "source": "HN"}] with ( patch("devskim.config.CACHE_PATH", cache_path), - patch("devskim.config.CONFIG_DIR", tmp_path), + patch("devskim.config.CACHE_DIR", tmp_path), ): save_cache(items) result = load_cache(10) @@ -81,8 +134,50 @@ def test_save_cache_writes_timestamp(tmp_path): before = time.time() with ( patch("devskim.config.CACHE_PATH", cache_path), - patch("devskim.config.CONFIG_DIR", tmp_path), + patch("devskim.config.CACHE_DIR", tmp_path), ): save_cache([]) data = json.loads(cache_path.read_text()) assert abs(data["ts"] - before) < 5 + + +def test_resolve_cache_dir_xdg_env(tmp_path): + with patch.dict(os.environ, {"XDG_CACHE_HOME": str(tmp_path)}, clear=False): + result = _resolve_cache_dir() + assert result == tmp_path / "devskim" + + +def test_resolve_cache_dir_xdg_relative_ignored(): + with patch.dict(os.environ, {"XDG_CACHE_HOME": "relative/cache"}, clear=False): + result = _resolve_cache_dir() + assert result.is_absolute() + + +def test_resolve_cache_dir_default(tmp_path): + env = {k: v for k, v in os.environ.items() if k != "XDG_CACHE_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_path.home.return_value = tmp_path + result = _resolve_cache_dir() + assert result == tmp_path / ".cache" / "devskim" + + +def test_resolve_data_dir_xdg_env(tmp_path): + with patch.dict(os.environ, {"XDG_DATA_HOME": str(tmp_path)}, clear=False): + result = _resolve_data_dir() + assert result == tmp_path / "devskim" + + +def test_resolve_data_dir_xdg_relative_ignored(): + with patch.dict(os.environ, {"XDG_DATA_HOME": "relative/data"}, clear=False): + result = _resolve_data_dir() + assert result.is_absolute() + + +def test_resolve_data_dir_default(tmp_path): + env = {k: v for k, v in os.environ.items() if k != "XDG_DATA_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_path.home.return_value = tmp_path + result = _resolve_data_dir() + assert result == tmp_path / ".local" / "share" / "devskim" diff --git a/tests/test_seen.py b/tests/test_seen.py index 1e3b4cb..96733ec 100644 --- a/tests/test_seen.py +++ b/tests/test_seen.py @@ -114,7 +114,7 @@ def test_load_seen_empty_file(monkeypatch, tmp_path): def test_mark_seen_creates_file(monkeypatch, tmp_path): seen_path = tmp_path / "seen.json" monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) assert mark_seen("HN:42") is True assert seen_path.exists() @@ -122,7 +122,7 @@ def test_mark_seen_creates_file(monkeypatch, tmp_path): def test_mark_seen_persists_id(monkeypatch, tmp_path): seen_path = tmp_path / "seen.json" monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) mark_seen("HN:99") data = json.loads(seen_path.read_text()) assert "HN:99" in data @@ -133,7 +133,7 @@ def test_mark_seen_prunes_expired(monkeypatch, tmp_path): old_ts = time.time() - 90_000 seen_path.write_text(json.dumps({"HN:old": old_ts})) monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) mark_seen("HN:new") data = json.loads(seen_path.read_text()) assert "HN:old" not in data @@ -145,7 +145,7 @@ def test_mark_seen_overwrites_existing_id(monkeypatch, tmp_path): old_ts = time.time() - 100 seen_path.write_text(json.dumps({"HN:1": old_ts})) monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) mark_seen("HN:1") data = json.loads(seen_path.read_text()) assert data["HN:1"] > old_ts @@ -154,5 +154,5 @@ def test_mark_seen_overwrites_existing_id(monkeypatch, tmp_path): def test_mark_seen_returns_false_on_write_failure(monkeypatch, tmp_path): # SEEN_PATH points to an existing directory — write_text will fail monkeypatch.setattr(seen_module, "SEEN_PATH", tmp_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) assert mark_seen("HN:42") is False