From 7f54eb4dbc0c4bd3e1a4df2c27a12b69c7f2b8e1 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 20 Aug 2026 13:23:00 +0300 Subject: [PATCH] Measure the CLI and its application layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli.py carried 40 pragmas over whole command bodies and app.py a class-level one over all 412 statements, while tests/test_cli.py already drove 29 commands through CliRunner. The pragmas hid lines the suite executed, so the 100% gate understated real coverage and gave new CLI code no scrutiny. Both are measured now. 38 CLI tests stub HaikuRAGApp and assert the parsed arguments reach the right method; 60 app tests stub the client and record the console, pinning what each command asks for and what it prints. The only pragma left in either file is cli() under __main__. The omit list is back to the two Textual TUIs. Three defects the coverage surfaced: haiku-rag settings masked only top-level secret-named fields, so nested ones printed in full — lancedb.api_key, providers.docling_serve.api_key, WebDAV source passwords. It uses redact_secrets, which walks the dump. chat guarded the wrong thing: haiku.rag.chat imports without Textual, and run_chat raises when it imports ChatApp, so the missing extra escaped as an ImportError. The guard is on the call. inspector raises at module import instead, so inspect keeps its guard on the import; each has a test that fails the way the real installation fails. search --limit/--search-type and history --limit default to None so the config resolves the default. Now pinned. CI passed --cov=haiku while pyproject declares source = ["haiku_rag_slim"]; pass --cov and let the config decide. build-docs.yml only ran on push to main, so a broken docs build merged and failed at deploy: build on pull requests, with configure-pages, upload-pages-artifact and deploy gated to push, and a per-ref concurrency group. --- .github/workflows/build-docs.yml | 8 +- .github/workflows/test.yml | 2 +- CHANGELOG.md | 2 + haiku_rag_slim/haiku/rag/app.py | 21 +- haiku_rag_slim/haiku/rag/cli.py | 100 ++-- pyproject.toml | 2 + tests/test_app.py | 810 +++++++++++++++++++++++++++++++ tests/test_cli.py | 305 +++++++++++- 8 files changed, 1183 insertions(+), 67 deletions(-) create mode 100644 tests/test_app.py 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