"""Tests for fff Python bindings.""" from __future__ import annotations import importlib.metadata as metadata import tempfile from pathlib import Path import pytest import fff from fff import FFFException, FileFinder, GrepCursor, MixedDirItem, MixedFileItem def rel(path: str) -> str: return path.replace("\\", "/") @pytest.fixture def sample_dir() -> str: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) (root / "src").mkdir() (root / "docs").mkdir() (root / "src" / "main.py").write_text( "from utils import helper\n\n" "def main():\n" " value = helper()\n" " return value\n" ) (root / "src" / "utils.py").write_text( "def helper():\n" " return 'alpha'\n" ) (root / "src" / "profile.ts").write_text( "class Profile {}\n" "function renderProfile() { return new Profile(); }\n" ) (root / "docs" / "guide.txt").write_text( "alpha line before\n" "needle target\n" "omega line after\n" ) (root / "README.md").write_text("# Sample project\n") yield str(root) def test_imports_and_package_version() -> None: assert fff.__version__ == metadata.version("fff-search") assert GrepCursor(12).offset == 12 assert "GrepCursor" in fff.__all__ def test_pathlib_base_path(sample_dir: str) -> None: with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.closed is False assert finder.base_path is not None assert finder.scan_progress.scanned_files_count >= 1 result = finder.search("main") assert result.total_matched >= 1 async def test_wait_for_scan_async(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert await finder.wait_for_scan(timeout_ms=5000) is True assert finder.is_scanning() is False result = finder.search("main") assert result.total_matched >= 1 async def test_wait_for_scan_async_does_not_block_loop(sample_dir: str) -> None: import asyncio ticks = 0 async def ticker() -> None: nonlocal ticks while True: ticks += 1 await asyncio.sleep(0.01) with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: background = asyncio.ensure_future(ticker()) try: assert await finder.wait_for_scan(timeout_ms=5000) is True finally: background.cancel() # the loop kept running other tasks while we awaited the scan assert ticks > 0 async def test_wait_for_scan_blocking_and_async_agree(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) is True # already finished, so the async wait resolves immediately to True assert await finder.wait_for_scan(timeout_ms=5000) is True def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None: with pytest.raises(TypeError): GrepCursor() with pytest.raises(TypeError): FileFinder(Path(sample_dir), None) with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) with pytest.raises(TypeError): finder.search("main", None) with pytest.raises(TypeError): finder.glob("*.py", None) with pytest.raises(TypeError): finder.directory_search("src", None) with pytest.raises(TypeError): finder.mixed_search("src", None) with pytest.raises(TypeError): finder.grep("needle", "plain") with pytest.raises(TypeError): finder.multi_grep(["needle"], None) def test_close_and_context_manager(sample_dir: str) -> None: finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False) assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.closed is False assert finder.base_path is not None finder.close() assert finder.closed is True with pytest.raises(FFFException, match="File picker not initialized"): finder.search("main") with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder: assert ctx_finder.wait_for_scan_blocking(timeout_ms=5000) assert ctx_finder.closed is False assert ctx_finder.closed is True with pytest.raises(FFFException, match="File picker not initialized"): ctx_finder.search("main") fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False) assert fresh.wait_for_scan_blocking(timeout_ms=5000) fresh.close() assert fresh.closed is True with pytest.raises(FFFException, match="File picker not initialized"): fresh.search("main") def test_reprs(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) assert repr(finder).startswith("FileFinder(") result = finder.search("main") assert repr(result).startswith("SearchResult(") assert repr(result.items[0]).startswith("FileItem(") assert repr(result.scores[0]).startswith("Score(") grep_result = finder.grep("needle") assert repr(grep_result).startswith("GrepResult(") assert repr(grep_result.items[0]).startswith("GrepMatch(") assert repr(grep_result.items[0].match_ranges[0]).startswith("MatchRange(") dir_result = finder.directory_search("src") assert repr(dir_result).startswith("DirSearchResult(") assert repr(dir_result.items[0]).startswith("DirItem(") mixed = finder.mixed_search("src", page_size=10) assert repr(mixed).startswith("MixedSearchResult(") cursor = GrepCursor(42) assert repr(cursor) == "GrepCursor(offset=42)" progress = finder.scan_progress assert repr(progress).startswith("ScanProgress(") def test_file_search_scores_and_pagination(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.search("main", page_size=1) assert result.total_matched >= 1 assert len(result) == len(result.items) == 1 assert bool(result) is True assert any("main.py" in rel(item.relative_path) for item in result.items) score = result.scores[0] assert isinstance(score.total, int) assert isinstance(score.exact_match, bool) assert isinstance(score.match_type, str) second_page = finder.search("", page_index=1, page_size=1) assert len(second_page) == len(second_page.items) == 1 empty = finder.search("definitely_no_such_file_xyz") assert len(empty) == 0 assert bool(empty) is False def test_glob_variants(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) py_files = finder.glob("*.py") assert {Path(rel(item.relative_path)).name for item in py_files.items} == { "main.py", "utils.py", } src_files = finder.glob("src/*.py") assert src_files.total_matched == 2 md_files = finder.glob("*.md") assert md_files.total_matched == 1 assert rel(md_files.items[0].relative_path) == "README.md" def test_directory_and_mixed_search(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) dirs = finder.directory_search("src") assert dirs.total_matched >= 1 assert len(dirs) == len(dirs.items) assert bool(dirs) is True assert any(rel(item.relative_path).startswith("src") for item in dirs.items) mixed = finder.mixed_search("src", page_size=10) assert mixed.total_matched >= 3 assert len(mixed) == len(mixed.items) assert bool(mixed) is True assert any(isinstance(item, MixedDirItem) for item in mixed.items) assert any(isinstance(item, MixedFileItem) for item in mixed.items) # max_access_frecency is the same field on both dir item types, so the # values must agree for a shared directory regardless of which search # produced them. dir_frecency = { rel(item.relative_path): item.max_access_frecency for item in dirs.items } for item in mixed.items: if isinstance(item, MixedDirItem): path = rel(item.relative_path) if path in dir_frecency: assert item.max_access_frecency == dir_frecency[path] def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) plain = finder.grep("needle", before_context=1, after_context=1) assert plain.total_matched == 1 assert len(plain) == len(plain.items) == 1 assert bool(plain) is True match = plain.items[0] assert rel(match.relative_path) == "docs/guide.txt" assert match.line_content == "needle target" assert match.context_before == ["alpha line before"] assert match.context_after == ["omega line after"] assert [(r.start, r.end) for r in match.match_ranges] == [(0, 6)] regex = finder.grep(r"def \w+", mode="regex") assert regex.total_matched == 2 assert {Path(rel(m.relative_path)).name for m in regex.items} == { "main.py", "utils.py", } fuzzy = finder.grep("df mn", mode="fuzzy") assert fuzzy.total_matched >= 1 assert any(rel(m.relative_path) == "src/main.py" for m in fuzzy.items) assert any(m.fuzzy_score is not None for m in fuzzy.items) invalid = finder.grep("[", mode="regex") assert invalid.regex_fallback_error is not None def test_grep_invalid_mode_raises(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) with pytest.raises(FFFException, match="invalid grep mode"): finder.grep("needle", mode="typo") with pytest.raises(FFFException, match="invalid grep mode"): finder.multi_grep(["needle"], mode="typo") def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) first = finder.grep("def", page_limit=1) assert first.total_matched >= 1 assert first.next_file_offset > 0 assert first.has_more is True assert first.next_cursor() is not None assert first.next_cursor().offset == first.next_file_offset second = finder.grep("def", cursor=GrepCursor(first.next_file_offset), page_limit=1) assert second.total_matched >= 1 first_paths = {rel(m.relative_path) for m in first.items} second_paths = {rel(m.relative_path) for m in second.items} assert first_paths.isdisjoint(second_paths) exhausted = finder.grep("nonexistent_xyz") assert exhausted.has_more is False assert exhausted.next_cursor() is None assert len(exhausted) == 0 assert bool(exhausted) is False def test_multi_grep_and_error_handling(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.multi_grep(("def main", "def helper")) assert result.total_matched == 2 assert {Path(rel(m.relative_path)).name for m in result.items} == { "main.py", "utils.py", } with pytest.raises(FFFException, match="patterns must not be empty"): finder.multi_grep([]) def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None: history_db = tmp_path / "history" selected_file = Path(sample_dir) / "src" / "main.py" with FileFinder( sample_dir, history_db_path=history_db, watch=False, enable_content_indexing=False, ) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.track_query("main", selected_file) assert finder.get_historical_query(0) == "main" with FileFinder( sample_dir, history_db_path=history_db, watch=False, enable_content_indexing=False, ) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.get_historical_query(0) == "main" def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None: other = tmp_path / "other-project" other.mkdir() (other / "other.py").write_text("def other():\n return 42\n") frecency_db = tmp_path / "frecency" history_db = tmp_path / "history" with FileFinder( sample_dir, frecency_db_path=frecency_db, history_db_path=history_db, watch=False, enable_content_indexing=False, ) as finder: assert finder.wait_for_scan_blocking(timeout_ms=5000) health = finder.health_check(Path(sample_dir)) assert health["file_picker"]["initialized"] is True assert health["frecency"]["initialized"] is True assert health["query_tracker"]["initialized"] is True # With no explicit path, the check inspects the indexed base path # rather than the process cwd. default_health = finder.health_check() assert default_health["file_picker"]["base_path"] is not None assert "error" not in default_health.get("git", {}) or default_health["git"][ "error" ] != "could not determine current directory" finder.reindex(str(other)) assert finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.search("other") assert result.total_matched == 1 assert rel(result.items[0].relative_path) == "other.py" finder.reindex(Path(other)) assert finder.wait_for_scan_blocking(timeout_ms=5000) result2 = finder.search("other") assert result2.total_matched == 1