Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
FileCache
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
12 / 12
27
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 set
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 has
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 delete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 clear
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isExpired
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getFilePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readEntry
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 writeEntry
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 deleteFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeTtl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3// ╔════════════════════════════════════════════════════════════╗
4// ║ MIT Licence (#Expat) - https://opensource.org/licenses/MIT ║
5// ║ Copyright 2026 Frederic Poeydomenge <dyno@phexium.com>     ║
6// ╚════════════════════════════════════════════════════════════╝
7
8declare(strict_types=1);
9
10namespace Phexium\Plugin\Cache\Adapter;
11
12use DateInterval;
13use DateTimeImmutable;
14use Override;
15use Phexium\Plugin\Cache\Exception\CacheDirectoryNotFoundException;
16use Phexium\Plugin\Cache\Internal\CacheEntry;
17use Phexium\Plugin\Cache\Internal\CacheMultipleOperationsTrait;
18use Phexium\Plugin\Cache\Port\CacheInterface;
19use Phexium\Plugin\Clock\Port\ClockInterface;
20
21final readonly class FileCache implements CacheInterface
22{
23    use CacheMultipleOperationsTrait;
24
25    public function __construct(
26        private string $cacheDirectory,
27        private ClockInterface $clock,
28    ) {
29        if (!is_dir($this->cacheDirectory)) {
30            throw CacheDirectoryNotFoundException::withPath($this->cacheDirectory);
31        }
32    }
33
34    #[Override]
35    public function get(string $key, mixed $default = null): mixed
36    {
37        if (!$this->has($key)) {
38            return $default;
39        }
40
41        $entry = $this->readEntry($key);
42
43        return $entry->value;
44    }
45
46    #[Override]
47    public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
48    {
49        $ttlSeconds = $this->normalizeTtl($ttl);
50
51        if ($ttlSeconds !== null && $ttlSeconds <= 0) {
52            $this->delete($key);
53
54            return true;
55        }
56
57        $expiresAt = null;
58
59        if ($ttlSeconds !== null) {
60            $expiresAt = $this->clock->now()->getTimestamp() + $ttlSeconds;
61        }
62
63        $entry = new CacheEntry($value, $expiresAt);
64
65        $this->writeEntry($key, $entry);
66
67        return true;
68    }
69
70    #[Override]
71    public function has(string $key): bool
72    {
73        $filePath = $this->getFilePath($key);
74
75        if (!file_exists($filePath)) {
76            return false;
77        }
78
79        $entry = $this->readEntry($key);
80
81        if (!$entry instanceof CacheEntry) {
82            return false;
83        }
84
85        $isExpired = $this->isExpired($entry);
86
87        if ($isExpired) {
88            $this->deleteFile($filePath);
89        }
90
91        return !$isExpired;
92    }
93
94    #[Override]
95    public function delete(string $key): bool
96    {
97        $filePath = $this->getFilePath($key);
98
99        if (!file_exists($filePath)) {
100            return false;
101        }
102
103        return $this->deleteFile($filePath);
104    }
105
106    #[Override]
107    public function clear(): bool
108    {
109        foreach (glob($this->cacheDirectory.'/*.cache') as $file) {
110            $this->deleteFile($file);
111        }
112
113        return true;
114    }
115
116    private function isExpired(CacheEntry $entry): bool
117    {
118        return $entry->expiresAt !== null
119            && $entry->expiresAt <= $this->clock->now()->getTimestamp();
120    }
121
122    private function getFilePath(string $key): string
123    {
124        return $this->cacheDirectory.'/'.hash('xxh128', $key).'.cache';
125    }
126
127    private function readEntry(string $key): ?CacheEntry
128    {
129        $filePath = $this->getFilePath($key);
130        $content = file_get_contents($filePath);
131        $data = json_decode($content, true);
132
133        if (!is_array($data) || !array_key_exists('value', $data) || !array_key_exists('expiresAt', $data)) {
134            return null;
135        }
136
137        return CacheEntry::fromArray($data);
138    }
139
140    private function writeEntry(string $key, CacheEntry $entry): void
141    {
142        $filePath = $this->getFilePath($key);
143        $content = json_encode($entry->toArray(), JSON_THROW_ON_ERROR);
144
145        file_put_contents($filePath, $content, LOCK_EX);
146    }
147
148    private function deleteFile(string $filePath): bool
149    {
150        return unlink($filePath);
151    }
152
153    private function normalizeTtl(DateInterval|int|null $ttl): ?int
154    {
155        if ($ttl instanceof DateInterval) {
156            $now = new DateTimeImmutable();
157
158            return $now->add($ttl)->getTimestamp() - $now->getTimestamp();
159        }
160
161        return $ttl;
162    }
163}