"""Test configuration management.""" import os import stat import tempfile import pytest from datetime import datetime from typing import Any, cast from basic_memory.config import ( BasicMemoryConfig, ConfigManager, ProjectEntry, ProjectMode, default_fastembed_cache_dir, resolve_data_dir, ) from pathlib import Path def _migrate_legacy_projects(data: dict[str, Any]) -> dict[str, Any]: return cast(dict[str, Any], cast(Any, BasicMemoryConfig.migrate_legacy_projects)(data)) class TestBasicMemoryConfig: """Test BasicMemoryConfig behavior with BASIC_MEMORY_HOME environment variable.""" def test_default_behavior_without_basic_memory_home(self, config_home, monkeypatch): """Test that config uses default path when BASIC_MEMORY_HOME is not set.""" # Ensure BASIC_MEMORY_HOME is not set monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) config = BasicMemoryConfig() # 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.""" custom_path = config_home / "app" / "data" monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path)) config = BasicMemoryConfig() # Should use the custom path from environment variable assert Path(config.projects["main"].path) == custom_path def test_model_post_init_respects_basic_memory_home_creates_main( self, config_home, monkeypatch ): """Test that model_post_init creates main project with BASIC_MEMORY_HOME when missing and no other projects.""" custom_path = config_home / "custom" / "memory" / "path" monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path)) # Create config without main project config = BasicMemoryConfig() # model_post_init should have added main project with BASIC_MEMORY_HOME assert "main" in config.projects assert Path(config.projects["main"].path) == custom_path def test_model_post_init_respects_basic_memory_home_sets_non_main_default( self, config_home, monkeypatch ): """Test that model_post_init does not create main project with BASIC_MEMORY_HOME when another project exists.""" custom_path = config_home / "custom" / "memory" / "path" monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path)) # Create config without main project other_path = config_home / "some" / "path" config = BasicMemoryConfig(projects={"other": {"path": str(other_path)}}) # 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.""" # Ensure BASIC_MEMORY_HOME is not set monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) # Create config without main project other_path = config_home / "some" / "path" config = BasicMemoryConfig(projects={"other": {"path": str(other_path)}}) # 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.""" relative_path = "relative/memory/path" monkeypatch.setenv("BASIC_MEMORY_HOME", relative_path) config = BasicMemoryConfig() # Should normalize to platform-native path format assert Path(config.projects["main"].path) == Path(relative_path) def test_basic_memory_home_overrides_existing_main_project(self, config_home, monkeypatch): """Test that BASIC_MEMORY_HOME is not used when a map is passed in the constructor.""" custom_path = str(config_home / "override" / "memory" / "path") monkeypatch.setenv("BASIC_MEMORY_HOME", custom_path) # Try to create config with a different main project path original_path = str(config_home / "original" / "path") config = BasicMemoryConfig(projects={"main": {"path": original_path}}) # The default_factory should override with BASIC_MEMORY_HOME value # Note: This tests the current behavior where default_factory takes precedence assert config.projects["main"].path == original_path def test_app_database_path_uses_custom_config_dir(self, tmp_path, monkeypatch): """Default SQLite DB should live under BASIC_MEMORY_CONFIG_DIR when set.""" custom_config_dir = tmp_path / "instance-a" / "state" monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_config_dir)) config = BasicMemoryConfig(projects={"main": {"path": str(tmp_path / "project")}}) assert config.data_dir_path == custom_config_dir assert config.app_database_path == custom_config_dir / "memory.db" assert config.app_database_path.exists() def test_app_database_path_defaults_to_home_data_dir(self, config_home, monkeypatch): """Without BASIC_MEMORY_CONFIG_DIR, default DB stays at ~/.basic-memory/memory.db.""" monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) config = BasicMemoryConfig() assert config.data_dir_path == config_home / ".basic-memory" assert config.app_database_path == config_home / ".basic-memory" / "memory.db" def test_semantic_embedding_cache_dir_field_stays_none_by_default( self, config_home, monkeypatch ): """The raw config field stays None so it isn't persisted into config.json. Resolution to a concrete path happens in embedding_provider_factory at provider construction time, so ``BASIC_MEMORY_CONFIG_DIR`` and ``FASTEMBED_CACHE_PATH`` changes take effect on every run instead of being frozen by the first save. See #741. """ monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False) config = BasicMemoryConfig() assert config.semantic_embedding_cache_dir is None def test_semantic_embedding_cache_dir_not_persisted_in_model_dump( self, config_home, monkeypatch ): """model_dump must not bake a resolved cache path into config.json. Regression guard for #741: persisting the default would freeze stale paths when users later change BASIC_MEMORY_CONFIG_DIR or FASTEMBED_CACHE_PATH. """ monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False) dumped = BasicMemoryConfig().model_dump(mode="json") assert dumped["semantic_embedding_cache_dir"] is None def test_semantic_embedding_cache_dir_explicit_user_value_preserved( self, config_home, monkeypatch ): """An explicit user override still round-trips through model_dump.""" monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False) config = BasicMemoryConfig(semantic_embedding_cache_dir="/custom/explicit/path") assert config.semantic_embedding_cache_dir == "/custom/explicit/path" assert ( config.model_dump(mode="json")["semantic_embedding_cache_dir"] == "/custom/explicit/path" ) 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 basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = 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 basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None loaded = config_manager.load_config() assert loaded.default_project == "work" class TestDataDirHelpers: """Module-level helpers that resolve the Basic Memory data directory.""" def test_resolve_data_dir_defaults_to_home_dot_basic_memory(self, config_home, monkeypatch): """Without BASIC_MEMORY_CONFIG_DIR and XDG_CONFIG_HOME, resolver returns ~/.basic-memory.""" monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) assert resolve_data_dir() == config_home / ".basic-memory" def test_resolve_data_dir_honors_config_dir_env(self, tmp_path, monkeypatch): """BASIC_MEMORY_CONFIG_DIR overrides the default location.""" custom = tmp_path / "elsewhere" monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom)) assert resolve_data_dir() == custom def test_resolve_data_dir_honors_xdg_config_home(self, tmp_path, monkeypatch): """XDG_CONFIG_HOME is honored when BASIC_MEMORY_CONFIG_DIR is not set.""" monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) xdg_config = tmp_path / "xdg-config" monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config)) assert resolve_data_dir() == xdg_config / "basic-memory" def test_basic_memory_config_dir_takes_precedence_over_xdg(self, tmp_path, monkeypatch): """BASIC_MEMORY_CONFIG_DIR takes precedence over XDG_CONFIG_HOME.""" xdg_config = tmp_path / "xdg-config" custom = tmp_path / "custom-config" monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config)) monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom)) assert resolve_data_dir() == custom def test_default_fastembed_cache_dir_uses_data_dir(self, config_home, monkeypatch): """Default cache path is a subdir of the Basic Memory data dir.""" monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False) assert default_fastembed_cache_dir() == str( config_home / ".basic-memory" / "fastembed_cache" ) def test_default_fastembed_cache_dir_env_override(self, tmp_path, monkeypatch): """FASTEMBED_CACHE_PATH is preferred when set.""" custom = tmp_path / "custom-cache" monkeypatch.setenv("FASTEMBED_CACHE_PATH", str(custom)) monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "state")) assert default_fastembed_cache_dir() == str(custom) def test_default_fastembed_cache_dir_never_falls_back_to_fastembed_tmp_default( self, config_home, monkeypatch ): """Regression guard for #741. FastEmbed's own fallback when ``cache_dir`` is ``None`` is ``/fastembed_cache`` — the exact path that disappears in sandboxed MCP runtimes (Codex CLI). Ensure Basic Memory's resolver never lands on that path. Compared as exact paths rather than ``startswith(tempfile.gettempdir())`` because the test runner itself can legitimately live under ``/tmp`` (pytest's ``tmp_path`` does on Linux CI), and that's not the bug we care about here. """ monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False) resolved = Path(default_fastembed_cache_dir()) # Must equal the data-dir-relative default. assert resolved == config_home / ".basic-memory" / "fastembed_cache" # And must not equal FastEmbed's own /fastembed_cache fallback. assert resolved != Path(tempfile.gettempdir()) / "fastembed_cache" class TestConfigManager: """Test ConfigManager functionality.""" @pytest.fixture def temp_config_manager(self): """Create a ConfigManager with temporary config file.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create a test ConfigManager instance config_manager = ConfigManager() # Override config paths to use temp directory config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.yaml" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Create initial config with test projects test_config = BasicMemoryConfig( default_project="main", projects={ "main": {"path": str(temp_path / "main")}, "test-project": {"path": str(temp_path / "test")}, "special-chars": { "path": str(temp_path / "special") }, # This will be the config key for "Special/Chars" }, ) config_manager.save_config(test_config) yield config_manager def test_set_default_project_with_exact_name_match(self, temp_config_manager): """Test set_default_project when project name matches config key exactly.""" config_manager = temp_config_manager # Set default to a project that exists with exact name match config_manager.set_default_project("test-project") # Verify the config was updated config = config_manager.load_config() assert config.default_project == "test-project" def test_set_default_project_with_permalink_lookup(self, temp_config_manager): """Test set_default_project when input needs permalink normalization.""" config_manager = temp_config_manager # Simulate a project that was created with special characters # The config key would be the permalink, but user might type the original name # First add a project with original name that gets normalized config = config_manager.load_config() config.projects["special-chars-project"] = ProjectEntry(path=str(Path("/tmp/special"))) config_manager.save_config(config) # Now test setting default using a name that will normalize to the config key config_manager.set_default_project( "Special Chars Project" ) # This should normalize to "special-chars-project" # Verify the config was updated with the correct config key updated_config = config_manager.load_config() assert updated_config.default_project == "special-chars-project" def test_set_default_project_uses_canonical_name(self, temp_config_manager): """Test that set_default_project uses the canonical config key, not user input.""" config_manager = temp_config_manager # Add a project with a config key that differs from user input config = config_manager.load_config() config.projects["my-test-project"] = ProjectEntry(path=str(Path("/tmp/mytest"))) config_manager.save_config(config) # Set default using input that will match but is different from config key config_manager.set_default_project("My Test Project") # Should find "my-test-project" # Verify that the canonical config key is used, not the user input updated_config = config_manager.load_config() assert updated_config.default_project == "my-test-project" # Should NOT be the user input assert updated_config.default_project != "My Test Project" def test_set_default_project_nonexistent_project(self, temp_config_manager): """Test set_default_project raises ValueError for nonexistent project.""" config_manager = temp_config_manager with pytest.raises(ValueError, match="Project 'nonexistent' not found"): config_manager.set_default_project("nonexistent") @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits are not portable to Windows") def test_save_config_uses_private_permissions(self, temp_config_manager): """Config can contain cloud credentials, so writes should enforce private modes.""" config_manager = temp_config_manager config = config_manager.load_config() config.cloud_api_key = "bmc_test123" config_manager.config_dir.chmod(0o777) config_manager.config_file.chmod(0o666) config_manager.save_config(config) dir_mode = stat.S_IMODE(config_manager.config_dir.stat().st_mode) file_mode = stat.S_IMODE(config_manager.config_file.stat().st_mode) assert dir_mode == 0o700 assert file_mode == 0o600 def test_disable_permalinks_flag_default(self): """Test that disable_permalinks flag defaults to False.""" config = BasicMemoryConfig() assert config.disable_permalinks is False def test_disable_permalinks_flag_can_be_enabled(self): """Test that disable_permalinks flag can be set to True.""" config = BasicMemoryConfig(disable_permalinks=True) assert config.disable_permalinks is True def test_ensure_frontmatter_on_sync_flag_default(self): """Test that ensure_frontmatter_on_sync defaults to True.""" config = BasicMemoryConfig() assert config.ensure_frontmatter_on_sync is True def test_ensure_frontmatter_on_sync_flag_can_be_disabled(self): """Test that ensure_frontmatter_on_sync can be set to False.""" config = BasicMemoryConfig(ensure_frontmatter_on_sync=False) assert config.ensure_frontmatter_on_sync is False def test_permalinks_include_project_flag_default(self): """Test that permalinks_include_project defaults to True.""" config = BasicMemoryConfig() assert config.permalinks_include_project is True def test_permalinks_include_project_flag_can_be_disabled(self): """Test that permalinks_include_project can be set to False.""" config = BasicMemoryConfig(permalinks_include_project=False) assert config.permalinks_include_project is False def test_config_manager_respects_custom_config_dir(self, monkeypatch): """Test that ConfigManager respects BASIC_MEMORY_CONFIG_DIR environment variable.""" with tempfile.TemporaryDirectory() as temp_dir: custom_config_dir = Path(temp_dir) / "custom" / "config" monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_config_dir)) config_manager = ConfigManager() # Verify config_dir is set to the custom path assert config_manager.config_dir == custom_config_dir # Verify config_file is in the custom directory assert config_manager.config_file == custom_config_dir / "config.json" # Verify the directory was created assert config_manager.config_dir.exists() def test_config_manager_default_without_custom_config_dir(self, config_home, monkeypatch): """Test that ConfigManager uses default location when BASIC_MEMORY_CONFIG_DIR is not set.""" monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) config_manager = ConfigManager() # Should use default location assert config_manager.config_dir == config_home / ".basic-memory" assert config_manager.config_file == config_home / ".basic-memory" / "config.json" def test_remove_project_with_exact_name_match(self, temp_config_manager): """Test remove_project when project name matches config key exactly.""" config_manager = temp_config_manager # Verify project exists config = config_manager.load_config() assert "test-project" in config.projects # Remove the project with exact name match config_manager.remove_project("test-project") # Verify the project was removed config = config_manager.load_config() assert "test-project" not in config.projects def test_remove_project_with_permalink_lookup(self, temp_config_manager): """Test remove_project when input needs permalink normalization.""" config_manager = temp_config_manager # Add a project with normalized key config = config_manager.load_config() config.projects["special-chars-project"] = ProjectEntry(path=str(Path("/tmp/special"))) config_manager.save_config(config) # Remove using a name that will normalize to the config key config_manager.remove_project( "Special Chars Project" ) # This should normalize to "special-chars-project" # Verify the project was removed using the correct config key updated_config = config_manager.load_config() assert "special-chars-project" not in updated_config.projects def test_remove_project_uses_canonical_name(self, temp_config_manager): """Test that remove_project uses the canonical config key, not user input.""" config_manager = temp_config_manager # Add a project with a config key that differs from user input config = config_manager.load_config() config.projects["my-test-project"] = ProjectEntry(path=str(Path("/tmp/mytest"))) config_manager.save_config(config) # Remove using input that will match but is different from config key config_manager.remove_project("My Test Project") # Should find "my-test-project" # Verify that the canonical config key was removed updated_config = config_manager.load_config() assert "my-test-project" not in updated_config.projects def test_remove_project_nonexistent_project(self, temp_config_manager): """Test remove_project raises ValueError for nonexistent project.""" config_manager = temp_config_manager with pytest.raises(ValueError, match="Project 'nonexistent' not found"): config_manager.remove_project("nonexistent") def test_remove_project_cannot_remove_default(self, temp_config_manager): """Test remove_project raises ValueError when trying to remove default project.""" config_manager = temp_config_manager # Try to remove the default project with pytest.raises(ValueError, match="Cannot remove the default project"): config_manager.remove_project("main") def test_config_project_entry_cloud_sync_defaults(self, temp_config_manager): """Test that ProjectEntry cloud sync fields default to None/False.""" config_manager = temp_config_manager config = config_manager.load_config() entry = config.projects["main"] assert entry.local_sync_path is None assert entry.bisync_initialized is False assert entry.last_sync is None def test_save_and_load_config_with_cloud_sync_fields(self): """Test that config with cloud sync fields can be saved and loaded.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Create config with cloud sync fields on a project entry now = datetime.now() test_config = BasicMemoryConfig( projects={ "main": {"path": str(temp_path / "main")}, "research": { "path": str(temp_path / "research"), "mode": "cloud", "local_sync_path": str(temp_path / "research-local"), "last_sync": now.isoformat(), "bisync_initialized": True, }, }, ) config_manager.save_config(test_config) # Load and verify loaded_config = config_manager.load_config() assert "research" in loaded_config.projects entry = loaded_config.projects["research"] assert entry.local_sync_path == str(temp_path / "research-local") assert entry.bisync_initialized is True assert entry.last_sync == now def test_add_cloud_sync_to_existing_project(self): """Test adding cloud sync fields to an existing project entry.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Create initial config without cloud sync fields initial_config = BasicMemoryConfig(projects={"main": {"path": str(temp_path / "main")}}) config_manager.save_config(initial_config) # Load, modify, and save config = config_manager.load_config() assert config.projects["main"].local_sync_path is None config.projects["main"].local_sync_path = str(temp_path / "work-local") config_manager.save_config(config) # Reload and verify persistence reloaded_config = config_manager.load_config() assert reloaded_config.projects["main"].local_sync_path == str(temp_path / "work-local") assert reloaded_config.projects["main"].bisync_initialized is False def test_backward_compatibility_loading_old_format_config(self): """Test that old config files with Dict[str, str] projects can be loaded and migrated.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Manually write old-style config with Dict[str, str] projects import json old_config_data = { "env": "dev", "projects": {"main": str(temp_path / "main")}, "default_project": "main", "log_level": "INFO", } config_manager.config_file.write_text(json.dumps(old_config_data, indent=2)) # Clear the config cache to ensure we load from the temp file import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None # Should load successfully with migration to ProjectEntry config = config_manager.load_config() assert isinstance(config.projects["main"], ProjectEntry) assert config.projects["main"].path == str(temp_path / "main") assert config.projects["main"].mode == ProjectMode.LOCAL def test_backward_compatibility_migrates_project_modes_and_cloud_projects(self): """Test that old config with project_modes and cloud_projects is migrated.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) import json old_config_data = { "env": "dev", "projects": { "main": str(temp_path / "main"), "research": str(temp_path / "research"), }, "default_project": "main", "project_modes": {"research": "cloud"}, "cloud_projects": { "research": { "local_path": str(temp_path / "research-local"), "bisync_initialized": True, "last_sync": "2026-02-06T17:36:38", } }, } config_manager.config_file.write_text(json.dumps(old_config_data, indent=2)) import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None config = config_manager.load_config() # Verify migration assert config.projects["research"].mode == ProjectMode.CLOUD assert config.projects["research"].local_sync_path == str(temp_path / "research-local") assert config.projects["research"].bisync_initialized is True assert config.projects["main"].mode == ProjectMode.LOCAL def test_legacy_cloud_mode_key_is_stripped_on_normalization_save(self): """Legacy cloud_mode should be removed from config.json after load/save normalization.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) import json legacy_config = { "env": "dev", "projects": {"main": str(temp_path / "main")}, "default_project": "main", "cloud_mode": True, } config_manager.config_file.write_text(json.dumps(legacy_config, indent=2)) import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None loaded = config_manager.load_config() assert isinstance(loaded, BasicMemoryConfig) raw = json.loads(config_manager.config_file.read_text(encoding="utf-8")) assert "cloud_mode" not in raw def test_migration_creates_backup_of_old_config(self): """Config migration should create a .bak backup before overwriting.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) import json old_config_data = { "env": "dev", "projects": {"main": str(temp_path / "main")}, "default_project": "main", } config_manager.config_file.write_text(json.dumps(old_config_data, indent=2)) original_content = config_manager.config_file.read_text() import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None config_manager.load_config() # Backup should exist with the original content backup_path = config_manager.config_file.with_suffix(".json.bak") assert backup_path.exists(), "Migration should create a backup file" assert backup_path.read_text() == original_content def test_no_backup_when_config_is_current_format(self): """No backup should be created when config is already in the current format.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) import json # Write config in the current ProjectEntry format — no migration needed current_config_data = { "env": "dev", "projects": {"main": {"path": str(temp_path / "main"), "mode": "local"}}, "default_project": "main", } config_manager.config_file.write_text(json.dumps(current_config_data, indent=2)) import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None config_manager.load_config() backup_path = config_manager.config_file.with_suffix(".json.bak") assert not backup_path.exists(), "No backup should be created for current-format config" class TestPlatformNativePathSeparators: """Test that config uses platform-native path separators.""" def test_project_paths_use_platform_native_separators_in_config(self, monkeypatch): """Test that project paths use platform-native separators when created.""" import platform with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Set up ConfigManager with temp directory config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Create a project path project_path = temp_path / "my" / "project" project_path.mkdir(parents=True, exist_ok=True) # Add project via ConfigManager config = BasicMemoryConfig(projects={}) config.projects["test-project"] = ProjectEntry(path=str(project_path)) config_manager.save_config(config) # Read the raw JSON file import json config_data = json.loads(config_manager.config_file.read_text()) # Verify path uses platform-native separators saved_path = config_data["projects"]["test-project"]["path"] # On Windows, should have backslashes; on Unix, forward slashes if platform.system() == "Windows": # Windows paths should contain backslashes assert "\\" in saved_path or ":" in saved_path # C:\\ or \\UNC assert "/" not in saved_path.replace(":/", "") # Exclude drive letter else: # Unix paths should use forward slashes assert "/" in saved_path # Should not force POSIX on non-Windows assert saved_path == str(project_path) def test_add_project_uses_platform_native_separators(self, monkeypatch): """Test that ConfigManager.add_project() uses platform-native separators.""" import platform with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Set up ConfigManager config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Initialize with empty projects initial_config = BasicMemoryConfig(projects={}) config_manager.save_config(initial_config) # Add project project_path = temp_path / "new" / "project" config_manager.add_project("new-project", str(project_path)) # Load and verify config = config_manager.load_config() saved_path = config.projects["new-project"].path # Verify platform-native separators if platform.system() == "Windows": assert "\\" in saved_path or ":" in saved_path else: assert "/" in saved_path assert saved_path == str(project_path) def test_add_project_never_creates_directory(self): """Test that ConfigManager.add_project() is pure config management — no mkdir. Directory creation is delegated to ProjectService via FileService, which supports both local and cloud (S3) backends. """ with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) initial_config = BasicMemoryConfig(projects={}) config_manager.save_config(initial_config) # Use a path that does not exist — ConfigManager should not create it nonexistent_path = str(temp_path / "nonexistent" / "project") config_manager.add_project("test-project", nonexistent_path) # Check directory does NOT exist right after add_project(), # before load_config() which triggers the model validator assert not Path(nonexistent_path).exists() # Verify project was persisted in config config = config_manager.load_config() assert "test-project" in config.projects assert config.projects["test-project"].path == nonexistent_path def test_model_post_init_uses_platform_native_separators(self, config_home, monkeypatch): """Test that model_post_init uses platform-native separators.""" import platform monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) # Create config without projects (triggers model_post_init to add main) config = BasicMemoryConfig(projects={}) # Verify main project path uses platform-native separators main_path = config.projects["main"].path if platform.system() == "Windows": # Windows: should have backslashes or drive letter assert "\\" in main_path or ":" in main_path else: # Unix: should have forward slashes assert "/" in main_path class TestSemanticSearchConfig: """Test semantic search configuration options.""" def test_semantic_search_enabled_defaults_to_true_when_semantic_modules_are_available( self, monkeypatch ): """Semantic search defaults on when fastembed and sqlite_vec are importable.""" import basic_memory.config as config_module monkeypatch.delenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", raising=False) monkeypatch.setattr( config_module.importlib.util, "find_spec", lambda name: object() if name in {"fastembed", "sqlite_vec"} else None, ) config = BasicMemoryConfig() assert config.semantic_search_enabled is True def test_semantic_search_enabled_defaults_to_false_when_any_semantic_module_is_unavailable( self, monkeypatch ): """Semantic search defaults off when required semantic modules are missing.""" import basic_memory.config as config_module monkeypatch.delenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", raising=False) monkeypatch.setattr( config_module.importlib.util, "find_spec", lambda name: object() if name == "fastembed" else None, ) config = BasicMemoryConfig() assert config.semantic_search_enabled is False def test_semantic_search_enabled_env_var_overrides_dependency_default(self, monkeypatch): """Environment overrides should win over dependency-based defaults.""" import basic_memory.config as config_module monkeypatch.setattr(config_module.importlib.util, "find_spec", lambda name: None) monkeypatch.setenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", "true") enabled = BasicMemoryConfig() assert enabled.semantic_search_enabled is True monkeypatch.setenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", "false") disabled = BasicMemoryConfig() assert disabled.semantic_search_enabled is False def test_semantic_embedding_dimensions_defaults_to_none(self): """Dimensions should default to None, letting the provider choose.""" config = BasicMemoryConfig() assert config.semantic_embedding_dimensions is None def test_semantic_embedding_dimensions_can_be_set(self): """Explicit dimensions should be stored on the config object.""" config = BasicMemoryConfig(semantic_embedding_dimensions=1536) assert config.semantic_embedding_dimensions == 1536 def test_semantic_embedding_forward_dimensions_defaults_to_none(self): """Dimension forwarding should default to provider auto-detection.""" config = BasicMemoryConfig() assert config.semantic_embedding_forward_dimensions is None def test_semantic_embedding_forward_dimensions_can_be_set(self): """Explicit LiteLLM dimension forwarding should be stored on the config object.""" config = BasicMemoryConfig(semantic_embedding_forward_dimensions=True) assert config.semantic_embedding_forward_dimensions is True def test_semantic_postgres_prepare_concurrency_defaults_to_4(self): """Postgres prepare concurrency should default to a conservative window of 4.""" config = BasicMemoryConfig() assert config.semantic_postgres_prepare_concurrency == 4 def test_semantic_postgres_prepare_concurrency_validation(self): """Postgres prepare concurrency must stay within the bounded safe range.""" config = BasicMemoryConfig(semantic_postgres_prepare_concurrency=8) assert config.semantic_postgres_prepare_concurrency == 8 with pytest.raises(Exception): BasicMemoryConfig(semantic_postgres_prepare_concurrency=0) with pytest.raises(Exception): BasicMemoryConfig(semantic_postgres_prepare_concurrency=17) def test_semantic_search_enabled_description_mentions_both_backends(self): """Description should not say 'SQLite only' anymore.""" field_info = BasicMemoryConfig.model_fields["semantic_search_enabled"] assert "SQLite only" not in (field_info.description or "") def test_semantic_min_similarity_defaults_to_055(self): """Threshold defaults to 0.55 to filter irrelevant vector results.""" config = BasicMemoryConfig() assert config.semantic_min_similarity == 0.55 def test_semantic_min_similarity_bounds_validation(self): """Threshold must be between 0.0 and 1.0.""" config = BasicMemoryConfig(semantic_min_similarity=0.55) assert config.semantic_min_similarity == 0.55 with pytest.raises(Exception): BasicMemoryConfig(semantic_min_similarity=-0.1) with pytest.raises(Exception): BasicMemoryConfig(semantic_min_similarity=1.1) def test_default_search_type_defaults_to_none(self): """default_search_type should be None by default (auto-detect).""" config = BasicMemoryConfig() assert config.default_search_type is None def test_default_search_type_accepts_valid_values(self): """default_search_type accepts text, vector, hybrid.""" for search_type in ("text", "vector", "hybrid"): config = BasicMemoryConfig(default_search_type=search_type) assert config.default_search_type == search_type def test_default_search_type_rejects_invalid_values(self): """default_search_type rejects unknown values.""" with pytest.raises(Exception): BasicMemoryConfig(default_search_type="invalid") class TestFormattingConfig: """Test file formatting configuration options.""" def test_format_on_save_defaults_to_false(self): """Test that format_on_save is disabled by default.""" config = BasicMemoryConfig() assert config.format_on_save is False def test_format_on_save_can_be_enabled(self): """Test that format_on_save can be set to True.""" config = BasicMemoryConfig(format_on_save=True) assert config.format_on_save is True def test_formatter_command_defaults_to_none(self): """Test that formatter_command defaults to None (uses built-in mdformat).""" config = BasicMemoryConfig() assert config.formatter_command is None def test_formatter_command_can_be_set(self): """Test that formatter_command can be configured.""" config = BasicMemoryConfig(formatter_command="prettier --write {file}") assert config.formatter_command == "prettier --write {file}" def test_formatters_defaults_to_empty_dict(self): """Test that formatters defaults to empty dict.""" config = BasicMemoryConfig() assert config.formatters == {} def test_formatters_can_be_configured(self): """Test that per-extension formatters can be configured.""" config = BasicMemoryConfig( formatters={ "md": "prettier --write {file}", "json": "jq . {file} > {file}.tmp && mv {file}.tmp {file}", } ) assert config.formatters["md"] == "prettier --write {file}" assert "json" in config.formatters def test_formatter_timeout_defaults_to_5_seconds(self): """Test that formatter_timeout defaults to 5.0 seconds.""" config = BasicMemoryConfig() assert config.formatter_timeout == 5.0 def test_formatter_timeout_can_be_customized(self): """Test that formatter_timeout can be set to a different value.""" config = BasicMemoryConfig(formatter_timeout=10.0) assert config.formatter_timeout == 10.0 def test_formatter_timeout_must_be_positive(self): """Test that formatter_timeout validation rejects non-positive values.""" import pydantic with pytest.raises(pydantic.ValidationError): BasicMemoryConfig(formatter_timeout=0) with pytest.raises(pydantic.ValidationError): BasicMemoryConfig(formatter_timeout=-1) def test_formatting_env_vars(self, monkeypatch): """Test that formatting config can be set via environment variables.""" monkeypatch.setenv("BASIC_MEMORY_FORMAT_ON_SAVE", "true") monkeypatch.setenv("BASIC_MEMORY_FORMATTER_COMMAND", "prettier --write {file}") monkeypatch.setenv("BASIC_MEMORY_FORMATTER_TIMEOUT", "10.0") config = BasicMemoryConfig() assert config.format_on_save is True assert config.formatter_command == "prettier --write {file}" assert config.formatter_timeout == 10.0 def test_formatters_env_var_json(self, monkeypatch): """Test that formatters dict can be set via JSON environment variable.""" import json formatters_json = json.dumps({"md": "prettier --write {file}", "json": "jq . {file}"}) monkeypatch.setenv("BASIC_MEMORY_FORMATTERS", formatters_json) config = BasicMemoryConfig() assert config.formatters == {"md": "prettier --write {file}", "json": "jq . {file}"} def test_save_and_load_formatting_config(self): """Test that formatting config survives save/load cycle.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Create config with formatting settings test_config = BasicMemoryConfig( projects={"main": {"path": str(temp_path / "main")}}, format_on_save=True, formatter_command="prettier --write {file}", formatters={"md": "prettier --write {file}", "json": "prettier --write {file}"}, formatter_timeout=10.0, ) config_manager.save_config(test_config) # Load and verify loaded_config = config_manager.load_config() assert loaded_config.format_on_save is True assert loaded_config.formatter_command == "prettier --write {file}" assert loaded_config.formatters == { "md": "prettier --write {file}", "json": "prettier --write {file}", } assert loaded_config.formatter_timeout == 10.0 class TestProjectMode: """Test per-project routing mode configuration.""" def test_project_mode_defaults(self): """Test that ProjectMode enum has expected values.""" assert ProjectMode.LOCAL.value == "local" assert ProjectMode.CLOUD.value == "cloud" def test_get_project_mode_defaults_to_cloud(self): """Test that unknown projects default to CLOUD mode. Unknown projects are not registered in local config, so they are assumed to be cloud-only projects discovered from the API. """ config = BasicMemoryConfig() assert config.get_project_mode("nonexistent") == ProjectMode.CLOUD def test_set_project_mode_cloud(self): """Test setting a project to cloud mode.""" config = BasicMemoryConfig() config.set_project_mode("research", ProjectMode.CLOUD) assert config.get_project_mode("research") == ProjectMode.CLOUD def test_set_project_mode_local_resets_to_default(self): """Test that setting a project back to LOCAL resets the entry's mode.""" config = BasicMemoryConfig() # Need a project entry to set mode on config.projects["research"] = ProjectEntry(path="/tmp/research") config.set_project_mode("research", ProjectMode.CLOUD) assert config.projects["research"].mode == ProjectMode.CLOUD config.set_project_mode("research", ProjectMode.LOCAL) assert config.projects["research"].mode == ProjectMode.LOCAL assert config.get_project_mode("research") == ProjectMode.LOCAL def test_is_locally_syncable_true_for_config_project_with_absolute_path(self, tmp_path): """A project in config with an absolute path is locally syncable.""" abs_path = str(tmp_path / "research") config = BasicMemoryConfig(projects={"research": ProjectEntry(path=abs_path)}) assert config.is_locally_syncable("research", abs_path) is True def test_is_locally_syncable_false_for_empty_path(self): """An empty path resolves to cwd, so it is never locally syncable (#949).""" config = BasicMemoryConfig(projects={"empty": ProjectEntry(path="")}) assert config.is_locally_syncable("empty", "") is False def test_is_locally_syncable_false_for_relative_path(self): """A relative (slug) path, as used by cloud-only projects, is not syncable.""" config = BasicMemoryConfig(projects={"cloud": ProjectEntry(path="cloud-slug")}) assert config.is_locally_syncable("cloud", "cloud-slug") is False def test_is_locally_syncable_false_for_orphan_not_in_config(self, tmp_path): """A DB row absent from config is not syncable even with an absolute path. Config is the source of truth; stale rows must not be synced (#949). """ config = BasicMemoryConfig(projects={}) assert config.is_locally_syncable("orphan", str(tmp_path / "orphan")) is False def test_cloud_api_key_defaults_to_none(self): """Test that cloud_api_key defaults to None.""" config = BasicMemoryConfig() assert config.cloud_api_key is None def test_cloud_api_key_can_be_set(self): """Test that cloud_api_key can be configured.""" config = BasicMemoryConfig(cloud_api_key="bmc_test123") assert config.cloud_api_key == "bmc_test123" def test_project_mode_round_trip(self): """Test that project mode survives save/load cycle.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Create config with project mode and cloud_api_key test_config = BasicMemoryConfig( projects={ "main": {"path": str(temp_path / "main")}, "research": {"path": str(temp_path / "research"), "mode": "cloud"}, }, cloud_api_key="bmc_test123", ) config_manager.save_config(test_config) # Load and verify loaded = config_manager.load_config() assert loaded.cloud_api_key == "bmc_test123" assert loaded.get_project_mode("research") == ProjectMode.CLOUD assert loaded.get_project_mode("main") == ProjectMode.LOCAL def test_backward_compat_loading_old_format_without_project_modes(self): """Test that old config files with Dict[str, str] projects are migrated.""" import json with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) # Write old-style config with Dict[str, str] projects old_config_data = { "env": "dev", "projects": {"main": str(temp_path / "main")}, "default_project": "main", "log_level": "INFO", } config_manager.config_file.write_text(json.dumps(old_config_data, indent=2)) # Clear config cache import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None # Should load successfully with migration config = config_manager.load_config() assert config.cloud_api_key is None assert config.get_project_mode("main") == ProjectMode.LOCAL assert isinstance(config.projects["main"], ProjectEntry) def test_project_list_includes_mode(self, config_home): """Test that project_list property includes mode information.""" config = BasicMemoryConfig( projects={ "main": {"path": str(config_home / "main")}, "research": {"path": str(config_home / "research"), "mode": "cloud"}, }, ) project_list = config.project_list modes_by_name = {p.name: p.mode for p in project_list} assert modes_by_name["main"] == ProjectMode.LOCAL assert modes_by_name["research"] == ProjectMode.CLOUD def test_workspace_id_defaults_to_none(self): """Test that workspace_id on ProjectEntry defaults to None.""" entry = ProjectEntry(path="/tmp/test") assert entry.workspace_id is None def test_workspace_id_can_be_set(self): """Test that workspace_id can be configured on ProjectEntry.""" entry = ProjectEntry( path="/tmp/test", workspace_id="11111111-1111-1111-1111-111111111111", ) assert entry.workspace_id == "11111111-1111-1111-1111-111111111111" def test_default_workspace_defaults_to_none(self): """Test that default_workspace on BasicMemoryConfig defaults to None.""" config = BasicMemoryConfig() assert config.default_workspace is None def test_default_workspace_can_be_set(self): """Test that default_workspace can be configured.""" config = BasicMemoryConfig(default_workspace="22222222-2222-2222-2222-222222222222") assert config.default_workspace == "22222222-2222-2222-2222-222222222222" def test_workspace_fields_round_trip(self): """Test that workspace fields survive save/load cycle.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) test_config = BasicMemoryConfig( projects={ "main": {"path": str(temp_path / "main")}, "research": { "path": str(temp_path / "research"), "mode": "cloud", "workspace_id": "11111111-1111-1111-1111-111111111111", }, }, default_workspace="22222222-2222-2222-2222-222222222222", ) config_manager.save_config(test_config) loaded = config_manager.load_config() assert loaded.default_workspace == "22222222-2222-2222-2222-222222222222" assert ( loaded.projects["research"].workspace_id == "11111111-1111-1111-1111-111111111111" ) assert loaded.projects["main"].workspace_id is None class TestConfigCacheMtimeInvalidation: """Test that config cache is invalidated when file is modified externally.""" def test_cache_returns_same_config_when_file_unchanged(self, config_home): """Verify cache hit when config file mtime has not changed.""" import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) test_config = BasicMemoryConfig( projects={"main": {"path": str(temp_path / "main")}}, default_project="main", ) config_manager.save_config(test_config) # First load populates cache config1 = config_manager.load_config() assert config1.default_project == "main" # Second load should return cached config (same object) config2 = config_manager.load_config() assert config1 is config2 def test_cache_invalidated_when_file_modified(self, config_home): """Verify cache miss when config file is modified by another process.""" import json import os import time import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) test_config = BasicMemoryConfig( projects={"main": {"path": str(temp_path / "main")}}, default_project="main", ) config_manager.save_config(test_config) # First load populates cache config1 = config_manager.load_config() assert config1.get_project_mode("main") == ProjectMode.LOCAL # Simulate external process modifying the config file config_data = json.loads(config_manager.config_file.read_text()) config_data["projects"]["main"]["mode"] = "cloud" # Ensure mtime actually changes (some filesystems have 1s granularity) time.sleep(0.05) config_manager.config_file.write_text(json.dumps(config_data, indent=2)) # Force mtime change on filesystems with coarse granularity new_mtime = os.path.getmtime(config_manager.config_file) + 1 os.utime(config_manager.config_file, (new_mtime, new_mtime)) # Next load should detect mtime change and re-read config2 = config_manager.load_config() assert config2.get_project_mode("main") == ProjectMode.CLOUD assert config1 is not config2 def test_save_config_resets_mtime(self, config_home): """Verify save_config clears both cache and mtime.""" import basic_memory.config basic_memory.config._CONFIG_CACHE = None basic_memory.config._CONFIG_MTIME = None basic_memory.config._CONFIG_SIZE = None with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) test_config = BasicMemoryConfig( projects={"main": {"path": str(temp_path / "main")}}, ) config_manager.save_config(test_config) # Load to populate cache config_manager.load_config() assert basic_memory.config._CONFIG_CACHE is not None assert basic_memory.config._CONFIG_MTIME is not None assert basic_memory.config._CONFIG_SIZE is not None # Save should clear all cache state config_manager.save_config(test_config) assert basic_memory.config._CONFIG_CACHE is None assert basic_memory.config._CONFIG_MTIME is None assert basic_memory.config._CONFIG_SIZE is None class TestLocalSyncPathMigration: """Test migration that promotes local_sync_path into path for cloud projects.""" def test_migrate_promotes_local_sync_path_to_path(self): """When path is a cloud slug and local_sync_path is set, path becomes local_sync_path.""" data = { "projects": { "specs": { "path": "specs", "mode": "cloud", "local_sync_path": "/Users/test/Documents/specs", } } } result = _migrate_legacy_projects(data) assert result["projects"]["specs"]["path"] == "/Users/test/Documents/specs" def test_migrate_does_not_overwrite_absolute_path(self): """When path is already absolute, migration should not change it.""" data = { "projects": { "specs": { "path": "/Users/test/Documents/specs", "mode": "cloud", "local_sync_path": "/Users/test/Documents/specs", } } } result = _migrate_legacy_projects(data) assert result["projects"]["specs"]["path"] == "/Users/test/Documents/specs" def test_migrate_skips_entries_without_local_sync_path(self): """Entries without local_sync_path should not be modified.""" data = { "projects": { "cloud-only": { "path": "cloud-only", "mode": "cloud", } } } result = _migrate_legacy_projects(data) assert result["projects"]["cloud-only"]["path"] == "cloud-only" def test_migrate_handles_mixed_projects(self, tmp_path): """Migration handles a mix of local, cloud-only, and cloud-with-bisync projects.""" local_path = str(tmp_path / "local") bisync_path = str(tmp_path / "bisync") data = { "projects": { "local-proj": {"path": local_path, "mode": "local"}, "cloud-only": {"path": "cloud-only", "mode": "cloud"}, "cloud-bisync": { "path": "cloud-bisync", "mode": "cloud", "local_sync_path": bisync_path, }, } } result = _migrate_legacy_projects(data) assert result["projects"]["local-proj"]["path"] == local_path assert result["projects"]["cloud-only"]["path"] == "cloud-only" assert result["projects"]["cloud-bisync"]["path"] == bisync_path class TestAutoUpdateConfig: """Test auto-update configuration fields.""" def test_auto_update_defaults(self): """Auto-update should default on with a daily check interval.""" config = BasicMemoryConfig() assert config.auto_update is True assert config.update_check_interval == 86400 assert config.auto_update_last_checked_at is None def test_auto_update_env_overrides(self, monkeypatch): """Environment variables should override auto-update defaults.""" monkeypatch.setenv("BASIC_MEMORY_AUTO_UPDATE", "false") monkeypatch.setenv("BASIC_MEMORY_UPDATE_CHECK_INTERVAL", "3600") config = BasicMemoryConfig() assert config.auto_update is False assert config.update_check_interval == 3600 def test_auto_update_round_trip_persistence(self): """Auto-update values should survive save/load cycle.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_manager = ConfigManager() config_manager.config_dir = temp_path / "basic-memory" config_manager.config_file = config_manager.config_dir / "config.json" config_manager.config_dir.mkdir(parents=True, exist_ok=True) checked_at = datetime.now() test_config = BasicMemoryConfig( projects={"main": {"path": str(temp_path / "main")}}, auto_update=False, update_check_interval=7200, auto_update_last_checked_at=checked_at, ) config_manager.save_config(test_config) loaded = config_manager.load_config() assert loaded.auto_update is False assert loaded.update_check_interval == 7200 assert loaded.auto_update_last_checked_at == checked_at class TestAtomicConfigSave: """Regression tests for #940: saving config must never tear the published file. Long-lived readers (the MCP stdio server's mtime-based config reload, the CLI background auto-update thread) re-read config.json while other code saves it. An in-place write truncates the file first, so a concurrent reader can observe empty/partial JSON — and load_config() raises SystemExit on invalid JSON. """ def test_interrupted_save_preserves_published_config(self, config_home, monkeypatch): """A write that dies mid-stream must leave the existing config untouched.""" import json from basic_memory.config import save_basic_memory_config with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_file = temp_path / "config.json" config = BasicMemoryConfig( projects={"main": {"path": str(temp_path / "main")}}, default_project="main", ) save_basic_memory_config(config_file, config) published = config_file.read_text(encoding="utf-8") json.loads(published) # sanity: complete, valid document def torn_write_text(self, content, *args, **kwargs): # Fault injection: the write dies halfway through. For an in-place # write this is exactly the truncated state a concurrent reader # observes mid-save; an atomic save must confine it to a temp file. with open(self, "w", encoding="utf-8") as fh: fh.write(content[: len(content) // 2]) raise OSError("simulated interrupted write") monkeypatch.setattr(Path, "write_text", torn_write_text) # save_basic_memory_config logs write failures instead of raising save_basic_memory_config(config_file, config) monkeypatch.undo() assert config_file.read_text(encoding="utf-8") == published def test_save_leaves_no_temp_files(self, config_home): """The atomic-write temp file must not survive a successful save.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) config_file = temp_path / "config.json" config = BasicMemoryConfig( projects={"main": {"path": str(temp_path / "main")}}, default_project="main", ) from basic_memory.config import save_basic_memory_config save_basic_memory_config(config_file, config) assert config_file.exists() assert not list(temp_path.glob("*.tmp"))