mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
d763d86798
Cloud projects with bisync had a split-brain problem: `path` held a cloud slug while the actual local directory lived in `local_sync_path`. This caused `bm status` and file sync to fail for bisync'd cloud projects. Changes: - Config migration promotes `local_sync_path` → `path` for entries where `path` is a non-absolute cloud slug - `ensure_project_paths_exists` skips cloud-only projects with slug paths - `initialize_file_sync` and watch service now keep cloud projects that have an absolute local path (bisync copy) instead of skipping all cloud projects - `sync-setup` and `project add --cloud --local-path` set both `path` and `local_sync_path` to the local directory - `sync-setup` creates the project in the local DB for immediate MCP use - `_get_sync_project` falls back from `local_sync_path` to `path` - Config load errors now show user-friendly messages instead of stack traces Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1180 lines
51 KiB
Python
1180 lines
51 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
|
|
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_explicit_default_project_preserved(self, config_home, monkeypatch):
|
|
"""Test that a valid explicit default_project is not overwritten by model_post_init."""
|
|
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
|
|
|
config = BasicMemoryConfig(
|
|
projects={
|
|
"alpha": {"path": str(config_home / "alpha")},
|
|
"beta": {"path": str(config_home / "beta")},
|
|
},
|
|
default_project="beta",
|
|
)
|
|
|
|
assert config.default_project == "beta"
|
|
|
|
def test_invalid_default_project_corrected(self, config_home, monkeypatch):
|
|
"""Test that an invalid default_project is corrected to the first project."""
|
|
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
|
|
|
config = BasicMemoryConfig(
|
|
projects={
|
|
"alpha": {"path": str(config_home / "alpha")},
|
|
"beta": {"path": str(config_home / "beta")},
|
|
},
|
|
default_project="nonexistent",
|
|
)
|
|
|
|
assert config.default_project == "alpha"
|
|
|
|
def test_no_default_project_key_uses_first_project(self, config_home, monkeypatch):
|
|
"""Test that config without default_project key sets it to the first project."""
|
|
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
|
|
|
# Simulate loading a config file that has no default_project key —
|
|
# the field default (None) kicks in, and model_post_init resolves it
|
|
config = BasicMemoryConfig(
|
|
projects={
|
|
"research": {"path": str(config_home / "research")},
|
|
"notes": {"path": str(config_home / "notes")},
|
|
},
|
|
)
|
|
|
|
assert config.default_project == "research"
|
|
|
|
def test_empty_string_default_project_corrected(self, config_home, monkeypatch):
|
|
"""Test that an empty-string default_project is corrected to the first project."""
|
|
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
|
|
|
config = BasicMemoryConfig(
|
|
projects={
|
|
"alpha": {"path": str(config_home / "alpha")},
|
|
},
|
|
default_project="",
|
|
)
|
|
|
|
# Empty string is not in projects, so model_post_init corrects it
|
|
assert config.default_project == "alpha"
|
|
|
|
def test_single_project_default_always_matches(self, config_home, monkeypatch):
|
|
"""Test that a config with one project always resolves default_project to it."""
|
|
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
|
|
|
config = BasicMemoryConfig(
|
|
projects={"only": {"path": str(config_home / "only")}},
|
|
)
|
|
|
|
assert config.default_project == "only"
|
|
|
|
def test_stale_default_project_loaded_from_file(self, config_home, monkeypatch):
|
|
"""Test that a config file with a stale default_project is corrected on load."""
|
|
import json
|
|
import basic_memory.config
|
|
|
|
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
|
|
|
config_manager = ConfigManager()
|
|
config_manager.config_dir = config_home / ".basic-memory"
|
|
config_manager.config_file = config_manager.config_dir / "config.json"
|
|
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write a config file where default_project references a removed project
|
|
config_data = {
|
|
"projects": {
|
|
"research": {"path": str(config_home / "research")},
|
|
"notes": {"path": str(config_home / "notes")},
|
|
},
|
|
"default_project": "deleted-project",
|
|
}
|
|
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
|
basic_memory.config._CONFIG_CACHE = None
|
|
|
|
loaded = config_manager.load_config()
|
|
assert loaded.default_project == "research"
|
|
|
|
def test_config_file_without_default_project_key(self, config_home, monkeypatch):
|
|
"""Test that a config file with no default_project key resolves dynamically."""
|
|
import json
|
|
import basic_memory.config
|
|
|
|
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
|
|
|
|
config_manager = ConfigManager()
|
|
config_manager.config_dir = config_home / ".basic-memory"
|
|
config_manager.config_file = config_manager.config_dir / "config.json"
|
|
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write a config file that deliberately omits default_project
|
|
config_data = {
|
|
"projects": {
|
|
"work": {"path": str(config_home / "work")},
|
|
"personal": {"path": str(config_home / "personal")},
|
|
},
|
|
}
|
|
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
|
basic_memory.config._CONFIG_CACHE = None
|
|
|
|
loaded = config_manager.load_config()
|
|
assert loaded.default_project == "work"
|
|
|
|
|
|
class TestConfigManager:
|
|
"""Test ConfigManager functionality."""
|
|
|
|
@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_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_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
|
|
|
|
|
|
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 = BasicMemoryConfig.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 = BasicMemoryConfig.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 = BasicMemoryConfig.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 = BasicMemoryConfig.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
|