Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
|-----|---------|-------------|
Expand Down
38 changes: 34 additions & 4 deletions devskim/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import os
import time

try:
Expand All @@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"]
Expand All @@ -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"]
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions devskim/seen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down
101 changes: 98 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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()

Comment thread
coderabbitai[bot] marked this conversation as resolved.

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():
Expand Down Expand Up @@ -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)
Expand All @@ -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"
10 changes: 5 additions & 5 deletions tests/test_seen.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,15 @@ 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()


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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Loading