diff --git a/src/cocoindex_code/cli.py b/src/cocoindex_code/cli.py index 4e25d29..cea64b6 100644 --- a/src/cocoindex_code/cli.py +++ b/src/cocoindex_code/cli.py @@ -32,7 +32,7 @@ # --------------------------------------------------------------------------- -# Shared CLI helpers (G1) +# Shared CLI helpers # --------------------------------------------------------------------------- @@ -70,7 +70,7 @@ def require_daemon_for_project() -> tuple[DaemonClient, str]: def resolve_default_path(project_root: Path) -> str | None: """Compute default ``--path`` filter from CWD relative to project root.""" - cwd = Path.cwd() + cwd = Path.cwd().resolve() try: rel = cwd.relative_to(project_root) except ValueError: @@ -120,8 +120,126 @@ def print_search_results(response: SearchResponse) -> None: _typer.echo(r.content) +def _run_index_with_progress(client: DaemonClient, project_root: str) -> None: + """Run indexing with streaming progress display. Exits on failure.""" + from rich.console import Console as _Console + from rich.live import Live as _Live + from rich.spinner import Spinner as _Spinner + + err_console = _Console(stderr=True) + last_progress_line: str | None = None + + with _Live(_Spinner("dots", "Indexing..."), console=err_console, transient=True) as live: + + def _on_waiting() -> None: + live.update( + _Spinner( + "dots", + "Another indexing is ongoing, waiting for it to finish...", + ) + ) + + def _on_progress(progress: IndexingProgress) -> None: + nonlocal last_progress_line + last_progress_line = f"Indexing: {_format_progress(progress)}" + live.update(_Spinner("dots", last_progress_line)) + + try: + resp = client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting) + except RuntimeError as e: + live.stop() + _typer.echo(f"Indexing failed: {e}", err=True) + raise _typer.Exit(code=1) + + # Print the final progress line so it remains visible after the spinner clears + if last_progress_line is not None: + _typer.echo(last_progress_line, err=True) + + if not resp.success: + _typer.echo(f"Indexing failed: {resp.message}", err=True) + raise _typer.Exit(code=1) + + +_GITIGNORE_COMMENT = "# cocoindex-code" +_GITIGNORE_ENTRY = "/.cocoindex_code/" + + +def add_to_gitignore(project_root: Path) -> None: + """Add ``/.cocoindex_code/`` to ``.gitignore`` if ``.git`` exists. + + Creates ``.gitignore`` if it doesn't exist. Skips if the entry is already + present. + """ + if not (project_root / ".git").is_dir(): + return + + gitignore = project_root / ".gitignore" + if gitignore.is_file(): + content = gitignore.read_text() + if _GITIGNORE_ENTRY in content.splitlines(): + return # already present + # Ensure a trailing newline before appending + if content and not content.endswith("\n"): + content += "\n" + content += f"{_GITIGNORE_COMMENT}\n{_GITIGNORE_ENTRY}\n" + gitignore.write_text(content) + else: + gitignore.write_text(f"{_GITIGNORE_COMMENT}\n{_GITIGNORE_ENTRY}\n") + + +def remove_from_gitignore(project_root: Path) -> None: + """Remove ``/.cocoindex_code/`` entry and its comment from ``.gitignore``.""" + gitignore = project_root / ".gitignore" + if not gitignore.is_file(): + return + + lines = gitignore.read_text().splitlines(keepends=True) + new_lines: list[str] = [] + i = 0 + while i < len(lines): + stripped = lines[i].rstrip("\n\r") + if stripped == _GITIGNORE_ENTRY: + # Skip this line; also remove preceding comment if it matches + if new_lines and new_lines[-1].rstrip("\n\r") == _GITIGNORE_COMMENT: + new_lines.pop() + i += 1 + continue + new_lines.append(lines[i]) + i += 1 + gitignore.write_text("".join(new_lines)) + + +def auto_init_project() -> Path: + """Auto-initialize project from CWD. + + Runs core ``init`` logic without parent-directory confirmation and without + the "run ``ccc index``" prompt. Returns the project root (CWD). + """ + from .settings import project_settings_path + + cwd = Path.cwd().resolve() + settings_file = project_settings_path(cwd) + + if not settings_file.is_file(): + # Create user settings if missing + user_path = user_settings_path() + if not user_path.is_file(): + save_user_settings(default_user_settings()) + _typer.echo(f"Created user settings: {user_path}") + + # Create project settings + save_project_settings(cwd, default_project_settings()) + _typer.echo(f"Created project settings: {settings_file}") + _typer.echo("You can edit the settings files to customize indexing behavior.") + + # Update .gitignore + add_to_gitignore(cwd) + + return cwd + + # --------------------------------------------------------------------------- -# Commands (G2-G5) +# Commands # --------------------------------------------------------------------------- @@ -130,10 +248,10 @@ def init( force: bool = _typer.Option(False, "-f", "--force", help="Skip parent directory warning"), ) -> None: """Initialize a project for cocoindex-code.""" - from .settings import project_settings_path + from .settings import project_settings_path as _project_settings_path - cwd = Path.cwd() - settings_file = project_settings_path(cwd) + cwd = Path.cwd().resolve() + settings_file = _project_settings_path(cwd) # Check if already initialized if settings_file.is_file(): @@ -160,49 +278,32 @@ def init( # Create project settings save_project_settings(cwd, default_project_settings()) _typer.echo(f"Created project settings: {settings_file}") - _typer.echo("Project initialized. Run `ccc index` to build the index.") + + # Add to .gitignore + add_to_gitignore(cwd) + + _typer.echo("You can edit the settings files to customize indexing behavior.") + _typer.echo("Run `ccc index` to build the index.") @app.command() def index() -> None: """Create/update index for the codebase.""" - from rich.console import Console as _Console - from rich.live import Live as _Live - from rich.spinner import Spinner as _Spinner - - client, project_root = require_daemon_for_project() - err_console = _Console(stderr=True) - last_progress_line: str | None = None - - with _Live(_Spinner("dots", "Indexing..."), console=err_console, transient=True) as live: - - def _on_waiting() -> None: - live.update( - _Spinner( - "dots", - "Another indexing is ongoing, waiting for it to finish...", - ) - ) - - def _on_progress(progress: IndexingProgress) -> None: - nonlocal last_progress_line - last_progress_line = f"Indexing: {_format_progress(progress)}" - live.update(_Spinner("dots", last_progress_line)) - - try: - resp = client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting) - except RuntimeError as e: - live.stop() - _typer.echo(f"Indexing failed: {e}", err=True) - raise _typer.Exit(code=1) + from .client import ensure_daemon - # Print the final progress line so it remains visible after the spinner clears - if last_progress_line is not None: - _typer.echo(last_progress_line, err=True) + # Auto-init if not in an initialized project + root = find_project_root(Path.cwd()) + if root is None: + root = auto_init_project() - if not resp.success: - _typer.echo(f"Indexing failed: {resp.message}", err=True) + try: + client = ensure_daemon() + except Exception as e: + _typer.echo(f"Error: Failed to connect to daemon: {e}", err=True) raise _typer.Exit(code=1) + project_root = str(root) + + _run_index_with_progress(client, project_root) status = client.project_status(project_root) print_index_stats(status) @@ -221,6 +322,10 @@ def search( client, project_root = require_daemon_for_project() query_str = " ".join(query) + # Refresh index with progress display before searching + if refresh: + _run_index_with_progress(client, project_root) + # Default path filter from CWD paths: list[str] | None = None if path is not None: @@ -237,7 +342,7 @@ def search( paths=paths, limit=limit, offset=offset, - refresh=refresh, + refresh=False, ) print_search_results(resp) @@ -250,6 +355,82 @@ def status() -> None: print_index_stats(resp) +@app.command() +def reset( + all_: bool = _typer.Option(False, "--all", help="Also remove settings and .gitignore entry"), + force: bool = _typer.Option(False, "-f", "--force", help="Skip confirmation"), +) -> None: + """Reset project databases and optionally remove settings.""" + project_root = require_project_root() + cocoindex_dir = project_root / ".cocoindex_code" + + db_files = [ + cocoindex_dir / "cocoindex.db", + cocoindex_dir / "target_sqlite.db", + ] + settings_file = cocoindex_dir / "settings.yml" + + # Determine what will be deleted + to_delete = [f for f in db_files if f.exists()] + if all_: + if settings_file.exists(): + to_delete.append(settings_file) + + if not to_delete and not all_: + _typer.echo("Nothing to reset.") + return + + # Show what will be deleted + if to_delete: + _typer.echo("The following files will be deleted:") + for f in to_delete: + _typer.echo(f" {f}") + + # Confirm + if not force: + if not _typer.confirm("Proceed?"): + _typer.echo("Aborted.") + raise _typer.Exit(code=0) + + # Remove project from daemon first so it releases file handles + try: + from .client import DaemonClient + + client = DaemonClient.connect() + client.handshake() + client.remove_project(str(project_root)) + client.close() + except (ConnectionRefusedError, OSError, RuntimeError): + pass # Daemon not running — that's fine + + # Delete files/directories + import shutil as _shutil + + for f in to_delete: + if f.is_dir(): + _shutil.rmtree(f) + else: + f.unlink(missing_ok=True) + + if all_: + # Remove .cocoindex_code/ if empty + try: + cocoindex_dir.rmdir() + except OSError: + pass # Not empty + + # Remove from .gitignore + remove_from_gitignore(project_root) + _typer.echo("Project fully reset.") + else: + _typer.echo("Databases deleted.") + if settings_file.exists(): + _typer.echo( + "Settings file still exists. Run `ccc reset --all` to remove it too,\n" + "or edit it manually." + ) + + @app.command() def mcp() -> None: """Run as MCP server (stdio mode).""" @@ -279,7 +460,7 @@ async def _bg_index(client, project_root: str) -> None: # type: ignore[no-untyp pass -# --- Daemon subcommands (G5) --- +# --- Daemon subcommands --- @daemon_app.command("status") diff --git a/src/cocoindex_code/client.py b/src/cocoindex_code/client.py index c650caa..bf115c7 100644 --- a/src/cocoindex_code/client.py +++ b/src/cocoindex_code/client.py @@ -26,6 +26,8 @@ IndexWaitingNotice, ProjectStatusRequest, ProjectStatusResponse, + RemoveProjectRequest, + RemoveProjectResponse, Request, Response, SearchRequest, @@ -72,7 +74,10 @@ def index( """Request indexing with streaming progress. Blocks until complete.""" self._conn.send_bytes(encode_request(IndexRequest(project_root=project_root))) while True: - data = self._conn.recv_bytes() + try: + data = self._conn.recv_bytes() + except EOFError: + raise RuntimeError("Connection to daemon lost during indexing") resp = decode_response(data) if isinstance(resp, ErrorResponse): raise RuntimeError(f"Daemon error: {resp.message}") @@ -121,6 +126,11 @@ def daemon_status(self) -> DaemonStatusResponse: return self._send(DaemonStatusRequest()) # type: ignore[return-value] + def remove_project(self, project_root: str) -> RemoveProjectResponse: + return self._send( # type: ignore[return-value] + RemoveProjectRequest(project_root=project_root) + ) + def stop(self) -> StopResponse: return self._send(StopRequest()) # type: ignore[return-value] @@ -236,7 +246,7 @@ def stop_daemon() -> None: pass -def _wait_for_daemon(timeout: float = 5.0) -> None: +def _wait_for_daemon(timeout: float = 10.0) -> None: """Wait for the daemon socket/pipe to become available.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: diff --git a/src/cocoindex_code/daemon.py b/src/cocoindex_code/daemon.py index 582f526..355aa53 100644 --- a/src/cocoindex_code/daemon.py +++ b/src/cocoindex_code/daemon.py @@ -31,6 +31,8 @@ IndexWaitingNotice, ProjectStatusRequest, ProjectStatusResponse, + RemoveProjectRequest, + RemoveProjectResponse, Request, Response, SearchRequest, @@ -107,8 +109,14 @@ def __init__(self, embedder: Embedder) -> None: self._indexing = {} self._embedder = embedder - async def get_project(self, project_root: str) -> Project: - """Get or create a Project for the given root. Lazy initialization.""" + async def get_project(self, project_root: str, *, suppress_auto_index: bool = False) -> Project: + """Get or create a Project for the given root. Lazy initialization. + + When a project is newly loaded and *suppress_auto_index* is False, + a background indexing task is fired so the project is indexed + immediately. Callers that will index right away (e.g. IndexRequest, + SearchRequest with refresh) should pass ``suppress_auto_index=True``. + """ if project_root not in self._projects: root = Path(project_root) project_settings = load_project_settings(root) @@ -116,11 +124,24 @@ async def get_project(self, project_root: str) -> Project: self._projects[project_root] = project self._index_locks[project_root] = asyncio.Lock() self._indexing[project_root] = False + + if not suppress_auto_index: + asyncio.create_task(self._auto_index(project_root)) return self._projects[project_root] - async def update_index(self, project_root: str) -> AsyncIterator[IndexStreamResponse]: + async def _auto_index(self, project_root: str) -> None: + """Background auto-index, consuming the update_index stream.""" + try: + async for _ in self.update_index(project_root): + pass + except Exception: + logger.exception("Auto-index failed for %s", project_root) + + async def update_index( + self, project_root: str, *, suppress_auto_index: bool = True + ) -> AsyncIterator[IndexStreamResponse]: """Update index, yielding progress updates and a final IndexResponse.""" - project = await self.get_project(project_root) + project = await self.get_project(project_root, suppress_auto_index=suppress_auto_index) lock = self._index_locks[project_root] # If lock is already held, notify the client and block until released @@ -221,6 +242,20 @@ def get_status(self, project_root: str) -> ProjectStatusResponse: progress=progress, ) + def remove_project(self, project_root: str) -> bool: + """Remove a project from the registry. Returns True if it was loaded.""" + import gc + + was_loaded = project_root in self._projects + project = self._projects.pop(project_root, None) + self._index_locks.pop(project_root, None) + self._indexing.pop(project_root, None) + if project is not None: + project.close() + del project + gc.collect() + return was_loaded + def list_projects(self) -> list[DaemonProjectInfo]: """List all loaded projects with their indexing state.""" return [ @@ -285,8 +320,12 @@ def _recv() -> bytes: result = await _dispatch(req, registry, start_time, shutdown_event) if isinstance(result, AsyncIterator): - async for resp in result: - conn.send_bytes(encode_response(resp)) + try: + async for resp in result: + conn.send_bytes(encode_response(resp)) + except Exception as exc: + logger.exception("Error during streaming response") + conn.send_bytes(encode_response(ErrorResponse(message=str(exc)))) else: conn.send_bytes(encode_response(result)) @@ -346,6 +385,10 @@ async def _dispatch( projects=registry.list_projects(), ) + if isinstance(req, RemoveProjectRequest): + registry.remove_project(req.project_root) + return RemoveProjectResponse(ok=True) + if isinstance(req, StopRequest): shutdown_event.set() return StopResponse(ok=True) diff --git a/src/cocoindex_code/project.py b/src/cocoindex_code/project.py index 3e9cf7f..e963935 100644 --- a/src/cocoindex_code/project.py +++ b/src/cocoindex_code/project.py @@ -22,6 +22,14 @@ class Project: _initial_index_done: bool = False _indexing_stats: IndexingProgress | None = None + def close(self) -> None: + """Close project resources to release file handles (LMDB, SQLite).""" + try: + db = self._env.get_context(SQLITE_DB) + db.close() + except Exception: + pass + async def update_index( self, *, diff --git a/src/cocoindex_code/protocol.py b/src/cocoindex_code/protocol.py index 5b7c6c5..8f6d062 100644 --- a/src/cocoindex_code/protocol.py +++ b/src/cocoindex_code/protocol.py @@ -35,6 +35,10 @@ class DaemonStatusRequest(_msgspec.Struct, tag="daemon_status"): pass +class RemoveProjectRequest(_msgspec.Struct, tag="remove_project"): + project_root: str + + class StopRequest(_msgspec.Struct, tag="stop"): pass @@ -45,6 +49,7 @@ class StopRequest(_msgspec.Struct, tag="stop"): | SearchRequest | ProjectStatusRequest | DaemonStatusRequest + | RemoveProjectRequest | StopRequest ) @@ -122,6 +127,10 @@ class DaemonStatusResponse(_msgspec.Struct, tag="daemon_status"): projects: list[DaemonProjectInfo] +class RemoveProjectResponse(_msgspec.Struct, tag="remove_project"): + ok: bool + + class StopResponse(_msgspec.Struct, tag="stop"): ok: bool @@ -138,6 +147,7 @@ class ErrorResponse(_msgspec.Struct, tag="error"): | SearchResponse | ProjectStatusResponse | DaemonStatusResponse + | RemoveProjectResponse | StopResponse | ErrorResponse ) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 13f33a1..b16e9ab 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -6,7 +6,13 @@ import pytest -from cocoindex_code.cli import require_project_root, resolve_default_path +from cocoindex_code.cli import ( + add_to_gitignore, + auto_init_project, + remove_from_gitignore, + require_project_root, + resolve_default_path, +) def test_require_project_root_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -62,3 +68,82 @@ def test_resolve_default_path_outside_project( monkeypatch.chdir(other) result = resolve_default_path(project_root) assert result is None + + +# --------------------------------------------------------------------------- +# .gitignore helpers +# --------------------------------------------------------------------------- + + +def test_add_to_gitignore_creates_file(tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() + add_to_gitignore(tmp_path) + gitignore = tmp_path / ".gitignore" + assert gitignore.is_file() + content = gitignore.read_text() + assert "# cocoindex-code" in content + assert "/.cocoindex_code/" in content + + +def test_add_to_gitignore_appends_to_existing(tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.pyc\n") + add_to_gitignore(tmp_path) + content = gitignore.read_text() + assert "*.pyc" in content + assert "/.cocoindex_code/" in content + + +def test_add_to_gitignore_idempotent(tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() + gitignore = tmp_path / ".gitignore" + gitignore.write_text("/.cocoindex_code/\n") + add_to_gitignore(tmp_path) + content = gitignore.read_text() + assert content.count("/.cocoindex_code/") == 1 + + +def test_add_to_gitignore_skips_when_no_git(tmp_path: Path) -> None: + add_to_gitignore(tmp_path) + assert not (tmp_path / ".gitignore").exists() + + +def test_remove_from_gitignore(tmp_path: Path) -> None: + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.pyc\n# cocoindex-code\n/.cocoindex_code/\n__pycache__/\n") + remove_from_gitignore(tmp_path) + content = gitignore.read_text() + assert "/.cocoindex_code/" not in content + assert "# cocoindex-code" not in content + assert "*.pyc" in content + assert "__pycache__/" in content + + +def test_remove_from_gitignore_no_entry(tmp_path: Path) -> None: + gitignore = tmp_path / ".gitignore" + original = "*.pyc\n__pycache__/\n" + gitignore.write_text(original) + remove_from_gitignore(tmp_path) + assert gitignore.read_text() == original + + +def test_auto_init_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / ".git").mkdir() + monkeypatch.chdir(tmp_path) + # Monkeypatch user settings dir to avoid touching real home + monkeypatch.setattr( + "cocoindex_code.cli.user_settings_path", + lambda: tmp_path / ".cocoindex_code_user" / "global_settings.yml", + ) + monkeypatch.setattr( + "cocoindex_code.settings.user_settings_dir", + lambda: tmp_path / ".cocoindex_code_user", + ) + + result = auto_init_project() + + assert result == tmp_path + assert (tmp_path / ".cocoindex_code" / "settings.yml").is_file() + assert (tmp_path / ".gitignore").is_file() + assert "/.cocoindex_code/" in (tmp_path / ".gitignore").read_text() diff --git a/tests/test_daemon.py b/tests/test_daemon.py index df3d099..73fcdac 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -26,6 +26,7 @@ IndexResponse, IndexWaitingNotice, ProjectStatusRequest, + RemoveProjectRequest, Response, SearchRequest, decode_response, @@ -196,3 +197,27 @@ def test_index_streams_progress(daemon_sock: str) -> None: assert len(updates) > 0, "Expected at least one IndexProgressUpdate" for u in updates: assert u.progress.num_execution_starts >= 0 + + +def test_daemon_remove_project(daemon_sock: str, daemon_project: str) -> None: + """Removing a loaded project should make it disappear from the status list.""" + conn, _ = _connect_and_handshake(daemon_sock) + conn.send_bytes(encode_request(RemoveProjectRequest(project_root=daemon_project))) + resp = decode_response(conn.recv_bytes()) + assert resp.ok is True # type: ignore[union-attr] + + # Verify project is gone from daemon status + conn.send_bytes(encode_request(DaemonStatusRequest())) + status = decode_response(conn.recv_bytes()) + project_roots = [p.project_root for p in status.projects] # type: ignore[union-attr] + assert daemon_project not in project_roots + conn.close() + + +def test_daemon_remove_project_not_loaded(daemon_sock: str) -> None: + """Removing a non-existent project should succeed (idempotent).""" + conn, _ = _connect_and_handshake(daemon_sock) + conn.send_bytes(encode_request(RemoveProjectRequest(project_root="/nonexistent/path"))) + resp = decode_response(conn.recv_bytes()) + assert resp.ok is True # type: ignore[union-attr] + conn.close() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 58f4b63..0f1ed32 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,7 +1,9 @@ """End-to-end tests exercising the full CLI → daemon → index → search flow. -Each test uses a real daemon subprocess (via COCOINDEX_CODE_DIR env var) -and the actual CLI commands through typer's CliRunner. +Each test function represents a complete session: a series of CLI commands +executed in order, verifying compound stateful effects. Tests use a real +daemon subprocess (via COCOINDEX_CODE_DIR env var) and the actual CLI +commands through typer's CliRunner. """ from __future__ import annotations @@ -91,12 +93,11 @@ def execute_query(self, sql: str) -> list[dict]: """ -@pytest.fixture(scope="module") -def e2e_env() -> Iterator[Path]: - """Set up a temp project dir with sample files and a daemon subprocess. +@pytest.fixture() +def e2e_project() -> Iterator[Path]: + """Set up a temp project dir with sample files. - Uses COCOINDEX_CODE_DIR to redirect the daemon to a temp directory, - so the subprocess picks up the right paths. + Cleans up with ``ccc reset --all -f`` and daemon stop. """ base_dir = Path(tempfile.mkdtemp(prefix="ccc_e2e_")) project_dir = base_dir / "project" @@ -106,122 +107,307 @@ def e2e_env() -> Iterator[Path]: lib_dir = project_dir / "lib" lib_dir.mkdir() (lib_dir / "database.py").write_text(SAMPLE_DATABASE_PY) + (project_dir / ".git").mkdir() old_env = os.environ.get("COCOINDEX_CODE_DIR") os.environ["COCOINDEX_CODE_DIR"] = str(base_dir) + old_cwd = os.getcwd() + os.chdir(project_dir) try: yield project_dir finally: + os.chdir(project_dir) + runner.invoke(app, ["reset", "--all", "-f"]) stop_daemon() + os.chdir(old_cwd) if old_env is None: os.environ.pop("COCOINDEX_CODE_DIR", None) else: os.environ["COCOINDEX_CODE_DIR"] = old_env -class TestCLIEndToEnd: - """Tests that exercise ccc init → index → search → status via the real CLI.""" - - def test_init_creates_settings(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["init"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert (e2e_env / ".cocoindex_code" / "settings.yml").exists() - assert "Created project settings" in result.output or "already initialized" in result.output - - def test_init_already_initialized(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["init"], catch_exceptions=False) - assert result.exit_code == 0 - assert "already initialized" in result.output - - def test_index(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["index"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert "Chunks:" in result.output - assert "Files:" in result.output - - def test_status(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["status"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert "Chunks:" in result.output - - def test_search_fibonacci(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["search", "fibonacci", "calculation"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert "main.py" in result.output - - def test_search_database(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["search", "database", "connection"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert "database.py" in result.output - - def test_search_with_lang_filter(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke( - app, ["search", "function", "--lang", "python"], catch_exceptions=False - ) - assert result.exit_code == 0, result.output - assert "python" in result.output.lower() - - def test_search_with_path_filter(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke( - app, ["search", "function", "--path", "lib/*"], catch_exceptions=False - ) - assert result.exit_code == 0, result.output - assert "lib/" in result.output - - def test_search_no_results(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke( - app, - ["search", "xyzzy_nonexistent_symbol_12345"], - catch_exceptions=False, - ) - assert result.exit_code == 0 - - def test_daemon_status(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["daemon", "status"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert "Daemon version:" in result.output - - def test_index_shows_stats(self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Indexing should complete and show final stats.""" - monkeypatch.chdir(e2e_env) - result = runner.invoke(app, ["index"], catch_exceptions=False) - assert result.exit_code == 0, result.output - assert "Chunks:" in result.output - assert "Files:" in result.output - - def test_incremental_index_new_file( - self, e2e_env: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Adding a file and re-indexing should make it searchable.""" - monkeypatch.chdir(e2e_env) - (e2e_env / "app.js").write_text(SAMPLE_APP_JS) - - result = runner.invoke(app, ["index"], catch_exceptions=False) - assert result.exit_code == 0 - - result = runner.invoke(app, ["search", "handleRequest"], catch_exceptions=False) - assert result.exit_code == 0 - assert "app.js" in result.output - - def test_not_initialized_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Running commands outside an initialized project should fail.""" - standalone = tmp_path / "standalone" - standalone.mkdir() - monkeypatch.chdir(standalone) - result = runner.invoke(app, ["index"]) - assert result.exit_code != 0 - assert "ccc init" in result.output +# --------------------------------------------------------------------------- +# Session tests — each function is a complete scenario +# --------------------------------------------------------------------------- + + +def test_session_happy_path(e2e_project: Path) -> None: + """Init → init (idempotent) → index → status → search variants → daemon status.""" + # Init + result = runner.invoke(app, ["init"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert (e2e_project / ".cocoindex_code" / "settings.yml").exists() + assert "Created project settings" in result.output or "settings" in result.output + + # Init again — already initialized + result = runner.invoke(app, ["init"], catch_exceptions=False) + assert result.exit_code == 0 + assert "already initialized" in result.output + + # Index + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "Chunks:" in result.output + assert "Files:" in result.output + + # Status + result = runner.invoke(app, ["status"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "Chunks:" in result.output + + # Search — fibonacci + result = runner.invoke(app, ["search", "fibonacci", "calculation"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "main.py" in result.output + + # Search — database + result = runner.invoke(app, ["search", "database", "connection"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "database.py" in result.output + + # Search — --lang filter + result = runner.invoke(app, ["search", "function", "--lang", "python"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "python" in result.output.lower() + + # Search — --path filter + result = runner.invoke(app, ["search", "function", "--path", "lib/*"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "lib/" in result.output + + # Search — no results + result = runner.invoke( + app, ["search", "xyzzy_nonexistent_symbol_12345"], catch_exceptions=False + ) + assert result.exit_code == 0 + + # Daemon status + result = runner.invoke(app, ["daemon", "status"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "Daemon version:" in result.output + + +def test_session_incremental_index(e2e_project: Path) -> None: + """Init → index → add new file → re-index → search finds new content.""" + runner.invoke(app, ["init"], catch_exceptions=False) + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + + # Add a new file + (e2e_project / "app.js").write_text(SAMPLE_APP_JS) + + # Re-index + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + + # Search should find the new file + result = runner.invoke(app, ["search", "handleRequest"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "app.js" in result.output + + +def test_session_reset_databases(e2e_project: Path) -> None: + """Init → index → search → reset (dbs only) → re-index → search works again.""" + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["index"], catch_exceptions=False) + + # Search works before reset + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0 + assert "main.py" in result.output + + # Reset databases only + result = runner.invoke(app, ["reset", "-f"], catch_exceptions=False) + assert result.exit_code == 0 + assert "Databases deleted" in result.output + + # Settings should still exist + assert (e2e_project / ".cocoindex_code" / "settings.yml").exists() + + # DB files should be gone + assert not (e2e_project / ".cocoindex_code" / "cocoindex.db").exists() + assert not (e2e_project / ".cocoindex_code" / "target_sqlite.db").exists() + + # Restart daemon to fully release LMDB handles. + # On free-threaded Python (3.14t), deferred refcounting in the daemon + # process prevents the Rust LMDB environment from being freed promptly + # after remove_project; restarting is the reliable way to ensure cleanup. + runner.invoke(app, ["daemon", "restart"], catch_exceptions=False) + + # Re-index — project is still initialized, just databases gone + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + + # Search works again + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0 + assert "main.py" in result.output + + +def test_session_reset_all(e2e_project: Path) -> None: + """Init → index → reset --all → verify full cleanup → search errors.""" + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["index"], catch_exceptions=False) + + # .gitignore should have the entry (project has .git dir) + gitignore = e2e_project / ".gitignore" + assert gitignore.is_file() + assert "/.cocoindex_code/" in gitignore.read_text() + + # Reset --all + result = runner.invoke(app, ["reset", "--all", "-f"], catch_exceptions=False) + assert result.exit_code == 0 + assert "fully reset" in result.output + + # Settings should be gone + assert not (e2e_project / ".cocoindex_code" / "settings.yml").exists() + + # .gitignore entry should be removed + assert "/.cocoindex_code/" not in gitignore.read_text() + + # Search should fail — not initialized + result = runner.invoke(app, ["search", "fibonacci"]) + assert result.exit_code != 0 + assert "ccc init" in result.output + + +def test_session_reset_then_full_reinit(e2e_project: Path) -> None: + """Init → index → reset --all → re-init → re-index → search works again.""" + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["index"], catch_exceptions=False) + + # Reset everything + runner.invoke(app, ["reset", "--all", "-f"], catch_exceptions=False) + + # Restart daemon to fully release LMDB handles (see test_session_reset_databases). + runner.invoke(app, ["daemon", "restart"], catch_exceptions=False) + + # Re-init from scratch + result = runner.invoke(app, ["init"], catch_exceptions=False) + assert result.exit_code == 0 + assert (e2e_project / ".cocoindex_code" / "settings.yml").exists() + + # Re-index + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + + # Search works again + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0 + assert "main.py" in result.output + + +@pytest.mark.usefixtures("e2e_project") +def test_session_daemon_stop_and_auto_start() -> None: + """Init → index → daemon stop → index auto-starts daemon → search works.""" + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["index"], catch_exceptions=False) + + # Stop daemon + result = runner.invoke(app, ["daemon", "stop"], catch_exceptions=False) + assert result.exit_code == 0 + + # Index should auto-start daemon via ensure_daemon() + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + + # Search should work with the new daemon + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0 + assert "main.py" in result.output + + +@pytest.mark.usefixtures("e2e_project") +def test_session_daemon_restart() -> None: + """Init → index → daemon restart → re-index → search works.""" + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["index"], catch_exceptions=False) + + # Restart daemon + result = runner.invoke(app, ["daemon", "restart"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "restarted" in result.output.lower() + + # Re-index in the new daemon + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + + # Search should work + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0 + assert "main.py" in result.output + + +@pytest.mark.usefixtures("e2e_project") +def test_session_search_refresh() -> None: + """Init (no explicit index) → search --refresh indexes then searches.""" + runner.invoke(app, ["init"], catch_exceptions=False) + + # search --refresh without prior explicit index + result = runner.invoke(app, ["search", "--refresh", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "main.py" in result.output + + +def test_session_index_auto_init(e2e_project: Path) -> None: + """Running ``ccc index`` from uninitialized dir auto-inits, then search works.""" + # Do NOT call init — just run index directly + result = runner.invoke(app, ["index"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert (e2e_project / ".cocoindex_code" / "settings.yml").exists() + + # Search should work + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0 + assert "main.py" in result.output + + +def test_session_subdirectory_path_default(e2e_project: Path) -> None: + """Search from a subdirectory defaults path filter to that subdirectory.""" + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["index"], catch_exceptions=False) + + # Search from project root — should find main.py + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "main.py" in result.output + + # Search from lib/ — default path filter restricts to lib/* + os.chdir(e2e_project / "lib") + result = runner.invoke(app, ["search", "database", "connection"], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert "database.py" in result.output + + # From lib/, searching for fibonacci should NOT find main.py (outside lib/) + result = runner.invoke(app, ["search", "fibonacci"], catch_exceptions=False) + assert result.exit_code == 0 + assert "main.py" not in result.output + + # Back to project root + os.chdir(e2e_project) + + +def test_session_not_initialized_errors(e2e_project: Path) -> None: + """Search and status from uninitialized dir should error with guidance.""" + standalone = Path(tempfile.mkdtemp(prefix="ccc_standalone_")) + os.chdir(standalone) + + result = runner.invoke(app, ["search", "hello"]) + assert result.exit_code != 0 + assert "ccc init" in result.output + + result = runner.invoke(app, ["status"]) + assert result.exit_code != 0 + assert "ccc init" in result.output + + # Return to project dir so fixture cleanup works + os.chdir(e2e_project) + + +# --------------------------------------------------------------------------- +# Unit tests (not session-based) +# --------------------------------------------------------------------------- class TestCodebaseRootDiscovery: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 08de1cf..fe02b8a 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -15,6 +15,8 @@ IndexWaitingNotice, ProjectStatusRequest, ProjectStatusResponse, + RemoveProjectRequest, + RemoveProjectResponse, Request, Response, SearchRequest, @@ -136,6 +138,7 @@ def test_all_request_types_round_trip() -> None: SearchRequest(project_root="/tmp", query="test"), ProjectStatusRequest(project_root="/tmp"), DaemonStatusRequest(), + RemoveProjectRequest(project_root="/tmp"), StopRequest(), ] for req in requests: @@ -226,6 +229,7 @@ def test_all_response_types_round_trip() -> None: SearchResponse(success=True), ProjectStatusResponse(indexing=False, total_chunks=0, total_files=0, languages={}), DaemonStatusResponse(version="1.0.0", uptime_seconds=0.0, projects=[]), + RemoveProjectResponse(ok=True), StopResponse(ok=True), ErrorResponse(message="err"), ]