mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user