diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index ebef14ff..e8edd35c 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 27864bef..a961b625 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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."""