From 652cca3da0940e9abbaa52bc1c430e4fe238f4fa Mon Sep 17 00:00:00 2001 From: Mike Hanley Date: Thu, 16 Jul 2026 13:39:09 -0500 Subject: [PATCH 1/4] feat: add project registry persistence --- src/projectmem/project_registry.py | 310 +++++++++++++++++++++++++++++ tests/test_project_registry.py | 153 ++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 src/projectmem/project_registry.py create mode 100644 tests/test_project_registry.py diff --git a/src/projectmem/project_registry.py b/src/projectmem/project_registry.py new file mode 100644 index 0000000..48311ea --- /dev/null +++ b/src/projectmem/project_registry.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import json +import os +import re +import tempfile +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Literal + + +BrainName = Literal["coding", "personal"] + +_IDENTIFIER_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_TAG_RE = _IDENTIFIER_RE +_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +_TOP_LEVEL_FIELDS = {"schema_version", "active_project", "projects"} +_PROJECT_FIELDS = { + "id", + "alias", + "path", + "default_brain", + "tags", + "created_at", + "updated_at", +} +_PROJECT_REQUIRED_FIELDS = _PROJECT_FIELDS - {"tags"} + + +class ProjectRegistryError(RuntimeError): + """Base error for project registry operations.""" + + +class RegistryValidationError(ProjectRegistryError): + """Raised when registry data does not match schema version 1.""" + + +class DuplicateProjectError(ProjectRegistryError): + """Raised when a registration conflicts with an existing project.""" + + +class ProjectNotRegisteredError(ProjectRegistryError): + """Raised when a requested project is not registered.""" + + +@dataclass(frozen=True) +class ProjectRecord: + id: str + alias: str + path: Path + default_brain: BrainName + tags: tuple[str, ...] + created_at: str + updated_at: str + + +@dataclass(frozen=True) +class Registry: + schema_version: int + active_project: str | None + projects: tuple[ProjectRecord, ...] + extras: Mapping[str, object] = field(default_factory=dict) + + +def registry_path() -> Path: + home = os.environ.get("PROJECTMEM_HOME") + base = Path(home).expanduser() if home else Path.home() / ".projectmem" + return base / "projects.json" + + +def _validation_error(message: str) -> RegistryValidationError: + return RegistryValidationError(f"Invalid project registry: {message}") + + +def _normalize_identifier(value: str, field_name: str) -> str: + if not isinstance(value, str): + raise _validation_error(f"{field_name} must be a string") + normalized = value.lower() + if not _IDENTIFIER_RE.fullmatch(normalized): + raise _validation_error( + f"{field_name} must match {_IDENTIFIER_RE.pattern}" + ) + return normalized + + +def _normalize_tag(value: str) -> str: + if not isinstance(value, str): + raise _validation_error("tags must contain only strings") + normalized = re.sub(r"\s+", "-", value.strip().lower()) + if not _TAG_RE.fullmatch(normalized): + raise _validation_error(f"tag must match {_TAG_RE.pattern}") + return normalized + + +def _path_key(path: str | Path) -> str: + resolved = Path(path).expanduser().resolve() + return os.path.normcase(str(resolved)) + + +def _parse_timestamp(value: object, field_name: str) -> str: + if not isinstance(value, str): + raise _validation_error(f"{field_name} must be an RFC 3339 string") + try: + datetime.strptime(value, _TIMESTAMP_FORMAT) + except ValueError as exc: + raise _validation_error( + f"{field_name} must use UTC RFC 3339 seconds ending in Z" + ) from exc + return value + + +def _parse_record(value: object, index: int) -> ProjectRecord: + if not isinstance(value, dict): + raise _validation_error(f"projects[{index}] must be an object") + keys = set(value) + missing = _PROJECT_REQUIRED_FIELDS - keys + unknown = keys - _PROJECT_FIELDS + if missing: + raise _validation_error( + f"projects[{index}] is missing fields: {', '.join(sorted(missing))}" + ) + if unknown: + raise _validation_error( + f"projects[{index}] has unknown fields: {', '.join(sorted(unknown))}" + ) + + project_id = _normalize_identifier(value["id"], f"projects[{index}].id") + alias = _normalize_identifier(value["alias"], f"projects[{index}].alias") + if value["id"] != project_id or value["alias"] != alias: + raise _validation_error(f"projects[{index}] identifiers must be lowercase") + + path_value = value["path"] + if not isinstance(path_value, str): + raise _validation_error(f"projects[{index}].path must be a string") + path = Path(path_value).expanduser() + if not path.is_absolute(): + raise _validation_error(f"projects[{index}].path must be absolute") + path = path.resolve() + + brain = value["default_brain"] + if brain not in ("coding", "personal"): + raise _validation_error( + f"projects[{index}].default_brain must be coding or personal" + ) + + raw_tags = value.get("tags", []) + if not isinstance(raw_tags, list): + raise _validation_error(f"projects[{index}].tags must be an array") + normalized_tags = tuple(sorted({_normalize_tag(tag) for tag in raw_tags})) + if list(normalized_tags) != raw_tags: + raise _validation_error( + f"projects[{index}].tags must be normalized, sorted, and unique" + ) + + return ProjectRecord( + id=project_id, + alias=alias, + path=path, + default_brain=brain, + tags=normalized_tags, + created_at=_parse_timestamp( + value["created_at"], f"projects[{index}].created_at" + ), + updated_at=_parse_timestamp( + value["updated_at"], f"projects[{index}].updated_at" + ), + ) + + +def _validate_uniqueness(projects: Iterable[ProjectRecord]) -> None: + names: dict[str, int] = {} + paths: dict[str, int] = {} + for index, record in enumerate(projects): + for name in {record.id, record.alias}: + previous = names.get(name) + if previous is not None and previous != index: + raise _validation_error( + f"project identifier {name!r} is used by multiple projects" + ) + names[name] = index + key = _path_key(record.path) + if key in paths: + raise _validation_error( + f"project path {str(record.path)!r} is registered more than once" + ) + paths[key] = index + + +def _registry_from_payload(payload: object) -> Registry: + if not isinstance(payload, dict): + raise _validation_error("top level must be an object") + if payload.get("schema_version") != 1 or type( + payload.get("schema_version") + ) is not int: + raise _validation_error("schema_version must be 1") + + active_project = payload.get("active_project") + if active_project is not None: + normalized_active = _normalize_identifier( + active_project, "active_project" + ) + if active_project != normalized_active: + raise _validation_error("active_project must be lowercase") + + raw_projects = payload.get("projects") + if not isinstance(raw_projects, list): + raise _validation_error("projects must be an array") + projects = tuple( + _parse_record(record, index) + for index, record in enumerate(raw_projects) + ) + _validate_uniqueness(projects) + if active_project is not None and active_project not in { + record.id for record in projects + }: + raise _validation_error( + f"active_project {active_project!r} does not reference a project ID" + ) + + extras = { + key: value for key, value in payload.items() if key not in _TOP_LEVEL_FIELDS + } + return Registry( + schema_version=1, + active_project=active_project, + projects=projects, + extras=extras, + ) + + +def _record_payload(record: ProjectRecord) -> dict[str, object]: + return { + "id": record.id, + "alias": record.alias, + "path": str(record.path.expanduser().resolve()), + "default_brain": record.default_brain, + "tags": list(record.tags), + "created_at": record.created_at, + "updated_at": record.updated_at, + } + + +def _registry_payload(registry: Registry) -> dict[str, object]: + if not isinstance(registry, Registry): + raise _validation_error("save value must be a Registry") + extras = dict(registry.extras) + reserved = set(extras) & _TOP_LEVEL_FIELDS + if reserved: + raise _validation_error( + f"extras contain reserved fields: {', '.join(sorted(reserved))}" + ) + payload: dict[str, object] = { + **extras, + "schema_version": registry.schema_version, + "active_project": registry.active_project, + "projects": [_record_payload(record) for record in registry.projects], + } + validated = _registry_from_payload(payload) + canonical: dict[str, object] = { + **dict(validated.extras), + "schema_version": validated.schema_version, + "active_project": validated.active_project, + "projects": [_record_payload(record) for record in validated.projects], + } + try: + json.dumps(canonical) + except (TypeError, ValueError) as exc: + raise _validation_error("extras must contain JSON-compatible values") from exc + return canonical + + +def load_registry(path: Path | None = None) -> Registry: + destination = path or registry_path() + if not destination.exists(): + return Registry(schema_version=1, active_project=None, projects=()) + try: + payload = json.loads(destination.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise _validation_error( + f"{destination} contains malformed JSON" + ) from exc + return _registry_from_payload(payload) + + +def save_registry(registry: Registry, path: Path | None = None) -> None: + destination = path or registry_path() + payload = _registry_payload(registry) + serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n" + + destination.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + delete=False, + ) as handle: + temporary = Path(handle.name) + handle.write(serialized) + handle.flush() + os.fsync(handle.fileno()) + temporary.replace(destination) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() diff --git a/tests/test_project_registry.py b/tests/test_project_registry.py new file mode 100644 index 0000000..f3632aa --- /dev/null +++ b/tests/test_project_registry.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from projectmem.project_registry import ( + ProjectRecord, + Registry, + RegistryValidationError, + load_registry, + save_registry, +) + + +def test_missing_registry_is_side_effect_free(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path)) + + registry = load_registry() + + assert registry.schema_version == 1 + assert registry.active_project is None + assert registry.projects == () + assert registry.extras == {} + assert not (tmp_path / "projects.json").exists() + + +def _project_payload(path: Path, **overrides): + payload = { + "id": "demo", + "alias": "demo", + "path": str(path.resolve()), + "default_brain": "coding", + "tags": ["local-first", "python"], + "created_at": "2026-07-16T12:00:00Z", + "updated_at": "2026-07-16T12:00:00Z", + } + payload.update(overrides) + return payload + + +def test_registry_round_trip_preserves_extras_and_missing_legacy_tags(tmp_path): + registry_file = tmp_path / "projects.json" + payload = { + "schema_version": 1, + "active_project": "demo", + "projects": [_project_payload(tmp_path / "demo", tags=None)], + "owner": "mike", + "settings": {"color": "blue"}, + } + del payload["projects"][0]["tags"] + registry_file.write_text(json.dumps(payload), encoding="utf-8") + + registry = load_registry(registry_file) + + assert registry.projects[0].tags == () + assert registry.extras == {"owner": "mike", "settings": {"color": "blue"}} + + save_registry(registry, registry_file) + + saved = json.loads(registry_file.read_text(encoding="utf-8")) + assert saved["owner"] == "mike" + assert saved["settings"] == {"color": "blue"} + assert saved["projects"][0]["tags"] == [] + assert registry_file.read_bytes().endswith(b"\n") + + +@pytest.mark.parametrize( + "payload", + [ + "{not json", + json.dumps({"schema_version": 2, "active_project": None, "projects": []}), + json.dumps( + { + "schema_version": 1, + "active_project": "missing", + "projects": [], + } + ), + json.dumps( + { + "schema_version": 1, + "active_project": None, + "projects": [ + _project_payload(Path.cwd(), unexpected="value") + ], + } + ), + ], +) +def test_invalid_registry_is_not_rewritten(tmp_path, payload): + registry_file = tmp_path / "projects.json" + registry_file.write_text(payload, encoding="utf-8") + before = registry_file.read_bytes() + + with pytest.raises(RegistryValidationError): + load_registry(registry_file) + + assert registry_file.read_bytes() == before + + +def test_save_validates_before_creating_temporary_file(tmp_path, monkeypatch): + registry_file = tmp_path / "projects.json" + + def unexpected_tempfile(*args, **kwargs): + raise AssertionError("temporary file created before validation") + + monkeypatch.setattr( + "projectmem.project_registry.tempfile.NamedTemporaryFile", + unexpected_tempfile, + ) + + with pytest.raises(RegistryValidationError): + save_registry( + Registry(schema_version=2, active_project=None, projects=()), + registry_file, + ) + + +def test_replace_failure_preserves_previous_registry(tmp_path, monkeypatch): + registry_file = tmp_path / "projects.json" + registry_file.write_text( + '{"schema_version":1,"active_project":null,"projects":[]}\n', + encoding="utf-8", + ) + before = registry_file.read_bytes() + registry = Registry( + schema_version=1, + active_project=None, + projects=( + ProjectRecord( + id="demo", + alias="demo", + path=(tmp_path / "demo").resolve(), + default_brain="coding", + tags=(), + created_at="2026-07-16T12:00:00Z", + updated_at="2026-07-16T12:00:00Z", + ), + ), + ) + + def fail_replace(self, target): + raise OSError("replace failed") + + monkeypatch.setattr(Path, "replace", fail_replace) + + with pytest.raises(OSError, match="replace failed"): + save_registry(registry, registry_file) + + assert registry_file.read_bytes() == before + assert list(tmp_path.glob(".projects.json.*.tmp")) == [] From b3712c71cb1c06342644b9e82ca5c1ae6de636d7 Mon Sep 17 00:00:00 2001 From: Mike Hanley Date: Thu, 16 Jul 2026 13:55:48 -0500 Subject: [PATCH 2/4] feat: add project registry operations --- src/projectmem/project_registry.py | 270 ++++++++++++++++++++++++++++- tests/test_project_registry.py | 167 ++++++++++++++++++ 2 files changed, 436 insertions(+), 1 deletion(-) diff --git a/src/projectmem/project_registry.py b/src/projectmem/project_registry.py index 48311ea..1d63fa4 100644 --- a/src/projectmem/project_registry.py +++ b/src/projectmem/project_registry.py @@ -6,7 +6,7 @@ import tempfile from collections.abc import Iterable, Mapping from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Literal @@ -308,3 +308,271 @@ def save_registry(registry: Registry, path: Path | None = None) -> None: finally: if temporary is not None and temporary.exists(): temporary.unlink() + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).strftime( + _TIMESTAMP_FORMAT + ) + + +def _registry_with( + registry: Registry, + *, + active_project: str | None | object = ..., + projects: tuple[ProjectRecord, ...] | None = None, +) -> Registry: + active = registry.active_project if active_project is ... else active_project + if active is not None and not isinstance(active, str): + raise _validation_error("active_project must be a string or null") + return Registry( + schema_version=registry.schema_version, + active_project=active, + projects=registry.projects if projects is None else projects, + extras=dict(registry.extras), + ) + + +def find_project(identifier: str, registry: Registry) -> ProjectRecord | None: + try: + normalized = _normalize_identifier(identifier, "identifier") + except RegistryValidationError: + return None + for record in registry.projects: + if record.id == normalized: + return record + for record in registry.projects: + if record.alias == normalized: + return record + return None + + +def find_project_by_path( + project_path: str | Path, registry: Registry +) -> ProjectRecord | None: + key = _path_key(project_path) + return next( + (record for record in registry.projects if _path_key(record.path) == key), + None, + ) + + +def detect_project( + project_path: str | Path, registry: Registry +) -> ProjectRecord | None: + candidate = Path(project_path).expanduser() + if not candidate.exists(): + return None + candidate = candidate.resolve() + if candidate.is_file(): + candidate = candidate.parent + matches = [ + record + for record in registry.projects + if candidate == record.path or record.path in candidate.parents + ] + if not matches: + return None + return max(matches, key=lambda record: len(record.path.parts)) + + +def register_project( + project_path: str | Path, + *, + alias: str | None = None, + brain: BrainName = "coding", + tags: Iterable[str] = (), + allow_uninitialized: bool = False, + registry_file: Path | None = None, +) -> ProjectRecord: + path = Path(project_path).expanduser() + if not path.exists() or not path.is_dir(): + raise ProjectRegistryError( + f"Project path must be an existing directory: {path}" + ) + path = path.resolve() + if not allow_uninitialized and not (path / ".projectmem").is_dir(): + raise ProjectRegistryError( + f"Project is not initialized: {path}. Run pjm init from that directory." + ) + if brain not in ("coding", "personal"): + raise RegistryValidationError("brain must be coding or personal") + + identifier = _normalize_identifier( + alias if alias is not None else path.name, + "alias", + ) + normalized_tags = tuple(sorted({_normalize_tag(tag) for tag in tags})) + registry = load_registry(registry_file) + if find_project_by_path(path, registry) is not None: + raise DuplicateProjectError(f"Project path is already registered: {path}") + + for record in registry.projects: + if {identifier} & {record.id, record.alias}: + raise DuplicateProjectError( + f"Project ID or alias is already registered: {identifier}" + ) + + now = _utc_now() + record = ProjectRecord( + id=identifier, + alias=identifier, + path=path, + default_brain=brain, + tags=normalized_tags, + created_at=now, + updated_at=now, + ) + save_registry( + _registry_with(registry, projects=(*registry.projects, record)), + registry_file, + ) + return record + + +def list_projects( + *, + brain: BrainName | None = None, + tags: Iterable[str] = (), + registry_file: Path | None = None, +) -> tuple[ProjectRecord, ...]: + if brain not in (None, "coding", "personal"): + raise RegistryValidationError("brain must be coding or personal") + required_tags = {_normalize_tag(tag) for tag in tags} + registry = load_registry(registry_file) + return tuple( + record + for record in registry.projects + if (brain is None or record.default_brain == brain) + and required_tags.issubset(record.tags) + ) + + +def _required_project(identifier: str, registry: Registry) -> ProjectRecord: + record = find_project(identifier, registry) + if record is None: + raise ProjectNotRegisteredError( + f"Project is not registered: {identifier}. Run pjm project list." + ) + return record + + +def _replace_project( + registry: Registry, replacement: ProjectRecord +) -> Registry: + projects = tuple( + replacement if record.id == replacement.id else record + for record in registry.projects + ) + return _registry_with(registry, projects=projects) + + +def set_active_project( + identifier: str, *, registry_file: Path | None = None +) -> ProjectRecord: + registry = load_registry(registry_file) + record = _required_project(identifier, registry) + if registry.active_project != record.id: + save_registry( + _registry_with(registry, active_project=record.id), + registry_file, + ) + return record + + +def clear_active_project(*, registry_file: Path | None = None) -> None: + registry = load_registry(registry_file) + if registry.active_project is not None: + save_registry( + _registry_with(registry, active_project=None), + registry_file, + ) + + +def remove_project( + identifier: str, *, registry_file: Path | None = None +) -> ProjectRecord: + registry = load_registry(registry_file) + record = _required_project(identifier, registry) + projects = tuple( + candidate for candidate in registry.projects if candidate.id != record.id + ) + active = None if registry.active_project == record.id else registry.active_project + save_registry( + _registry_with(registry, active_project=active, projects=projects), + registry_file, + ) + return record + + +def set_project_brain( + identifier: str, + brain: BrainName, + *, + registry_file: Path | None = None, +) -> ProjectRecord: + if brain not in ("coding", "personal"): + raise RegistryValidationError("brain must be coding or personal") + registry = load_registry(registry_file) + record = _required_project(identifier, registry) + if record.default_brain == brain: + return record + replacement = ProjectRecord( + id=record.id, + alias=record.alias, + path=record.path, + default_brain=brain, + tags=record.tags, + created_at=record.created_at, + updated_at=_utc_now(), + ) + save_registry(_replace_project(registry, replacement), registry_file) + return replacement + + +def add_project_tag( + identifier: str, + tag: str, + *, + registry_file: Path | None = None, +) -> ProjectRecord: + normalized = _normalize_tag(tag) + registry = load_registry(registry_file) + record = _required_project(identifier, registry) + if normalized in record.tags: + return record + replacement = ProjectRecord( + id=record.id, + alias=record.alias, + path=record.path, + default_brain=record.default_brain, + tags=tuple(sorted((*record.tags, normalized))), + created_at=record.created_at, + updated_at=_utc_now(), + ) + save_registry(_replace_project(registry, replacement), registry_file) + return replacement + + +def remove_project_tag( + identifier: str, + tag: str, + *, + registry_file: Path | None = None, +) -> ProjectRecord: + normalized = _normalize_tag(tag) + registry = load_registry(registry_file) + record = _required_project(identifier, registry) + if normalized not in record.tags: + return record + replacement = ProjectRecord( + id=record.id, + alias=record.alias, + path=record.path, + default_brain=record.default_brain, + tags=tuple(existing for existing in record.tags if existing != normalized), + created_at=record.created_at, + updated_at=_utc_now(), + ) + save_registry(_replace_project(registry, replacement), registry_file) + return replacement diff --git a/tests/test_project_registry.py b/tests/test_project_registry.py index f3632aa..cc0e264 100644 --- a/tests/test_project_registry.py +++ b/tests/test_project_registry.py @@ -6,11 +6,25 @@ import pytest from projectmem.project_registry import ( + DuplicateProjectError, ProjectRecord, + ProjectNotRegisteredError, + ProjectRegistryError, Registry, RegistryValidationError, + add_project_tag, + clear_active_project, + detect_project, + find_project, + find_project_by_path, + list_projects, load_registry, + register_project, + remove_project, + remove_project_tag, save_registry, + set_active_project, + set_project_brain, ) @@ -151,3 +165,156 @@ def fail_replace(self, target): assert registry_file.read_bytes() == before assert list(tmp_path.glob(".projects.json.*.tmp")) == [] + + +def _initialized_repo(tmp_path: Path, name: str = "demo") -> Path: + repo = tmp_path / name + (repo / ".projectmem").mkdir(parents=True) + return repo + + +def test_register_and_find_by_id_alias_and_path(tmp_path): + registry_file = tmp_path / "home" / "projects.json" + repo = _initialized_repo(tmp_path) + + record = register_project( + repo, + alias="My-Demo", + brain="personal", + tags=(" Python ", "local first", "python"), + registry_file=registry_file, + ) + + assert record.id == "my-demo" + assert record.alias == "my-demo" + assert record.path == repo.resolve() + assert record.default_brain == "personal" + assert record.tags == ("local-first", "python") + registry = load_registry(registry_file) + assert find_project("MY-DEMO", registry) == record + assert find_project_by_path(repo, registry) == record + + +def test_registration_requires_existing_directory_and_initialization(tmp_path): + registry_file = tmp_path / "projects.json" + missing = tmp_path / "missing" + uninitialized = tmp_path / "plain" + uninitialized.mkdir() + + with pytest.raises(ProjectRegistryError): + register_project( + missing, allow_uninitialized=True, registry_file=registry_file + ) + with pytest.raises(ProjectRegistryError): + register_project(uninitialized, registry_file=registry_file) + + record = register_project( + uninitialized, + allow_uninitialized=True, + registry_file=registry_file, + ) + assert record.path == uninitialized.resolve() + + +def test_duplicate_registration_does_not_mutate_registry(tmp_path): + registry_file = tmp_path / "projects.json" + repo = _initialized_repo(tmp_path) + register_project(repo, registry_file=registry_file) + before = registry_file.read_bytes() + + with pytest.raises(DuplicateProjectError): + register_project(repo, alias="other", registry_file=registry_file) + + assert registry_file.read_bytes() == before + + +def test_detect_project_selects_deepest_registered_ancestor(tmp_path): + registry_file = tmp_path / "projects.json" + parent = _initialized_repo(tmp_path, "parent") + child = parent / "child" + (child / ".projectmem").mkdir(parents=True) + target = child / "src" / "module.py" + target.parent.mkdir() + target.write_text("", encoding="utf-8") + parent_record = register_project(parent, registry_file=registry_file) + child_record = register_project(child, registry_file=registry_file) + registry = load_registry(registry_file) + other = parent / "other" + other.mkdir() + + assert detect_project(target, registry) == child_record + assert detect_project(other, registry) == parent_record + + +def test_active_brain_tag_and_remove_mutations_are_atomic(tmp_path): + registry_file = tmp_path / "projects.json" + repo = _initialized_repo(tmp_path) + original = register_project(repo, registry_file=registry_file) + + active = set_active_project(original.alias.upper(), registry_file=registry_file) + assert active.id == original.id + assert load_registry(registry_file).active_project == original.id + + personal = set_project_brain( + original.id, "personal", registry_file=registry_file + ) + assert personal.created_at == original.created_at + assert personal.updated_at >= original.updated_at + + tagged = add_project_tag( + original.id, " Local First ", registry_file=registry_file + ) + tagged_again = add_project_tag( + original.id, "local-first", registry_file=registry_file + ) + assert tagged.tags == ("local-first",) + assert tagged_again.tags == tagged.tags + + untagged = remove_project_tag( + original.id, "missing", registry_file=registry_file + ) + assert untagged.tags == ("local-first",) + + removed = remove_project(original.id, registry_file=registry_file) + assert removed.id == original.id + registry = load_registry(registry_file) + assert registry.projects == () + assert registry.active_project is None + + +def test_list_filters_use_brain_and_tag_and_semantics(tmp_path): + registry_file = tmp_path / "projects.json" + first_repo = _initialized_repo(tmp_path, "first") + second_repo = _initialized_repo(tmp_path, "second") + first = register_project( + first_repo, + brain="coding", + tags=("python", "local"), + registry_file=registry_file, + ) + register_project( + second_repo, + brain="personal", + tags=("python",), + registry_file=registry_file, + ) + + assert list_projects( + brain="coding", tags=("python", "local"), registry_file=registry_file + ) == (first,) + assert list_projects( + tags=("python", "missing"), registry_file=registry_file + ) == () + + +def test_missing_mutation_target_does_not_write(tmp_path): + registry_file = tmp_path / "projects.json" + save_registry(Registry(1, None, ()), registry_file) + before = registry_file.read_bytes() + + with pytest.raises(ProjectNotRegisteredError): + set_active_project("missing", registry_file=registry_file) + + assert registry_file.read_bytes() == before + clear_active_project(registry_file=registry_file) + assert registry_file.read_bytes() == before From fd98c7f7fbd5fbd51b6c8444197070beb749232a Mon Sep 17 00:00:00 2001 From: Mike Hanley Date: Fri, 17 Jul 2026 01:52:28 -0500 Subject: [PATCH 3/4] feat: add project registry CLI --- README.md | 23 +++++ TUTORIAL.md | 17 ++++ src/projectmem/cli.py | 2 + src/projectmem/commands/project.py | 152 +++++++++++++++++++++++++++++ tests/test_project_cli.py | 121 +++++++++++++++++++++++ 5 files changed, 315 insertions(+) create mode 100644 src/projectmem/commands/project.py create mode 100644 tests/test_project_cli.py diff --git a/README.md b/README.md index b7b368d..4c08b2c 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,29 @@ All 14 tools your AI can call: | `pjm brief` | One-screen session-start briefing: warnings, stale memories, open issues, decisions, score | | `pjm export [--claude-md\|--cursor]` | Compile live memory into CLAUDE.md / .cursorrules for agents without MCP | +### Project registry + +The optional project registry tracks initialized repositories, their active +selection, brain, and explicit tags. It is stored at +PROJECTMEM_HOME/projects.json, or ~/.projectmem/projects.json when +PROJECTMEM_HOME is unset. The file contains local absolute filesystem paths and +is never uploaded by projectmem. + +~~~text +pjm project register C:\path\to\repo --brain coding --tag python +pjm project list --brain coding --tag python +pjm project detect --path C:\path\to\repo\src +pjm project use repo +pjm project set-brain repo personal +pjm project tag add repo local-first +pjm project tag list repo +pjm project tag remove repo local-first +pjm project remove repo +~~~ + +Repeated tag filters use AND semantics. Registry commands never modify a +project's repo-local events or summary. + ### Intelligence layer | Command | Purpose | diff --git a/TUTORIAL.md b/TUTORIAL.md index 9f17eb2..51487cd 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -58,6 +58,23 @@ You should see, in order: ## Step 3 — Wire up your AI client +### Optional: register projects + +The registry is useful when you work across several initialized repositories. +Registration is explicit in this release: + +~~~text +pjm project register C:\path\to\repo --brain coding --tag python +pjm project list +pjm project use repo +~~~ + +Use pjm project detect --path PATH to find the deepest registered project +containing a file or directory. Manage metadata with pjm project set-brain and +pjm project tag add, remove, or list. The registry lives at +PROJECTMEM_HOME/projects.json (default ~/.projectmem/projects.json), contains +local absolute paths, and does not change repo-local .projectmem events. + Paste the printed config block into your client's MCP config file: | Client | Config file | diff --git a/src/projectmem/cli.py b/src/projectmem/cli.py index 1d1e409..09dc5aa 100644 --- a/src/projectmem/cli.py +++ b/src/projectmem/cli.py @@ -20,6 +20,7 @@ from projectmem.commands import map as map_command from projectmem.commands import note as note_command from projectmem.commands import precheck as precheck_command +from projectmem.commands import project as project_command from projectmem.commands import regenerate as regenerate_command from projectmem.commands import score as score_command from projectmem.commands import search as search_command @@ -35,6 +36,7 @@ no_args_is_help=True, add_completion=False, ) +app.add_typer(project_command.project_app, name="project") @app.callback() diff --git a/src/projectmem/commands/project.py b/src/projectmem/commands/project.py new file mode 100644 index 0000000..7e8b63e --- /dev/null +++ b/src/projectmem/commands/project.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any, TypeVar + +import typer + +from projectmem.project_registry import ( + ProjectRecord, + ProjectRegistryError, + add_project_tag, + detect_project, + list_projects, + load_registry, + register_project, + remove_project, + remove_project_tag, + set_active_project, + set_project_brain, +) + + +project_app = typer.Typer( + help="Register and select ProjectMem projects.", + no_args_is_help=True, + add_completion=False, +) +tag_app = typer.Typer( + help="Manage project tags.", + no_args_is_help=True, + add_completion=False, +) +project_app.add_typer(tag_app, name="tag") + +T = TypeVar("T") + + +def _domain_call(function: Callable[..., T], *args: Any, **kwargs: Any) -> T: + try: + return function(*args, **kwargs) + except ProjectRegistryError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + + +def _render_record(record: ProjectRecord) -> str: + tags = ",".join(record.tags) + return ( + f"{record.alias} {record.default_brain} {tags} " + f"{record.path}" + ) + + +@project_app.command("register") +def register_command( + path: Path, + alias: str | None = typer.Option(None, "--alias"), + brain: str = typer.Option("coding", "--brain"), + tags: list[str] | None = typer.Option(None, "--tag"), + allow_uninitialized: bool = typer.Option( + False, "--allow-uninitialized" + ), +) -> None: + record = _domain_call( + register_project, + path, + alias=alias, + brain=brain, + tags=tags or (), + allow_uninitialized=allow_uninitialized, + ) + typer.echo(f"Registered {_render_record(record)}") + + +@project_app.command("list") +def list_command( + brain: str | None = typer.Option(None, "--brain"), + tags: list[str] | None = typer.Option(None, "--tag"), +) -> None: + records = _domain_call( + list_projects, + brain=brain, + tags=tags or (), + ) + registry = _domain_call(load_registry) + typer.echo("ACTIVE ALIAS BRAIN TAGS PATH") + for record in records: + active = "*" if registry.active_project == record.id else "" + typer.echo( + f"{active:<6} {record.alias} {record.default_brain} " + f"{','.join(record.tags)} {record.path}" + ) + + +@project_app.command("detect") +def detect_command( + path: Path = typer.Option(..., "--path"), +) -> None: + expanded = path.expanduser() + if not expanded.exists(): + typer.echo(f"Path does not exist: {expanded}", err=True) + raise typer.Exit(1) + resolved = expanded.resolve() + record = _domain_call(detect_project, resolved, _domain_call(load_registry)) + if record is None: + typer.echo(f'pjm project register "{resolved}"', err=True) + raise typer.Exit(1) + typer.echo(_render_record(record)) + + +@project_app.command("use") +def use_command(identifier: str) -> None: + record = _domain_call(set_active_project, identifier) + typer.echo(f"Using project {record.id}") + + +@project_app.command("remove") +def remove_command(identifier: str) -> None: + record = _domain_call(remove_project, identifier) + typer.echo(f"Removed project {record.id}") + + +@project_app.command("set-brain") +def set_brain_command(identifier: str, brain: str) -> None: + record = _domain_call(set_project_brain, identifier, brain) + typer.echo(f"Project {record.id} brain: {record.default_brain}") + + +@tag_app.command("add") +def tag_add_command(identifier: str, tag: str) -> None: + record = _domain_call(add_project_tag, identifier, tag) + typer.echo(f"Project {record.id} tags: {','.join(record.tags)}") + + +@tag_app.command("remove") +def tag_remove_command(identifier: str, tag: str) -> None: + record = _domain_call(remove_project_tag, identifier, tag) + typer.echo(f"Project {record.id} tags: {','.join(record.tags)}") + + +@tag_app.command("list") +def tag_list_command(identifier: str) -> None: + registry = _domain_call(load_registry) + from projectmem.project_registry import find_project + + record = find_project(identifier, registry) + if record is None: + typer.echo(f"Project is not registered: {identifier}", err=True) + raise typer.Exit(1) + for tag in record.tags: + typer.echo(tag) diff --git a/tests/test_project_cli.py b/tests/test_project_cli.py new file mode 100644 index 0000000..5e73798 --- /dev/null +++ b/tests/test_project_cli.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from projectmem.cli import app + + +runner = CliRunner() + + +def _initialized_repo(tmp_path: Path, name: str = "demo") -> Path: + repo = tmp_path / name + (repo / ".projectmem").mkdir(parents=True) + return repo + + +def test_project_register_list_and_use(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + repo = _initialized_repo(tmp_path) + + registered = runner.invoke( + app, + [ + "project", + "register", + str(repo), + "--brain", + "personal", + "--tag", + "Python", + "--tag", + "Local First", + ], + ) + assert registered.exit_code == 0 + + listed = runner.invoke( + app, + ["project", "list", "--brain", "personal", "--tag", "python"], + ) + assert listed.exit_code == 0 + assert all( + column in listed.stdout + for column in ("ACTIVE", "ALIAS", "BRAIN", "TAGS", "PATH") + ) + assert "local-first,python" in listed.stdout + + selected = runner.invoke(app, ["project", "use", repo.name.upper()]) + assert selected.exit_code == 0 + assert repo.name in selected.stdout + assert "*" in runner.invoke(app, ["project", "list"]).stdout + + +def test_project_detect_unregistered_prints_exact_command(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + repo = _initialized_repo(tmp_path) + + result = runner.invoke(app, ["project", "detect", "--path", str(repo)]) + + assert result.exit_code == 1 + assert f'pjm project register "{repo.resolve()}"' in result.output + + +def test_project_detect_invalid_path_exits_one(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + + result = runner.invoke( + app, + ["project", "detect", "--path", str(tmp_path / "missing")], + ) + + assert result.exit_code == 1 + assert "does not exist" in result.output + + +def test_project_brain_and_tag_commands(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + repo = _initialized_repo(tmp_path) + assert runner.invoke(app, ["project", "register", str(repo)]).exit_code == 0 + + assert ( + runner.invoke( + app, ["project", "set-brain", repo.name, "personal"] + ).exit_code + == 0 + ) + assert ( + runner.invoke( + app, ["project", "tag", "add", repo.name, "Local First"] + ).exit_code + == 0 + ) + tags = runner.invoke(app, ["project", "tag", "list", repo.name]) + assert tags.exit_code == 0 + assert tags.stdout.strip() == "local-first" + assert ( + runner.invoke( + app, ["project", "tag", "remove", repo.name, "local-first"] + ).exit_code + == 0 + ) + + +def test_project_remove_and_domain_error_exit_one(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + repo = _initialized_repo(tmp_path) + runner.invoke(app, ["project", "register", str(repo)]) + + removed = runner.invoke(app, ["project", "remove", repo.name]) + assert removed.exit_code == 0 + + missing = runner.invoke(app, ["project", "use", repo.name]) + assert missing.exit_code == 1 + assert "not registered" in missing.output + + +def test_project_syntax_errors_remain_exit_two(): + result = runner.invoke(app, ["project", "set-brain"]) + assert result.exit_code == 2 From d5fa9c6ffddc9ec209bc4f446c15f5c922438b18 Mon Sep 17 00:00:00 2001 From: Mike Hanley Date: Sat, 18 Jul 2026 00:21:32 -0500 Subject: [PATCH 4/4] fix: address project registry review findings --- README.md | 6 +- SECURITY.md | 3 + src/projectmem/commands/project.py | 38 +++-- src/projectmem/project_registry.py | 62 ++++++- tests/test_project_cli.py | 149 ++++++++++++++++- tests/test_project_registry.py | 249 ++++++++++++++++++++++++++++- 6 files changed, 484 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1c47a5d..d46e5b8 100644 --- a/README.md +++ b/README.md @@ -387,8 +387,10 @@ All 15 tools your AI can call: ### Project registry -The optional project registry tracks initialized repositories, their active -selection, brain, and explicit tags. It is stored at +The optional project registry tracks registered project directories, their +active selection, brain, and explicit tags. Registration normally requires an +initialized `.projectmem/` directory; use `--allow-uninitialized` to register +an existing directory before initialization. The registry is stored at PROJECTMEM_HOME/projects.json, or ~/.projectmem/projects.json when PROJECTMEM_HOME is unset. The file contains local absolute filesystem paths and is never uploaded by projectmem. diff --git a/SECURITY.md b/SECURITY.md index aa1b249..3733f0c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,8 +22,11 @@ projectmem is open source and we'd rather be transparent about the trade-offs th | `.projectmem/summary.md` | Distilled briefing for AI agents | Committed to git | | `.projectmem/PROJECT_MAP.md` | Architecture map | Committed to git | | `~/.projectmem/global/` | Cross-project patterns and gotchas | Local only, shared across all your projects | +| `$PROJECTMEM_HOME/projects.json` (default: `~/.projectmem/projects.json`) | Registered project IDs, aliases, absolute local paths, brains, tags, and active selection | Local only | | `.projectmem/watch.pid` · `watch.log` | Watcher state and runtime log | Gitignored, local only | +The project registry contains local filesystem paths. It remains on your machine; projectmem adds no telemetry or network transfer for registry data. + ### Honest trade-offs 1. **Never paste secrets into `pjm log/note/decision`.** The event log is append-only. Treat it like git history: don't commit anything you wouldn't want re-read later. diff --git a/src/projectmem/commands/project.py b/src/projectmem/commands/project.py index 7e8b63e..9efaf51 100644 --- a/src/projectmem/commands/project.py +++ b/src/projectmem/commands/project.py @@ -9,9 +9,11 @@ from projectmem.project_registry import ( ProjectRecord, ProjectRegistryError, + _filter_projects, + _normalize_project_filters, + _required_project, add_project_tag, detect_project, - list_projects, load_registry, register_project, remove_project, @@ -19,6 +21,7 @@ set_active_project, set_project_brain, ) +from projectmem.storage import discover_mem_dir project_app = typer.Typer( @@ -52,6 +55,12 @@ def _render_record(record: ProjectRecord) -> str: ) +def _find_initialized_root(path: Path) -> Path | None: + candidate = path.parent if path.is_file() else path + mem_dir = discover_mem_dir(candidate) + return mem_dir.parent if mem_dir is not None else None + + @project_app.command("register") def register_command( path: Path, @@ -78,12 +87,18 @@ def list_command( brain: str | None = typer.Option(None, "--brain"), tags: list[str] | None = typer.Option(None, "--tag"), ) -> None: - records = _domain_call( - list_projects, + normalized_brain, required_tags = _domain_call( + _normalize_project_filters, brain=brain, tags=tags or (), ) registry = _domain_call(load_registry) + records = _domain_call( + _filter_projects, + registry, + brain=normalized_brain, + required_tags=required_tags, + ) typer.echo("ACTIVE ALIAS BRAIN TAGS PATH") for record in records: active = "*" if registry.active_project == record.id else "" @@ -104,7 +119,15 @@ def detect_command( resolved = expanded.resolve() record = _domain_call(detect_project, resolved, _domain_call(load_registry)) if record is None: - typer.echo(f'pjm project register "{resolved}"', err=True) + project_root = _find_initialized_root(resolved) + if project_root is None: + typer.echo( + f"Project is not initialized: {resolved}. " + "Run pjm init from the project root.", + err=True, + ) + raise typer.Exit(1) + typer.echo(f'pjm project register "{project_root}"', err=True) raise typer.Exit(1) typer.echo(_render_record(record)) @@ -142,11 +165,6 @@ def tag_remove_command(identifier: str, tag: str) -> None: @tag_app.command("list") def tag_list_command(identifier: str) -> None: registry = _domain_call(load_registry) - from projectmem.project_registry import find_project - - record = find_project(identifier, registry) - if record is None: - typer.echo(f"Project is not registered: {identifier}", err=True) - raise typer.Exit(1) + record = _domain_call(_required_project, identifier, registry) for tag in record.tags: typer.echo(tag) diff --git a/src/projectmem/project_registry.py b/src/projectmem/project_registry.py index 1d63fa4..bcd0f89 100644 --- a/src/projectmem/project_registry.py +++ b/src/projectmem/project_registry.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Literal +from typing import Literal, cast BrainName = Literal["coding", "personal"] @@ -16,6 +16,9 @@ _IDENTIFIER_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") _TAG_RE = _IDENTIFIER_RE _TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +_TIMESTAMP_RE = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z" +) _TOP_LEVEL_FIELDS = {"schema_version", "active_project", "projects"} _PROJECT_FIELDS = { "id", @@ -102,6 +105,10 @@ def _path_key(path: str | Path) -> str: def _parse_timestamp(value: object, field_name: str) -> str: if not isinstance(value, str): raise _validation_error(f"{field_name} must be an RFC 3339 string") + if not _TIMESTAMP_RE.fullmatch(value): + raise _validation_error( + f"{field_name} must use UTC RFC 3339 seconds ending in Z" + ) try: datetime.strptime(value, _TIMESTAMP_FORMAT) except ValueError as exc: @@ -137,7 +144,12 @@ def _parse_record(value: object, index: int) -> ProjectRecord: path = Path(path_value).expanduser() if not path.is_absolute(): raise _validation_error(f"projects[{index}].path must be absolute") - path = path.resolve() + resolved_path = path.resolve() + if path_value != str(resolved_path): + raise _validation_error( + f"projects[{index}].path must be an absolute resolved path" + ) + path = resolved_path brain = value["default_brain"] if brain not in ("coding", "personal"): @@ -191,6 +203,11 @@ def _validate_uniqueness(projects: Iterable[ProjectRecord]) -> None: def _registry_from_payload(payload: object) -> Registry: if not isinstance(payload, dict): raise _validation_error("top level must be an object") + missing = _TOP_LEVEL_FIELDS - set(payload) + if missing: + raise _validation_error( + f"top level is missing fields: {', '.join(sorted(missing))}" + ) if payload.get("schema_version") != 1 or type( payload.get("schema_version") ) is not int: @@ -277,6 +294,10 @@ def load_registry(path: Path | None = None) -> Registry: return Registry(schema_version=1, active_project=None, projects=()) try: payload = json.loads(destination.read_text(encoding="utf-8")) + except UnicodeDecodeError as exc: + raise _validation_error( + f"{destination} must contain valid UTF-8" + ) from exc except json.JSONDecodeError as exc: raise _validation_error( f"{destination} contains malformed JSON" @@ -430,16 +451,24 @@ def register_project( return record -def list_projects( +def _normalize_project_filters( *, - brain: BrainName | None = None, + brain: str | None = None, tags: Iterable[str] = (), - registry_file: Path | None = None, -) -> tuple[ProjectRecord, ...]: +) -> tuple[BrainName | None, frozenset[str]]: if brain not in (None, "coding", "personal"): raise RegistryValidationError("brain must be coding or personal") - required_tags = {_normalize_tag(tag) for tag in tags} - registry = load_registry(registry_file) + return cast(BrainName | None, brain), frozenset( + _normalize_tag(tag) for tag in tags + ) + + +def _filter_projects( + registry: Registry, + *, + brain: BrainName | None, + required_tags: frozenset[str], +) -> tuple[ProjectRecord, ...]: return tuple( record for record in registry.projects @@ -448,6 +477,23 @@ def list_projects( ) +def list_projects( + *, + brain: BrainName | None = None, + tags: Iterable[str] = (), + registry_file: Path | None = None, +) -> tuple[ProjectRecord, ...]: + normalized_brain, required_tags = _normalize_project_filters( + brain=brain, + tags=tags, + ) + return _filter_projects( + load_registry(registry_file), + brain=normalized_brain, + required_tags=required_tags, + ) + + def _required_project(identifier: str, registry: Registry) -> ProjectRecord: record = find_project(identifier, registry) if record is None: diff --git a/tests/test_project_cli.py b/tests/test_project_cli.py index 5e73798..e0fba08 100644 --- a/tests/test_project_cli.py +++ b/tests/test_project_cli.py @@ -4,6 +4,8 @@ from typer.testing import CliRunner +import projectmem.commands.project as project_commands +import projectmem.project_registry as project_registry from projectmem.cli import app @@ -12,7 +14,9 @@ def _initialized_repo(tmp_path: Path, name: str = "demo") -> Path: repo = tmp_path / name - (repo / ".projectmem").mkdir(parents=True) + mem_dir = repo / ".projectmem" + mem_dir.mkdir(parents=True) + (mem_dir / "config.toml").write_text("", encoding="utf-8") return repo @@ -53,6 +57,28 @@ def test_project_register_list_and_use(tmp_path, monkeypatch): assert "*" in runner.invoke(app, ["project", "list"]).stdout +def test_project_list_uses_one_registry_snapshot(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + repo = _initialized_repo(tmp_path) + assert runner.invoke(app, ["project", "register", str(repo)]).exit_code == 0 + + real_load = project_registry.load_registry + load_count = 0 + + def counted_load(*args, **kwargs): + nonlocal load_count + load_count += 1 + return real_load(*args, **kwargs) + + monkeypatch.setattr(project_registry, "load_registry", counted_load) + monkeypatch.setattr(project_commands, "load_registry", counted_load) + + result = runner.invoke(app, ["project", "list"]) + + assert result.exit_code == 0 + assert load_count == 1 + + def test_project_detect_unregistered_prints_exact_command(tmp_path, monkeypatch): monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) repo = _initialized_repo(tmp_path) @@ -63,6 +89,25 @@ def test_project_detect_unregistered_prints_exact_command(tmp_path, monkeypatch) assert f'pjm project register "{repo.resolve()}"' in result.output +def test_project_detect_unregistered_nested_file_prints_root_command( + tmp_path, monkeypatch +): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + repo = _initialized_repo(tmp_path) + target = repo / "src" / "module.py" + target.parent.mkdir() + target.write_text("", encoding="utf-8") + + result = runner.invoke( + app, + ["project", "detect", "--path", str(target)], + ) + + assert result.exit_code == 1 + assert f'pjm project register "{repo.resolve()}"' in result.output + assert str(target.resolve()) not in result.output + + def test_project_detect_invalid_path_exits_one(tmp_path, monkeypatch): monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) @@ -75,6 +120,63 @@ def test_project_detect_invalid_path_exits_one(tmp_path, monkeypatch): assert "does not exist" in result.output +def test_project_detect_uninitialized_path_does_not_suggest_registration( + tmp_path, monkeypatch +): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + uninitialized = tmp_path / "plain" + uninitialized.mkdir() + + result = runner.invoke( + app, + ["project", "detect", "--path", str(uninitialized)], + ) + + assert result.exit_code == 1 + assert "not initialized" in result.output + assert "pjm project register" not in result.output + + +def test_project_detect_does_not_treat_global_store_as_project( + tmp_path, monkeypatch +): + simulated_home = tmp_path / "home" + (simulated_home / ".projectmem").mkdir(parents=True) + target = simulated_home / "work" / "project" + target.mkdir(parents=True) + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "registry-home")) + + result = runner.invoke( + app, + ["project", "detect", "--path", str(target)], + ) + + assert result.exit_code == 1 + assert "not initialized" in result.output + assert ( + f'pjm project register "{simulated_home.resolve()}"' + not in result.output + ) + + +def test_project_detect_registered_nested_file_succeeds(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + repo = _initialized_repo(tmp_path) + target = repo / "src" / "module.py" + target.parent.mkdir() + target.write_text("", encoding="utf-8") + assert runner.invoke(app, ["project", "register", str(repo)]).exit_code == 0 + + result = runner.invoke( + app, + ["project", "detect", "--path", str(target)], + ) + + assert result.exit_code == 0 + assert repo.name in result.output + assert str(repo.resolve()) in result.output + + def test_project_brain_and_tag_commands(tmp_path, monkeypatch): monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) repo = _initialized_repo(tmp_path) @@ -103,6 +205,17 @@ def test_project_brain_and_tag_commands(tmp_path, monkeypatch): ) +def test_project_tag_list_missing_project_is_actionable(tmp_path, monkeypatch): + monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) + + result = runner.invoke(app, ["project", "tag", "list", "missing"]) + + assert result.exit_code == 1 + assert result.output.splitlines() == [ + "Project is not registered: missing. Run pjm project list." + ] + + def test_project_remove_and_domain_error_exit_one(tmp_path, monkeypatch): monkeypatch.setenv("PROJECTMEM_HOME", str(tmp_path / "home")) repo = _initialized_repo(tmp_path) @@ -116,6 +229,40 @@ def test_project_remove_and_domain_error_exit_one(tmp_path, monkeypatch): assert "not registered" in missing.output +def test_project_list_invalid_utf8_registry_exits_one(tmp_path, monkeypatch): + projectmem_home = tmp_path / "home" + projectmem_home.mkdir() + (projectmem_home / "projects.json").write_bytes(b"\xff") + monkeypatch.setenv("PROJECTMEM_HOME", str(projectmem_home)) + + result = runner.invoke(app, ["project", "list"]) + + assert result.exit_code == 1 + assert "valid UTF-8" in result.output + assert "Traceback" not in result.output + + +def test_project_list_validates_filters_before_loading_registry( + tmp_path, monkeypatch +): + projectmem_home = tmp_path / "home" + projectmem_home.mkdir() + (projectmem_home / "projects.json").write_text( + "{not json", + encoding="utf-8", + ) + monkeypatch.setenv("PROJECTMEM_HOME", str(projectmem_home)) + + result = runner.invoke( + app, + ["project", "list", "--brain", "invalid"], + ) + + assert result.exit_code == 1 + assert "brain" in result.output + assert "malformed JSON" not in result.output + + def test_project_syntax_errors_remain_exit_two(): result = runner.invoke(app, ["project", "set-brain"]) assert result.exit_code == 2 diff --git a/tests/test_project_registry.py b/tests/test_project_registry.py index cc0e264..d629b7e 100644 --- a/tests/test_project_registry.py +++ b/tests/test_project_registry.py @@ -1,10 +1,12 @@ from __future__ import annotations import json +import os from pathlib import Path import pytest +import projectmem.project_registry as project_registry from projectmem.project_registry import ( DuplicateProjectError, ProjectRecord, @@ -114,6 +116,79 @@ def test_invalid_registry_is_not_rewritten(tmp_path, payload): assert registry_file.read_bytes() == before +def test_registry_requires_active_project_field(tmp_path): + registry_file = tmp_path / "projects.json" + registry_file.write_text( + json.dumps({"schema_version": 1, "projects": []}), + encoding="utf-8", + ) + before = registry_file.read_bytes() + + with pytest.raises(RegistryValidationError, match="active_project"): + load_registry(registry_file) + + assert registry_file.read_bytes() == before + + +def test_registry_rejects_noncanonical_timestamp(tmp_path): + registry_file = tmp_path / "projects.json" + registry_file.write_text( + json.dumps( + { + "schema_version": 1, + "active_project": None, + "projects": [ + _project_payload( + tmp_path / "demo", + created_at="2026-7-1T2:3:4Z", + ) + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(RegistryValidationError, match="created_at"): + load_registry(registry_file) + + +def test_registry_rejects_invalid_utf8_without_rewriting(tmp_path): + registry_file = tmp_path / "projects.json" + registry_file.write_bytes(b"\xff") + before = registry_file.read_bytes() + + with pytest.raises(RegistryValidationError, match="UTF-8"): + load_registry(registry_file) + + assert registry_file.read_bytes() == before + + +def test_registry_rejects_noncanonical_path_without_rewriting(tmp_path): + registry_file = tmp_path / "projects.json" + noncanonical_path = tmp_path / "parent" / ".." / "demo" + project = _project_payload(tmp_path / "demo") + project["path"] = str(noncanonical_path) + registry_file.write_text( + json.dumps( + { + "schema_version": 1, + "active_project": None, + "projects": [project], + } + ), + encoding="utf-8", + ) + before = registry_file.read_bytes() + + with pytest.raises( + RegistryValidationError, + match=r"projects\[0\]\.path", + ): + load_registry(registry_file) + + assert registry_file.read_bytes() == before + + def test_save_validates_before_creating_temporary_file(tmp_path, monkeypatch): registry_file = tmp_path / "projects.json" @@ -167,6 +242,74 @@ def fail_replace(self, target): assert list(tmp_path.glob(".projects.json.*.tmp")) == [] +def test_write_failure_preserves_previous_registry(tmp_path, monkeypatch): + registry_file = tmp_path / "projects.json" + registry_file.write_text( + '{"schema_version":1,"active_project":null,"projects":[]}\n', + encoding="utf-8", + ) + before = registry_file.read_bytes() + registry = Registry(schema_version=1, active_project=None, projects=()) + real_named_temporary_file = project_registry.tempfile.NamedTemporaryFile + + class FailingWriteHandle: + def __init__(self, *args, **kwargs): + self._handle = real_named_temporary_file(*args, **kwargs) + + @property + def name(self): + return self._handle.name + + def __enter__(self): + self._handle.__enter__() + return self + + def __exit__(self, *args): + return self._handle.__exit__(*args) + + def write(self, value): + self._handle.write(value[:1]) + raise OSError("write failed") + + def flush(self): + self._handle.flush() + + def fileno(self): + return self._handle.fileno() + + monkeypatch.setattr( + project_registry.tempfile, + "NamedTemporaryFile", + FailingWriteHandle, + ) + + with pytest.raises(OSError, match="write failed"): + save_registry(registry, registry_file) + + assert registry_file.read_bytes() == before + assert list(tmp_path.glob(".projects.json.*.tmp")) == [] + + +def test_save_fsyncs_before_replacing_registry(tmp_path, monkeypatch): + registry_file = tmp_path / "projects.json" + events = [] + real_replace = Path.replace + + def record_fsync(_file_descriptor): + events.append("fsync") + + def record_replace(self, target): + events.append("replace") + return real_replace(self, target) + + monkeypatch.setattr(project_registry.os, "fsync", record_fsync) + monkeypatch.setattr(Path, "replace", record_replace) + + save_registry(Registry(1, None, ()), registry_file) + + assert events == ["fsync", "replace"] + + def _initialized_repo(tmp_path: Path, name: str = "demo") -> Path: repo = tmp_path / name (repo / ".projectmem").mkdir(parents=True) @@ -228,6 +371,89 @@ def test_duplicate_registration_does_not_mutate_registry(tmp_path): assert registry_file.read_bytes() == before +def test_registry_rejects_duplicate_id_alias_namespace(tmp_path): + registry_file = tmp_path / "projects.json" + registry_file.write_text( + json.dumps( + { + "schema_version": 1, + "active_project": None, + "projects": [ + _project_payload( + tmp_path / "first", + id="first", + alias="shared", + ), + _project_payload( + tmp_path / "second", + id="shared", + alias="second", + ), + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(RegistryValidationError, match="identifier 'shared'"): + load_registry(registry_file) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows path identity") +def test_registry_rejects_windows_equivalent_paths(tmp_path): + registry_file = tmp_path / "projects.json" + path = (tmp_path / "Demo").resolve() + equivalent = Path(str(path).swapcase()) + registry_file.write_text( + json.dumps( + { + "schema_version": 1, + "active_project": None, + "projects": [ + _project_payload(path, id="first", alias="first"), + _project_payload( + equivalent, + id="second", + alias="second", + ), + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(RegistryValidationError, match="registered more than once"): + load_registry(registry_file) + + +@pytest.mark.parametrize( + "overrides", + [ + {"id": "Demo"}, + {"alias": "demo_alias"}, + {"tags": [""]}, + {"updated_at": "2026-02-30T12:00:00Z"}, + ], +) +def test_registry_rejects_noncanonical_project_fields( + tmp_path, overrides +): + registry_file = tmp_path / "projects.json" + registry_file.write_text( + json.dumps( + { + "schema_version": 1, + "active_project": None, + "projects": [_project_payload(tmp_path / "demo", **overrides)], + } + ), + encoding="utf-8", + ) + + with pytest.raises(RegistryValidationError): + load_registry(registry_file) + + def test_detect_project_selects_deepest_registered_ancestor(tmp_path): registry_file = tmp_path / "projects.json" parent = _initialized_repo(tmp_path, "parent") @@ -246,7 +472,7 @@ def test_detect_project_selects_deepest_registered_ancestor(tmp_path): assert detect_project(other, registry) == parent_record -def test_active_brain_tag_and_remove_mutations_are_atomic(tmp_path): +def test_active_brain_tag_and_remove_mutations_are_atomic(tmp_path, monkeypatch): registry_file = tmp_path / "projects.json" repo = _initialized_repo(tmp_path) original = register_project(repo, registry_file=registry_file) @@ -255,11 +481,13 @@ def test_active_brain_tag_and_remove_mutations_are_atomic(tmp_path): assert active.id == original.id assert load_registry(registry_file).active_project == original.id + updated_at = "2099-01-01T00:00:00Z" + monkeypatch.setattr(project_registry, "_utc_now", lambda: updated_at) personal = set_project_brain( original.id, "personal", registry_file=registry_file ) assert personal.created_at == original.created_at - assert personal.updated_at >= original.updated_at + assert personal.updated_at == updated_at tagged = add_project_tag( original.id, " Local First ", registry_file=registry_file @@ -307,6 +535,23 @@ def test_list_filters_use_brain_and_tag_and_semantics(tmp_path): ) == () +@pytest.mark.parametrize( + ("filters", "message"), + [ + ({"brain": "invalid"}, "brain"), + ({"tags": ("bad_tag",)}, "tag"), + ], +) +def test_list_filters_validate_before_loading_registry( + tmp_path, filters, message +): + registry_file = tmp_path / "projects.json" + registry_file.write_text("{not json", encoding="utf-8") + + with pytest.raises(RegistryValidationError, match=message): + list_projects(registry_file=registry_file, **filters) + + def test_missing_mutation_target_does_not_write(tmp_path): registry_file = tmp_path / "projects.json" save_registry(Registry(1, None, ()), registry_file)