diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fb7dda..0bf7d360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - Default `None` preserves backwards compatibility (bare state emission) - **Page Image Generation Control**: New `generate_page_images` option in `ConversionOptions` to control PDF page image extraction +### Changed + +- **CLI Error Handling**: Commands (`rebuild`, `vacuum`, `create-index`, `ask`, `research`) now propagate errors with proper exit codes instead of swallowing exceptions + ### Fixed - **Embed-only rebuild with changed vector dimensions**: Fixed `haiku-rag rebuild --embed-only` failing when the configured embedding model has different dimensions than the database diff --git a/tests/test_app.py b/tests/test_app.py index 259e6f08..8443ba36 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -506,3 +506,95 @@ async def test_history_nonexistent_db(tmp_path, monkeypatch): calls = [str(c) for c in mock_print.call_args_list] assert any("does not exist" in c for c in calls) + + +@pytest.mark.asyncio +async def test_init_creates_database(tmp_path, monkeypatch): + """Test init creates a new database.""" + db_path = tmp_path / "new.lancedb" + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + assert not db_path.exists() + await app.init() + + assert db_path.exists() + calls = [str(c) for c in mock_print.call_args_list] + assert any("initialized" in c for c in calls) + + +@pytest.mark.asyncio +async def test_init_existing_database(tmp_path, monkeypatch): + """Test init with existing database shows warning.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "existing.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.init() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("already exists" in c for c in calls) + + +@pytest.mark.asyncio +async def test_vacuum(tmp_path, monkeypatch): + """Test vacuum operation.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "test.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.vacuum() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("Vacuum completed" in c for c in calls) + + +@pytest.mark.asyncio +async def test_create_index_insufficient_chunks(tmp_path, monkeypatch): + """Test create_index with insufficient chunks shows warning.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "test.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.create_index() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("Need at least 256 chunks" in c for c in calls) + + +@pytest.mark.asyncio +async def test_rebuild_empty_database(tmp_path, monkeypatch): + """Test rebuild with empty database shows warning.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "test.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.rebuild() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("No documents found" in c for c in calls) diff --git a/tests/test_settings.py b/tests/test_settings.py index 1da0cab6..c888e582 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,4 +1,7 @@ -from haiku.rag.config import Config +import pytest + +from haiku.rag.config import AppConfig, Config +from haiku.rag.store.repositories.settings import ConfigMismatchError def test_settings_table_populated_on_store_init(temp_db_path): @@ -48,3 +51,108 @@ def test_monitor_filter_patterns_config(): assert isinstance(Config.monitor.ignore_patterns, list) assert isinstance(Config.monitor.include_patterns, list) assert isinstance(Config.monitor.directories, list) + + +class TestValidateConfigCompatibility: + """Tests for validate_config_compatibility method.""" + + def test_empty_settings_saves_config(self, temp_db_path): + """When settings row is missing, validation saves current config.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + store = Store(temp_db_path, create=True, skip_validation=True) + settings_repo = SettingsRepository(store) + + # Clear settings to simulate empty state + store.settings_table.delete("id = 'settings'") + assert settings_repo.get_current_settings() == {} + + # Validation should save settings + settings_repo.validate_config_compatibility() + + # Now settings should exist + saved = settings_repo.get_current_settings() + assert saved.get("embeddings", {}).get("model", {}).get("provider") is not None + store.close() + + def test_compatible_config_no_error(self, temp_db_path): + """Compatible config does not raise error.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + store = Store(temp_db_path, create=True) + settings_repo = SettingsRepository(store) + + # Should not raise - same config + settings_repo.validate_config_compatibility() + store.close() + + def test_provider_mismatch_raises_error(self, temp_db_path): + """Different embedding provider raises ConfigMismatchError.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + # Create store with default config (ollama) + store = Store(temp_db_path, create=True) + store.close() + + # Create new config with different provider + new_config = AppConfig() + new_config.embeddings.model.provider = "openai" + + store2 = Store(temp_db_path, config=new_config, skip_validation=True) + settings_repo = SettingsRepository(store2) + + with pytest.raises(ConfigMismatchError) as exc_info: + settings_repo.validate_config_compatibility() + + assert "embedding provider" in str(exc_info.value) + assert "ollama" in str(exc_info.value) + assert "openai" in str(exc_info.value) + store2.close() + + def test_model_mismatch_raises_error(self, temp_db_path): + """Different embedding model raises ConfigMismatchError.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + # Create store with default config + store = Store(temp_db_path, create=True) + store.close() + + # Create new config with different model + new_config = AppConfig() + new_config.embeddings.model.name = "different-model" + + store2 = Store(temp_db_path, config=new_config, skip_validation=True) + settings_repo = SettingsRepository(store2) + + with pytest.raises(ConfigMismatchError) as exc_info: + settings_repo.validate_config_compatibility() + + assert "embedding model" in str(exc_info.value) + store2.close() + + def test_vector_dim_mismatch_raises_error(self, temp_db_path): + """Different vector dimension raises ConfigMismatchError.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + # Create store with default config + store = Store(temp_db_path, create=True) + store.close() + + # Create new config with different vector dimension + new_config = AppConfig() + new_config.embeddings.model.vector_dim = 9999 + + store2 = Store(temp_db_path, config=new_config, skip_validation=True) + settings_repo = SettingsRepository(store2) + + with pytest.raises(ConfigMismatchError) as exc_info: + settings_repo.validate_config_compatibility() + + assert "vector dimension" in str(exc_info.value) + assert "9999" in str(exc_info.value) + store2.close()