diff --git a/codeprism/cli.py b/codeprism/cli.py index a14226c..a77f39d 100644 --- a/codeprism/cli.py +++ b/codeprism/cli.py @@ -10,6 +10,7 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table +from rich.text import Text from .core.languages import SUPPORTED_LANGUAGES, normalize_language from .core.models import SYMBOL_KINDS @@ -449,12 +450,23 @@ async def _stats(project: str, verbose: bool, json_output: bool = False) -> None if verbose and file_map: table = Table(title="Files") - table.add_column("Path") + table.add_column("Path", max_width=60, overflow="ellipsis") table.add_column("Lang") table.add_column("Lines", justify="right") table.add_column("Symbols", justify="right") + project_root = Path(project).resolve() for e in file_map.entries: - table.add_row(e.path, e.language, str(e.line_count), str(e.symbol_count)) + try: + display_path = str(Path(e.path).relative_to(project_root)) + except ValueError: + display_path = e.path + # Let the column shrink on narrow terminals, but keep each path on one line. + table.add_row( + Text(display_path, no_wrap=True), + e.language, + str(e.line_count), + str(e.symbol_count), + ) console.print(table) diff --git a/docs/changelog.md b/docs/changelog.md index 206f452..f6cdca7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,8 @@ Versioning follows [Semantic Versioning](https://semver.org/). ### Fixed - `index --languages` now rejects unknown names with a list of supported languages. Names remain case-insensitive, and supported aliases such as `py` and `C++` still work. (#55) +- `stats --verbose` shows project-relative paths and truncates very long paths instead of + wrapping the file table across multiple lines. (#17) - `codeprism --version` now exists, and `codeprism.__version__` is read from the installed package metadata instead of a hard-coded `0.1.0`. (#54) - `scan --diff` now rejects ranges beginning with `-`, so Git options such as `--stat` cannot be diff --git a/tests/test_cli_stats.py b/tests/test_cli_stats.py new file mode 100644 index 0000000..45bb1b8 --- /dev/null +++ b/tests/test_cli_stats.py @@ -0,0 +1,123 @@ +"""Verbose stats rendering using real temporary project indexes.""" + +import asyncio +from pathlib import Path + +import pytest +from rich.console import Console +from typer.testing import CliRunner + +from codeprism.cli import app +from codeprism.core.models import FileRecord +from codeprism.core.paths import get_db_path +from codeprism.core.storage import StorageManager + +runner = CliRunner() + + +def _index_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, relative_paths: tuple[Path, ...] +) -> Path: + """Create a deep project and index its files in isolated SQLite storage.""" + monkeypatch.setattr("codeprism.core.paths.get_data_dir", lambda: tmp_path / "data") + monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path)) + project = tmp_path / ("deep_project_" * 6) + for relative_path in relative_paths: + source = project / relative_path + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("def process():\n return 1\n", encoding="utf-8") + result = runner.invoke(app, ["index", str(project), "--languages", "python", "--workers", "1"]) + assert result.exit_code == 0, result.output + return project + + +def _file_rows(output: str) -> list[str]: + """Read data rows from the final Files table, excluding its header.""" + table = output.rsplit("Files", 1)[1] + return [ + line + for line in table.splitlines() + if line.lstrip().startswith("│") and line.split("│")[1].strip() != "Path" + ] + + +@pytest.mark.parametrize("width", [80, 120]) +@pytest.mark.parametrize("relative_project", [False, True]) +def test_stats_verbose_shows_distinct_project_relative_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, width: int, relative_project: bool +) -> None: + """Deep absolute prefixes disappear while same-named files stay identifiable.""" + relative_paths = ( + Path("src/domain/payments/processor.py"), + Path("src/domain/refunds/processor.py"), + ) + project = _index_project(tmp_path, monkeypatch, relative_paths) + monkeypatch.setattr("codeprism.cli.console", Console(width=width)) + monkeypatch.chdir(tmp_path) + project_arg = project.name if relative_project else str(project) + + result = runner.invoke(app, ["stats", "--verbose", "--project", project_arg]) + + assert result.exit_code == 0, result.output + rows = _file_rows(result.output) + assert len(rows) == len(relative_paths), result.output + for relative_path in relative_paths: + assert sum(str(relative_path) in row for row in rows) == 1, result.output + assert project.name not in result.output + assert all(len(line) <= width for line in result.output.splitlines()) + + +@pytest.mark.parametrize("width", [80, 120]) +def test_stats_verbose_truncates_long_relative_paths_to_one_row( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, width: int +) -> None: + """Very long relative paths truncate without wrapping the numeric columns.""" + relative_path = Path("src") / ("nested directory " * 6) / "processor.py" + project = _index_project(tmp_path, monkeypatch, (relative_path,)) + monkeypatch.setattr("codeprism.cli.console", Console(width=width)) + + result = runner.invoke(app, ["stats", "--verbose", "--project", str(project)]) + + assert result.exit_code == 0, result.output + rows = _file_rows(result.output) + assert len(rows) == 1, result.output + columns = [column.strip() for column in rows[0].split("│")[1:-1]] + assert columns[0].startswith(str(Path("src") / "nested directory")) + assert columns[0].endswith("…"), result.output + assert columns[1:] == ["python", "3", "1"] + assert all(len(line) <= width for line in result.output.splitlines()) + + +def test_stats_verbose_keeps_indexed_paths_outside_project_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An out-of-root indexed record stays visible instead of breaking stats.""" + project = _index_project(tmp_path, monkeypatch, (Path("app.py"),)) + external = tmp_path / "external.py" + external.write_text("value = 1\n", encoding="utf-8") + + async def add_external_file() -> None: + storage = StorageManager(get_db_path(project)) + await storage.initialize() + try: + await storage.upsert_file( + FileRecord.create(path=str(external), language="python", line_count=1) + ) + finally: + await storage.close() + + asyncio.run(add_external_file()) + monkeypatch.setattr("codeprism.cli.console", Console(width=120)) + + result = runner.invoke(app, ["stats", "--verbose", "--project", str(project)]) + + assert result.exit_code == 0, result.output + rows = _file_rows(result.output) + assert len(rows) == 2, result.output + external_rows = [ + row + for row in rows + if [column.strip() for column in row.split("│")[2:-1]] == ["python", "1", "0"] + ] + assert len(external_rows) == 1, result.output + assert external_rows[0].split("│")[1].strip().startswith(external.anchor)