""" Cache directory service for MarkiTect - Convention over Configuration. This module provides standardized cache directory resolution following best practices: - Project-local cache in .ast_cache/ (like .git/, node_modules/) - Respects XDG Base Directory Specification for system caches - Provides fallback behavior for different environments """ import os from pathlib import Path from typing import Optional class CacheDirectoryService: """ Service for resolving cache directory locations following conventions. Convention over Configuration approach: 1. Project-local: .ast_cache/ in current working directory (default) 2. User cache: ~/.cache/markitect/ (XDG Base Directory compliant) 3. System temp: /tmp/markitect-cache/ (fallback) """ def get_cache_directory(self, prefer_local: bool = True) -> Path: """ Get cache directory following convention over configuration. Args: prefer_local: Whether to prefer project-local cache over user cache Returns: Path to cache directory (created if needed) """ if prefer_local: # Project-local cache (like .git/, node_modules/) cache_dir = Path.cwd() / ".ast_cache" else: # User cache following XDG Base Directory Specification cache_dir = self._get_user_cache_directory() # Ensure directory exists cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir def get_project_cache_directory(self) -> Path: """Get project-local cache directory (.ast_cache in current directory).""" cache_dir = Path.cwd() / ".ast_cache" cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir def get_user_cache_directory(self) -> Path: """Get user cache directory following XDG Base Directory Specification.""" cache_dir = self._get_user_cache_directory() cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir def _get_user_cache_directory(self) -> Path: """Get user cache directory path (XDG compliant).""" # Follow XDG Base Directory Specification xdg_cache_home = os.environ.get('XDG_CACHE_HOME') if xdg_cache_home: return Path(xdg_cache_home) / "markitect" else: # Fallback: ~/.cache/markitect (Linux/macOS) or equivalent return Path.home() / ".cache" / "markitect" def find_cache_files(self, cache_dir: Optional[Path] = None) -> list[Path]: """ Find all AST cache files in specified or default cache directory. Args: cache_dir: Cache directory to search (defaults to project cache) Returns: List of cache file paths """ if cache_dir is None: cache_dir = self.get_project_cache_directory() if not cache_dir.exists(): return [] return list(cache_dir.glob("*.ast.json")) def get_cache_stats(self, cache_dir: Optional[Path] = None) -> dict: """ Get cache statistics for specified or default cache directory. Args: cache_dir: Cache directory to analyze (defaults to project cache) Returns: Dictionary with cache statistics """ if cache_dir is None: cache_dir = self.get_project_cache_directory() cache_files = self.find_cache_files(cache_dir) if not cache_files: return { 'directory': str(cache_dir.absolute()), 'exists': cache_dir.exists(), 'total_files': 0, 'total_size': 0, 'size_formatted': '0 bytes' } total_size = sum(f.stat().st_size for f in cache_files) # Format size in appropriate units if total_size < 1024: size_formatted = f"{total_size} bytes" elif total_size < 1024 * 1024: size_formatted = f"{total_size / 1024:.1f} KB" else: size_formatted = f"{total_size / (1024 * 1024):.1f} MB" return { 'directory': str(cache_dir.absolute()), 'exists': cache_dir.exists(), 'total_files': len(cache_files), 'total_size': total_size, 'size_formatted': size_formatted, 'files': [str(f) for f in cache_files] } def clean_cache(self, cache_dir: Optional[Path] = None) -> dict: """ Clean all cache files from specified or default cache directory. Args: cache_dir: Cache directory to clean (defaults to project cache) Returns: Dictionary with cleaning results """ if cache_dir is None: cache_dir = self.get_project_cache_directory() if not cache_dir.exists(): return { 'success': True, 'message': 'Cache directory does not exist - nothing to clean', 'files_removed': 0 } cache_files = self.find_cache_files(cache_dir) if not cache_files: return { 'success': True, 'message': 'Cache is already empty - nothing to clean', 'files_removed': 0 } removed_count = 0 errors = [] for cache_file in cache_files: try: cache_file.unlink() removed_count += 1 except (OSError, PermissionError) as e: errors.append(f"Could not remove {cache_file}: {e}") except Exception as e: # Log unexpected errors but continue cleanup from infrastructure.logging import get_logger logger = get_logger(__name__) logger.warning( f"Unexpected error removing cache file {cache_file}: {e}" ) errors.append(f"Unexpected error removing {cache_file}: {e}") if errors: return { 'success': False, 'message': f"Removed {removed_count} files with {len(errors)} errors", 'files_removed': removed_count, 'errors': errors } else: return { 'success': True, 'message': f"Cache cleaned successfully - removed {removed_count} file(s)", 'files_removed': removed_count } def invalidate_file_cache(self, file_path: str, cache_dir: Optional[Path] = None) -> dict: """ Invalidate cache for specific file. Args: file_path: Path to file whose cache should be invalidated cache_dir: Cache directory to search (defaults to project cache) Returns: Dictionary with invalidation results """ if cache_dir is None: cache_dir = self.get_project_cache_directory() source_path = Path(file_path) cache_filename = f"{source_path.name}.ast.json" cache_file = cache_dir / cache_filename if not cache_file.exists(): return { 'success': True, 'message': f'No cache found for {source_path.name} - nothing to invalidate', 'file_removed': False } try: cache_file.unlink() return { 'success': True, 'message': f'Cache invalidated for {source_path.name}', 'file_removed': True, 'cache_file': str(cache_file) } except (OSError, PermissionError) as e: return { 'success': False, 'message': f'File system error removing cache for {source_path.name}: {e}', 'file_removed': False, 'error': str(e) } except Exception as e: from infrastructure.logging import get_logger logger = get_logger(__name__) logger.error( f"Unexpected error removing cache for {source_path.name}: {e}", exc_info=True ) return { 'success': False, 'message': f'Unexpected error removing cache for {source_path.name}: {e}', 'file_removed': False, 'error': str(e) }