Files
basicmachines-co-basic-memory/tests/test_config.py
T
phernandez 2e5813d31e feat: CLI refactoring + workspace-aware cloud project listing
Refactor CLI commands to use typed ProjectClient instead of raw HTTP calls,
and add workspace metadata to cloud project listings so users can distinguish
personal vs organization projects.

Key changes:
- 🔧 CLI commands now use ProjectClient typed API clients instead of
  call_get/call_post with manual URL construction
- 🏢 Cloud project listings include workspace_name, workspace_type, and
  workspace_tenant_id for each cloud-sourced project
- Pass config.default_workspace when fetching cloud projects via
  _fetch_cloud_projects() and CLI list_projects
- Add --workspace flag to `bm project list` for explicit workspace override
- Add "Workspace" column to CLI project list table
- Add `bm tool list-projects` and `bm tool list-workspaces` JSON commands
- Comprehensive tests for workspace passthrough, merge behavior, and CLI routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-02-21 20:04:03 -06:00

990 lines
43 KiB
Python

"""Test configuration management."""
import tempfile
import pytest
from datetime import datetime
from basic_memory.config import (
BasicMemoryConfig,
ConfigManager,
ProjectEntry,
ProjectMode,
)
from pathlib import Path
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
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
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
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"
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")
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 False."""
config = BasicMemoryConfig()
assert config.ensure_frontmatter_on_sync is False
def test_ensure_frontmatter_on_sync_flag_can_be_enabled(self):
"""Test that ensure_frontmatter_on_sync can be set to True."""
config = BasicMemoryConfig(ensure_frontmatter_on_sync=True)
assert config.ensure_frontmatter_on_sync is True
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
# 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
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
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
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_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)
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_local(self):
"""Test that unknown projects default to LOCAL mode."""
config = BasicMemoryConfig()
assert config.get_project_mode("nonexistent") == ProjectMode.LOCAL
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_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
# 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