diff --git a/librarian/cli.py b/librarian/cli.py index 5f879b9..bf8f28a 100644 --- a/librarian/cli.py +++ b/librarian/cli.py @@ -446,7 +446,7 @@ def list_sources( table = Table(title="Document Sources", show_header=True, header_style="bold cyan") table.add_column("Name", style="green") - table.add_column("Path", style="blue") + table.add_column("Path", style="blue", overflow="fold") table.add_column("Type", style="magenta") table.add_column("Status", style="yellow") @@ -1029,7 +1029,7 @@ def docs_overview(ctx: typer.Context) -> None: table = Table(title="Sources", show_header=True, header_style="bold cyan") table.add_column("Name", style="green") - table.add_column("Path", style="blue") + table.add_column("Path", style="blue", overflow="fold") table.add_column("Docs", justify="right", style="yellow") table.add_column("Status") @@ -1102,7 +1102,7 @@ def docs_list( ) table.add_column("ID", style="dim", width=4) table.add_column("Title", style="green", max_width=38) - table.add_column("Path", style="blue", max_width=46) + table.add_column("Path", style="blue", max_width=46, overflow="fold") table.add_column("Type", style="magenta", width=6) home = str(Path.home()) @@ -1161,7 +1161,7 @@ def docs_search( ) table.add_column("ID", style="dim", width=4) table.add_column("Title", style="green") - table.add_column("Path", style="blue", max_width=50) + table.add_column("Path", style="blue", max_width=50, overflow="fold") home = str(Path.home()) for doc in matches[:limit]: @@ -1331,7 +1331,7 @@ def search_cmd( table = Table(show_header=True, header_style="bold cyan", box=None) table.add_column("#", style="dim", width=3) table.add_column("Score", justify="right", width=7) - table.add_column("Path", style="blue") + table.add_column("Path", style="blue", overflow="fold") for i, result in enumerate(results, 1): score = result.get("score", 0) diff --git a/tests/test_cli.py b/tests/test_cli.py index bd42a9b..807ceb6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,13 +1,41 @@ """Tests for CLI behavior.""" from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest +from rich.console import Console from typer.testing import CliRunner from librarian import cli +LONG_WINDOWS_PATH = ( + r"C:\Users\example\Documents\Codex\2026-05-16" + r"\concurso-browser-deckbuilder\docs\planning" + r"\vgf-56-revolt-fx-distribution-and-ui-editor-plan.md" +) +LONG_WINDOWS_FILENAME = "vgf-56-revolt-fx-distribution-and-ui-editor-plan.md" + + +def assert_table_preserves_long_path_reference(result: Any) -> None: + assert result.exit_code == 0 + assert "\ufffd" not in result.output + assert "…" not in result.output + normalized_output = "".join( + char for char in result.output if char.isalnum() or char in "\\/:._-" + ) + assert LONG_WINDOWS_FILENAME in normalized_output + + +def fake_document() -> SimpleNamespace: + return SimpleNamespace( + id=1, + title="VGF-56 plan", + path=LONG_WINDOWS_PATH, + asset_type=SimpleNamespace(value="text"), + ) + def test_add_directory_exits_nonzero_when_indexing_errors( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -23,6 +51,7 @@ def test_add_directory_exits_nonzero_when_indexing_errors( monkeypatch.setattr(cli, "SOURCES_FILE", config_dir / "sources.json") monkeypatch.setattr(cli, "SETTINGS_FILE", config_dir / "settings.json") monkeypatch.setattr(cli, "_get_config", lambda: {"ensure_directories": lambda: None}) + monkeypatch.setattr(cli, "console", Console(width=500, color_system=None)) async def fake_server_ingest(context: Any, directory: str) -> dict[str, Any]: return { @@ -31,7 +60,7 @@ async def fake_server_ingest(context: Any, directory: str) -> dict[str, Any]: "indexed": 0, "updated": 0, "skipped": 0, - "errors": [{"path": str(fixture), "error": "parser exploded"}], + "errors": [{"path": fixture.name, "error": "parser exploded"}], "files": [], } @@ -44,3 +73,128 @@ async def fake_server_ingest(context: Any, directory: str) -> dict[str, Any]: assert result.exit_code == 1 assert "Errors:" in result.output assert "parser exploded" in result.output + + +def test_search_table_wraps_long_windows_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Narrow table output should wrap paths instead of replacing them with ellipses.""" + monkeypatch.setattr(cli, "_get_config", lambda: {"ensure_directories": lambda: None}) + monkeypatch.setattr(cli, "console", Console(width=80, color_system=None)) + + async def fake_search_library(**kwargs: Any) -> list[dict[str, Any]]: + return [ + { + "score": 1.0, + "document_path": LONG_WINDOWS_PATH, + "content": "matched content", + "heading_path": None, + } + ] + + monkeypatch.setattr("librarian.server.search_library", fake_search_library) + + result = CliRunner().invoke( + cli.app, + [ + "search", + "VGF-56 UI editor Fabric Tweakpane Pixi Layout Pixi UI", + "--format", + "table", + ], + ) + + assert_table_preserves_long_path_reference(result) + + +def test_list_table_wraps_long_windows_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """Source listing should not truncate long paths with an ellipsis.""" + monkeypatch.setattr(cli, "console", Console(width=80, color_system=None)) + monkeypatch.setattr( + cli, + "_load_sources", + lambda: [{"name": "docs", "path": LONG_WINDOWS_PATH, "is_file": False}], + ) + + result = CliRunner().invoke(cli.app, ["list"]) + + assert_table_preserves_long_path_reference(result) + + +def test_docs_overview_wraps_long_windows_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """Document source overview should wrap long paths.""" + monkeypatch.setattr(cli, "_get_config", lambda: {"ensure_directories": lambda: None}) + monkeypatch.setattr(cli, "console", Console(width=80, color_system=None)) + monkeypatch.setattr( + cli, + "_load_sources", + lambda: [{"name": "docs", "path": LONG_WINDOWS_PATH, "is_file": False}], + ) + monkeypatch.setattr( + "librarian.storage.database.get_database", + lambda: SimpleNamespace(list_documents=lambda: [fake_document()]), + ) + + result = CliRunner().invoke(cli.app, ["docs"]) + + assert_table_preserves_long_path_reference(result) + + +def test_docs_list_table_wraps_long_windows_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """Document listing should wrap long paths in table output.""" + monkeypatch.setattr(cli, "_get_config", lambda: {"ensure_directories": lambda: None}) + monkeypatch.setattr(cli, "console", Console(width=80, color_system=None)) + monkeypatch.setattr( + "librarian.storage.database.get_database", + lambda: SimpleNamespace(list_documents=lambda: [fake_document()]), + ) + + result = CliRunner().invoke(cli.app, ["docs", "list", "--format", "table"]) + + assert_table_preserves_long_path_reference(result) + + +def test_docs_search_table_wraps_long_windows_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """Document title search should wrap long paths in table output.""" + monkeypatch.setattr(cli, "_get_config", lambda: {"ensure_directories": lambda: None}) + monkeypatch.setattr(cli, "console", Console(width=80, color_system=None)) + monkeypatch.setattr( + "librarian.storage.database.get_database", + lambda: SimpleNamespace(list_documents=lambda: [fake_document()]), + ) + + result = CliRunner().invoke(cli.app, ["docs", "search", "VGF", "--format", "table"]) + + assert_table_preserves_long_path_reference(result) + + +def test_search_paths_outputs_complete_long_windows_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Paths output remains the copyable full-path mode for search results.""" + monkeypatch.setattr(cli, "_get_config", lambda: {"ensure_directories": lambda: None}) + + async def fake_search_library(**kwargs: Any) -> list[dict[str, Any]]: + return [ + { + "score": 1.0, + "document_path": LONG_WINDOWS_PATH, + "content": "matched content", + "heading_path": None, + } + ] + + monkeypatch.setattr("librarian.server.search_library", fake_search_library) + + result = CliRunner().invoke( + cli.app, + [ + "search", + "VGF-56 UI editor Fabric Tweakpane Pixi Layout Pixi UI", + "--format", + "paths", + ], + ) + + assert result.exit_code == 0 + assert result.output == f"{LONG_WINDOWS_PATH}\n"