diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 06275e26..4df269d2 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -3,12 +3,15 @@ on: push: branches: - main + pull_request: + branches: + - main permissions: contents: read pages: write id-token: write concurrency: - group: pages + group: pages-${{ github.ref }} cancel-in-progress: false jobs: build: @@ -19,11 +22,14 @@ jobs: - run: uv sync --group dev - run: uv run zensical build - uses: actions/configure-pages@v5 + if: github.event_name == 'push' - uses: actions/upload-pages-artifact@v3 + if: github.event_name == 'push' with: path: ./site deploy: needs: build + if: github.event_name == 'push' runs-on: ubuntu-latest environment: name: github-pages diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18ecd291..38d9d2e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: env: HF_HUB_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }} TRANSFORMERS_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }} - run: uv run pytest -m "not integration" --cov=haiku --cov-report=xml --cov-report=term-missing:skip-covered + run: uv run pytest -m "not integration" --cov --cov-report=xml --cov-report=term-missing:skip-covered - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 495a23c1..5829ac75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ ### Fixed +- `haiku-rag settings` masked only top-level secret-named fields, printing nested ones in full (`lancedb.api_key`, `providers.docling_serve.api_key`, WebDAV source passwords). It redacts the whole dump. +- `haiku-rag chat` reports a missing `tui` extra instead of raising `ImportError`, matching `haiku-rag inspect`. - `storage.data_dir: ""` resolves to the platform data directory, as documented; it coerced to `Path("")`, so the database was created in the process's working directory. Set `data_dir: .` to keep the old placement. - Documented `search.limit` default is 5, was stated as 10. - The documented way to disable reranking is omitting `reranking.model` or setting it to `null`; the previous `provider: ""` example raised `Unknown reranking provider`. diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 8f800cb6..0c3cf373 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -24,12 +24,13 @@ from haiku.rag.store.models.document import Document if TYPE_CHECKING: from haiku.rag.store.engine import Store from haiku.rag.store.models import SearchResult +from haiku.rag.config import redact_secrets from haiku.rag.utils import format_bytes, format_citations_rich logger = logging.getLogger(__name__) -class HaikuRAGApp: # pragma: no cover +class HaikuRAGApp: def __init__( self, db_path: Path, @@ -822,21 +823,11 @@ class HaikuRAGApp: # pragma: no cover self.console.print("[bold]haiku.rag configuration[/bold]") self.console.print() - # Get all config fields dynamically - for field_name, field_value in self.config.model_dump().items(): - # Format the display value - if isinstance(field_value, str) and ( - "key" in field_name.lower() - or "password" in field_name.lower() - or "token" in field_name.lower() - ): - # Hide sensitive values but show if they're set - display_value = "✓ Set" if field_value else "✗ Not set" - else: - display_value = field_value - + # redact_secrets walks the whole dump: masking only top-level names left + # nested api keys, tokens and source passwords printed in full. + for field_name, field_value in redact_secrets(self.config.model_dump()).items(): self.console.print( - f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {display_value}" + f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {field_value}" ) def _rich_print_document(self, doc: Document, truncate: bool = False): diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index 8757eb67..bbaef037 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -50,7 +50,7 @@ def cli(): _read_only: bool = False -def create_app(db: Path | None = None) -> "HaikuRAGApp": # pragma: no cover +def create_app(db: Path | None = None) -> "HaikuRAGApp": """Create HaikuRAGApp with loaded config and resolved database path. Args: @@ -66,7 +66,7 @@ def create_app(db: Path | None = None) -> "HaikuRAGApp": # pragma: no cover return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only) -async def check_version(): # pragma: no cover +async def check_version(): """Check if haiku.rag is up to date and show warning if not.""" up_to_date, current_version, latest_version = await is_up_to_date() if not up_to_date: @@ -76,7 +76,7 @@ async def check_version(): # pragma: no cover typer.echo("Please update.") -def version_callback(value: bool): # pragma: no cover +def version_callback(value: bool): if value: v = version("haiku.rag-slim") typer.echo(f"haiku.rag version {v}") @@ -130,13 +130,13 @@ def main( # Run version check before any command try: asyncio.run(check_version()) - except Exception: # pragma: no cover + except Exception: # Do not block CLI on version check issues pass @_cli.command("list", help="List all stored documents") -def list_documents( # pragma: no cover +def list_documents( db: Path | None = typer.Option( None, "--db", @@ -178,7 +178,7 @@ def _parse_meta_options(meta: list[str] | None) -> dict[str, Any]: @_cli.command("add", help="Add a document from text input") -def add_document_text( # pragma: no cover +def add_document_text( text: str = typer.Argument( help="The text content of the document to add", ), @@ -207,7 +207,7 @@ def add_document_text( # pragma: no cover @_cli.command("add-src", help="Add a document from a file path, directory, or URL") -def add_document_src( # pragma: no cover +def add_document_src( source: str = typer.Argument( help="The file path, directory, or URL of the document(s) to add", ), @@ -238,7 +238,7 @@ def add_document_src( # pragma: no cover @_cli.command("get", help="Get and display a document by its ID") -def get_document( # pragma: no cover +def get_document( doc_id: str = typer.Argument( help="The ID of the document to get", ), @@ -253,7 +253,7 @@ def get_document( # pragma: no cover @_cli.command("delete", help="Delete a document by its ID") -def delete_document( # pragma: no cover +def delete_document( doc_id: str = typer.Argument( help="The ID of the document to delete", ), @@ -274,7 +274,7 @@ _cli.command("rm", help="Alias for delete: remove a document by its ID")( @_cli.command("search", help="Search for documents by a query") -def search( # pragma: no cover +def search( query: str | None = typer.Argument( None, help="The search query (omit when using --image)", @@ -321,7 +321,7 @@ def search( # pragma: no cover @_cli.command("visualize", help="Show visual grounding for a chunk") -def visualize( # pragma: no cover +def visualize( chunk_id: str = typer.Argument( help="The ID of the chunk to visualize", ), @@ -341,7 +341,7 @@ def visualize( # pragma: no cover @_cli.command("ask", help="Ask a question using the QA agent") -def ask( # pragma: no cover +def ask( question: str = typer.Argument( help="The question to ask", ), @@ -373,7 +373,7 @@ def ask( # pragma: no cover @_cli.command("analyze", help="Answer questions using the analysis capability") -def analyze( # pragma: no cover +def analyze( question: str = typer.Argument( help="The question to answer", ), @@ -405,7 +405,7 @@ def analyze( # pragma: no cover @_cli.command("settings", help="Display current configuration settings") -def settings(): # pragma: no cover +def settings(): from haiku.rag.app import HaikuRAGApp config = get_config() @@ -499,25 +499,25 @@ def rebuild( ) raise typer.Exit(1) - if embed_only: # pragma: no cover + if embed_only: mode = RebuildMode.EMBED_ONLY - elif rechunk: # pragma: no cover + elif rechunk: mode = RebuildMode.RECHUNK - elif title_only: # pragma: no cover + elif title_only: mode = RebuildMode.TITLE_ONLY - elif descriptions: # pragma: no cover + elif descriptions: mode = RebuildMode.DESCRIPTIONS - elif set_embedder: # pragma: no cover + elif set_embedder: mode = RebuildMode.SET_EMBEDDER - else: # pragma: no cover + else: mode = RebuildMode.FULL - app = create_app(db) # pragma: no cover - asyncio.run(app.rebuild(mode=mode)) # pragma: no cover + app = create_app(db) + asyncio.run(app.rebuild(mode=mode)) @_cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage") -def vacuum( # pragma: no cover +def vacuum( db: Path | None = typer.Option( None, "--db", @@ -529,7 +529,7 @@ def vacuum( # pragma: no cover @_cli.command("migrate", help="Run pending database migrations") -def migrate( # pragma: no cover +def migrate( db: Path | None = typer.Option( None, "--db", @@ -554,7 +554,7 @@ def migrate( # pragma: no cover @_cli.command( "create-index", help="Create vector index for efficient similarity search" ) -def create_index( # pragma: no cover +def create_index( db: Path | None = typer.Option( None, "--db", @@ -566,7 +566,7 @@ def create_index( # pragma: no cover @_cli.command("init", help="Initialize a new database") -def init_db( # pragma: no cover +def init_db( db: Path | None = typer.Option( None, "--db", @@ -578,7 +578,7 @@ def init_db( # pragma: no cover @_cli.command("info", help="Show database info") -def info( # pragma: no cover +def info( db: Path | None = typer.Option( None, "--db", @@ -590,7 +590,7 @@ def info( # pragma: no cover @_cli.command("doctor", help="Check database and provider health") -def doctor( # pragma: no cover +def doctor( db: Path | None = typer.Option( None, "--db", @@ -608,7 +608,7 @@ def doctor( # pragma: no cover @_cli.command("history", help="Show version history for database tables") -def history( # pragma: no cover +def history( db: Path | None = typer.Option( None, "--db", @@ -639,7 +639,7 @@ _cli.add_typer(tag_cli, name="tag") @tag_cli.command("create", help="Tag the current database state") -def tag_create( # pragma: no cover +def tag_create( name: str = typer.Argument(help="Name of the tag to create"), db: Path | None = typer.Option( None, @@ -656,7 +656,7 @@ def tag_create( # pragma: no cover @tag_cli.command("list", help="List database tags") -def tag_list( # pragma: no cover +def tag_list( db: Path | None = typer.Option( None, "--db", @@ -672,7 +672,7 @@ def tag_list( # pragma: no cover @tag_cli.command("delete", help="Delete a tag") -def tag_delete( # pragma: no cover +def tag_delete( name: str = typer.Argument(help="Name of the tag to delete"), db: Path | None = typer.Option( None, @@ -689,7 +689,7 @@ def tag_delete( # pragma: no cover @tag_cli.command("restore", help="Restore the database to a tagged state") -def tag_restore( # pragma: no cover +def tag_restore( name: str = typer.Argument(help="Name of the tag to restore"), yes: bool = typer.Option( False, @@ -724,7 +724,7 @@ def tag_restore( # pragma: no cover @_cli.command("download-models", help="Download Docling and Ollama models per config") -def download_models_cmd(): # pragma: no cover +def download_models_cmd(): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=Path(), config=get_config(), read_only=True) @@ -736,7 +736,7 @@ def download_models_cmd(): # pragma: no cover @_cli.command("inspect", help="Launch interactive TUI to inspect database contents") -def inspect( # pragma: no cover +def inspect( db: Path | None = typer.Option( None, "--db", @@ -755,7 +755,7 @@ def inspect( # pragma: no cover @_cli.command("chat", help="Launch interactive chat TUI for conversational RAG") -def chat( # pragma: no cover +def chat( db: Path | None = typer.Option( None, "--db", @@ -779,12 +779,16 @@ def chat( # pragma: no cover db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" capabilities = capability if capability else ["rag"] - run_chat( - db_path, - read_only=True, - model=model, - capabilities=capabilities, - ) + try: + run_chat( + db_path, + read_only=True, + model=model, + capabilities=capabilities, + ) + except ImportError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) from e @_cli.command( @@ -814,14 +818,12 @@ def mcp( ), ) -> None: """Run the MCP server.""" - app = create_app(db) # pragma: no cover + app = create_app(db) - transport = "stdio" if stdio else None # pragma: no cover + transport = "stdio" if stdio else None - asyncio.run( # pragma: no cover - app.run_mcp(transport=transport, host=host, port=port) - ) + asyncio.run(app.run_mcp(transport=transport, host=host, port=port)) -if __name__ == "__main__": # pragma: no cover - cli() +if __name__ == "__main__": + cli() # pragma: no cover - module-as-script entry, never imported by tests diff --git a/pyproject.toml b/pyproject.toml index 5cdc8688..9fda72e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,6 +153,8 @@ source = ["haiku_rag_slim"] # flow via a greenlet + worker thread; track both so that code isn't reported # as uncovered. concurrency = ["greenlet", "thread"] +# Textual TUIs only: driving a terminal UI is not what this gate is for. +# Everything else, the CLI application layer included, is measured. omit = [ "haiku_rag_slim/haiku/rag/chat/*", "haiku_rag_slim/haiku/rag/inspector/*", diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 00000000..bca18636 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,810 @@ +"""Tests for the CLI application layer. + +They stub the client and record the console: each test pins what the app asks +the client for and what it renders, without a database or a model. +""" + +from unittest.mock import AsyncMock + +import pytest +from rich.console import Console + +from haiku.rag.app import HaikuRAGApp +from haiku.rag.client import RebuildMode +from haiku.rag.config.models import AppConfig, LanceDBConfig +from haiku.rag.store.models.chunk import Chunk, SearchResult +from haiku.rag.store.models.document import Document + + +@pytest.fixture +def client(): + return AsyncMock() + + +@pytest.fixture +def app(tmp_path, client, monkeypatch): + class StubHaikuRAG: + # run_mcp passes db_path positionally; every other caller uses kwargs. + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return client + + async def __aexit__(self, *exc): + return False + + monkeypatch.setattr("haiku.rag.app.HaikuRAG", StubHaikuRAG) + db = tmp_path / "db.lancedb" + db.mkdir() + application = HaikuRAGApp(db_path=db, config=AppConfig()) + application.console = Console(record=True, width=200) + return application + + +def out(app) -> str: + # clear=False: export_text() empties the buffer by default, so a second + # assertion in the same test would see nothing. + return app.console.export_text(clear=False) + + +def _doc(content: str = "body text", **kwargs) -> Document: + doc = Document(content=content, uri=kwargs.pop("uri", "test://doc"), **kwargs) + doc.id = kwargs.get("id", "doc-1") + return doc + + +async def test_list_documents_prints_each_document(app, client): + client.list_documents.return_value = [_doc("first"), _doc("second")] + + await app.list_documents() + + client.list_documents.assert_awaited_once_with(filter=None) + assert "first" in out(app) + assert "second" in out(app) + + +async def test_add_document_from_text_reports_the_new_id(app, client): + client.create_document.return_value = _doc("added body") + + await app.add_document_from_text("added body", title="T", metadata={"k": "v"}) + + client.create_document.assert_awaited_once_with( + "added body", title="T", metadata={"k": "v"} + ) + assert "doc-1 added successfully" in out(app) + + +async def test_add_document_from_source_reports_one_document(app, client): + client.create_document_from_source.return_value = _doc("from file") + + await app.add_document_from_source("/tmp/x.md") + + assert "doc-1 added successfully" in out(app) + + +async def test_add_document_from_source_reports_a_directory_count(app, client): + client.create_document_from_source.return_value = [_doc("a"), _doc("b")] + + await app.add_document_from_source("/tmp/dir") + + assert "2 documents added successfully" in out(app) + + +async def test_get_document_reports_a_missing_id(app, client): + client.get_document_by_id.return_value = None + + await app.get_document("nope") + + assert "not found" in out(app) + + +async def test_get_document_prints_it_untruncated(app, client): + client.get_document_by_id.return_value = _doc("line1\nline2\nline3\nline4") + + await app.get_document("doc-1") + + assert "line4" in out(app) + + +async def test_delete_document_confirms(app, client): + client.delete_document.return_value = True + + await app.delete_document("doc-1") + + assert "deleted successfully" in out(app) + + +async def test_delete_document_reports_a_missing_id(app, client): + client.delete_document.return_value = False + + await app.delete_document("nope") + + assert "not found" in out(app) + + +async def test_search_requires_a_query_or_an_image(app): + await app.search() + + assert "Provide either a query argument or --image" in out(app) + + +async def test_search_refuses_both_a_query_and_an_image(app, tmp_path): + await app.search(query="q", image=tmp_path / "i.png") + + assert "not both" in out(app) + + +async def test_search_type_needs_a_text_query(app, tmp_path): + await app.search(image=tmp_path / "i.png", search_type="vector") + + assert "only for text queries" in out(app) + + +async def test_search_reports_no_results(app, client): + client.search.return_value = [] + + await app.search(query="q") + + assert "No results found" in out(app) + + +async def test_search_prints_results(app, client): + client.search.return_value = [ + SearchResult(content="hit one", score=0.9, chunk_id="c1") + ] + + await app.search(query="q", limit=3) + + client.search.assert_awaited_once_with("q", limit=3, filter=None, search_type=None) + assert "hit one" in out(app) + + +async def test_search_by_image_reads_the_bytes(app, client, tmp_path): + image = tmp_path / "query.png" + image.write_bytes(b"pixels") + client.search.return_value = [] + + await app.search(image=image) + + assert client.search.await_args.args[0] == b"pixels" + + +async def test_visualize_reports_a_missing_chunk(app, client): + client.get_chunk_by_id.return_value = None + + await app.visualize_chunk("nope") + + assert "not found" in out(app) + + +async def test_visualize_reports_no_grounding(app, client): + client.get_chunk_by_id.return_value = Chunk(content="c", order=0) + client.visualize_chunk.return_value = [] + + await app.visualize_chunk("c1") + + assert "No visual grounding available" in out(app) + + +async def test_ask_prints_question_answer_and_citations(app, client, monkeypatch): + client.ask.return_value = ("The answer.", []) + + async def no_citations(citations, client=None): + return ["citation block"] + + monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations) + + await app.ask("why?", filter="uri LIKE 'x%'") + + client.ask.assert_awaited_once_with("why?", filter="uri LIKE 'x%'", images=None) + printed = out(app) + assert "why?" in printed + assert "The answer." in printed + assert "citation block" in printed + + +async def test_ask_attaches_image_bytes(app, client, monkeypatch, tmp_path): + image = tmp_path / "a.png" + image.write_bytes(b"img") + client.ask.return_value = ("answer", []) + + async def no_citations(citations, client=None): + return [] + + monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations) + + await app.ask("why?", images=[image]) + + assert client.ask.await_args.kwargs["images"] == [b"img"] + + +async def test_analyze_prints_the_answer(app, client, monkeypatch): + result = AsyncMock() + result.answer = "computed answer" + result.citations = [] + client.analyze.return_value = result + + async def no_citations(citations, client=None): + return [] + + monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations) + + await app.analyze("how many?") + + printed = out(app) + assert "how many?" in printed + assert "computed answer" in printed + + +async def test_rebuild_set_embedder_reports_settings_updated(app, client): + async def one_document(mode): + yield "doc-1" + + client.rebuild_database = one_document + + await app.rebuild(mode=RebuildMode.SET_EMBEDDER) + + assert "Stored embedder settings updated" in out(app) + + +async def test_rebuild_reports_an_empty_database(app, client): + client.list_documents.return_value = [] + + await app.rebuild(mode=RebuildMode.FULL) + + assert "No documents found" in out(app) + + +@pytest.mark.parametrize( + "mode, description", + [ + (RebuildMode.FULL, "full rebuild"), + (RebuildMode.RECHUNK, "rechunk"), + (RebuildMode.EMBED_ONLY, "embed only"), + (RebuildMode.TITLE_ONLY, "title only"), + (RebuildMode.DESCRIPTIONS, "picture descriptions"), + ], +) +async def test_rebuild_names_the_mode_and_completes(app, client, mode, description): + client.list_documents.return_value = [_doc()] + + async def one_document(mode): + yield "doc-1" + + client.rebuild_database = one_document + + await app.rebuild(mode=mode) + + printed = out(app) + assert description in printed + assert "rebuild completed successfully" in printed + + +async def test_vacuum_confirms(app, client): + await app.vacuum() + + client.vacuum.assert_awaited_once() + assert "Vacuum completed successfully" in out(app) + + +async def test_create_index_refuses_a_small_table(app, client): + client.store.chunks_table.count_rows = AsyncMock(return_value=10) + + await app.create_index() + + assert "Need at least 256 chunks" in out(app) + + +async def test_create_index_creates_one(app, client): + client.store.chunks_table.count_rows = AsyncMock(return_value=512) + client.store.chunks_table.list_indices = AsyncMock(return_value=[]) + + await app.create_index() + + client.store._ensure_vector_index.assert_awaited_once() + assert "Vector index created successfully" in out(app) + + +async def test_create_index_rebuilds_an_existing_one(app, client): + client.store.chunks_table.count_rows = AsyncMock(return_value=512) + client.store.chunks_table.list_indices = AsyncMock(return_value=["vector_idx"]) + + await app.create_index() + + assert "Rebuilding existing vector index" in out(app) + + +def test_show_settings_hides_secrets(tmp_path): + config = AppConfig(lancedb=LanceDBConfig(uri="db://x", api_key="secret-value")) + app = HaikuRAGApp(db_path=tmp_path / "db", config=config) + app.console = Console(record=True, width=200) + + app.show_settings() + + printed = app.console.export_text() + assert "haiku.rag configuration" in printed + assert "secret-value" not in printed + + +def test_remote_uri_is_the_display_path(tmp_path): + config = AppConfig(lancedb=LanceDBConfig(uri="s3://bucket/path")) + app = HaikuRAGApp(db_path=tmp_path / "db", config=config) + + assert app._display_path == "s3://bucket/path" + assert app._is_local is False + + +# --- paths that do not open a client ----------------------------------------- + + +@pytest.fixture +def store_stub(monkeypatch): + """Stub the Store for the paths that use it directly (history, migrate, tags).""" + store = AsyncMock() + + class StubStore: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return store + + async def __aexit__(self, *exc): + return False + + monkeypatch.setattr("haiku.rag.store.engine.Store", StubStore) + return store + + +async def test_init_reports_an_existing_database(app): + await app.init() + + assert "Database already exists" in out(app) + + +async def test_info_reports_a_missing_path(tmp_path): + application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig()) + application.console = Console(record=True, width=200) + + await application.info() + + assert "Database path does not exist" in application.console.export_text( + clear=False + ) + + +async def test_history_reports_a_missing_path(tmp_path): + application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig()) + application.console = Console(record=True, width=200) + + await application.history() + + assert "Database path does not exist" in application.console.export_text( + clear=False + ) + + +async def test_history_rejects_an_unknown_table(app, store_stub): + await app.history(table="nope") + + assert "Unknown table: nope" in out(app) + + +async def test_migrate_returns_what_the_store_applied(app, store_stub): + store_stub.migrate.return_value = ["v0_40_0"] + + assert await app.migrate() == ["v0_40_0"] + + +async def test_list_tags_reports_none(app, monkeypatch): + store = AsyncMock() + store.list_tags.return_value = {} + monkeypatch.setattr(app, "_tag_read_store", lambda: _as_cm(store)) + + await app.list_tags() + + assert "No tags" in out(app) + + +async def test_list_tags_flags_a_partial_tag(app, monkeypatch): + from haiku.rag.store.engine import TagInfo + + store = AsyncMock() + store.list_tags.return_value = { + "release-1": TagInfo( + tables={"documents": 3}, + missing_tables=["chunks"], + ) + } + monkeypatch.setattr(app, "_tag_read_store", lambda: _as_cm(store)) + + await app.list_tags() + + printed = out(app) + assert "release-1" in printed + assert "partial" in printed + assert "chunks" in printed + + +async def test_create_and_delete_tag_confirm(app, monkeypatch): + store = AsyncMock() + monkeypatch.setattr(app, "_tag_write_store", lambda: _as_cm(store)) + + await app.create_tag("release-1") + await app.delete_tag("release-1") + + printed = out(app) + assert "Created tag 'release-1'" in printed + assert "Deleted tag 'release-1'" in printed + + +async def test_restore_tag_reports_the_safety_tag(app, monkeypatch): + store = AsyncMock() + store.restore_tag.return_value = "before-restore-20260820T000000Z" + monkeypatch.setattr(app, "_tag_write_store", lambda: _as_cm(store)) + + await app.restore_tag("release-1") + + assert "Restored database to tag 'release-1'" in out(app) + + +@pytest.mark.parametrize( + "method, args", + [ + ("create_tag", ("release-1",)), + ("list_tags", ()), + ("delete_tag", ("release-1",)), + ("restore_tag", ("release-1",)), + ], +) +async def test_tag_operations_require_the_database(tmp_path, method, args): + application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig()) + + with pytest.raises(ValueError, match="does not exist"): + await getattr(application, method)(*args) + + +async def test_visualize_prints_a_page_per_image(app, client, monkeypatch): + chunk = Chunk(content="c", order=0) + chunk.document_uri = "test://doc" + client.get_chunk_by_id.return_value = chunk + client.visualize_chunk.return_value = [object(), object()] + monkeypatch.setattr("textual_image.renderable.Image", lambda img: "") + + await app.visualize_chunk("c1") + + printed = out(app) + assert "Visual grounding for chunk c1" in printed + assert "Page 1/2" in printed + assert "Page 2/2" in printed + + +def test_search_result_rendering_includes_provenance(app): + result = SearchResult( + content="hit", + score=0.5, + chunk_id="c1", + document_id="doc-1", + document_uri="test://doc", + document_title="The Title", + page_numbers=[2, 3], + headings=["Chapter", "Section"], + ) + + app._rich_print_search_result(result) + + printed = out(app) + assert "The Title" in printed + assert "2, 3" in printed + assert "Chapter > Section" in printed + + +async def test_run_mcp_stdio(app, client, monkeypatch): + server = AsyncMock() + monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server) + + await app.run_mcp(transport="stdio") + + server.run_stdio_async.assert_awaited_once() + + +async def test_run_mcp_http(app, client, monkeypatch): + server = AsyncMock() + monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server) + + await app.run_mcp(host="0.0.0.0", port=9001) + + server.run_http_async.assert_awaited_once_with( + transport="streamable-http", host="0.0.0.0", port=9001 + ) + + +async def test_run_mcp_survives_interruption(app, client, monkeypatch): + server = AsyncMock() + server.run_stdio_async.side_effect = KeyboardInterrupt + monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server) + + await app.run_mcp(transport="stdio") + + +def _as_cm(store): + class _CM: + async def __aenter__(self): + return store + + async def __aexit__(self, *exc): + return False + + return _CM() + + +async def test_doctor_renders_the_report(app, monkeypatch): + from haiku.rag.doctor import CheckResult, Severity + + class Report: + results = [ + CheckResult(name="tables", severity=Severity.OK, message="tables present"), + CheckResult( + name="orphans", + severity=Severity.WARN, + message="2 orphaned chunks", + details=["chunk #1", "chunk #2"], + remediation="run haiku-rag rebuild", + ), + CheckResult( + name="provider:ollama", + severity=Severity.FAIL, + message="ollama unreachable", + ), + ] + failed = True + + def count(self, severity): + return sum(1 for r in self.results if r.severity is severity) + + async def report(*args, **kwargs): + kwargs["on_progress"]("checking tables") + return Report() + + monkeypatch.setattr("haiku.rag.doctor.run_doctor", report) + + failed = await app.doctor() + + assert failed is True + printed = out(app) + assert "tables present" in printed + assert "2 orphaned chunks" in printed + assert "chunk #1" in printed + assert "run haiku-rag rebuild" in printed + # providers are reported under their own rule + assert "ollama unreachable" in printed + assert "1 ok" in printed and "1 warning(s)" in printed and "1 failure(s)" in printed + + +async def test_doctor_reports_the_duplicates_export(app, monkeypatch, tmp_path): + class Report: + results = [] + failed = False + + def count(self, severity): + return 0 + + async def report(*args, **kwargs): + return Report() + + monkeypatch.setattr("haiku.rag.doctor.run_doctor", report) + target = tmp_path / "dupes.yaml" + + assert await app.doctor(duplicates_out=target) is False + assert f"written to {target}" in out(app) + + +async def test_doctor_reports_a_missing_database(tmp_path): + application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig()) + application.console = Console(record=True, width=200) + + assert await application.doctor() is True + assert "does not exist" in application.console.export_text(clear=False) + + +async def test_download_models_reports_each_stage(app, monkeypatch): + from haiku.rag.client.downloads import DownloadProgress + + events = [ + DownloadProgress(status="start", model="docling"), + DownloadProgress(status="done", model="docling"), + DownloadProgress(status="pulling", model="qwen3-embedding"), + DownloadProgress( + status="downloading", + model="qwen3-embedding", + digest="sha256:abcdef0123456789", + total=100, + completed=50, + ), + DownloadProgress( + status="verifying sha256 digest", + model="qwen3-embedding", + ), + DownloadProgress(status="done", model="qwen3-embedding"), + ] + + async def stream(config): + for event in events: + yield event + + monkeypatch.setattr("haiku.rag.client.downloads.download_models", stream) + + await app.download_models() + + printed = out(app) + assert "Downloading docling" in printed + assert "Pulling qwen3-embedding" in printed + assert "qwen3-embedding" in printed + + +async def test_document_content_is_truncated_in_lists(app, client): + long_doc = _doc("l1\nl2\nl3\nl4\nl5") + client.list_documents.return_value = [long_doc] + + await app.list_documents() + + printed = out(app) + assert "l1" in printed + assert "l5" not in printed + + +def _database_info(vector_index, chunk_rows=300): + """A complete DatabaseInfo: info() indexes every required table by name.""" + from haiku.rag.store.info import ( + DatabaseInfo, + EmbeddingsInfo, + TableInfo, + VectorIndexInfo, + ) + + rows = {"chunks": chunk_rows} + return DatabaseInfo( + path="/tmp/db.lancedb", + exists=True, + stored_version="0.75.0", + embeddings=EmbeddingsInfo(provider="ollama", name="m", vector_dim=3), + tables=[ + TableInfo( + name=name, + exists=True, + num_rows=rows.get(name, 1), + num_versions=2 if name == "documents" else 3, + ) + for name in ( + "documents", + "document_meta", + "chunks", + "document_items", + "settings", + ) + ], + vector_index=VectorIndexInfo(**vector_index), + # info() indexes these by name when printing the version block. + packages={ + "haiku_rag": "0.75.0", + "lancedb": "0.26.0", + "docling": "2.102.2", + "pydantic_ai": "2.18.0", + "docling_document_schema": "1.7.0", + }, + ) + + +async def test_info_reports_unindexed_chunks_and_document_meta_versions( + app, monkeypatch +): + """The index-status branches read from gather_database_info, so drive them + through a stubbed report rather than building databases.""" + info = _database_info({"exists": True, "indexed_rows": 200, "unindexed_rows": 100}) + + async def stub_info(config, db_path): + return info + + # info() imports it inside the method, so patch where it is looked up. + monkeypatch.setattr("haiku.rag.store.info.gather_database_info", stub_info) + + await app.info() + + printed = out(app) + assert "unindexed chunks" in printed + assert "100" in printed + assert "versions (document_meta)" in printed + + +async def test_info_suggests_creating_an_index_when_there_are_enough_chunks( + app, monkeypatch +): + info = _database_info({"exists": False}) + + async def stub_info(config, db_path): + return info + + # info() imports it inside the method, so patch where it is looked up. + monkeypatch.setattr("haiku.rag.store.info.gather_database_info", stub_info) + + await app.info() + + assert "haiku-rag create-index" in out(app) + + +async def test_doctor_updates_a_terminal_status(app, monkeypatch): + """On a terminal the checks report progress through console.status.""" + + class Report: + results = [] + failed = False + + def count(self, severity): + return 0 + + labels: list[str] = [] + + async def report(*args, **kwargs): + kwargs["on_progress"]("scanning vectors") + return Report() + + class Status: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def update(self, label): + labels.append(label) + + monkeypatch.setattr("haiku.rag.doctor.run_doctor", report) + monkeypatch.setattr(type(app.console), "is_terminal", property(lambda self: True)) + monkeypatch.setattr(app.console, "status", lambda label: Status()) + + await app.doctor() + + assert labels == ["scanning vectors..."] + + +async def test_history_limits_the_versions_shown(app, monkeypatch): + store = AsyncMock() + store.list_tags.return_value = {} + store.list_table_versions.return_value = [ + {"version": v, "timestamp": None} for v in (1, 2, 3) + ] + + class StubStore: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return store + + async def __aexit__(self, *exc): + return False + + monkeypatch.setattr("haiku.rag.store.engine.Store", StubStore) + + await app.history(table="documents", limit=1) + + printed = out(app) + assert "v3" in printed + assert "v1" not in printed + + +async def test_analyze_prints_citation_renderables(app, client, monkeypatch): + result = AsyncMock() + result.answer = "computed" + result.citations = ["c1"] + client.analyze.return_value = result + + async def one_citation(citations, client=None): + return ["citation renderable"] + + monkeypatch.setattr("haiku.rag.app.format_citations_rich", one_citation) + + await app.analyze("how many?") + + assert "citation renderable" in out(app) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5abee1a3..0187e1c5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,6 @@ import subprocess import sys -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from click.exceptions import BadParameter @@ -349,3 +349,306 @@ class TestAskAnalyzeImageOption: await app.ask("q", images=[img_path]) assert mock_ask.call_args.kwargs["images"] == [buffer.getvalue()] + + +@pytest.fixture +def app_stub(monkeypatch, tmp_path): + """Stand in for HaikuRAGApp so a command's wiring can be checked without a + database or a model. These tests pin argument parsing and dispatch, not what + the application layer renders.""" + # AsyncMock so every command's `asyncio.run(app.x(...))` gets a coroutine. + stub = AsyncMock() + monkeypatch.setattr("haiku.rag.cli.create_app", lambda db=None: stub) + return stub + + +DB_ARGS = ["--db", "/tmp/test.lancedb"] + + +@pytest.mark.parametrize( + "argv, method, expected", + [ + (["list"], "list_documents", {"filter": None}), + ( + ["list", "--filter", "uri LIKE 'x%'"], + "list_documents", + {"filter": "uri LIKE 'x%'"}, + ), + ( + ["add", "some text", "--title", "T"], + "add_document_from_text", + {"text": "some text", "title": "T", "metadata": None}, + ), + ( + ["add-src", "/tmp/doc.md"], + "add_document_from_source", + {"source": "/tmp/doc.md", "title": None, "metadata": None}, + ), + (["get", "doc-1"], "get_document", {"doc_id": "doc-1"}), + (["delete", "doc-1"], "delete_document", {"doc_id": "doc-1"}), + ( + ["visualize", "chunk-1"], + "visualize_chunk", + {"chunk_id": "chunk-1", "expand": True}, + ), + ( + ["visualize", "chunk-1", "--no-expand"], + "visualize_chunk", + {"chunk_id": "chunk-1", "expand": False}, + ), + (["vacuum"], "vacuum", {}), + (["create-index"], "create_index", {}), + (["init"], "init", {}), + (["info"], "info", {}), + # limit/search_type default to None: the app layer resolves the config + # default, so the CLI must not invent one. + (["history"], "history", {"table": None, "limit": None}), + ( + ["history", "--table", "chunks", "--limit", "5"], + "history", + {"table": "chunks", "limit": 5}, + ), + ], +) +def test_command_dispatches_to_the_app(app_stub, argv, method, expected): + result = runner.invoke(cli, argv + DB_ARGS) + + assert result.exit_code == 0, result.output + getattr(app_stub, method).assert_called_once_with(**expected) + + +@pytest.mark.parametrize( + "argv, expected", + [ + ( + ["search", "q"], + { + "query": "q", + "limit": None, + "filter": None, + "search_type": None, + "image": None, + }, + ), + ( + ["search", "q", "--limit", "3", "--search-type", "vector"], + { + "query": "q", + "limit": 3, + "filter": None, + "search_type": "vector", + "image": None, + }, + ), + ], +) +def test_search_dispatch(app_stub, argv, expected): + result = runner.invoke(cli, argv + DB_ARGS) + + assert result.exit_code == 0, result.output + app_stub.search.assert_called_once_with(**expected) + + +@pytest.mark.parametrize("command, method", [("ask", "ask"), ("analyze", "analyze")]) +def test_question_commands_dispatch(app_stub, command, method): + result = runner.invoke(cli, [command, "why?"] + DB_ARGS) + + assert result.exit_code == 0, result.output + getattr(app_stub, method).assert_called_once_with( + question="why?", filter=None, images=None + ) + + +@pytest.mark.parametrize( + "flag, mode_name", + [ + (None, "FULL"), + ("--embed-only", "EMBED_ONLY"), + ("--rechunk", "RECHUNK"), + ("--title-only", "TITLE_ONLY"), + ("--descriptions", "DESCRIPTIONS"), + ("--set-embedder", "SET_EMBEDDER"), + ], +) +def test_rebuild_flag_selects_the_mode(app_stub, flag, mode_name): + """Each flag picks one rebuild mode, and no flag means a full rebuild.""" + result = runner.invoke(cli, ["rebuild"] + ([flag] if flag else []) + DB_ARGS) + + assert result.exit_code == 0, result.output + (_, kwargs) = app_stub.rebuild.call_args + assert kwargs["mode"].name == mode_name + + +def test_migrate_reports_applied_migrations(app_stub): + app_stub.migrate.return_value = ["v0_40_0: add document_items"] + + result = runner.invoke(cli, ["migrate"] + DB_ARGS) + + assert result.exit_code == 0, result.output + assert "Applied 1 migration(s)" in result.output + assert "add document_items" in result.output + + +def test_migrate_reports_an_up_to_date_database(app_stub): + app_stub.migrate.return_value = [] + + result = runner.invoke(cli, ["migrate"] + DB_ARGS) + + assert result.exit_code == 0, result.output + assert "No migrations pending" in result.output + + +def test_migrate_exits_nonzero_on_failure(app_stub): + app_stub.migrate.side_effect = RuntimeError("schema is from the future") + + result = runner.invoke(cli, ["migrate"] + DB_ARGS) + + assert result.exit_code == 1 + assert "Migration failed: schema is from the future" in result.output + + +def test_mcp_stdio_selects_the_transport(app_stub): + result = runner.invoke(cli, ["mcp", "--stdio"] + DB_ARGS) + + assert result.exit_code == 0, result.output + app_stub.run_mcp.assert_called_once() + kwargs = app_stub.run_mcp.call_args.kwargs + assert kwargs["transport"] == "stdio" + + +def test_mcp_without_stdio_leaves_the_transport_unset(app_stub): + result = runner.invoke(cli, ["mcp"] + DB_ARGS) + + assert result.exit_code == 0, result.output + assert app_stub.run_mcp.call_args.kwargs["transport"] is None + + +def test_version_flag_prints_the_version(): + result = runner.invoke(cli, ["--version"]) + + assert result.exit_code == 0, result.output + assert "haiku.rag version" in result.output + + +def test_outdated_install_warns(app_stub, monkeypatch): + """The startup check warns but does not block the command.""" + + async def outdated(): + return False, "0.1.0", "9.9.9" + + monkeypatch.setattr("haiku.rag.cli.is_up_to_date", outdated) + + result = runner.invoke(cli, ["list"] + DB_ARGS) + + assert result.exit_code == 0, result.output + assert "haiku.rag is outdated" in result.output + assert "Current: 0.1.0, Latest: 9.9.9" in result.output + app_stub.list_documents.assert_called_once() + + +def test_up_to_date_install_says_nothing(app_stub, monkeypatch): + async def current(): + return True, "9.9.9", "9.9.9" + + monkeypatch.setattr("haiku.rag.cli.is_up_to_date", current) + + result = runner.invoke(cli, ["list"] + DB_ARGS) + + assert result.exit_code == 0, result.output + assert "outdated" not in result.output + + +def test_a_failing_version_check_does_not_block_the_cli(app_stub, monkeypatch): + """PyPI being unreachable must not stop a command from running.""" + + async def boom(): + raise RuntimeError("no network") + + monkeypatch.setattr("haiku.rag.cli.is_up_to_date", boom) + + result = runner.invoke(cli, ["list"] + DB_ARGS) + + assert result.exit_code == 0, result.output + app_stub.list_documents.assert_called_once() + + +def test_settings_command_shows_the_configuration(monkeypatch): + shown = [] + + class StubApp: + def __init__(self, **kwargs): + shown.append(kwargs) + + def show_settings(self): + shown.append("shown") + + monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp) + + result = runner.invoke(cli, ["settings"]) + + assert result.exit_code == 0, result.output + assert "shown" in shown + assert shown[0]["read_only"] is True + + +def test_download_models_reports_failure(monkeypatch): + class StubApp: + def __init__(self, **kwargs): + pass + + async def download_models(self): + raise RuntimeError("hub unreachable") + + monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp) + + result = runner.invoke(cli, ["download-models"]) + + assert result.exit_code == 1 + assert "Error downloading models: hub unreachable" in result.output + + +def test_download_models_succeeds(monkeypatch): + calls = [] + + class StubApp: + def __init__(self, **kwargs): + pass + + async def download_models(self): + calls.append("downloaded") + + monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp) + + result = runner.invoke(cli, ["download-models"]) + + assert result.exit_code == 0, result.output + assert calls == ["downloaded"] + + +def test_chat_reports_a_missing_tui_extra(monkeypatch): + """run_chat imports the Textual app itself, so a missing tui extra surfaces + from the call, not from importing haiku.rag.chat. The CLI must report it and + exit nonzero rather than traceback.""" + import sys + + monkeypatch.setitem(sys.modules, "haiku.rag.chat.app", None) + + result = runner.invoke(cli, ["chat"]) + + assert result.exit_code == 1 + assert "textual is not installed" in result.output + assert "haiku.rag-slim[tui]" in result.output + + +def test_inspect_reports_a_missing_tui_extra(monkeypatch): + """run_inspector raises at import instead, so the guard sits on the import.""" + import sys + + monkeypatch.delitem(sys.modules, "haiku.rag.inspector", raising=False) + monkeypatch.setitem(sys.modules, "haiku.rag.inspector.app", None) + + result = runner.invoke(cli, ["inspect"]) + + assert result.exit_code == 1 + assert "textual is not installed" in result.output + assert "haiku.rag-slim[tui]" in result.output