diff --git a/.gitignore b/.gitignore index de8c042..15a5510 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ !/bin/phpcca.bat /.phive/ /.phpunit.cache/ +/.phpcca.cache/ /tmp/ /tools/ /benchmarks/storage/ diff --git a/composer.json b/composer.json index e2f863b..38f3866 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "require": { "php": "^8.1", "nikic/php-parser": "^5.1", + "psr/cache": "^3.0", "symfony/console": "^6.0||^7.0", "symfony/config": "^6.0||^7.0", "symfony/yaml": "^6.0||^7.0", diff --git a/composer.lock b/composer.lock index 54a57c3..5de4bb4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "60d45f0f109155e1ca2504ecf9b6c62c", + "content-hash": "822640c462f0d98b33c92e0f7b9231ad", "packages": [ { "name": "nikic/php-parser", @@ -1469,102 +1469,6 @@ ], "time": "2021-04-09T19:40:06+00:00" }, - { - "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.1.2", - "source": { - "type": "git", - "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", - "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^2.2", - "php": ">=5.4", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" - }, - "require-dev": { - "composer/composer": "^2.2", - "ext-json": "*", - "ext-zip": "*", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcompatibility/php-compatibility": "^9.0", - "yoast/phpunit-polyfills": "^1.0" - }, - "type": "composer-plugin", - "extra": { - "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" - }, - "autoload": { - "psr-4": { - "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Franck Nijhof", - "email": "opensource@frenck.dev", - "homepage": "https://frenck.dev", - "role": "Open source developer" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" - } - ], - "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "keywords": [ - "PHPCodeSniffer", - "PHP_CodeSniffer", - "code quality", - "codesniffer", - "composer", - "installer", - "phpcbf", - "phpcs", - "plugin", - "qa", - "quality", - "standard", - "standards", - "style guide", - "stylecheck", - "tests" - ], - "support": { - "issues": "https://github.com/PHPCSStandards/composer-installer/issues", - "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", - "source": "https://github.com/PHPCSStandards/composer-installer" - }, - "funding": [ - { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", - "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - }, - { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" - } - ], - "time": "2025-07-17T20:45:56+00:00" - }, { "name": "doctrine/annotations", "version": "2.0.2", @@ -2751,53 +2655,6 @@ ], "time": "2023-12-11T08:22:20+00:00" }, - { - "name": "phpstan/phpdoc-parser", - "version": "1.33.0", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0" - }, - "time": "2024-10-13T11:25:22+00:00" - }, { "name": "phpstan/phpstan", "version": "2.1.0", @@ -5395,71 +5252,6 @@ ], "time": "2024-07-11T14:55:45+00:00" }, - { - "name": "slevomat/coding-standard", - "version": "8.15.0", - "source": { - "type": "git", - "url": "https://github.com/slevomat/coding-standard.git", - "reference": "7d1d957421618a3803b593ec31ace470177d7817" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/7d1d957421618a3803b593ec31ace470177d7817", - "reference": "7d1d957421618a3803b593ec31ace470177d7817", - "shasum": "" - }, - "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", - "php": "^7.2 || ^8.0", - "phpstan/phpdoc-parser": "^1.23.1", - "squizlabs/php_codesniffer": "^3.9.0" - }, - "require-dev": { - "phing/phing": "2.17.4", - "php-parallel-lint/php-parallel-lint": "1.3.2", - "phpstan/phpstan": "1.10.60", - "phpstan/phpstan-deprecation-rules": "1.1.4", - "phpstan/phpstan-phpunit": "1.3.16", - "phpstan/phpstan-strict-rules": "1.5.2", - "phpunit/phpunit": "8.5.21|9.6.8|10.5.11" - }, - "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-master": "8.x-dev" - } - }, - "autoload": { - "psr-4": { - "SlevomatCodingStandard\\": "SlevomatCodingStandard/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", - "keywords": [ - "dev", - "phpcs" - ], - "support": { - "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.15.0" - }, - "funding": [ - { - "url": "https://github.com/kukulich", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", - "type": "tidelift" - } - ], - "time": "2024-03-09T15:20:58+00:00" - }, { "name": "squizlabs/php_codesniffer", "version": "3.10.0", @@ -6134,8 +5926,6 @@ "platform": { "php": "^8.1" }, - "platform-dev": { - "ext-dom": "*" - }, + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config.yml b/config.yml index 09c2097..ac537b5 100644 --- a/config.yml +++ b/config.yml @@ -40,6 +40,9 @@ cognitive: threshold: 1 scale: 1.0 enabled: true + cache: + enabled: false + directory: './.phpcca.cache' # Example of custom reporters: # customReporters: # cognitive: diff --git a/src/Application.php b/src/Application.php index 63a5e97..0809b89 100644 --- a/src/Application.php +++ b/src/Application.php @@ -21,6 +21,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\ScoreCalculator; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; use Phauthentic\CognitiveCodeAnalysis\Business\Utility\DirectoryScanner; +use Phauthentic\CognitiveCodeAnalysis\Cache\FileCache; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnCommand; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnValidationSpecificationFactory; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsCommand; @@ -42,6 +43,7 @@ use PhpParser\NodeTraverser; use PhpParser\NodeTraverserInterface; use PhpParser\ParserFactory; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Input\ArgvInput; @@ -99,6 +101,12 @@ private function registerCoreServices(): void $this->containerBuilder->register(ConfigService::class, ConfigService::class) ->setPublic(true); + $this->containerBuilder->register(CacheItemPoolInterface::class, FileCache::class) + ->setArguments([ + './.phpcca.cache' // Default cache directory, can be overridden by config + ]) + ->setPublic(true); + $this->containerBuilder->register(Baseline::class, Baseline::class) ->setPublic(true); @@ -242,7 +250,8 @@ private function bootstrapMetricsCollectors(): void new Reference(Parser::class), new Reference(DirectoryScanner::class), new Reference(ConfigService::class), - new Reference(MessageBusInterface::class) + new Reference(MessageBusInterface::class), + new Reference(CacheItemPoolInterface::class) ]) ->setPublic(true); } diff --git a/src/Business/Cognitive/CognitiveMetricsCollection.php b/src/Business/Cognitive/CognitiveMetricsCollection.php index d537c6a..1c24d9a 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollection.php +++ b/src/Business/Cognitive/CognitiveMetricsCollection.php @@ -130,6 +130,8 @@ public function groupBy(string $property): array $grouped[$key]->add($metric); } + ksort($grouped); + return $grouped; } diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index 4cc9a7d..aed0258 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -8,9 +8,12 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\ParserFailed; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\SourceFilesFound; use Phauthentic\CognitiveCodeAnalysis\Business\Utility\DirectoryScanner; +use Phauthentic\CognitiveCodeAnalysis\Business\Utility\FilenameNormalizer; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; use SplFileInfo; use Symfony\Component\Messenger\MessageBusInterface; use Throwable; @@ -20,11 +23,17 @@ */ class CognitiveMetricsCollector { + /** + * @var array + */ + private array $ignoredItems = []; + public function __construct( protected readonly Parser $parser, protected readonly DirectoryScanner $directoryScanner, protected readonly ConfigService $configService, protected readonly MessageBusInterface $messageBus, + protected readonly CacheItemPoolInterface $cachePool, ) { } @@ -34,7 +43,7 @@ public function __construct( * @param string $path * @param CognitiveConfig $config * @return CognitiveMetricsCollection - * @throws CognitiveAnalysisException + * @throws \Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException|\InvalidArgumentException */ public function collect(string $path, CognitiveConfig $config): CognitiveMetricsCollection { @@ -47,7 +56,7 @@ public function collect(string $path, CognitiveConfig $config): CognitiveMetrics * @param array $paths Array of paths to process * @param CognitiveConfig $config * @return CognitiveMetricsCollection Merged collection of metrics from all paths - * @throws CognitiveAnalysisException + * @throws \Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException|\InvalidArgumentException */ public function collectFromPaths(array $paths, CognitiveConfig $config): CognitiveMetricsCollection { @@ -82,51 +91,36 @@ private function getCodeFromFile(SplFileInfo $file): string * * @param iterable $files * @return CognitiveMetricsCollection + * @throws \InvalidArgumentException + * @throws \InvalidArgumentException */ private function findMetrics(iterable $files): CognitiveMetricsCollection { $metricsCollection = new CognitiveMetricsCollection(); $fileCount = 0; + $config = $this->configService->getConfig(); + $configHash = $this->generateConfigHash($config); + $useCache = $config->cache?->enabled === true; foreach ($files as $file) { - try { - $metrics = $this->parser->parse( - $this->getCodeFromFile($file) - ); - - $fileCount++; - - // Clear memory periodically to prevent memory leaks - if ($fileCount % 50 === 0) { - $this->parser->clearStaticCaches(); - gc_collect_cycles(); - } - } catch (Throwable $exception) { - $this->messageBus->dispatch(new ParserFailed( - $file, - $exception - )); - continue; - } + // Try to get cached metrics + $cached = $this->getCachedMetrics($file, $configHash, $useCache); + $metrics = $cached['metrics']; - $filename = $file->getRealPath(); + // If not cached, process the file + if ($metrics === null) { + $metrics = $this->processFile($file, $fileCount, $cached['cacheItem'], $useCache, $configHash); - if (getenv('APP_ENV') === 'test') { - $projectRoot = $this->getProjectRoot(); - if ($projectRoot && str_starts_with($filename, $projectRoot)) { - $filename = substr($filename, strlen($projectRoot) + 1); + if ($metrics === null) { + continue; } } $metricsCollection = $this->processMethodMetrics( $metrics, $metricsCollection, - $filename + FilenameNormalizer::normalize($file) ); - - $this->messageBus->dispatch(new FileProcessed( - $file, - )); } return $metricsCollection; @@ -148,10 +142,8 @@ private function processMethodMetrics( continue; } - [$class, $method] = explode('::', $classAndMethod); - $metricsArray = array_merge($metrics, [ 'class' => $class, 'method' => $method, @@ -199,23 +191,121 @@ private function findSourceFiles(string $path, array $exclude = []): iterable ); } + + /** + * Generate a cache key for a file based on path, modification time, and config hash + */ + private function generateCacheKey(SplFileInfo $file, string $configHash): string + { + $filePath = $file->getRealPath(); + $fileMtime = $file->getMTime(); + + return 'phpcca_' . md5($filePath . '|' . $fileMtime . '|' . $configHash); + } + /** - * Get the project root directory path. + * Generate configuration hash for cache invalidation + */ + private function generateConfigHash(CognitiveConfig $config): string + { + return md5(serialize($config->toArray())); + } + + /** + * Cache the analysis result for a file + */ + /** @param array $metrics */ + private function cacheResult( + CacheItemInterface $cacheItem, + SplFileInfo $file, + array $metrics, + string $configHash + ): void { + $cacheItem->set([ + 'version' => '1.0', + 'file_path' => $file->getRealPath(), + 'file_mtime' => $file->getMTime(), + 'config_hash' => $configHash, + 'analysis_result' => $metrics, + 'ignored_items' => $this->ignoredItems, + 'cached_at' => time() + ]); + + $this->cachePool->save($cacheItem); + } + + public function clearCache(): void + { + $this->cachePool->clear(); + } + + /** + * Try to get cached metrics for a file * - * @return string|null The project root path or null if not found + * @return array{metrics: array|null, cacheItem: CacheItemInterface|null} + * @throws \InvalidArgumentException */ - private function getProjectRoot(): ?string + private function getCachedMetrics(SplFileInfo $file, string $configHash, bool $useCache): array { - // Start from the current file's directory and traverse up to find composer.json - $currentDir = __DIR__; + if (!$useCache) { + return ['metrics' => null, 'cacheItem' => null]; + } - while ($currentDir !== dirname($currentDir)) { - if (file_exists($currentDir . DIRECTORY_SEPARATOR . 'composer.json')) { - return $currentDir; - } - $currentDir = dirname($currentDir); + $cacheKey = $this->generateCacheKey($file, $configHash); + $cacheItem = $this->cachePool->getItem($cacheKey); + + if (!$cacheItem->isHit()) { + return ['metrics' => null, 'cacheItem' => $cacheItem]; } - return null; + $cachedData = $cacheItem->get(); + $this->ignoredItems = $cachedData['ignored_items'] ?? []; + $this->messageBus->dispatch(new FileProcessed($file)); + + return ['metrics' => $cachedData['analysis_result'], 'cacheItem' => $cacheItem]; + } + + /** + * Process a single file and parse its metrics + * + * @return array|null + * @throws \InvalidArgumentException + */ + private function processFile( + SplFileInfo $file, + int &$fileCount, + ?CacheItemInterface $cacheItem, + bool $useCache, + string $configHash + ): ?array { + try { + $metrics = $this->parser->parse( + $this->getCodeFromFile($file) + ); + + $fileCount++; + + // Clear memory periodically to prevent memory leaks + if ($fileCount % 50 === 0) { + $this->parser->clearStaticCaches(); + gc_collect_cycles(); + } + + // Cache the result if caching is enabled + if ($useCache && $cacheItem !== null) { + $this->cacheResult($cacheItem, $file, $metrics, $configHash); + } + + $this->messageBus->dispatch(new FileProcessed($file)); + + return $metrics; + } catch (Throwable $exception) { + $this->messageBus->dispatch(new ParserFailed( + $file, + $exception + )); + + return null; + } } } diff --git a/src/Business/MetricsFacade.php b/src/Business/MetricsFacade.php index 651adef..d9d193a 100644 --- a/src/Business/MetricsFacade.php +++ b/src/Business/MetricsFacade.php @@ -184,4 +184,9 @@ private function addMethodLevelCoverage( // Fall back to class-level coverage if method not found $metric->setCoverage($coverageDetails->getLineRate()); } + + public function clearCache(): void + { + $this->cognitiveMetricsCollector->clearCache(); + } } diff --git a/src/Business/Utility/FilenameNormalizer.php b/src/Business/Utility/FilenameNormalizer.php new file mode 100644 index 0000000..14e46f2 --- /dev/null +++ b/src/Business/Utility/FilenameNormalizer.php @@ -0,0 +1,59 @@ +getRealPath(); + + if (getenv('APP_ENV') !== 'test') { + return $filename; + } + + $projectRoot = self::getProjectRoot(); + if ($projectRoot && str_starts_with($filename, $projectRoot)) { + $filename = substr($filename, strlen($projectRoot) + 1); + } + + return $filename; + } + + /** + * Get the project root directory by traversing up from the current directory + * until composer.json is found. + * + * Start from the current file's directory and traverse up to find composer.json + * + * @return string|null The project root path or null if not found + */ + private static function getProjectRoot(): ?string + { + $currentDir = __DIR__; + + while ($currentDir !== dirname($currentDir)) { + if (file_exists($currentDir . DIRECTORY_SEPARATOR . 'composer.json')) { + return $currentDir; + } + $currentDir = dirname($currentDir); + } + + return null; + } +} diff --git a/src/Cache/CacheItem.php b/src/Cache/CacheItem.php new file mode 100644 index 0000000..2c44b8d --- /dev/null +++ b/src/Cache/CacheItem.php @@ -0,0 +1,74 @@ +key = $key; + $this->value = $value; + $this->isHit = $isHit; + } + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->value; + } + + public function set(mixed $value): static + { + $this->value = $value; + return $this; + } + + public function isHit(): bool + { + return $this->isHit; + } + + public function setExpiration(?int $expiration): static + { + // Not used in this file-based cache implementation + // Cache validity is determined by file modification time and config hash + return $this; + } + + public function getExpiration(): ?int + { + // Not used in this file-based cache implementation + return null; + } + + public function expiresAt(?\DateTimeInterface $expiration): static + { + // Not used in this file-based cache implementation + // Cache validity is determined by file modification time and config hash + return $this; + } + + public function expiresAfter(int|\DateInterval|null $time): static + { + // Not used in this file-based cache implementation + // Cache validity is determined by file modification time and config hash + return $this; + } +} diff --git a/src/Cache/Exception/CacheException.php b/src/Cache/Exception/CacheException.php new file mode 100644 index 0000000..a3e1204 --- /dev/null +++ b/src/Cache/Exception/CacheException.php @@ -0,0 +1,14 @@ + + */ + private array $deferred = []; + + /** + * @throws CacheException + */ + public function __construct(string $cacheDirectory = './.phpcca.cache') + { + $this->cacheDirectory = rtrim($cacheDirectory, '/'); + $this->ensureCacheDirectory(); + } + + /** + * @throws CacheException + */ + public function getItem(string $key): CacheItemInterface + { + $filePath = $this->getCacheFilePath($key); + + if (!file_exists($filePath)) { + return new CacheItem($key, null, false); + } + + $data = $this->loadCacheData($filePath); + if ($data === null) { + return new CacheItem($key, null, false); + } + + return new CacheItem($key, $data, true); + } + + /** + * @return array + * @throws CacheException + */ + public function getItems(array $keys = []): iterable + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->getItem($key); + } + + return $items; + } + + /** + * @throws CacheException + */ + public function hasItem(string $key): bool + { + $filePath = $this->getCacheFilePath($key); + + return file_exists($filePath) && $this->loadCacheData($filePath) !== null; + } + + public function clear(): bool + { + try { + $this->removeDirectory($this->cacheDirectory); + $this->ensureCacheDirectory(); + return true; + } catch (CacheException $e) { + return false; + } + } + + /** + * @throws CacheException + */ + public function deleteItem(string $key): bool + { + $filePath = $this->getCacheFilePath($key); + + if (file_exists($filePath)) { + return unlink($filePath); + } + + return true; + } + + /** + * @throws CacheException + */ + public function deleteItems(array $keys): bool + { + $success = true; + foreach ($keys as $key) { + if ($this->deleteItem($key)) { + continue; + } + + $success = false; + } + return $success; + } + + /** + * @throws CacheException + */ + public function save(CacheItemInterface $item): bool + { + $filePath = $this->getCacheFilePath($item->getKey()); + $data = $item->get(); + + if ($data === null) { + return $this->deleteItem($item->getKey()); + } + + return $this->saveCacheData($filePath, $data); + } + + public function saveDeferred(CacheItemInterface $item): bool + { + $this->deferred[] = $item; + + return true; + } + + public function commit(): bool + { + $success = true; + foreach ($this->deferred as $item) { + if ($this->save($item)) { + continue; + } + + $success = false; + } + $this->deferred = []; + return $success; + } + + /** + * @SuppressWarnings("PHPMD.ErrorControlOperator") + * @throws CacheException + */ + private function ensureCacheDirectory(): void + { + if ( + !is_dir($this->cacheDirectory) + && !@mkdir($this->cacheDirectory, 0755, true) + ) { + throw new CacheException("Failed to create cache directory: {$this->cacheDirectory}"); + } + } + + /** + * Create subdirectories to avoid too many files in one directory + * + * @SuppressWarnings("PHPMD.ErrorControlOperator") + * @throws CacheException + */ + private function getCacheFilePath(string $key): string + { + $hash = md5($key); + $subDir = substr($hash, 0, 2); + $dir = $this->cacheDirectory . '/' . $subDir; + + if ( + !is_dir($dir) + && @!mkdir($dir, 0755, true) + ) { + throw new CacheException("Failed to create cache subdirectory: {$dir}"); + } + + return $dir . '/' . $hash . '.cache'; + } + + private function loadCacheData(string $filePath): mixed + { + $content = file_get_contents($filePath); + if ($content === false) { + return null; + } + + $data = json_decode($content, true); + if ($data === null) { + return null; + } + + return $data; + } + + /** + * Store data + * + * Sanitize data to ensure valid UTF-8 encoding + * + * @param mixed $data + */ + private function saveCacheData(string $filePath, mixed $data): bool + { + $data = $this->sanitizeUtf8($data); + + $json = json_encode($data, JSON_PRETTY_PRINT); + if ($json === false) { + return false; + } + + $dir = dirname($filePath); + if ( + !is_dir($dir) + && !mkdir($dir, 0755, true) + ) { + return false; + } + + $result = file_put_contents($filePath, $json); + + return $result !== false; + } + + /** + * Recursively sanitize UTF-8 data to ensure valid encoding + */ + private function sanitizeUtf8(mixed $data): mixed + { + if (is_string($data)) { + // Remove or replace invalid UTF-8 characters + return mb_convert_encoding($data, 'UTF-8', 'UTF-8'); + } + + if (is_array($data)) { + $sanitized = []; + foreach ($data as $key => $value) { + $sanitizedKey = is_string($key) ? mb_convert_encoding($key, 'UTF-8', 'UTF-8') : $key; + $sanitized[$sanitizedKey] = $this->sanitizeUtf8($value); + } + return $sanitized; + } + + if (is_object($data)) { + // Convert objects to arrays for sanitization + $array = (array) $data; + $sanitized = $this->sanitizeUtf8($array); + return (object) $sanitized; + } + + return $data; + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $scanResult = scandir($dir); + if ($scanResult === false) { + return; + } + + $files = array_diff($scanResult, ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->removeDirectory($path); + continue; + } + unlink($path); + } + + rmdir($dir); + } +} diff --git a/src/Config/CacheConfig.php b/src/Config/CacheConfig.php new file mode 100644 index 0000000..78296a4 --- /dev/null +++ b/src/Config/CacheConfig.php @@ -0,0 +1,30 @@ + + */ + public function toArray(): array + { + return [ + 'enabled' => $this->enabled, + 'directory' => $this->directory, + ]; + } +} diff --git a/src/Config/CognitiveConfig.php b/src/Config/CognitiveConfig.php index 6ec5d33..8f38f14 100644 --- a/src/Config/CognitiveConfig.php +++ b/src/Config/CognitiveConfig.php @@ -6,6 +6,7 @@ /** * @SuppressWarnings(BooleanArgumentFlag) + * @SuppressWarnings(ExcessiveParameterList) */ class CognitiveConfig { @@ -26,7 +27,35 @@ public function __construct( public readonly bool $showCyclomaticComplexity = false, public readonly bool $groupByClass = false, public readonly bool $showDetailedCognitiveMetrics = true, + public readonly ?CacheConfig $cache = null, public readonly array $customReporters = [], ) { } + + /** + * Convert the cognitive configuration to an array + * + * @return array + */ + public function toArray(): array + { + $metricsArray = []; + foreach ($this->metrics as $key => $metric) { + $metricsArray[$key] = $metric->toArray(); + } + + return [ + 'excludeFilePatterns' => $this->excludeFilePatterns, + 'excludePatterns' => $this->excludePatterns, + 'metrics' => $metricsArray, + 'showOnlyMethodsExceedingThreshold' => $this->showOnlyMethodsExceedingThreshold, + 'scoreThreshold' => $this->scoreThreshold, + 'showHalsteadComplexity' => $this->showHalsteadComplexity, + 'showCyclomaticComplexity' => $this->showCyclomaticComplexity, + 'groupByClass' => $this->groupByClass, + 'showDetailedCognitiveMetrics' => $this->showDetailedCognitiveMetrics, + 'cache' => $this->cache?->toArray(), + 'customReporters' => $this->customReporters, + ]; + } } diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php index 55876b4..6cf98ba 100644 --- a/src/Config/ConfigFactory.php +++ b/src/Config/ConfigFactory.php @@ -20,6 +20,14 @@ public function fromArray(array $config): CognitiveConfig ); }, $config['cognitive']['metrics']); + $cacheConfig = null; + if (isset($config['cognitive']['cache'])) { + $cacheConfig = new CacheConfig( + enabled: $config['cognitive']['cache']['enabled'] ?? true, + directory: $config['cognitive']['cache']['directory'] ?? './.phpcca.cache', + ); + } + return new CognitiveConfig( excludeFilePatterns: $config['cognitive']['excludeFilePatterns'], excludePatterns: $config['cognitive']['excludePatterns'], @@ -30,6 +38,7 @@ public function fromArray(array $config): CognitiveConfig showCyclomaticComplexity: $config['cognitive']['showCyclomaticComplexity'] ?? false, groupByClass: $config['cognitive']['groupByClass'] ?? true, showDetailedCognitiveMetrics: $config['cognitive']['showDetailedCognitiveMetrics'] ?? true, + cache: $cacheConfig, customReporters: $config['cognitive']['customReporters'] ?? [] ); } diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index 7834c5f..1eca064 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -132,6 +132,16 @@ public function getConfigTreeBuilder(): TreeBuilder }) ->end() ->end() + ->arrayNode('cache') + ->children() + ->booleanNode('enabled') + ->defaultValue(true) + ->end() + ->scalarNode('directory') + ->defaultValue('./.phpcca.cache') + ->end() + ->end() + ->end() ->arrayNode('customReporters') ->children() ->arrayNode('cognitive') diff --git a/src/Config/MetricsConfig.php b/src/Config/MetricsConfig.php index 98ea5e8..9aef638 100644 --- a/src/Config/MetricsConfig.php +++ b/src/Config/MetricsConfig.php @@ -12,4 +12,18 @@ public function __construct( public readonly bool $enabled ) { } + + /** + * Convert the metrics configuration to an array + * + * @return array + */ + public function toArray(): array + { + return [ + 'threshold' => $this->threshold, + 'scale' => $this->scale, + 'enabled' => $this->enabled, + ]; + } } diff --git a/tests/Unit/Business/Cognitive/CognitiveMetricsCollectorTest.php b/tests/Unit/Business/Cognitive/CognitiveMetricsCollectorTest.php index e42ee9b..b1ecb5c 100644 --- a/tests/Unit/Business/Cognitive/CognitiveMetricsCollectorTest.php +++ b/tests/Unit/Business/Cognitive/CognitiveMetricsCollectorTest.php @@ -8,6 +8,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollector; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Parser; use Phauthentic\CognitiveCodeAnalysis\Business\Utility\DirectoryScanner; +use Phauthentic\CognitiveCodeAnalysis\Cache\FileCache; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigLoader; @@ -26,6 +27,9 @@ class CognitiveMetricsCollectorTest extends TestCase private ConfigService $configService; private MessageBusInterface $messageBus; + /** + * @throws CacheException + */ protected function setUp(): void { parent::setUp(); @@ -50,7 +54,8 @@ protected function setUp(): void new Processor(), new ConfigLoader(), ), - $bus + $bus, + new FileCache(sys_get_temp_dir()) ); $this->configService = new ConfigService( @@ -88,7 +93,8 @@ public function testCollectWithExcludedClasses(): void ), new DirectoryScanner(), $configService, - $this->messageBus + $this->messageBus, + new FileCache(sys_get_temp_dir()) ); $path = './tests/TestCode'; @@ -354,6 +360,11 @@ public function testCollectFromPathsWithMixedTypes(): void $this->assertGreaterThan(0, $metricsCollection->count(), 'Should have metrics from directory and file'); } + /** + * @throws CognitiveAnalysisException + * @throws CacheException + * @throws ExceptionInterface + */ #[Test] public function testFindSourceFilesExcludePatternsNotMergedProperly(): void { @@ -369,7 +380,8 @@ public function testFindSourceFilesExcludePatternsNotMergedProperly(): void ), new DirectoryScanner(), $configService, - $this->messageBus + $this->messageBus, + new FileCache(sys_get_temp_dir()) ); $excludePatterns = ['Paginator\.php$', 'FileWithTwoClasses\.php$']; diff --git a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_AllMetrics.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_AllMetrics.md index 70ef966..8e2d541 100644 --- a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_AllMetrics.md +++ b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_AllMetrics.md @@ -6,22 +6,22 @@ --- -* **Class:** TestClass -* **File:** TestClass.php +* **Class:** AnotherClass +* **File:** AnotherClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 573.211 | 12.500 | 7,165.138 | 5 (low) | -| anotherMethod | 5 (0.200) | 1 (0.100) | 1 (0.100) | 2 (0.100) | 1 (0.100) | 1 (0.200) | 1 (0.100) | 0 (0.000) | 0.050 | 185.470 | 6.250 | 1,159.188 | 2 (low) | +| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 1,357.824 | 25.000 | 33,945.600 | 12 (medium) | --- -* **Class:** AnotherClass -* **File:** AnotherClass.php +* **Class:** TestClass +* **File:** TestClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 1,357.824 | 25.000 | 33,945.600 | 12 (medium) | +| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 573.211 | 12.500 | 7,165.138 | 5 (low) | +| anotherMethod | 5 (0.200) | 1 (0.100) | 1 (0.100) | 2 (0.100) | 1 (0.100) | 1 (0.200) | 1 (0.100) | 0 (0.000) | 0.050 | 185.470 | 6.250 | 1,159.188 | 2 (low) | --- diff --git a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_CyclomaticOnly.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_CyclomaticOnly.md index 8670297..1e17cbf 100644 --- a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_CyclomaticOnly.md +++ b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_CyclomaticOnly.md @@ -6,22 +6,22 @@ --- -* **Class:** TestClass -* **File:** TestClass.php +* **Class:** AnotherClass +* **File:** AnotherClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 5 (low) | -| anotherMethod | 5 (0.200) | 1 (0.100) | 1 (0.100) | 2 (0.100) | 1 (0.100) | 1 (0.200) | 1 (0.100) | 0 (0.000) | 0.050 | 2 (low) | +| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 12 (medium) | --- -* **Class:** AnotherClass -* **File:** AnotherClass.php +* **Class:** TestClass +* **File:** TestClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 12 (medium) | +| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 5 (low) | +| anotherMethod | 5 (0.200) | 1 (0.100) | 1 (0.100) | 2 (0.100) | 1 (0.100) | 1 (0.200) | 1 (0.100) | 0 (0.000) | 0.050 | 2 (low) | --- diff --git a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_HalsteadOnly.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_HalsteadOnly.md index 43b64de..f7eade0 100644 --- a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_HalsteadOnly.md +++ b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_HalsteadOnly.md @@ -6,22 +6,22 @@ --- -* **Class:** TestClass -* **File:** TestClass.php +* **Class:** AnotherClass +* **File:** AnotherClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 573.211 | 12.500 | 7,165.138 | -| anotherMethod | 5 (0.200) | 1 (0.100) | 1 (0.100) | 2 (0.100) | 1 (0.100) | 1 (0.200) | 1 (0.100) | 0 (0.000) | 0.050 | 185.470 | 6.250 | 1,159.188 | +| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 1,357.824 | 25.000 | 33,945.600 | --- -* **Class:** AnotherClass -* **File:** AnotherClass.php +* **Class:** TestClass +* **File:** TestClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 1,357.824 | 25.000 | 33,945.600 | +| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 573.211 | 12.500 | 7,165.138 | +| anotherMethod | 5 (0.200) | 1 (0.100) | 1 (0.100) | 2 (0.100) | 1 (0.100) | 1 (0.200) | 1 (0.100) | 0 (0.000) | 0.050 | 185.470 | 6.250 | 1,159.188 | --- diff --git a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Minimal.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Minimal.md index 7f6ee37..4d1870e 100644 --- a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Minimal.md +++ b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Minimal.md @@ -6,22 +6,22 @@ --- -* **Class:** TestClass -* **File:** TestClass.php +* **Class:** AnotherClass +* **File:** AnotherClass.php | Method | Cognitive Complexity | |--------|--------| -| testMethod | 0.300 | -| anotherMethod | 0.050 | +| complexMethod | 0.800 | --- -* **Class:** AnotherClass -* **File:** AnotherClass.php +* **Class:** TestClass +* **File:** TestClass.php | Method | Cognitive Complexity | |--------|--------| -| complexMethod | 0.800 | +| testMethod | 0.300 | +| anotherMethod | 0.050 | --- diff --git a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_NoDetailedMetrics.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_NoDetailedMetrics.md index 3c21cf5..26dcb92 100644 --- a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_NoDetailedMetrics.md +++ b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_NoDetailedMetrics.md @@ -6,22 +6,22 @@ --- -* **Class:** TestClass -* **File:** TestClass.php +* **Class:** AnotherClass +* **File:** AnotherClass.php | Method | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------| -| testMethod | 0.300 | 573.211 | 12.500 | 7,165.138 | 5 (low) | -| anotherMethod | 0.050 | 185.470 | 6.250 | 1,159.188 | 2 (low) | +| complexMethod | 0.800 | 1,357.824 | 25.000 | 33,945.600 | 12 (medium) | --- -* **Class:** AnotherClass -* **File:** AnotherClass.php +* **Class:** TestClass +* **File:** TestClass.php | Method | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------| -| complexMethod | 0.800 | 1,357.824 | 25.000 | 33,945.600 | 12 (medium) | +| testMethod | 0.300 | 573.211 | 12.500 | 7,165.138 | 5 (low) | +| anotherMethod | 0.050 | 185.470 | 6.250 | 1,159.188 | 2 (low) | --- diff --git a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Threshold.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Threshold.md index a944ba3..b6f7fd6 100644 --- a/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Threshold.md +++ b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Threshold.md @@ -8,21 +8,21 @@ --- -* **Class:** TestClass -* **File:** TestClass.php +* **Class:** AnotherClass +* **File:** AnotherClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 573.211 | 12.500 | 7,165.138 | 5 (low) | +| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 1,357.824 | 25.000 | 33,945.600 | 12 (medium) | --- -* **Class:** AnotherClass -* **File:** AnotherClass.php +* **Class:** TestClass +* **File:** TestClass.php | Method | Lines | Args | Returns | Variables | Property Calls | If | If Nesting | Else | Cognitive Complexity | Halstead Volume | Halstead Difficulty | Halstead Effort | Cyclomatic Complexity | |--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------|--------| -| complexMethod | 20 (1.000) | 4 (0.600) | 3 (0.500) | 10 (0.800) | 5 (0.500) | 8 (1.200) | 3 (1.000) | 4 (0.600) | 0.800 | 1,357.824 | 25.000 | 33,945.600 | 12 (medium) | +| testMethod | 10 (0.500) | 2 (0.300) | 1 (0.200) | 5 (0.400) | 3 (0.300) | 4 (0.600) | 2 (0.500) | 1 (0.200) | 0.300 | 573.211 | 12.500 | 7,165.138 | 5 (low) | --- diff --git a/tests/Unit/Cache/CacheItemTest.php b/tests/Unit/Cache/CacheItemTest.php new file mode 100644 index 0000000..cbcee42 --- /dev/null +++ b/tests/Unit/Cache/CacheItemTest.php @@ -0,0 +1,188 @@ +assertEquals($key, $item->getKey()); + $this->assertEquals($value, $item->get()); + $this->assertTrue($item->isHit()); + } + + public function testConstructorWithMiss(): void + { + $key = 'test-key'; + $value = null; + $isHit = false; + + $item = new CacheItem($key, $value, $isHit); + + $this->assertEquals($key, $item->getKey()); + $this->assertNull($item->get()); + $this->assertFalse($item->isHit()); + } + + public function testSetValue(): void + { + $item = new CacheItem('test-key', 'initial-value', true); + + $newValue = 'new-value'; + $result = $item->set($newValue); + + $this->assertSame($item, $result); + $this->assertEquals($newValue, $item->get()); + } + + public function testSetWithDifferentTypes(): void + { + $item = new CacheItem('test-key', null, false); + + // Test with string + $item->set('string-value'); + $this->assertEquals('string-value', $item->get()); + + // Test with array + $arrayValue = ['key' => 'value', 'number' => 123]; + $item->set($arrayValue); + $this->assertEquals($arrayValue, $item->get()); + + // Test with object + $objectValue = (object) ['property' => 'value']; + $item->set($objectValue); + $this->assertEquals($objectValue, $item->get()); + + // Test with boolean + $item->set(true); + $this->assertTrue($item->get()); + + // Test with integer + $item->set(42); + $this->assertEquals(42, $item->get()); + + // Test with float + $item->set(3.14); + $this->assertEquals(3.14, $item->get()); + } + + public function testSetExpirationReturnsSelf(): void + { + $item = new CacheItem('test-key', 'value', true); + + $result = $item->setExpiration(3600); + + $this->assertSame($item, $result); + } + + public function testGetExpirationReturnsNull(): void + { + $item = new CacheItem('test-key', 'value', true); + + $this->assertNull($item->getExpiration()); + } + + public function testExpiresAtReturnsSelf(): void + { + $item = new CacheItem('test-key', 'value', true); + $expiration = new \DateTime('+1 hour'); + + $result = $item->expiresAt($expiration); + + $this->assertSame($item, $result); + } + + public function testExpiresAtWithNull(): void + { + $item = new CacheItem('test-key', 'value', true); + + $result = $item->expiresAt(null); + + $this->assertSame($item, $result); + } + + public function testExpiresAfterWithInteger(): void + { + $item = new CacheItem('test-key', 'value', true); + + $result = $item->expiresAfter(3600); + + $this->assertSame($item, $result); + } + + public function testExpiresAfterWithDateInterval(): void + { + $item = new CacheItem('test-key', 'value', true); + $interval = new \DateInterval('PT1H'); + + $result = $item->expiresAfter($interval); + + $this->assertSame($item, $result); + } + + public function testExpiresAfterWithNull(): void + { + $item = new CacheItem('test-key', 'value', true); + + $result = $item->expiresAfter(null); + + $this->assertSame($item, $result); + } + + public function testKeyIsImmutable(): void + { + $key = 'original-key'; + $item = new CacheItem($key, 'value', true); + + // The key should remain the same throughout the item's lifecycle + $this->assertEquals($key, $item->getKey()); + + $item->set('new-value'); + $this->assertEquals($key, $item->getKey()); + + $item->setExpiration(3600); + $this->assertEquals($key, $item->getKey()); + } + + public function testIsHitIsImmutable(): void + { + $item = new CacheItem('test-key', 'value', true); + + // isHit should remain true + $this->assertTrue($item->isHit()); + + $item->set('new-value'); + $this->assertTrue($item->isHit()); + + $item->setExpiration(3600); + $this->assertTrue($item->isHit()); + } + + public function testIsHitIsImmutableForMiss(): void + { + $item = new CacheItem('test-key', null, false); + + // isHit should remain false + $this->assertFalse($item->isHit()); + + $item->set('new-value'); + $this->assertFalse($item->isHit()); + + $item->setExpiration(3600); + $this->assertFalse($item->isHit()); + } +} diff --git a/tests/Unit/Cache/Exception/CacheExceptionTest.php b/tests/Unit/Cache/Exception/CacheExceptionTest.php new file mode 100644 index 0000000..7900d2e --- /dev/null +++ b/tests/Unit/Cache/Exception/CacheExceptionTest.php @@ -0,0 +1,124 @@ +assertInstanceOf(CognitiveAnalysisException::class, $exception); + $this->assertInstanceOf(\Exception::class, $exception); + } + + public function testDefaultConstructor(): void + { + $exception = new CacheException(); + + $this->assertEquals('', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testConstructorWithMessage(): void + { + $message = 'Cache operation failed'; + $exception = new CacheException($message); + + $this->assertEquals($message, $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testConstructorWithMessageAndCode(): void + { + $message = 'Cache operation failed'; + $code = 500; + $exception = new CacheException($message, $code); + + $this->assertEquals($message, $exception->getMessage()); + $this->assertEquals($code, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testConstructorWithMessageCodeAndPrevious(): void + { + $message = 'Cache operation failed'; + $code = 500; + $previous = new \RuntimeException('Previous exception'); + $exception = new CacheException($message, $code, $previous); + + $this->assertEquals($message, $exception->getMessage()); + $this->assertEquals($code, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } + + public function testCanBeThrownAndCaught(): void + { + $this->expectException(CacheException::class); + $this->expectExceptionMessage('Test exception message'); + + throw new CacheException('Test exception message'); + } + + public function testCanBeCaughtAsParentException(): void + { + $caught = false; + + try { + throw new CacheException('Test message'); + } catch (CognitiveAnalysisException $e) { + $caught = true; + $this->assertEquals('Test message', $e->getMessage()); + } + + $this->assertTrue($caught); + } + + public function testCanBeCaughtAsGenericException(): void + { + $caught = false; + + try { + throw new CacheException('Test message'); + } catch (\Exception $e) { + $caught = true; + $this->assertEquals('Test message', $e->getMessage()); + } + + $this->assertTrue($caught); + } + + public function testExceptionWithSpecialCharacters(): void + { + $message = 'Cache failed: "Invalid UTF-8 sequence \x80"'; + $exception = new CacheException($message); + + $this->assertEquals($message, $exception->getMessage()); + } + + public function testExceptionWithEmptyMessage(): void + { + $exception = new CacheException(''); + + $this->assertEquals('', $exception->getMessage()); + } + + public function testExceptionWithVeryLongMessage(): void + { + $message = str_repeat('A', 1000); + $exception = new CacheException($message); + + $this->assertEquals($message, $exception->getMessage()); + } +} diff --git a/tests/Unit/Cache/FileCacheTest.php b/tests/Unit/Cache/FileCacheTest.php new file mode 100644 index 0000000..f0c6259 --- /dev/null +++ b/tests/Unit/Cache/FileCacheTest.php @@ -0,0 +1,458 @@ +testCacheDir = sys_get_temp_dir() . '/phpcca-cache-test-' . uniqid(); + $this->cache = new FileCache($this->testCacheDir); + } + + protected function tearDown(): void + { + if (!is_dir($this->testCacheDir)) { + return; + } + + $this->removeDirectory($this->testCacheDir); + } + + public function testConstructorCreatesCacheDirectory(): void + { + $this->assertDirectoryExists($this->testCacheDir); + $this->assertDirectoryIsWritable($this->testCacheDir); + } + + public function testConstructorWithTrailingSlash(): void + { + $cacheDirWithSlash = $this->testCacheDir . '/'; + $cache = new FileCache($cacheDirWithSlash); + + $this->assertDirectoryExists($this->testCacheDir); + } + + public function testGetItemReturnsMissForNonExistentKey(): void + { + $item = $this->cache->getItem('non-existent-key'); + + $this->assertEquals('non-existent-key', $item->getKey()); + $this->assertNull($item->get()); + $this->assertFalse($item->isHit()); + } + + public function testSaveAndGetItem(): void + { + $key = 'test-key'; + $value = 'test-value'; + + $item = new CacheItem($key, $value, true); + $this->assertTrue($this->cache->save($item)); + + $retrievedItem = $this->cache->getItem($key); + $this->assertEquals($key, $retrievedItem->getKey()); + $this->assertEquals($value, $retrievedItem->get()); + $this->assertTrue($retrievedItem->isHit()); + } + + public function testSaveAndGetItemWithArray(): void + { + $key = 'array-key'; + $value = ['key1' => 'value1', 'key2' => 123, 'key3' => ['nested' => true]]; + + $item = new CacheItem($key, $value, true); + $this->assertTrue($this->cache->save($item)); + + $retrievedItem = $this->cache->getItem($key); + $retrievedValue = $retrievedItem->get(); + $this->assertIsArray($retrievedValue); + $this->assertEquals('value1', $retrievedValue['key1']); + $this->assertEquals(123, $retrievedValue['key2']); + $this->assertIsArray($retrievedValue['key3']); + $this->assertTrue($retrievedValue['key3']['nested']); + $this->assertTrue($retrievedItem->isHit()); + } + + public function testSaveAndGetItemWithObject(): void + { + $key = 'object-key'; + $value = (object) ['property1' => 'value1', 'property2' => 456]; + + $item = new CacheItem($key, $value, true); + $this->assertTrue($this->cache->save($item)); + + $retrievedItem = $this->cache->getItem($key); + $retrievedValue = $retrievedItem->get(); + $this->assertIsArray($retrievedValue); + $this->assertEquals('value1', $retrievedValue['property1']); + $this->assertEquals(456, $retrievedValue['property2']); + $this->assertTrue($retrievedItem->isHit()); + } + + public function testSaveAndGetItemWithNullValue(): void + { + $key = 'null-key'; + + $item = new CacheItem($key, null, true); + $this->assertTrue($this->cache->save($item)); + + // Saving null should delete the item + $this->assertFalse($this->cache->hasItem($key)); + } + + public function testHasItem(): void + { + $key = 'has-item-test'; + + $this->assertFalse($this->cache->hasItem($key)); + + $item = new CacheItem($key, 'value', true); + $this->cache->save($item); + + $this->assertTrue($this->cache->hasItem($key)); + } + + public function testDeleteItem(): void + { + $key = 'delete-test'; + + // Save an item first + $item = new CacheItem($key, 'value', true); + $this->cache->save($item); + $this->assertTrue($this->cache->hasItem($key)); + + // Delete the item + $this->assertTrue($this->cache->deleteItem($key)); + $this->assertFalse($this->cache->hasItem($key)); + } + + public function testDeleteNonExistentItem(): void + { + $this->assertTrue($this->cache->deleteItem('non-existent-key')); + } + + public function testDeleteItems(): void + { + $keys = ['key1', 'key2', 'key3']; + + // Save items first + foreach ($keys as $key) { + $item = new CacheItem($key, "value-{$key}", true); + $this->cache->save($item); + } + + // Verify all items exist + foreach ($keys as $key) { + $this->assertTrue($this->cache->hasItem($key)); + } + + // Delete all items + $this->assertTrue($this->cache->deleteItems($keys)); + + // Verify all items are deleted + foreach ($keys as $key) { + $this->assertFalse($this->cache->hasItem($key)); + } + } + + public function testGetItems(): void + { + $keys = ['key1', 'key2', 'key3']; + + // Save some items + $item1 = new CacheItem('key1', 'value1', true); + $item2 = new CacheItem('key2', 'value2', true); + $this->cache->save($item1); + $this->cache->save($item2); + + $items = $this->cache->getItems($keys); + + $this->assertCount(3, $items); + $this->assertArrayHasKey('key1', $items); + $this->assertArrayHasKey('key2', $items); + $this->assertArrayHasKey('key3', $items); + + $this->assertTrue($items['key1']->isHit()); + $this->assertEquals('value1', $items['key1']->get()); + + $this->assertTrue($items['key2']->isHit()); + $this->assertEquals('value2', $items['key2']->get()); + + $this->assertFalse($items['key3']->isHit()); + $this->assertNull($items['key3']->get()); + } + + public function testSaveDeferredAndCommit(): void + { + $key1 = 'deferred-key1'; + $key2 = 'deferred-key2'; + + $item1 = new CacheItem($key1, 'value1', true); + $item2 = new CacheItem($key2, 'value2', true); + + $this->assertTrue($this->cache->saveDeferred($item1)); + $this->assertTrue($this->cache->saveDeferred($item2)); + + // Items should not be saved yet + $this->assertFalse($this->cache->hasItem($key1)); + $this->assertFalse($this->cache->hasItem($key2)); + + // Commit the deferred items + $this->assertTrue($this->cache->commit()); + + // Now items should be saved + $this->assertTrue($this->cache->hasItem($key1)); + $this->assertTrue($this->cache->hasItem($key2)); + + $retrievedItem1 = $this->cache->getItem($key1); + $this->assertEquals('value1', $retrievedItem1->get()); + + $retrievedItem2 = $this->cache->getItem($key2); + $this->assertEquals('value2', $retrievedItem2->get()); + } + + public function testClear(): void + { + // Save some items + $item1 = new CacheItem('key1', 'value1', true); + $item2 = new CacheItem('key2', 'value2', true); + $this->cache->save($item1); + $this->cache->save($item2); + + $this->assertTrue($this->cache->hasItem('key1')); + $this->assertTrue($this->cache->hasItem('key2')); + + // Clear the cache + $this->assertTrue($this->cache->clear()); + + // Items should be gone + $this->assertFalse($this->cache->hasItem('key1')); + $this->assertFalse($this->cache->hasItem('key2')); + + // Cache directory should still exist + $this->assertDirectoryExists($this->testCacheDir); + } + + public function testCacheFileStructure(): void + { + $key = 'test-structure'; + $value = 'test-value'; + + $item = new CacheItem($key, $value, true); + $this->cache->save($item); + + // Check that subdirectory was created + $hash = md5($key); + $subDir = substr($hash, 0, 2); + $expectedSubDir = $this->testCacheDir . '/' . $subDir; + + $this->assertDirectoryExists($expectedSubDir); + + // Check that cache file was created + $expectedFile = $expectedSubDir . '/' . $hash . '.cache'; + $this->assertFileExists($expectedFile); + + // Verify file content + $content = file_get_contents($expectedFile); + $this->assertNotFalse($content); + + $data = json_decode($content, true); + $this->assertEquals($value, $data); + } + + public function testUtf8Sanitization(): void + { + $key = 'utf8-test'; + $value = [ + 'valid_utf8' => 'Hello World', + 'invalid_utf8' => "Invalid \x80 sequence", + 'mixed' => "Valid text with \x80 invalid chars", + 'unicode' => 'Unicode: 你好世界 🌍' + ]; + + $item = new CacheItem($key, $value, true); + $this->assertTrue($this->cache->save($item)); + + $retrievedItem = $this->cache->getItem($key); + $retrievedValue = $retrievedItem->get(); + + $this->assertIsArray($retrievedValue); + $this->assertArrayHasKey('valid_utf8', $retrievedValue); + $this->assertArrayHasKey('invalid_utf8', $retrievedValue); + $this->assertArrayHasKey('mixed', $retrievedValue); + $this->assertArrayHasKey('unicode', $retrievedValue); + + // Verify UTF-8 sanitization worked + $this->assertIsString($retrievedValue['invalid_utf8']); + $this->assertIsString($retrievedValue['mixed']); + } + + public function testCorruptedCacheFile(): void + { + $key = 'corrupted-test'; + $value = 'test-value'; + + // Save a valid item first + $item = new CacheItem($key, $value, true); + $this->cache->save($item); + + // Corrupt the cache file + $hash = md5($key); + $subDir = substr($hash, 0, 2); + $cacheFile = $this->testCacheDir . '/' . $subDir . '/' . $hash . '.cache'; + + file_put_contents($cacheFile, 'invalid json content'); + + // Should return a miss for corrupted file + $retrievedItem = $this->cache->getItem($key); + $this->assertFalse($retrievedItem->isHit()); + $this->assertNull($retrievedItem->get()); + } + + public function testEmptyCacheFile(): void + { + $key = 'empty-test'; + $value = 'test-value'; + + // Save a valid item first + $item = new CacheItem($key, $value, true); + $this->cache->save($item); + + // Empty the cache file + $hash = md5($key); + $subDir = substr($hash, 0, 2); + $cacheFile = $this->testCacheDir . '/' . $subDir . '/' . $hash . '.cache'; + + file_put_contents($cacheFile, ''); + + // Should return a miss for empty file + $retrievedItem = $this->cache->getItem($key); + $this->assertFalse($retrievedItem->isHit()); + $this->assertNull($retrievedItem->get()); + } + + public function testCacheDirectoryCreationFailure(): void + { + // Create a cache directory that's not writable + $nonWritableDir = '/root/non-writable-cache'; + + $this->expectException(CacheException::class); + $this->expectExceptionMessage('Failed to create cache directory'); + + new FileCache($nonWritableDir); + } + + public function testCacheSubdirectoryCreationFailure(): void + { + // Create a cache with a non-writable parent directory + $parentDir = $this->testCacheDir . '/parent'; + mkdir($parentDir, 0444); // Read-only + + $this->expectException(CacheException::class); + $this->expectExceptionMessage('Failed to create cache directory'); + + $cache = new FileCache($parentDir . '/cache'); + + // Try to save an item which will trigger subdirectory creation + $item = new CacheItem('test-key', 'test-value', true); + $cache->save($item); + } + + public function testJsonEncodeFailure(): void + { + // Create a value that cannot be JSON encoded + $key = 'json-fail-test'; + $value = "\x80"; // Invalid UTF-8 that might cause JSON encoding issues + + $item = new CacheItem($key, $value, true); + + // This should handle the encoding gracefully + $result = $this->cache->save($item); + + // The save might succeed due to UTF-8 sanitization + if (!$result) { + return; + } + + $retrievedItem = $this->cache->getItem($key); + $this->assertTrue($retrievedItem->isHit()); + } + + public function testLargeDataHandling(): void + { + $key = 'large-data-test'; + $value = str_repeat('A', 10000); // 10KB string + + $item = new CacheItem($key, $value, true); + $this->assertTrue($this->cache->save($item)); + + $retrievedItem = $this->cache->getItem($key); + $this->assertEquals($value, $retrievedItem->get()); + $this->assertTrue($retrievedItem->isHit()); + } + + public function testSpecialCharactersInKey(): void + { + $keys = [ + 'key with spaces', + 'key-with-dashes', + 'key_with_underscores', + 'key.with.dots', + 'key/with/slashes', + 'key\\with\\backslashes', + 'key:with:colons', + 'key;with;semicolons', + 'key"with"quotes', + "key'with'singlequotes" + ]; + + foreach ($keys as $key) { + $value = "value-for-{$key}"; + $item = new CacheItem($key, $value, true); + + $this->assertTrue($this->cache->save($item), "Failed to save key: {$key}"); + $this->assertTrue($this->cache->hasItem($key), "Failed to verify key exists: {$key}"); + + $retrievedItem = $this->cache->getItem($key); + $this->assertEquals($value, $retrievedItem->get(), "Failed to retrieve value for key: {$key}"); + } + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $scanResult = scandir($dir); + if ($scanResult === false) { + return; + } + + $files = array_diff($scanResult, ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->removeDirectory($path); + continue; + } + unlink($path); + } + + rmdir($dir); + } +}