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>
This commit is contained in:
phernandez
2026-02-23 21:59:15 -06:00
parent da4d369c32
commit 18f00f861d
2 changed files with 137 additions and 9 deletions
+16 -9
View File
@@ -132,7 +132,7 @@ class BasicMemoryConfig(BaseSettings):
description="Mapping of project names to their ProjectEntry configuration",
)
default_project: Optional[str] = Field(
default="main",
default=None,
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.",
)
@@ -493,18 +493,25 @@ class BasicMemoryConfig(BaseSettings):
if self.database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
return # pragma: no cover
# Ensure at least one project exists; if none exist then create main
if not self.projects: # pragma: no cover
# Trigger: no projects configured (fresh install or empty config)
# Why: every config needs at least one project to be functional
# Outcome: creates "main" project using BASIC_MEMORY_HOME or ~/basic-memory
if not self.projects:
self.projects["main"] = ProjectEntry(
path=str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
)
# Ensure default project is valid (i.e. points to an existing project)
# None means "no default" — intentionally left unset
if (
self.default_project is not None and self.default_project not in self.projects
): # pragma: no cover
# Set default to first available project
# Trigger: default_project was not explicitly provided in the input data
# (config file omitted the key, or BasicMemoryConfig() called with no args)
# Why: callers like get_project_config() expect a valid project name;
# but explicit None (discovery mode) must be preserved
# Outcome: sets default_project to the first available project
if "default_project" not in self.model_fields_set:
self.default_project = next(iter(self.projects.keys()))
# Trigger: default_project was explicitly set but references a non-existent project
# Why: project may have been removed or renamed since config was saved
# Outcome: corrects to the first available project
elif self.default_project is not None and self.default_project not in self.projects:
self.default_project = next(iter(self.projects.keys()))
@property
+121
View File
@@ -26,6 +26,7 @@ class TestBasicMemoryConfig:
# Should use the default path (home/basic-memory)
expected_path = config_home / "basic-memory"
assert Path(config.projects["main"].path) == expected_path
assert config.default_project == "main"
def test_respects_basic_memory_home_environment_variable(self, config_home, monkeypatch):
"""Test that config respects BASIC_MEMORY_HOME environment variable."""
@@ -65,6 +66,7 @@ class TestBasicMemoryConfig:
# model_post_init should not add main project with BASIC_MEMORY_HOME
assert "main" not in config.projects
assert Path(config.projects["other"].path) == other_path
assert config.default_project == "other"
def test_model_post_init_fallback_without_basic_memory_home(self, config_home, monkeypatch):
"""Test that model_post_init can set a non-main default when BASIC_MEMORY_HOME is not set."""
@@ -78,6 +80,7 @@ class TestBasicMemoryConfig:
# model_post_init should not add main project, but "other" should now be the default
assert "main" not in config.projects
assert Path(config.projects["other"].path) == other_path
assert config.default_project == "other"
def test_basic_memory_home_with_relative_path(self, config_home, monkeypatch):
"""Test that BASIC_MEMORY_HOME works with relative paths."""
@@ -121,6 +124,124 @@ class TestBasicMemoryConfig:
assert config.data_dir_path == config_home / ".basic-memory"
assert config.app_database_path == config_home / ".basic-memory" / "memory.db"
def test_explicit_default_project_preserved(self, config_home, monkeypatch):
"""Test that a valid explicit default_project is not overwritten by model_post_init."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={
"alpha": {"path": str(config_home / "alpha")},
"beta": {"path": str(config_home / "beta")},
},
default_project="beta",
)
assert config.default_project == "beta"
def test_invalid_default_project_corrected(self, config_home, monkeypatch):
"""Test that an invalid default_project is corrected to the first project."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={
"alpha": {"path": str(config_home / "alpha")},
"beta": {"path": str(config_home / "beta")},
},
default_project="nonexistent",
)
assert config.default_project == "alpha"
def test_no_default_project_key_uses_first_project(self, config_home, monkeypatch):
"""Test that config without default_project key sets it to the first project."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
# Simulate loading a config file that has no default_project key —
# the field default (None) kicks in, and model_post_init resolves it
config = BasicMemoryConfig(
projects={
"research": {"path": str(config_home / "research")},
"notes": {"path": str(config_home / "notes")},
},
)
assert config.default_project == "research"
def test_empty_string_default_project_corrected(self, config_home, monkeypatch):
"""Test that an empty-string default_project is corrected to the first project."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={
"alpha": {"path": str(config_home / "alpha")},
},
default_project="",
)
# Empty string is not in projects, so model_post_init corrects it
assert config.default_project == "alpha"
def test_single_project_default_always_matches(self, config_home, monkeypatch):
"""Test that a config with one project always resolves default_project to it."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={"only": {"path": str(config_home / "only")}},
)
assert config.default_project == "only"
def test_stale_default_project_loaded_from_file(self, config_home, monkeypatch):
"""Test that a config file with a stale default_project is corrected on load."""
import json
import basic_memory.config
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config_manager = ConfigManager()
config_manager.config_dir = config_home / ".basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Write a config file where default_project references a removed project
config_data = {
"projects": {
"research": {"path": str(config_home / "research")},
"notes": {"path": str(config_home / "notes")},
},
"default_project": "deleted-project",
}
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
basic_memory.config._CONFIG_CACHE = None
loaded = config_manager.load_config()
assert loaded.default_project == "research"
def test_config_file_without_default_project_key(self, config_home, monkeypatch):
"""Test that a config file with no default_project key resolves dynamically."""
import json
import basic_memory.config
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config_manager = ConfigManager()
config_manager.config_dir = config_home / ".basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Write a config file that deliberately omits default_project
config_data = {
"projects": {
"work": {"path": str(config_home / "work")},
"personal": {"path": str(config_home / "personal")},
},
}
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
basic_memory.config._CONFIG_CACHE = None
loaded = config_manager.load_config()
assert loaded.default_project == "work"
class TestConfigManager:
"""Test ConfigManager functionality."""