diff --git a/mempalace/searcher.py b/mempalace/searcher.py index db809d9..ef951e2 100644 --- a/mempalace/searcher.py +++ b/mempalace/searcher.py @@ -283,6 +283,7 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1): similarity = round(max(0.0, 1 - dist), 3) + meta = meta or {} source = Path(meta.get("source_file", "?")).name wing_name = meta.get("wing", "?") room_name = meta.get("room", "?") diff --git a/tests/test_searcher.py b/tests/test_searcher.py index 11e788d..127e95f 100644 --- a/tests/test_searcher.py +++ b/tests/test_searcher.py @@ -141,3 +141,22 @@ class TestSearchCLI: captured = capsys.readouterr() # Should have output with at least one result block assert "[1]" in captured.out + + def test_search_handles_none_metadata_without_crash(self, palace_path, capsys): + """ChromaDB can return `None` entries in the metadatas list when a + drawer has no metadata. The CLI print path must not crash on them + mid-render — it used to raise `AttributeError: 'NoneType' object has + no attribute 'get'` after printing earlier results.""" + mock_col = MagicMock() + mock_col.query.return_value = { + "documents": [["first doc", "second doc"]], + "metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]], + "distances": [[0.1, 0.2]], + } + with patch("mempalace.searcher.get_collection", return_value=mock_col): + search("anything", "/fake/path") + captured = capsys.readouterr() + assert "[1]" in captured.out + assert "[2]" in captured.out + # Second result renders with fallback '?' values instead of crashing + assert "second doc" in captured.out