Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.74% covered (success)
94.74%
54 / 57
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileCache
94.74% covered (success)
94.74%
54 / 57
75.00% covered (warning)
75.00%
9 / 12
30.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 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
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 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
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 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\Adapter\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        if (!$entry instanceof CacheEntry) {
44            return $default;
45        }
46
47        return $entry->value;
48    }
49
50    #[Override]
51    public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
52    {
53        $ttlSeconds = $this->normalizeTtl($ttl);
54
55        if ($ttlSeconds !== null && $ttlSeconds <= 0) {
56            $this->delete($key);
57
58            return true;
59        }
60
61        $expiresAt = null;
62
63        if ($ttlSeconds !== null) {
64            $expiresAt = $this->clock->now()->getTimestamp() + $ttlSeconds;
65        }
66
67        $entry = new CacheEntry($value, $expiresAt);
68
69        $this->writeEntry($key, $entry);
70
71        return true;
72    }
73
74    #[Override]
75    public function has(string $key): bool
76    {
77        $filePath = $this->getFilePath($key);
78
79        if (!file_exists($filePath)) {
80            return false;
81        }
82
83        $entry = $this->readEntry($key);
84
85        if (!$entry instanceof CacheEntry) {
86            return false;
87        }
88
89        $isExpired = $this->isExpired($entry);
90
91        if ($isExpired) {
92            $this->deleteFile($filePath);
93        }
94
95        return !$isExpired;
96    }
97
98    #[Override]
99    public function delete(string $key): bool
100    {
101        $filePath = $this->getFilePath($key);
102
103        if (!file_exists($filePath)) {
104            return false;
105        }
106
107        return $this->deleteFile($filePath);
108    }
109
110    #[Override]
111    public function clear(): bool
112    {
113        $files = glob($this->cacheDirectory.'/*.cache');
114
115        if ($files === false) {
116            return false;
117        }
118
119        foreach ($files as $file) {
120            $this->deleteFile($file);
121        }
122
123        return true;
124    }
125
126    private function isExpired(CacheEntry $entry): bool
127    {
128        return $entry->expiresAt !== null
129            && $entry->expiresAt <= $this->clock->now()->getTimestamp();
130    }
131
132    private function getFilePath(string $key): string
133    {
134        return $this->cacheDirectory.'/'.hash('xxh128', $key).'.cache';
135    }
136
137    private function readEntry(string $key): ?CacheEntry
138    {
139        $filePath = $this->getFilePath($key);
140        $content = file_get_contents($filePath);
141
142        if ($content === false) {
143            return null;
144        }
145
146        $data = json_decode($content, true);
147
148        if (!is_array($data) || !array_key_exists('value', $data) || !array_key_exists('expiresAt', $data)) {
149            return null;
150        }
151
152        return CacheEntry::fromArray($data);
153    }
154
155    private function writeEntry(string $key, CacheEntry $entry): void
156    {
157        $filePath = $this->getFilePath($key);
158        $content = json_encode($entry->toArray(), JSON_THROW_ON_ERROR);
159
160        file_put_contents($filePath, $content, LOCK_EX);
161    }
162
163    private function deleteFile(string $filePath): bool
164    {
165        return unlink($filePath);
166    }
167
168    private function normalizeTtl(DateInterval|int|null $ttl): ?int
169    {
170        if ($ttl instanceof DateInterval) {
171            $now = new DateTimeImmutable();
172
173            return $now->add($ttl)->getTimestamp() - $now->getTimestamp();
174        }
175
176        return $ttl;
177    }
178}