Skip to content
Open
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
2 changes: 1 addition & 1 deletion mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
73 changes: 65 additions & 8 deletions mempalace/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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 = {}
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
170 changes: 168 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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")