diff --git a/mempalace/cli.py b/mempalace/cli.py index be05b1264..2ffa7072e 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -918,7 +918,7 @@ def main(): parser.add_argument( "--palace", default=None, - help="Where the palace lives (default: from ~/.mempalace/config.json or ~/.mempalace/palace)", + help="Where the palace lives (default: palace_path from config.json, resolved via XDG — see mempalace.config)", ) sub = parser.add_subparsers(dest="command") diff --git a/mempalace/config.py b/mempalace/config.py index cacd1f918..1667e2306 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -1,7 +1,16 @@ """ MemPalace configuration system. -Priority: env vars > config file (~/.mempalace/config.json) > defaults +Priority: env vars > config file > defaults. + +The default config directory follows the XDG Base Directory Specification +(https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) +with back-compat for existing installs: + + 1. $MEMPALACE_CONFIG_DIR if set (explicit override, used by tests and CI) + 2. ~/.mempalace if it already exists (existing installs keep working) + 3. $XDG_CONFIG_HOME/mempalace if XDG_CONFIG_HOME is set + 4. ~/.config/mempalace (XDG default fallback) """ import json @@ -92,6 +101,51 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str: return value +# ── XDG Base Directory resolution ───────────────────────────────────────────── + +# Files that mark a ~/.mempalace directory as a real legacy install rather +# than an empty/leftover directory. Any one is enough for back-compat. +_LEGACY_MARKERS = ("config.json", "people_map.json") + + +def _has_legacy_install(legacy: Path) -> bool: + """Return True if ``legacy`` is a real legacy install, not an empty dir. + + A bare ``palace`` directory created by some other tool should not hijack + the XDG path, so the palace sub-directory only counts when it contains + an actual ChromaDB store (``chroma.sqlite3``). + """ + if any((legacy / marker).is_file() for marker in _LEGACY_MARKERS): + return True + return (legacy / "palace" / "chroma.sqlite3").is_file() + + +def _default_config_dir() -> Path: + """Return the default config directory. + + See the module docstring for the resolution order. + """ + env_dir = os.environ.get("MEMPALACE_CONFIG_DIR") + if env_dir and env_dir.strip(): + return Path(env_dir).expanduser() + + legacy = Path.home() / ".mempalace" + if legacy.is_dir() and _has_legacy_install(legacy): + return legacy + + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg and xdg.strip(): + xdg_path = Path(xdg).expanduser() + # Per XDG spec, relative paths must be ignored as invalid. + if xdg_path.is_absolute(): + return xdg_path / "mempalace" + + return Path.home() / ".config" / "mempalace" + + +# DEPRECATED: kept only for backward compatibility with external importers. +# This constant is frozen at the legacy location and is NOT XDG-aware — prefer +# MempalaceConfig().palace_path in new code. DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace") DEFAULT_COLLECTION_NAME = "mempalace_drawers" @@ -157,11 +211,10 @@ def __init__(self, config_dir=None): Args: config_dir: Override config directory (useful for testing). - Defaults to ~/.mempalace. + Defaults to the XDG-aware base directory — see + _default_config_dir() for the resolution order. """ - self._config_dir = ( - Path(config_dir) if config_dir else Path(os.path.expanduser("~/.mempalace")) - ) + self._config_dir = Path(config_dir).expanduser() if config_dir else _default_config_dir() self._config_file = self._config_dir / "config.json" self._people_map_file = self._config_dir / "people_map.json" self._file_config = {} @@ -175,14 +228,18 @@ def __init__(self, config_dir=None): @property def palace_path(self): - """Path to the memory palace data directory.""" + """Path to the memory palace data directory. + + Defaults to a "palace" sub-directory inside the active config + directory, so the palace follows the config wherever XDG places it. + """ env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH") if env_val: # Normalize: expand ~ and collapse .. to match the CLI --palace # code path (mcp_server.py:62) and prevent surprise redirection # when the env var contains unresolved components. return os.path.abspath(os.path.expanduser(env_val)) - return self._file_config.get("palace_path", DEFAULT_PALACE_PATH) + return self._file_config.get("palace_path", str(self._config_dir / "palace")) @property def collection_name(self): @@ -320,7 +377,7 @@ def init(self): pass # Windows doesn't support Unix permissions if not self._config_file.exists(): default_config = { - "palace_path": DEFAULT_PALACE_PATH, + "palace_path": str(self._config_dir / "palace"), "collection_name": DEFAULT_COLLECTION_NAME, "topic_wings": DEFAULT_TOPIC_WINGS, "hall_keywords": DEFAULT_HALL_KEYWORDS, diff --git a/tests/test_config.py b/tests/test_config.py index d7707d982..19063444e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,9 +1,23 @@ -import os import json +import os import tempfile import pytest -from mempalace.config import MempalaceConfig, normalize_wing_name, sanitize_kg_value, sanitize_name +from mempalace.config import ( + MempalaceConfig, + _default_config_dir, + normalize_wing_name, + sanitize_kg_value, + sanitize_name, +) + + +def _set_home(monkeypatch, home): + """Point HOME and USERPROFILE at ``home`` so Path.home() is consistent + on both POSIX and Windows. + """ + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) def test_default_config(): @@ -212,3 +226,155 @@ def test_kg_value_rejects_null_bytes(): def test_kg_value_rejects_over_length(): with pytest.raises(ValueError): sanitize_kg_value("a" * 129) + + +# --- XDG Base Directory --- + + +def test_default_config_dir_uses_xdg_when_set(monkeypatch, tmp_path): + fake_home = tmp_path / "home" + fake_home.mkdir() + xdg = tmp_path / "xdg" + xdg.mkdir() + _set_home(monkeypatch, fake_home) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + assert _default_config_dir() == xdg / "mempalace" + + cfg = MempalaceConfig() + assert cfg.palace_path == str(xdg / "mempalace" / "palace") + + +def test_default_config_dir_falls_back_to_dot_config(monkeypatch, tmp_path): + fake_home = tmp_path / "home" + fake_home.mkdir() + _set_home(monkeypatch, fake_home) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + assert _default_config_dir() == fake_home / ".config" / "mempalace" + + +def test_legacy_mempalace_dir_respected_for_backcompat(monkeypatch, tmp_path): + fake_home = tmp_path / "home" + fake_home.mkdir() + legacy = fake_home / ".mempalace" + legacy.mkdir() + (legacy / "config.json").write_text("{}") + xdg = tmp_path / "xdg" + xdg.mkdir() + + _set_home(monkeypatch, fake_home) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + assert _default_config_dir() == legacy + + cfg = MempalaceConfig() + assert cfg.palace_path == str(legacy / "palace") + + +def test_empty_legacy_dir_does_not_hijack_xdg(monkeypatch, tmp_path): + fake_home = tmp_path / "home" + fake_home.mkdir() + (fake_home / ".mempalace").mkdir() + xdg = tmp_path / "xdg" + xdg.mkdir() + + _set_home(monkeypatch, fake_home) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + assert _default_config_dir() == xdg / "mempalace" + + +def test_bare_palace_dir_does_not_trigger_legacy(monkeypatch, tmp_path): + # A bare ~/.mempalace/palace directory (without an actual ChromaDB + # store inside) should not be treated as a legacy install -- some + # other tool may have created the directory. + fake_home = tmp_path / "home" + fake_home.mkdir() + legacy = fake_home / ".mempalace" + legacy.mkdir() + (legacy / "palace").mkdir() + xdg = tmp_path / "xdg" + xdg.mkdir() + + _set_home(monkeypatch, fake_home) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + assert _default_config_dir() == xdg / "mempalace" + + +def test_palace_with_chromadb_triggers_legacy(monkeypatch, tmp_path): + # A ~/.mempalace/palace directory that actually contains the ChromaDB + # store counts as a real legacy install even without config.json. + fake_home = tmp_path / "home" + fake_home.mkdir() + legacy = fake_home / ".mempalace" + legacy.mkdir() + palace = legacy / "palace" + palace.mkdir() + (palace / "chroma.sqlite3").write_bytes(b"") + xdg = tmp_path / "xdg" + xdg.mkdir() + + _set_home(monkeypatch, fake_home) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + assert _default_config_dir() == legacy + + +def test_mempalace_config_dir_env_overrides_everything(monkeypatch, tmp_path): + fake_home = tmp_path / "home" + fake_home.mkdir() + legacy = fake_home / ".mempalace" + legacy.mkdir() + (legacy / "config.json").write_text("{}") + xdg = tmp_path / "xdg" + xdg.mkdir() + override = tmp_path / "override" + override.mkdir() + + _set_home(monkeypatch, fake_home) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + monkeypatch.setenv("MEMPALACE_CONFIG_DIR", str(override)) + + assert _default_config_dir() == override + + cfg = MempalaceConfig() + assert cfg.palace_path == str(override / "palace") + + +def test_empty_xdg_config_home_falls_back_to_dot_config(monkeypatch, tmp_path): + fake_home = tmp_path / "home" + fake_home.mkdir() + _set_home(monkeypatch, fake_home) + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + monkeypatch.setenv("XDG_CONFIG_HOME", "") + assert _default_config_dir() == fake_home / ".config" / "mempalace" + + monkeypatch.setenv("XDG_CONFIG_HOME", " ") + assert _default_config_dir() == fake_home / ".config" / "mempalace" + + +def test_relative_xdg_config_home_is_ignored(monkeypatch, tmp_path): + fake_home = tmp_path / "home" + fake_home.mkdir() + _set_home(monkeypatch, fake_home) + monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path") + monkeypatch.delenv("MEMPALACE_CONFIG_DIR", raising=False) + + assert _default_config_dir() == fake_home / ".config" / "mempalace" + + +def test_init_writes_xdg_aware_palace_path(tmp_path): + cfg = MempalaceConfig(config_dir=str(tmp_path)) + cfg.init() + with open(tmp_path / "config.json") as f: + written = json.load(f) + assert written["palace_path"] == str(tmp_path / "palace")