"""Unit: the phase-105 hidden-folders flag in ``iter_importable_files``. Phase 105 (TODO.md L3 — "…a toggle per input (next to the ignores button) to allow indexing hidden .folders."): ``include_hidden`` lifts ONLY the dot-prefixed-component skip for one source. A1 (owner-confirmed 2026-09-14): when True, files inside hidden folders AND hidden files with an importable extension become importable; ``EXCLUDED_DIRS`` (``.venv``, ``node_modules``, ``.git``, …) stay excluded in BOTH states; the extension filter always applies; the phase-89 *ignore* tuple composes additively. A4: the default (``False``) is byte-identical to the pre-phase-105 walk — pinned here against a tmp fixture tree. The DB-facing behavior (hidden files indexed / pruned through ``import_sources``) is integration-gated by ``tests/integration/test_importer_include_hidden.py``. """ from __future__ import annotations from pathlib import Path import pytest from app.rag.importer import _include_hidden_for_root, iter_importable_files #: Explicit extension set (not the A9 config default) — the tests pin #: the walk rules, not the config. EXTS = frozenset({".md"}) @pytest.fixture() def tree(tmp_path: Path) -> Path: """The task's fixture layout (plus the no-extension ``.env``). visible.md .hidden/note.md .notes.md .venv/junk.md node_modules/x.md .hidden/.deep.md keep/ok.md .env """ root = tmp_path / "HiddenFix" (root / ".hidden").mkdir(parents=True) (root / ".venv").mkdir() (root / "node_modules").mkdir() (root / "keep").mkdir() (root / "visible.md").write_text("visible\n", encoding="utf-8") (root / ".hidden" / "note.md").write_text("note\n", encoding="utf-8") (root / ".notes.md").write_text("notes\n", encoding="utf-8") (root / ".venv" / "junk.md").write_text("junk\n", encoding="utf-8") (root / "node_modules" / "x.md").write_text("x\n", encoding="utf-8") (root / ".hidden" / ".deep.md").write_text("deep\n", encoding="utf-8") (root / "keep" / "ok.md").write_text("ok\n", encoding="utf-8") (root / ".env").write_text("SECRET=1\n", encoding="utf-8") return root def _rels(paths: list[Path], root: Path) -> set[str]: return {p.relative_to(root).as_posix() for p in paths} # --- default (flag False): today's behavior, byte-identical ----------------- def test_iter_default_flag_skips_all_hidden(tree: Path) -> None: # A4: the default is the pre-phase-105 walk — hidden dir, hidden # file, and the excluded dirs are all absent. Sorted order pinned # verbatim (not just the set). got = iter_importable_files(tree, EXTS) assert got == [tree / "keep" / "ok.md", tree / "visible.md"] def test_iter_default_flag_rejects_explicit_false(tree: Path) -> None: assert iter_importable_files(tree, EXTS, include_hidden=False) == [ tree / "keep" / "ok.md", tree / "visible.md", ] # --- flag True (A1) ----------------------------------------------------------- def test_iter_flag_on_admits_hidden_dirs_and_hidden_files(tree: Path) -> None: got = _rels(iter_importable_files(tree, EXTS, include_hidden=True), tree) assert got == { ".hidden/note.md", # inside a hidden dir ".notes.md", # hidden file with an importable extension ".hidden/.deep.md", # both at once "visible.md", "keep/ok.md", } def test_iter_flag_on_keeps_excluded_dirs_out(tree: Path) -> None: # A1: EXCLUDED_DIRS are skipped in BOTH states — caches are never # content, even with the flag on. got = _rels(iter_importable_files(tree, EXTS, include_hidden=True), tree) assert ".venv/junk.md" not in got assert "node_modules/x.md" not in got def test_iter_flag_on_keeps_extension_filter_in_force(tree: Path) -> None: # ``.env`` has no A9 extension — the extension filter is the real # content gate, so a secret-flavoured file is never listed. for flag in (False, True): got = _rels(iter_importable_files(tree, EXTS, include_hidden=flag), tree) assert ".env" not in got # --- composition with the phase-89 ignore tuple ------------------------------- def test_iter_flag_on_ignore_composes_additively(tree: Path) -> None: # The ignored prefix still bites when the flag is ON; everything # else hidden is admitted. got = _rels( iter_importable_files(tree, EXTS, include_hidden=True, ignore=(".hidden",)), tree, ) assert got == {".notes.md", "visible.md", "keep/ok.md"} def test_iter_ignore_only_flag_off(tree: Path) -> None: # The pre-phase-105 combination still works untouched: ignore # prefix on a visible subtree, flag at its default. got = _rels(iter_importable_files(tree, EXTS, ignore=("keep",)), tree) assert got == {"visible.md"} # --- _include_hidden_for_root: the single read point -------------------------- def test_include_hidden_for_root_none_map_is_false(tmp_path: Path) -> None: root = tmp_path / "src" root.mkdir() assert _include_hidden_for_root(root, None) is False def test_include_hidden_for_root_unlisted_root_is_false(tmp_path: Path) -> None: root = tmp_path / "src" root.mkdir() assert _include_hidden_for_root(root, {"/other/root": True}) is False def test_include_hidden_for_root_listed_true_is_true(tmp_path: Path) -> None: root = tmp_path / "src" root.mkdir() assert _include_hidden_for_root(root, {str(root): True}) is True def test_include_hidden_for_root_listed_false_is_false(tmp_path: Path) -> None: root = tmp_path / "src" root.mkdir() assert _include_hidden_for_root(root, {str(root): False}) is False def test_include_hidden_for_root_keys_by_str_root(tmp_path: Path) -> None: # Two distinct Path objects whose ``str()`` agrees — the answer must # be the same (the ``_ignore_for_root`` keying convention, phase 89). key = str(tmp_path / "src") a = tmp_path / "src" b = tmp_path / "src" / "." # different object, same str() assert str(a) == str(b) == key mp = {key: True} assert _include_hidden_for_root(a, mp) is True assert _include_hidden_for_root(b, mp) is True