Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
FileCache
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
14 / 14
32
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
5
 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
 getMultiple
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setMultiple
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 deleteMultiple
100.00% covered (success)
100.00%
3 / 3
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\Internal\CacheEntry;
16use Phexium\Plugin\Cache\Port\CacheInterface;
17use Phexium\Plugin\Clock\Port\ClockInterface;
18use RuntimeException;
19
20final readonly class FileCache implements CacheInterface
21{
22    public function __construct(
23        private string $cacheDirectory,
24        private ClockInterface $clock,
25    ) {
26        if (!is_dir($this->cacheDirectory)) {
27            throw new RuntimeException(sprintf('Cache directory "%s" does not exist', $this->cacheDirectory));
28        }
29    }
30
31    #[Override]
32    public function get(string $key, mixed $default = null): mixed
33    {
34        if (!$this->has($key)) {
35            return $default;
36        }
37
38        $entry = $this->readEntry($key);
39
40        return $entry->value;
41    }
42
43    #[Override]
44    public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
45    {
46        $ttlSeconds = $this->normalizeTtl($ttl);
47
48        if ($ttlSeconds !== null && $ttlSeconds <= 0) {
49            $this->delete($key);
50
51            return true;
52        }
53
54        $expiresAt = null;
55
56        if ($ttlSeconds !== null) {
57            $expiresAt = $this->clock->now()->getTimestamp() + $ttlSeconds;
58        }
59
60        $entry = new CacheEntry($value, $expiresAt);
61
62        $this->writeEntry($key, $entry);
63
64        return true;
65    }
66
67    #[Override]
68    public function has(string $key): bool
69    {
70        $filePath = $this->getFilePath($key);
71
72        if (!file_exists($filePath)) {
73            return false;
74        }
75
76        $entry = $this->readEntry($key);
77
78        if (!$entry instanceof CacheEntry) {
79            return false;
80        }
81
82        if ($entry->expiresAt !== null && $entry->expiresAt <= $this->clock->now()->getTimestamp()) {
83            $this->deleteFile($filePath);
84
85            return false;
86        }
87
88        return true;
89    }
90
91    #[Override]
92    public function delete(string $key): bool
93    {
94        $filePath = $this->getFilePath($key);
95
96        if (!file_exists($filePath)) {
97            return false;
98        }
99
100        return $this->deleteFile($filePath);
101    }
102
103    #[Override]
104    public function clear(): bool
105    {
106        foreach (glob($this->cacheDirectory.'/*.cache') as $file) {
107            $this->deleteFile($file);
108        }
109
110        return true;
111    }
112
113    #[Override]
114    public function getMultiple(iterable $keys, mixed $default = null): iterable
115    {
116        $result = [];
117
118        foreach ($keys as $key) {
119            $result[$key] = $this->get($key, $default);
120        }
121
122        return $result;
123    }
124
125    #[Override]
126    public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
127    {
128        foreach ($values as $key => $value) {
129            $this->set($key, $value, $ttl);
130        }
131
132        return true;
133    }
134
135    #[Override]
136    public function deleteMultiple(iterable $keys): bool
137    {
138        foreach ($keys as $key) {
139            $this->delete($key);
140        }
141
142        return true;
143    }
144
145    private function getFilePath(string $key): string
146    {
147        return $this->cacheDirectory.'/'.md5($key).'.cache';
148    }
149
150    private function readEntry(string $key): ?CacheEntry
151    {
152        $filePath = $this->getFilePath($key);
153        $content = file_get_contents($filePath);
154        $data = json_decode($content, true);
155
156        if (!is_array($data) || !array_key_exists('value', $data) || !array_key_exists('expiresAt', $data)) {
157            return null;
158        }
159
160        return CacheEntry::fromArray($data);
161    }
162
163    private function writeEntry(string $key, CacheEntry $entry): void
164    {
165        $filePath = $this->getFilePath($key);
166        $content = json_encode($entry->toArray(), JSON_THROW_ON_ERROR);
167
168        file_put_contents($filePath, $content, LOCK_EX);
169    }
170
171    private function deleteFile(string $filePath): bool
172    {
173        return unlink($filePath);
174    }
175
176    private function normalizeTtl(DateInterval|int|null $ttl): ?int
177    {
178        if ($ttl instanceof DateInterval) {
179            $now = new DateTimeImmutable();
180
181            return $now->add($ttl)->getTimestamp() - $now->getTimestamp();
182        }
183
184        return $ttl;
185    }
186}