Files

301 lines
10 KiB
Python

"""Path mapping utilities for source paths and index paths.
This module provides bidirectional mapping between source code directories
and their corresponding index storage locations.
Storage Structure:
~/.codexlens/
├── registry.db # Global mapping table
└── indexes/
└── D/
└── Claude_dms3/
├── _index.db # Root directory index
└── src/
└── _index.db # src/ directory index
"""
import json
import os
import platform
from pathlib import Path
from typing import Optional
def _get_configured_index_root() -> Path:
"""Get the index root from environment or config file.
Priority order:
1. CODEXLENS_INDEX_DIR environment variable
2. index_dir from ~/.codexlens/config.json
3. Default: ~/.codexlens/indexes
"""
env_override = os.getenv("CODEXLENS_INDEX_DIR")
if env_override:
return Path(env_override).expanduser().resolve()
config_file = Path.home() / ".codexlens" / "config.json"
if config_file.exists():
try:
cfg = json.loads(config_file.read_text(encoding="utf-8"))
if "index_dir" in cfg:
return Path(cfg["index_dir"]).expanduser().resolve()
except (json.JSONDecodeError, OSError):
pass
return Path.home() / ".codexlens" / "indexes"
class PathMapper:
"""Bidirectional mapping tool for source paths ↔ index paths.
Handles cross-platform path normalization and conversion between
source code directories and their index storage locations.
Attributes:
DEFAULT_INDEX_ROOT: Default root directory for all indexes
INDEX_DB_NAME: Standard name for index database files
index_root: Configured index root directory
"""
DEFAULT_INDEX_ROOT = _get_configured_index_root()
INDEX_DB_NAME = "_index.db"
def __init__(self, index_root: Optional[Path] = None):
"""Initialize PathMapper with optional custom index root.
Args:
index_root: Custom index root directory. If None, uses DEFAULT_INDEX_ROOT.
"""
self.index_root = (index_root or self.DEFAULT_INDEX_ROOT).resolve()
def source_to_index_dir(self, source_path: Path) -> Path:
"""Convert source directory to its index directory path.
Maps a source code directory to where its index data should be stored.
The mapping preserves the directory structure but normalizes paths
for cross-platform compatibility.
Args:
source_path: Source directory path to map
Returns:
Index directory path under index_root
Examples:
>>> mapper = PathMapper()
>>> mapper.source_to_index_dir(Path("D:/Claude_dms3/src"))
PosixPath('/home/user/.codexlens/indexes/D/Claude_dms3/src')
>>> mapper.source_to_index_dir(Path("/home/user/project"))
PosixPath('/home/user/.codexlens/indexes/home/user/project')
"""
source_path = source_path.resolve()
normalized = self.normalize_path(source_path)
return self.index_root / normalized
def source_to_index_db(self, source_path: Path) -> Path:
"""Convert source directory to its index database file path.
Maps a source directory to the full path of its index database file,
including the standard INDEX_DB_NAME.
Args:
source_path: Source directory path to map
Returns:
Full path to the index database file
Examples:
>>> mapper = PathMapper()
>>> mapper.source_to_index_db(Path("D:/Claude_dms3/src"))
PosixPath('/home/user/.codexlens/indexes/D/Claude_dms3/src/_index.db')
"""
index_dir = self.source_to_index_dir(source_path)
return index_dir / self.INDEX_DB_NAME
def index_to_source(self, index_path: Path) -> Path:
"""Convert index path back to original source path.
Performs reverse mapping from an index storage location to the
original source directory. Handles both directory paths and
database file paths.
Args:
index_path: Index directory or database file path
Returns:
Original source directory path
Raises:
ValueError: If index_path is not under index_root
Examples:
>>> mapper = PathMapper()
>>> mapper.index_to_source(
... Path("~/.codexlens/indexes/D/Claude_dms3/src/_index.db")
... )
WindowsPath('D:/Claude_dms3/src')
>>> mapper.index_to_source(
... Path("~/.codexlens/indexes/D/Claude_dms3/src")
... )
WindowsPath('D:/Claude_dms3/src')
"""
index_path = index_path.resolve()
# Remove _index.db if present
if index_path.name == self.INDEX_DB_NAME:
index_path = index_path.parent
# Verify path is under index_root
try:
relative = index_path.relative_to(self.index_root)
except ValueError:
raise ValueError(
f"Index path {index_path} is not under index root {self.index_root}"
)
# Convert normalized path back to source path
normalized_str = str(relative).replace("\\", "/")
return self.denormalize_path(normalized_str)
def get_project_root(self, source_path: Path) -> Path:
"""Find the project root directory (topmost indexed directory).
Walks up the directory tree to find the highest-level directory
that has an index database.
Args:
source_path: Source directory to start from
Returns:
Project root directory path. Returns source_path itself if
no parent index is found.
Examples:
>>> mapper = PathMapper()
>>> mapper.get_project_root(Path("D:/Claude_dms3/src/codexlens"))
WindowsPath('D:/Claude_dms3')
"""
source_path = source_path.resolve()
current = source_path
project_root = source_path
# Walk up the tree
while current.parent != current: # Stop at filesystem root
parent_index_db = self.source_to_index_db(current.parent)
if parent_index_db.exists():
project_root = current.parent
current = current.parent
else:
break
return project_root
def get_relative_depth(self, source_path: Path, project_root: Path) -> int:
"""Calculate directory depth relative to project root.
Args:
source_path: Target directory path
project_root: Project root directory path
Returns:
Number of directory levels from project_root to source_path
Raises:
ValueError: If source_path is not under project_root
Examples:
>>> mapper = PathMapper()
>>> mapper.get_relative_depth(
... Path("D:/Claude_dms3/src/codexlens"),
... Path("D:/Claude_dms3")
... )
2
"""
source_path = source_path.resolve()
project_root = project_root.resolve()
try:
relative = source_path.relative_to(project_root)
# Count path components
return len(relative.parts)
except ValueError:
raise ValueError(
f"Source path {source_path} is not under project root {project_root}"
)
def normalize_path(self, path: Path) -> str:
"""Normalize path to cross-platform storage format.
Converts OS-specific paths to a standardized format for storage:
- Windows: Removes drive colons (D: → D)
- Unix: Removes leading slash
- Uses forward slashes throughout
Args:
path: Path to normalize
Returns:
Normalized path string
Examples:
>>> mapper = PathMapper()
>>> mapper.normalize_path(Path("D:/path/to/dir"))
'D/path/to/dir'
>>> mapper.normalize_path(Path("/home/user/path"))
'home/user/path'
"""
path = path.resolve()
path_str = str(path)
# Handle Windows paths with drive letters
if platform.system() == "Windows" and len(path.parts) > 0:
# Convert D:\path\to\dir → D/path/to/dir
drive = path.parts[0].replace(":", "") # D: → D
rest = Path(*path.parts[1:]) if len(path.parts) > 1 else Path()
normalized = f"{drive}/{rest}".replace("\\", "/")
return normalized.rstrip("/")
# Handle Unix paths
# /home/user/path → home/user/path
return path_str.lstrip("/").replace("\\", "/")
def denormalize_path(self, normalized: str) -> Path:
"""Convert normalized path back to OS-specific path.
Reverses the normalization process to restore OS-native path format:
- Windows: Adds drive colons (D → D:)
- Unix: Adds leading slash
Args:
normalized: Normalized path string
Returns:
OS-specific Path object
Examples:
>>> mapper = PathMapper()
>>> mapper.denormalize_path("D/path/to/dir") # On Windows
WindowsPath('D:/path/to/dir')
>>> mapper.denormalize_path("home/user/path") # On Unix
PosixPath('/home/user/path')
"""
parts = normalized.split("/")
# Handle Windows paths
if platform.system() == "Windows" and len(parts) > 0:
# Check if first part is a drive letter
if len(parts[0]) == 1 and parts[0].isalpha():
# D/path/to/dir → D:/path/to/dir
drive = f"{parts[0]}:"
if len(parts) > 1:
return Path(drive) / Path(*parts[1:])
return Path(drive)
# Handle Unix paths or relative paths
# home/user/path → /home/user/path
return Path("/") / Path(*parts)