diff --git a/src/companion/config.py b/src/companion/config.py index 7ba76a8..2495024 100644 --- a/src/companion/config.py +++ b/src/companion/config.py @@ -1,6 +1,5 @@ import json import os -from pathlib import Path from typing import Any, Dict, List from pydantic import BaseModel, Field diff --git a/tests/test_config.py b/tests/test_config.py index 7df4a53..73a0130 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -140,5 +140,187 @@ def test_load_config_reads_json_and_expands_tilde(): assert config.rag.vector_store.path == os.path.expanduser( "~/.companion/vectors.lance" ) + assert config.companion.memory.persistent_store == os.path.expanduser( + "~/mem.db" + ) + finally: + os.unlink(path) + + +def test_tilde_expansion_in_list_values(): + """Test that tilde expansion works for list values.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump( + { + "companion": { + "name": "SAN", + "persona": { + "role": "companion", + "tone": "reflective", + "style": "questioning", + "boundaries": [], + }, + "memory": { + "session_turns": 20, + "persistent_store": "~/mem.db", + "summarize_after": 10, + }, + "chat": { + "streaming": True, + "max_response_tokens": 2048, + "default_temperature": 0.7, + "allow_temperature_override": True, + }, + }, + "vault": { + "path": "~/test-vault", + "indexing": { + "auto_sync": False, + "auto_sync_interval_minutes": 1440, + "watch_fs_events": False, + "file_patterns": ["*.md"], + "deny_dirs": ["~/secret", ".git"], + "deny_patterns": [".*"], + }, + "chunking_rules": {}, + }, + "rag": { + "embedding": { + "provider": "ollama", + "model": "dummy", + "base_url": "http://localhost:11434", + "dimensions": 4, + "batch_size": 2, + }, + "vector_store": { + "type": "lancedb", + "path": "~/.companion/vectors.lance", + }, + "search": { + "default_top_k": 8, + "max_top_k": 20, + "similarity_threshold": 0.75, + "hybrid_search": { + "enabled": False, + "keyword_weight": 0.3, + "semantic_weight": 0.7, + }, + "filters": { + "date_range_enabled": True, + "tag_filter_enabled": True, + "directory_filter_enabled": True, + }, + }, + }, + "model": { + "inference": { + "backend": "llama.cpp", + "model_path": "", + "context_length": 8192, + "gpu_layers": 35, + "batch_size": 512, + "threads": 8, + }, + "fine_tuning": { + "base_model": "", + "output_dir": "", + "lora_rank": 16, + "lora_alpha": 32, + "learning_rate": 0.0002, + "batch_size": 4, + "gradient_accumulation_steps": 4, + "num_epochs": 3, + "warmup_steps": 100, + "save_steps": 500, + "eval_steps": 250, + "training_data_path": "", + "validation_split": 0.1, + }, + "retrain_schedule": { + "auto_reminder": True, + "default_interval_days": 90, + "reminder_channels": [], + }, + }, + "api": { + "host": "127.0.0.1", + "port": 7373, + "cors_origins": [], + "auth": {"enabled": False}, + }, + "ui": { + "web": { + "enabled": True, + "theme": "obsidian", + "features": { + "streaming": True, + "citations": True, + "source_preview": True, + }, + }, + "cli": {"enabled": True, "rich_output": True}, + }, + "logging": { + "level": "INFO", + "file": "", + "max_size_mb": 100, + "backup_count": 5, + }, + "security": { + "local_only": True, + "vault_path_traversal_check": True, + "sensitive_content_detection": True, + "sensitive_patterns": [], + "require_confirmation_for_external_apis": True, + }, + }, + f, + ) + path = f.name + try: + config = load_config(path) + # Check tilde expansion in list values + assert config.vault.indexing.deny_dirs[0] == os.path.expanduser("~/secret") + finally: + os.unlink(path) + + +def test_load_config_missing_file(): + """Test that loading a missing file raises FileNotFoundError.""" + with tempfile.TemporaryDirectory() as tmp: + missing_path = os.path.join(tmp, "nonexistent.json") + try: + load_config(missing_path) + assert False, "Should have raised an exception" + except FileNotFoundError: + pass # Expected + + +def test_load_config_malformed_json(): + """Test that malformed JSON raises JSONDecodeError.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{invalid json") + path = f.name + try: + load_config(path) + assert False, "Should have raised an exception" + except json.JSONDecodeError: + pass # Expected + finally: + os.unlink(path) + + +def test_load_config_missing_required_field(): + """Test that missing required field raises ValidationError.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + # Missing required fields like "companion" + json.dump({"vault": {"path": "~/test"}}, f) + path = f.name + try: + load_config(path) + assert False, "Should have raised an exception" + except Exception as e: + # Should be a Pydantic validation error + assert "companion" in str(e).lower() or "validation" in str(e).lower() finally: os.unlink(path)