From 84d5ea085817266e3ff584b91b594cecf41dfc97 Mon Sep 17 00:00:00 2001 From: Barry2llen <62414767+Barry2llen@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:56:35 +0000 Subject: [PATCH 1/5] fix(cli): bound index discovery scans --- packages/leann-core/src/leann/cli.py | 46 +++++++--- packages/leann-core/src/leann/registry.py | 35 ++++++- tests/test_cli_list_performance.py | 107 ++++++++++++++++++++++ 3 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 tests/test_cli_list_performance.py diff --git a/packages/leann-core/src/leann/cli.py b/packages/leann-core/src/leann/cli.py index 405cf2cd..eee1ca4f 100644 --- a/packages/leann-core/src/leann/cli.py +++ b/packages/leann-core/src/leann/cli.py @@ -19,7 +19,7 @@ from .api import Fts5BM25Index, LeannBuilder, LeannChat, LeannSearcher from .embedding_server_manager import EmbeddingServerManager from .interactive_utils import create_cli_session -from .registry import register_project_directory +from .registry import DEFAULT_INDEX_SCAN_DEPTH, iter_index_meta_files, register_project_directory from .settings import ( resolve_anthropic_base_url, resolve_minimax_api_key, @@ -31,6 +31,13 @@ from .sync import DEFAULT_INDEX_EXTENSIONS, FileSynchronizer, parse_include_extensions +def _non_negative_int(value: str) -> int: + parsed_value = int(value) + if parsed_value < 0: + raise argparse.ArgumentTypeError("must be non-negative") + return parsed_value + + def _default_embedding_model() -> str: """Pick a sensible default embedding model based on platform. @@ -832,7 +839,16 @@ def _add_index_args(p, default_name): ) # List command - subparsers.add_parser("list", help="List all indexes") + list_parser = subparsers.add_parser("list", help="List all indexes") + list_parser.add_argument( + "--max-depth", + type=_non_negative_int, + default=DEFAULT_INDEX_SCAN_DEPTH, + help=( + "Maximum directory depth for app index discovery " + f"(default: {DEFAULT_INDEX_SCAN_DEPTH})" + ), + ) # Remove command remove_parser = subparsers.add_parser("remove", help="Remove an index") @@ -923,7 +939,7 @@ def _is_git_submodule(self, path: Path) -> bool: # If anything goes wrong, assume it's not a submodule return False - def list_indexes(self): + def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): # Get all project directories with .leann global_registry = Path.home() / ".leann" / "projects.json" all_projects = [] @@ -968,7 +984,7 @@ def list_indexes(self): print(" " + "─" * 45) current_indexes = self._discover_indexes_in_project( - current_path, exclude_dirs=other_projects + current_path, exclude_dirs=other_projects, max_depth=max_depth ) if current_indexes: for idx in current_indexes: @@ -987,7 +1003,9 @@ def list_indexes(self): print(" " + "─" * 45) for project_path in other_projects: - project_indexes = self._discover_indexes_in_project(project_path) + project_indexes = self._discover_indexes_in_project( + project_path, max_depth=max_depth + ) if not project_indexes: continue @@ -1011,9 +1029,11 @@ def list_indexes(self): projects_count = 0 for p in valid_projects: if p == current_path: - discovered = self._discover_indexes_in_project(p, exclude_dirs=other_projects) + discovered = self._discover_indexes_in_project( + p, exclude_dirs=other_projects, max_depth=max_depth + ) else: - discovered = self._discover_indexes_in_project(p) + discovered = self._discover_indexes_in_project(p, max_depth=max_depth) if len(discovered) > 0: projects_count += 1 print(f"📊 Total: {total_indexes} indexes across {projects_count} projects") @@ -1033,13 +1053,17 @@ def list_indexes(self): print(" leann build my-docs --docs ./documents") def _discover_indexes_in_project( - self, project_path: Path, exclude_dirs: Optional[list[Path]] = None + self, + project_path: Path, + exclude_dirs: Optional[list[Path]] = None, + max_depth: int = DEFAULT_INDEX_SCAN_DEPTH, ): """Discover all indexes in a project directory (both CLI and apps formats) exclude_dirs: when provided, skip any APP-format index files that are located under these directories. This prevents duplicates when the current project is a parent directory of other registered projects. + max_depth: maximum number of directories to descend for APP-format indexes. """ indexes = [] exclude_dirs = exclude_dirs or [] @@ -1076,9 +1100,9 @@ def _discover_indexes_in_project( } ) - # 2. Apps format: *.leann.meta.json files anywhere in the project + # 2. Apps format: *.leann.meta.json files within the configured scan depth cli_indexes_dir = project_path / ".leann" / "indexes" - for meta_file in project_path.rglob("*.leann.meta.json"): + for meta_file in iter_index_meta_files(project_path, max_depth=max_depth): if meta_file.is_file(): # Skip CLI-built indexes (which store meta under .leann/indexes//) try: @@ -3692,7 +3716,7 @@ async def run(self, args=None): suppress = not getattr(args, "verbose", False) if args.command == "list": - self.list_indexes() + self.list_indexes(args.max_depth) elif args.command == "remove": self.remove_index(args.index_name, args.force) elif args.command == "build": diff --git a/packages/leann-core/src/leann/registry.py b/packages/leann-core/src/leann/registry.py index 2b74a203..624adba9 100644 --- a/packages/leann-core/src/leann/registry.py +++ b/packages/leann-core/src/leann/registry.py @@ -4,6 +4,8 @@ import importlib.metadata import json import logging +import os +from collections.abc import Iterator from pathlib import Path from typing import TYPE_CHECKING, Optional, Union @@ -14,6 +16,35 @@ logger = logging.getLogger(__name__) BACKEND_REGISTRY: dict[str, "LeannBackendFactoryInterface"] = {} +DEFAULT_INDEX_SCAN_DEPTH = 5 +INDEX_SCAN_SKIP_DIRS = frozenset( + {".git", ".venv", "venv", "node_modules", "__pycache__", "Library"} +) + + +def iter_index_meta_files( + root: Union[str, Path], max_depth: int = DEFAULT_INDEX_SCAN_DEPTH +) -> Iterator[Path]: + """Yield LEANN metadata files within a bounded directory tree. + + The root directory is depth zero. Known dependency, cache, and system + directories are pruned before traversal. + """ + if max_depth < 0: + raise ValueError("max_depth must be non-negative") + + root_path = Path(root) + for current_dir, dirnames, filenames in os.walk(root_path): + current_path = Path(current_dir) + depth = len(current_path.relative_to(root_path).parts) + if depth >= max_depth: + dirnames.clear() + else: + dirnames[:] = [name for name in dirnames if name not in INDEX_SCAN_SKIP_DIRS] + + for filename in filenames: + if filename.endswith(".leann.meta.json"): + yield current_path / filename def register_backend(name: str): @@ -64,9 +95,9 @@ def register_project_directory(project_dir: Optional[Union[str, Path]] = None): project_dir = Path(project_dir) # Only register directories that have some kind of LEANN content. - # Check CLI-format first to avoid an expensive rglob on large directories. + # Check CLI-format first to avoid even a bounded scan when it is unnecessary. has_cli_indexes = (project_dir / ".leann" / "indexes").exists() - if not has_cli_indexes and not any(project_dir.rglob("*.leann.meta.json")): + if not has_cli_indexes and not any(iter_index_meta_files(project_dir)): # Don't register if there are no LEANN indexes return diff --git a/tests/test_cli_list_performance.py b/tests/test_cli_list_performance.py new file mode 100644 index 00000000..8ce31611 --- /dev/null +++ b/tests/test_cli_list_performance.py @@ -0,0 +1,107 @@ +import asyncio +import sys + +import pytest +from leann import registry +from leann.cli import LeannCLI +from leann.registry import iter_index_meta_files + + +def test_index_discovery_finds_metadata_within_default_depth(tmp_path): + meta_file = tmp_path / "project" / "data" / "sample.leann.meta.json" + meta_file.parent.mkdir(parents=True) + meta_file.touch() + + assert list(iter_index_meta_files(tmp_path)) == [meta_file] + + +def test_index_discovery_does_not_descend_past_max_depth(tmp_path): + shallow_meta = tmp_path / "one" / "shallow.leann.meta.json" + shallow_meta.parent.mkdir() + shallow_meta.touch() + deep_meta = tmp_path / "one" / "two" / "deep.leann.meta.json" + deep_meta.parent.mkdir() + deep_meta.touch() + + assert list(iter_index_meta_files(tmp_path, max_depth=1)) == [shallow_meta] + + +@pytest.mark.parametrize("excluded_dir", [".git", ".venv", "node_modules", "Library"]) +def test_index_discovery_skips_large_irrelevant_directories(tmp_path, excluded_dir): + hidden_meta = tmp_path / excluded_dir / "hidden.leann.meta.json" + hidden_meta.parent.mkdir() + hidden_meta.touch() + + assert list(iter_index_meta_files(tmp_path)) == [] + + +def test_index_discovery_rejects_negative_max_depth(tmp_path): + with pytest.raises(ValueError, match="max_depth must be non-negative"): + list(iter_index_meta_files(tmp_path, max_depth=-1)) + + +def test_list_parser_accepts_custom_max_depth(): + args = LeannCLI().create_parser().parse_args(["list", "--max-depth", "5"]) + + assert args.max_depth == 5 + + +def test_list_parser_rejects_negative_max_depth(capsys): + with pytest.raises(SystemExit): + LeannCLI().create_parser().parse_args(["list", "--max-depth", "-1"]) + + assert "must be non-negative" in capsys.readouterr().err + + +def test_cli_project_discovery_respects_max_depth(tmp_path): + shallow_meta = tmp_path / "shallow" / "index.leann.meta.json" + shallow_meta.parent.mkdir() + shallow_meta.touch() + deep_meta = tmp_path / "one" / "two" / "index.leann.meta.json" + deep_meta.parent.mkdir(parents=True) + deep_meta.touch() + + indexes = LeannCLI()._discover_indexes_in_project(tmp_path, max_depth=1) + + assert [index["name"] for index in indexes] == ["shallow"] + + +def test_list_command_passes_max_depth_to_discovery(monkeypatch): + cli = LeannCLI() + received = {} + + def capture_max_depth(max_depth): + received["max_depth"] = max_depth + + monkeypatch.setattr(cli, "list_indexes", capture_max_depth) + monkeypatch.setattr(sys, "argv", ["leann", "list", "--max-depth", "7"]) + + asyncio.run(cli.run()) + + assert received == {"max_depth": 7} + + +def test_project_registration_does_not_scan_beyond_default_depth(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + project = tmp_path / "project" + deep_meta = ( + project / "one" / "two" / "three" / "four" / "five" / "six" / "index.leann.meta.json" + ) + deep_meta.parent.mkdir(parents=True) + deep_meta.touch() + monkeypatch.setattr(registry.Path, "home", classmethod(lambda cls: home)) + + registry.register_project_directory(project) + + assert not (home / ".leann" / "projects.json").exists() + + +def test_cli_format_index_discovery_is_not_limited_by_app_scan_depth(tmp_path): + index_dir = tmp_path / ".leann" / "indexes" / "sample" + index_dir.mkdir(parents=True) + (index_dir / "documents.leann.meta.json").touch() + + indexes = LeannCLI()._discover_indexes_in_project(tmp_path, max_depth=0) + + assert [(index["name"], index["type"]) for index in indexes] == [("sample", "cli")] From d5a6618dcf0cc11a2e0eedc513236df3bf359b61 Mon Sep 17 00:00:00 2001 From: Barry2llen <62414767+Barry2llen@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:13:10 +0000 Subject: [PATCH 2/5] fix(cli): preserve registered app indexes --- docs/CHANGELOG.md | 6 +++ packages/leann-core/src/leann/cli.py | 21 ++++++-- packages/leann-core/src/leann/registry.py | 8 ++- tests/test_cli_list_performance.py | 62 +++++++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2ce43876..471927a6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -40,3 +40,9 @@ fixes). Newest entries at the bottom. at nprobe=32, ~6.5x lower single-query latency / ~75x higher batched throughput at comparable recall (GPU latency stays ~flat while CPU grows linearly with nprobe). - Docs: `docs/flashlib_backend_guide.md` gains a `flashlib_ivf` section. + +## 2026-07-27: Bounded `leann list` index discovery + +- Bound App-format metadata discovery to a configurable directory depth; `leann list` defaults to depth 5 and accepts `--max-depth` for deeper projects. +- Prune common dependency, virtual-environment, cache, and system directories during discovery. +- Preserve discovery of registered CLI-format and App-format projects without recursively scanning an entire home directory. diff --git a/packages/leann-core/src/leann/cli.py b/packages/leann-core/src/leann/cli.py index eee1ca4f..e59c964c 100644 --- a/packages/leann-core/src/leann/cli.py +++ b/packages/leann-core/src/leann/cli.py @@ -939,6 +939,14 @@ def _is_git_submodule(self, path: Path) -> bool: # If anything goes wrong, assume it's not a submodule return False + @staticmethod + def _project_has_discoverable_indexes(project_path: Path, max_depth: int) -> bool: + """Return whether a project contains an index within the scan boundary.""" + cli_indexes_dir = project_path / ".leann" / "indexes" + return cli_indexes_dir.exists() or any( + iter_index_meta_files(project_path, max_depth=max_depth) + ) + def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): # Get all project directories with .leann global_registry = Path.home() / ".leann" / "projects.json" @@ -953,16 +961,21 @@ def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): except Exception: pass - # Filter to only existing directories with .leann + # Filter to existing projects with a discoverable CLI- or App-format index. valid_projects = [] for project_dir in all_projects: project_path = Path(project_dir) - if project_path.exists() and (project_path / ".leann" / "indexes").exists(): + if project_path.exists() and self._project_has_discoverable_indexes( + project_path, max_depth + ): valid_projects.append(project_path) - # Add current project if it has .leann but not in registry + # Add current project if it has a discoverable index but is not in registry. current_path = Path.cwd() - if (current_path / ".leann" / "indexes").exists() and current_path not in valid_projects: + if ( + self._project_has_discoverable_indexes(current_path, max_depth) + and current_path not in valid_projects + ): valid_projects.append(current_path) # Separate current and other projects diff --git a/packages/leann-core/src/leann/registry.py b/packages/leann-core/src/leann/registry.py index 624adba9..89efd72a 100644 --- a/packages/leann-core/src/leann/registry.py +++ b/packages/leann-core/src/leann/registry.py @@ -80,7 +80,10 @@ def autodiscover_backends(): # print("INFO: Backend auto-discovery finished.") -def register_project_directory(project_dir: Optional[Union[str, Path]] = None): +def register_project_directory( + project_dir: Optional[Union[str, Path]] = None, + max_depth: int = DEFAULT_INDEX_SCAN_DEPTH, +): """ Register a project directory in the global LEANN registry. @@ -88,6 +91,7 @@ def register_project_directory(project_dir: Optional[Union[str, Path]] = None): Args: project_dir: Directory to register. If None, uses current working directory. + max_depth: Maximum directory depth used when looking for App-format indexes. """ if project_dir is None: project_dir = Path.cwd() @@ -97,7 +101,7 @@ def register_project_directory(project_dir: Optional[Union[str, Path]] = None): # Only register directories that have some kind of LEANN content. # Check CLI-format first to avoid even a bounded scan when it is unnecessary. has_cli_indexes = (project_dir / ".leann" / "indexes").exists() - if not has_cli_indexes and not any(iter_index_meta_files(project_dir)): + if not has_cli_indexes and not any(iter_index_meta_files(project_dir, max_depth=max_depth)): # Don't register if there are no LEANN indexes return diff --git a/tests/test_cli_list_performance.py b/tests/test_cli_list_performance.py index 8ce31611..2dc6eafa 100644 --- a/tests/test_cli_list_performance.py +++ b/tests/test_cli_list_performance.py @@ -1,4 +1,5 @@ import asyncio +import json import sys import pytest @@ -97,6 +98,67 @@ def test_project_registration_does_not_scan_beyond_default_depth(tmp_path, monke assert not (home / ".leann" / "projects.json").exists() +def test_project_registration_accepts_custom_max_depth(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + project = tmp_path / "project" + deep_meta = ( + project / "one" / "two" / "three" / "four" / "five" / "six" / "index.leann.meta.json" + ) + deep_meta.parent.mkdir(parents=True) + deep_meta.touch() + monkeypatch.setattr(registry.Path, "home", classmethod(lambda cls: home)) + + registry.register_project_directory(project, max_depth=6) + + registry_file = home / ".leann" / "projects.json" + assert json.loads(registry_file.read_text()) == [str(project.resolve())] + + +def test_registered_app_project_is_listed(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + project = tmp_path / "registered-project" + meta_file = project / "app-index" / "documents.leann.meta.json" + meta_file.parent.mkdir(parents=True) + meta_file.touch() + current = tmp_path / "current-project" + current.mkdir() + monkeypatch.setattr(registry.Path, "home", classmethod(lambda cls: home)) + registry.register_project_directory(project) + monkeypatch.chdir(current) + + LeannCLI().list_indexes() + + output = capsys.readouterr().out + assert "registered-project" in output + assert "app-index" in output + + +def test_registered_app_project_respects_requested_max_depth(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + project = tmp_path / "registered-project" + meta_file = ( + project / "one" / "two" / "three" / "four" / "five" / "six" / "deep-app.leann.meta.json" + ) + meta_file.parent.mkdir(parents=True) + meta_file.touch() + registry_file = home / ".leann" / "projects.json" + registry_file.parent.mkdir() + registry_file.write_text(json.dumps([str(project.resolve())])) + current = tmp_path / "current-project" + current.mkdir() + monkeypatch.setattr(registry.Path, "home", classmethod(lambda cls: home)) + monkeypatch.chdir(current) + + LeannCLI().list_indexes(max_depth=6) + + output = capsys.readouterr().out + assert "registered-project" in output + assert "six" in output + + def test_cli_format_index_discovery_is_not_limited_by_app_scan_depth(tmp_path): index_dir = tmp_path / ".leann" / "indexes" / "sample" index_dir.mkdir(parents=True) From 8107f4f8841e56780a093d666503640574086c8e Mon Sep 17 00:00:00 2001 From: Barry2llen <62414767+Barry2llen@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:38:01 +0000 Subject: [PATCH 3/5] fix(cli): keep nested project indexes visible --- packages/leann-core/src/leann/cli.py | 12 ++++++++++-- tests/test_cli_list_performance.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/leann-core/src/leann/cli.py b/packages/leann-core/src/leann/cli.py index e59c964c..0dc7eed3 100644 --- a/packages/leann-core/src/leann/cli.py +++ b/packages/leann-core/src/leann/cli.py @@ -985,6 +985,14 @@ def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): if project_path != current_path: other_projects.append(project_path) + # Exclude only registered projects nested below the current project. + # Registered ancestors must not hide indexes owned by the current directory. + current_exclude_dirs = [ + project_path + for project_path in other_projects + if project_path.is_relative_to(current_path) + ] + print("📚 LEANN Indexes") print("=" * 50) @@ -997,7 +1005,7 @@ def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): print(" " + "─" * 45) current_indexes = self._discover_indexes_in_project( - current_path, exclude_dirs=other_projects, max_depth=max_depth + current_path, exclude_dirs=current_exclude_dirs, max_depth=max_depth ) if current_indexes: for idx in current_indexes: @@ -1043,7 +1051,7 @@ def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): for p in valid_projects: if p == current_path: discovered = self._discover_indexes_in_project( - p, exclude_dirs=other_projects, max_depth=max_depth + p, exclude_dirs=current_exclude_dirs, max_depth=max_depth ) else: discovered = self._discover_indexes_in_project(p, max_depth=max_depth) diff --git a/tests/test_cli_list_performance.py b/tests/test_cli_list_performance.py index 2dc6eafa..a149b33a 100644 --- a/tests/test_cli_list_performance.py +++ b/tests/test_cli_list_performance.py @@ -159,6 +159,26 @@ def test_registered_app_project_respects_requested_max_depth(tmp_path, monkeypat assert "six" in output +def test_registered_ancestor_does_not_hide_current_app_index(tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + registered_ancestor = tmp_path / "registered-ancestor" + current = registered_ancestor / "current-project" + meta_file = current / "current-app" / "documents.leann.meta.json" + meta_file.parent.mkdir(parents=True) + meta_file.touch() + registry_file = home / ".leann" / "projects.json" + registry_file.parent.mkdir() + registry_file.write_text(json.dumps([str(registered_ancestor.resolve())])) + monkeypatch.setattr(registry.Path, "home", classmethod(lambda cls: home)) + monkeypatch.chdir(current) + + LeannCLI().list_indexes() + + current_section = capsys.readouterr().out.split("Other Projects", maxsplit=1)[0] + assert "current-app" in current_section + + def test_cli_format_index_discovery_is_not_limited_by_app_scan_depth(tmp_path): index_dir = tmp_path / ".leann" / "indexes" / "sample" index_dir.mkdir(parents=True) From 2c0116b2931346fcbecb399a50e7c8bc59c8d5c2 Mon Sep 17 00:00:00 2001 From: Barry2llen <62414767+Barry2llen@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:45:49 +0000 Subject: [PATCH 4/5] fix(cli): deduplicate nested project indexes --- packages/leann-core/src/leann/cli.py | 23 ++++++++++++++++------- tests/test_cli_list_performance.py | 4 +++- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/leann-core/src/leann/cli.py b/packages/leann-core/src/leann/cli.py index 0dc7eed3..c71724b3 100644 --- a/packages/leann-core/src/leann/cli.py +++ b/packages/leann-core/src/leann/cli.py @@ -947,6 +947,15 @@ def _project_has_discoverable_indexes(project_path: Path, max_depth: int) -> boo iter_index_meta_files(project_path, max_depth=max_depth) ) + @staticmethod + def _nested_project_dirs(project_path: Path, projects: list[Path]) -> list[Path]: + """Return registered project roots nested below a project.""" + return [ + candidate + for candidate in projects + if candidate != project_path and candidate.is_relative_to(project_path) + ] + def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): # Get all project directories with .leann global_registry = Path.home() / ".leann" / "projects.json" @@ -987,11 +996,7 @@ def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): # Exclude only registered projects nested below the current project. # Registered ancestors must not hide indexes owned by the current directory. - current_exclude_dirs = [ - project_path - for project_path in other_projects - if project_path.is_relative_to(current_path) - ] + current_exclude_dirs = self._nested_project_dirs(current_path, valid_projects) print("📚 LEANN Indexes") print("=" * 50) @@ -1024,8 +1029,9 @@ def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): print(" " + "─" * 45) for project_path in other_projects: + nested_projects = self._nested_project_dirs(project_path, valid_projects) project_indexes = self._discover_indexes_in_project( - project_path, max_depth=max_depth + project_path, exclude_dirs=nested_projects, max_depth=max_depth ) if not project_indexes: continue @@ -1054,7 +1060,10 @@ def list_indexes(self, max_depth: int = DEFAULT_INDEX_SCAN_DEPTH): p, exclude_dirs=current_exclude_dirs, max_depth=max_depth ) else: - discovered = self._discover_indexes_in_project(p, max_depth=max_depth) + nested_projects = self._nested_project_dirs(p, valid_projects) + discovered = self._discover_indexes_in_project( + p, exclude_dirs=nested_projects, max_depth=max_depth + ) if len(discovered) > 0: projects_count += 1 print(f"📊 Total: {total_indexes} indexes across {projects_count} projects") diff --git a/tests/test_cli_list_performance.py b/tests/test_cli_list_performance.py index a149b33a..17e68784 100644 --- a/tests/test_cli_list_performance.py +++ b/tests/test_cli_list_performance.py @@ -175,8 +175,10 @@ def test_registered_ancestor_does_not_hide_current_app_index(tmp_path, monkeypat LeannCLI().list_indexes() - current_section = capsys.readouterr().out.split("Other Projects", maxsplit=1)[0] + output = capsys.readouterr().out + current_section = output.split("Other Projects", maxsplit=1)[0] assert "current-app" in current_section + assert output.count("current-app") == 1 def test_cli_format_index_discovery_is_not_limited_by_app_scan_depth(tmp_path): From f922bcbe78b52f535d6cfba0f2786d7f9ef54827 Mon Sep 17 00:00:00 2001 From: Barry2llen <62414767+Barry2llen@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:09:57 +0000 Subject: [PATCH 5/5] fix(cli): preserve deep app index discovery --- packages/leann-core/src/leann/cli.py | 3 ++- packages/leann-core/src/leann/registry.py | 15 ++++++----- tests/test_cli_list_performance.py | 33 +++++++++++++++++++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/leann-core/src/leann/cli.py b/packages/leann-core/src/leann/cli.py index c71724b3..baee379d 100644 --- a/packages/leann-core/src/leann/cli.py +++ b/packages/leann-core/src/leann/cli.py @@ -1086,7 +1086,7 @@ def _discover_indexes_in_project( self, project_path: Path, exclude_dirs: Optional[list[Path]] = None, - max_depth: int = DEFAULT_INDEX_SCAN_DEPTH, + max_depth: Optional[int] = None, ): """Discover all indexes in a project directory (both CLI and apps formats) @@ -1094,6 +1094,7 @@ def _discover_indexes_in_project( located under these directories. This prevents duplicates when the current project is a parent directory of other registered projects. max_depth: maximum number of directories to descend for APP-format indexes. + None preserves full-depth discovery for non-CLI callers. """ indexes = [] exclude_dirs = exclude_dirs or [] diff --git a/packages/leann-core/src/leann/registry.py b/packages/leann-core/src/leann/registry.py index 89efd72a..b950ee65 100644 --- a/packages/leann-core/src/leann/registry.py +++ b/packages/leann-core/src/leann/registry.py @@ -23,21 +23,21 @@ def iter_index_meta_files( - root: Union[str, Path], max_depth: int = DEFAULT_INDEX_SCAN_DEPTH + root: Union[str, Path], max_depth: Optional[int] = DEFAULT_INDEX_SCAN_DEPTH ) -> Iterator[Path]: - """Yield LEANN metadata files within a bounded directory tree. + """Yield LEANN metadata files within an optionally bounded directory tree. - The root directory is depth zero. Known dependency, cache, and system - directories are pruned before traversal. + The root directory is depth zero. A max_depth of None scans all depths. + Known dependency, cache, and system directories are always pruned before traversal. """ - if max_depth < 0: + if max_depth is not None and max_depth < 0: raise ValueError("max_depth must be non-negative") root_path = Path(root) for current_dir, dirnames, filenames in os.walk(root_path): current_path = Path(current_dir) depth = len(current_path.relative_to(root_path).parts) - if depth >= max_depth: + if max_depth is not None and depth >= max_depth: dirnames.clear() else: dirnames[:] = [name for name in dirnames if name not in INDEX_SCAN_SKIP_DIRS] @@ -82,7 +82,7 @@ def autodiscover_backends(): def register_project_directory( project_dir: Optional[Union[str, Path]] = None, - max_depth: int = DEFAULT_INDEX_SCAN_DEPTH, + max_depth: Optional[int] = None, ): """ Register a project directory in the global LEANN registry. @@ -92,6 +92,7 @@ def register_project_directory( Args: project_dir: Directory to register. If None, uses current working directory. max_depth: Maximum directory depth used when looking for App-format indexes. + None preserves full-depth discovery for existing API callers. """ if project_dir is None: project_dir = Path.cwd() diff --git a/tests/test_cli_list_performance.py b/tests/test_cli_list_performance.py index 17e68784..de93fabc 100644 --- a/tests/test_cli_list_performance.py +++ b/tests/test_cli_list_performance.py @@ -67,6 +67,18 @@ def test_cli_project_discovery_respects_max_depth(tmp_path): assert [index["name"] for index in indexes] == ["shallow"] +def test_cli_project_discovery_without_max_depth_finds_deep_app_index(tmp_path): + deep_meta = ( + tmp_path / "one" / "two" / "three" / "four" / "five" / "six" / "index.leann.meta.json" + ) + deep_meta.parent.mkdir(parents=True) + deep_meta.touch() + + indexes = LeannCLI()._discover_indexes_in_project(tmp_path) + + assert [index["name"] for index in indexes] == ["six"] + + def test_list_command_passes_max_depth_to_discovery(monkeypatch): cli = LeannCLI() received = {} @@ -82,7 +94,7 @@ def capture_max_depth(max_depth): assert received == {"max_depth": 7} -def test_project_registration_does_not_scan_beyond_default_depth(tmp_path, monkeypatch): +def test_project_registration_respects_explicit_max_depth(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() project = tmp_path / "project" @@ -93,11 +105,28 @@ def test_project_registration_does_not_scan_beyond_default_depth(tmp_path, monke deep_meta.touch() monkeypatch.setattr(registry.Path, "home", classmethod(lambda cls: home)) - registry.register_project_directory(project) + registry.register_project_directory(project, max_depth=registry.DEFAULT_INDEX_SCAN_DEPTH) assert not (home / ".leann" / "projects.json").exists() +def test_project_registration_without_max_depth_finds_deep_app_index(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + project = tmp_path / "project" + deep_meta = ( + project / "one" / "two" / "three" / "four" / "five" / "six" / "index.leann.meta.json" + ) + deep_meta.parent.mkdir(parents=True) + deep_meta.touch() + monkeypatch.setattr(registry.Path, "home", classmethod(lambda cls: home)) + + registry.register_project_directory(project) + + registry_file = home / ".leann" / "projects.json" + assert json.loads(registry_file.read_text()) == [str(project.resolve())] + + def test_project_registration_accepts_custom_max_depth(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir()