• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 14606771954

22 Apr 2025 11:49PM UTC coverage: 83.782%. First build
14606771954

Pull #2148

github

web-flow
Merge 8ed8408c0 into 74366c56a
Pull Request #2148: refactor: configuration and cache

360 of 422 new or added lines in 26 files covered. (85.31%)

3048 of 3638 relevant lines covered (83.78%)

0.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.83
/src/Assets/Cache.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <arnaud@ligny.fr>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13

14
namespace Cecil\Assets;
15

16
use Cecil\Builder;
17
use Cecil\Collection\Page\Page;
18
use Cecil\Exception\RuntimeException;
19
use Cecil\Util;
20
use Psr\SimpleCache\CacheInterface;
21

22
class Cache implements CacheInterface
23
{
24
    /** @var Builder */
25
    protected $builder;
26

27
    /** @var string */
28
    protected $cacheDir;
29

30
    /** @var int */
31
    protected $duration;
32

33
    public function __construct(Builder $builder, string $pool = '')
34
    {
35
        $this->builder = $builder;
1✔
36
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
1✔
37
        $this->duration = 31536000; // 1 year
1✔
38
    }
39

40
    /**
41
     * {@inheritdoc}
42
     */
43
    public function set($key, $value, $ttl = null): bool
44
    {
45
        try {
46
            $key = self::sanitizeKey($key);
1✔
47
            $this->prune($key);
1✔
48
            // put file content in a dedicated file
49
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
1✔
50
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
1✔
51
                unset($value['content']);
1✔
52
            }
53
            // serialize data
54
            $data = serialize([
1✔
55
                'value'      => $value,
1✔
56
                'expiration' => time() + $this->duration($ttl),
1✔
57
            ]);
1✔
58
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
1✔
NEW
59
        } catch (\Exception $e) {
×
NEW
60
            $this->builder->getLogger()->error($e->getMessage());
×
61

NEW
62
            return false;
×
63
        }
64

65
        return true;
1✔
66
    }
67

68
    /**
69
     * {@inheritdoc}
70
     */
71
    public function has($key): bool
72
    {
73
        $key = self::sanitizeKey($key);
1✔
74
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
1✔
75
            return false;
1✔
76
        }
77

78
        return true;
1✔
79
    }
80

81
    /**
82
     * {@inheritdoc}
83
     */
84
    public function get($key, $default = null): mixed
85
    {
86
        try {
87
            $key = self::sanitizeKey($key);
1✔
88
            // return default value if file doesn't exists
89
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
1✔
90
                return $default;
×
91
            }
92
            // unserialize data
93
            $data = unserialize($content);
1✔
94
            // check expiration
95
            if ($data['expiration'] <= time()) {
1✔
96
                $this->delete($key);
×
97

98
                return $default;
×
99
            }
100
            // get content from dedicated file
101
            if (\is_array($data['value']) && isset($data['value']['path'])) {
1✔
102
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
1✔
103
                    $data['value']['content'] = $content;
1✔
104
                }
105
            }
106
        } catch (\Exception $e) {
×
107
            $this->builder->getLogger()->error($e->getMessage());
×
108

109
            return $default;
×
110
        }
111

112
        return $data['value'];
1✔
113
    }
114

115
    /**
116
     * {@inheritdoc}
117
     */
118
    public function delete($key): bool
119
    {
120
        try {
NEW
121
            $key = self::sanitizeKey($key);
×
122
            Util\File::getFS()->remove($this->getFilePathname($key));
×
123
            $this->prune($key);
×
124
        } catch (\Exception $e) {
×
125
            $this->builder->getLogger()->error($e->getMessage());
×
126

127
            return false;
×
128
        }
129

130
        return true;
×
131
    }
132

133
    /**
134
     * {@inheritdoc}
135
     */
136
    public function clear(): bool
137
    {
138
        try {
139
            Util\File::getFS()->remove($this->cacheDir);
×
140
        } catch (\Exception $e) {
×
141
            $this->builder->getLogger()->error($e->getMessage());
×
142

143
            return false;
×
144
        }
145

146
        return true;
×
147
    }
148

149
    /**
150
     * {@inheritdoc}
151
     */
152
    public function getMultiple($keys, $default = null): iterable
153
    {
154
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
155
    }
156

157
    /**
158
     * {@inheritdoc}
159
     */
160
    public function setMultiple($values, $ttl = null): bool
161
    {
162
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
163
    }
164

165
    /**
166
     * {@inheritdoc}
167
     */
168
    public function deleteMultiple($keys): bool
169
    {
170
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
171
    }
172

173
    /**
174
     * Creates key from a string: "$name|uniqid__HASH__VERSION".
175
     * $name is optional to add a human readable name to the key.
176
     */
177
    public function createKey(?string $name, string $value): string
178
    {
179
        $hash = hash('md5', $value);
1✔
180
        $name = $name ? self::sanitizeKey($name) : $hash;
1✔
181

182
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
1✔
183
    }
184

185
    /**
186
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
187
     */
188
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
189
    {
190
        $t = $tags;
1✔
191
        $tags = [];
1✔
192

193
        if ($t !== null) {
1✔
194
            ksort($t);
1✔
195
            foreach ($t as $key => $value) {
1✔
196
                switch (\gettype($value)) {
1✔
197
                    case 'boolean':
1✔
198
                        if ($value === true) {
1✔
199
                            $tags[] = $key;
1✔
200
                        }
201
                        break;
1✔
202
                    case 'string':
1✔
203
                    case 'integer':
1✔
204
                        if (!empty($value)) {
1✔
205
                            $tags[] = substr($key, 0, 1) . $value;
1✔
206
                        }
207
                        break;
1✔
208
                }
209
            }
210
        }
211

212
        $tagsInline = implode('_', $tags);
1✔
213
        $name = "{$asset['_path']}_{$asset['ext']}_$tagsInline";
1✔
214

215
        return $this->createKey($name, $asset['content'] ?? '');
1✔
216
    }
217

218
    /**
219
     * Creates key from a file: "RelativePathname__MD5".
220
     *
221
     * @throws RuntimeException
222
     */
223
    public function createKeyFromFile(\Symfony\Component\Finder\SplFileInfo $file): string
224
    {
225
        if (false === $content = Util\File::fileGetContents($file->getRealPath())) {
1✔
NEW
226
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $file));
×
227
        }
228

229
        return $this->createKey($file->getRelativePathname(), $content);
1✔
230
    }
231

232
    /**
233
     * Clear cache by pattern.
234
     */
235
    public function clearByPattern(string $pattern): int
236
    {
237
        try {
238
            if (!Util\File::getFS()->exists($this->cacheDir)) {
×
239
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
×
240
            }
241
            $fileCount = 0;
×
242
            $iterator = new \RecursiveIteratorIterator(
×
243
                new \RecursiveDirectoryIterator($this->cacheDir),
×
244
                \RecursiveIteratorIterator::SELF_FIRST
×
245
            );
×
246
            foreach ($iterator as $file) {
×
247
                if ($file->isFile()) {
×
248
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
×
249
                        Util\File::getFS()->remove($file->getPathname());
×
250
                        $fileCount++;
×
NEW
251
                        $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
×
252
                    }
253
                }
254
            }
255
        } catch (\Exception $e) {
×
256
            $this->builder->getLogger()->error($e->getMessage());
×
257

258
            return 0;
×
259
        }
260

261
        return $fileCount;
×
262
    }
263

264
    /**
265
     * Returns cache content file pathname from path.
266
     */
267
    public function getContentFilePathname(string $path): string
268
    {
269
        $path = str_replace(['https://', 'http://'], '', $path); // remove protocol (if URL)
1✔
270

271
        return Util::joinFile($this->cacheDir, 'files', $path);
1✔
272
    }
273

274
    /**
275
     * Returns cache file pathname from key.
276
     */
277
    private function getFilePathname(string $key): string
278
    {
279
        return Util::joinFile($this->cacheDir, "$key.ser");
1✔
280
    }
281

282
    /**
283
     * Prepares and validate $key.
284
     */
285
    public static function sanitizeKey(string $key): string
286
    {
287
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
1✔
288
        $key = Page::slugify($key);                            // slugify
1✔
289
        $key = trim($key, '/');                                // remove leading/trailing slashes
1✔
290
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
1✔
291
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
1✔
292

293
        return $key;
1✔
294
    }
295

296
    /**
297
     * Removes previous cache files.
298
     */
299
    private function prune(string $key): bool
300
    {
301
        try {
302
            $keyAsArray = explode('__', self::sanitizeKey($key));
1✔
303
            // if 2 or more parts (with hash), remove all files with the same first part
304
            // pattern: `name__hash__version`
305
            if (!empty($keyAsArray[0]) && \count($keyAsArray) >= 2) {
1✔
306
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
1✔
307
                foreach (glob($pattern) ?: [] as $filename) {
1✔
308
                    Util\File::getFS()->remove($filename);
1✔
309
                    $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
1✔
310
                }
311
            }
312
        } catch (\Exception $e) {
×
313
            $this->builder->getLogger()->error($e->getMessage());
×
314

315
            return false;
×
316
        }
317

318
        return true;
1✔
319
    }
320

321
    /**
322
     * Convert the various expressions of a TTL value into duration in seconds.
323
     */
324
    protected function duration(\DateInterval|int|null $ttl): int
325
    {
326
        if ($ttl === null) {
1✔
327
            return $this->duration;
1✔
328
        }
329
        if (\is_int($ttl)) {
1✔
330
            return $ttl;
×
331
        }
332
        if ($ttl instanceof \DateInterval) {
1✔
333
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
1✔
334
        }
335

336
        throw new \InvalidArgumentException('TTL values must be one of null, int, \DateInterval');
×
337
    }
338
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc