Skip to content

Commit 18f00f8

Browse files
phernandezclaude
andcommitted
fix: remove hardcoded "main" default from default_project (#575)
When a user's config.json had projects with names other than "main" and no explicit default_project key, the hardcoded field default of "main" would not match any project. The model_post_init fixup logic existed but was untested and only handled the stale-name case, not the None case. Changes: - Change default_project field default from "main" to None - Use model_fields_set to distinguish "config omitted the key" (auto-resolve to first project) from "user explicitly set None" (preserve for discovery mode) - Split model_post_init into two branches: auto-resolve when not explicitly provided, correct stale names when explicitly set but invalid - Remove # pragma: no cover from now-tested branches - Add 10 new tests covering valid defaults, stale defaults, empty string, single project, config file round-trips, and discovery mode preservation Fixes #575 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent da4d369 commit 18f00f8

2 files changed

Lines changed: 137 additions & 9 deletions

File tree

src/basic_memory/config.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ class BasicMemoryConfig(BaseSettings):
132132
description="Mapping of project names to their ProjectEntry configuration",
133133
)
134134
default_project: Optional[str] = Field(
135-
default="main",
135+
default=None,
136136
description="Name of the default project to use. When set, acts as fallback when no project parameter is specified. Set to null to disable automatic project resolution.",
137137
)
138138

@@ -493,18 +493,25 @@ def model_post_init(self, __context: Any) -> None:
493493
if self.database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
494494
return # pragma: no cover
495495

496-
# Ensure at least one project exists; if none exist then create main
497-
if not self.projects: # pragma: no cover
496+
# Trigger: no projects configured (fresh install or empty config)
497+
# Why: every config needs at least one project to be functional
498+
# Outcome: creates "main" project using BASIC_MEMORY_HOME or ~/basic-memory
499+
if not self.projects:
498500
self.projects["main"] = ProjectEntry(
499501
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
500502
)
501503

502-
# Ensure default project is valid (i.e. points to an existing project)
503-
# None means "no default" — intentionally left unset
504-
if (
505-
self.default_project is not None and self.default_project not in self.projects
506-
): # pragma: no cover
507-
# Set default to first available project
504+
# Trigger: default_project was not explicitly provided in the input data
505+
# (config file omitted the key, or BasicMemoryConfig() called with no args)
506+
# Why: callers like get_project_config() expect a valid project name;
507+
# but explicit None (discovery mode) must be preserved
508+
# Outcome: sets default_project to the first available project
509+
if "default_project" not in self.model_fields_set:
510+
self.default_project = next(iter(self.projects.keys()))
511+
# Trigger: default_project was explicitly set but references a non-existent project
512+
# Why: project may have been removed or renamed since config was saved
513+
# Outcome: corrects to the first available project
514+
elif self.default_project is not None and self.default_project not in self.projects:
508515
self.default_project = next(iter(self.projects.keys()))
509516

510517
@property

tests/test_config.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def test_default_behavior_without_basic_memory_home(self, config_home, monkeypat
2626
# Should use the default path (home/basic-memory)
2727
expected_path = config_home / "basic-memory"
2828
assert Path(config.projects["main"].path) == expected_path
29+
assert config.default_project == "main"
2930

3031
def test_respects_basic_memory_home_environment_variable(self, config_home, monkeypatch):
3132
"""Test that config respects BASIC_MEMORY_HOME environment variable."""
@@ -65,6 +66,7 @@ def test_model_post_init_respects_basic_memory_home_sets_non_main_default(
6566
# model_post_init should not add main project with BASIC_MEMORY_HOME
6667
assert "main" not in config.projects
6768
assert Path(config.projects["other"].path) == other_path
69+
assert config.default_project == "other"
6870

6971
def test_model_post_init_fallback_without_basic_memory_home(self, config_home, monkeypatch):
7072
"""Test that model_post_init can set a non-main default when BASIC_MEMORY_HOME is not set."""
@@ -78,6 +80,7 @@ def test_model_post_init_fallback_without_basic_memory_home(self, config_home, m
7880
# model_post_init should not add main project, but "other" should now be the default
7981
assert "main" not in config.projects
8082
assert Path(config.projects["other"].path) == other_path
83+
assert config.default_project == "other"
8184

8285
def test_basic_memory_home_with_relative_path(self, config_home, monkeypatch):
8386
"""Test that BASIC_MEMORY_HOME works with relative paths."""
@@ -121,6 +124,124 @@ def test_app_database_path_defaults_to_home_data_dir(self, config_home, monkeypa
121124
assert config.data_dir_path == config_home / ".basic-memory"
122125
assert config.app_database_path == config_home / ".basic-memory" / "memory.db"
123126

127+
def test_explicit_default_project_preserved(self, config_home, monkeypatch):
128+
"""Test that a valid explicit default_project is not overwritten by model_post_init."""
129+
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
130+
131+
config = BasicMemoryConfig(
132+
projects={
133+
"alpha": {"path": str(config_home / "alpha")},
134+
"beta": {"path": str(config_home / "beta")},
135+
},
136+
default_project="beta",
137+
)
138+
139+
assert config.default_project == "beta"
140+
141+
def test_invalid_default_project_corrected(self, config_home, monkeypatch):
142+
"""Test that an invalid default_project is corrected to the first project."""
143+
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
144+
145+
config = BasicMemoryConfig(
146+
projects={
147+
"alpha": {"path": str(config_home / "alpha")},
148+
"beta": {"path": str(config_home / "beta")},
149+
},
150+
default_project="nonexistent",
151+
)
152+
153+
assert config.default_project == "alpha"
154+
155+
def test_no_default_project_key_uses_first_project(self, config_home, monkeypatch):
156+
"""Test that config without default_project key sets it to the first project."""
157+
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
158+
159+
# Simulate loading a config file that has no default_project key —
160+
# the field default (None) kicks in, and model_post_init resolves it
161+
config = BasicMemoryConfig(
162+
projects={
163+
"research": {"path": str(config_home / "research")},
164+
"notes": {"path": str(config_home / "notes")},
165+
},
166+
)
167+
168+
assert config.default_project == "research"
169+
170+
def test_empty_string_default_project_corrected(self, config_home, monkeypatch):
171+
"""Test that an empty-string default_project is corrected to the first project."""
172+
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
173+
174+
config = BasicMemoryConfig(
175+
projects={
176+
"alpha": {"path": str(config_home / "alpha")},
177+
},
178+
default_project="",
179+
)
180+
181+
# Empty string is not in projects, so model_post_init corrects it
182+
assert config.default_project == "alpha"
183+
184+
def test_single_project_default_always_matches(self, config_home, monkeypatch):
185+
"""Test that a config with one project always resolves default_project to it."""
186+
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
187+
188+
config = BasicMemoryConfig(
189+
projects={"only": {"path": str(config_home / "only")}},
190+
)
191+
192+
assert config.default_project == "only"
193+
194+
def test_stale_default_project_loaded_from_file(self, config_home, monkeypatch):
195+
"""Test that a config file with a stale default_project is corrected on load."""
196+
import json
197+
import basic_memory.config
198+
199+
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
200+
201+
config_manager = ConfigManager()
202+
config_manager.config_dir = config_home / ".basic-memory"
203+
config_manager.config_file = config_manager.config_dir / "config.json"
204+
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
205+
206+
# Write a config file where default_project references a removed project
207+
config_data = {
208+
"projects": {
209+
"research": {"path": str(config_home / "research")},
210+
"notes": {"path": str(config_home / "notes")},
211+
},
212+
"default_project": "deleted-project",
213+
}
214+
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
215+
basic_memory.config._CONFIG_CACHE = None
216+
217+
loaded = config_manager.load_config()
218+
assert loaded.default_project == "research"
219+
220+
def test_config_file_without_default_project_key(self, config_home, monkeypatch):
221+
"""Test that a config file with no default_project key resolves dynamically."""
222+
import json
223+
import basic_memory.config
224+
225+
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
226+
227+
config_manager = ConfigManager()
228+
config_manager.config_dir = config_home / ".basic-memory"
229+
config_manager.config_file = config_manager.config_dir / "config.json"
230+
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
231+
232+
# Write a config file that deliberately omits default_project
233+
config_data = {
234+
"projects": {
235+
"work": {"path": str(config_home / "work")},
236+
"personal": {"path": str(config_home / "personal")},
237+
},
238+
}
239+
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
240+
basic_memory.config._CONFIG_CACHE = None
241+
242+
loaded = config_manager.load_config()
243+
assert loaded.default_project == "work"
244+
124245

125246
class TestConfigManager:
126247
"""Test ConfigManager functionality."""

0 commit comments

Comments
 (0)