Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.72% covered (warning)
84.72%
61 / 72
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PackageBuilderService
84.51% covered (warning)
84.51%
60 / 71
25.00% covered (danger)
25.00%
1 / 4
19.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
82.05% covered (warning)
82.05%
32 / 39
0.00% covered (danger)
0.00%
0 / 1
7.28
 resolveOutputDir
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 collectAndArchiveFiles
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
7.06
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Packaging\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Packaging\Dto\BuildConfigDto;
12use App\Core\Packaging\Exception\PackagingException;
13use FilesystemIterator;
14use RecursiveDirectoryIterator;
15use RecursiveIteratorIterator;
16use SplFileInfo;
17use Yiisoft\Files\FileHelper;
18use ZipArchive;
19
20/**
21 * Service responsible for assembling stripped, production-ready release and update packages.
22 *
23 * @package App\Core\Packaging\Service
24 */
25final class PackageBuilderService
26{
27    /**
28     * PackageBuilderService constructor.
29     *
30     * @param string $projectRoot Absolute path to project root.
31     * @param PackageFilterService $filterService File filtering service.
32     */
33    public function __construct(
34        private readonly string $projectRoot,
35        private readonly PackageFilterService $filterService = new PackageFilterService()
36    ) {
37    }
38
39    /**
40     * Builds release ZIP package according to configuration DTO.
41     *
42     * @param BuildConfigDto $config Build options and version specification.
43     * @return array{package_path: string, checksum: string, files_count: int, version: string} Build summary.
44     * @throws PackagingException If ZIP extension is missing or archive creation fails.
45     */
46    public function build(BuildConfigDto $config): array
47    {
48        if (!class_exists(ZipArchive::class)) {
49            throw new PackagingException('PHP zip extension (ZipArchive) is required to build packages.');
50        }
51
52        $outputDir = $this->resolveOutputDir($config->outputDir);
53        FileHelper::ensureDirectory($outputDir);
54
55        $packageFilename = sprintf('ammonly-v%s-%s.zip', $config->version, $config->type);
56        $packagePath = rtrim($outputDir, '/\\') . '/' . $packageFilename;
57
58        if (file_exists($packagePath)) {
59            FileHelper::unlink($packagePath);
60        }
61
62        $zip = new ZipArchive();
63        $res = $zip->open($packagePath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
64        if ($res !== true) {
65            throw new PackagingException(
66                sprintf('Failed to create ZIP package at: %s (code %d)', $packagePath, $res)
67            );
68        }
69
70        $canonicalRoot = str_replace('\\', '/', realpath($this->projectRoot) ?: $this->projectRoot);
71        [$filesManifest, $filesCount] = $this->collectAndArchiveFiles($zip, $canonicalRoot);
72
73        // Generate and embed package manifest
74        $manifestData = [
75            'name' => 'ammonly/release-package',
76            'version' => $config->version,
77            'type' => $config->type,
78            'from_version' => $config->fromVersion,
79            'created_at' => date('c'),
80            'files_count' => $filesCount,
81            'files' => $filesManifest,
82        ];
83
84        $manifestJson = json_encode($manifestData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
85        if ($manifestJson === false) {
86            throw new PackagingException('Failed to serialize package manifest JSON.');
87        }
88
89        $zip->addFromString('manifest.json', $manifestJson);
90        $zip->close();
91
92        $packageChecksum = hash_file('sha256', $packagePath);
93        if ($packageChecksum === false) {
94            throw new PackagingException('Failed to calculate package checksum.');
95        }
96
97        return [
98            'package_path' => $packagePath,
99            'checksum' => $packageChecksum,
100            'files_count' => $filesCount,
101            'version' => $config->version,
102        ];
103    }
104
105    private function resolveOutputDir(string $outputDir): string
106    {
107        if (str_starts_with($outputDir, '/') || preg_match('/^[A-Za-z]:[\\\\\/]/', $outputDir)) {
108            return $outputDir;
109        }
110
111        return $this->projectRoot . '/' . ltrim($outputDir, '/\\');
112    }
113
114    /**
115     * @return array{0: list<array{path: string, sha256: string|false, size: int}>, 1: int}
116     */
117    private function collectAndArchiveFiles(ZipArchive $zip, string $canonicalRoot): array
118    {
119        $filesManifest = [];
120        $filesCount = 0;
121
122        $iterator = new RecursiveIteratorIterator(
123            new RecursiveDirectoryIterator($this->projectRoot, FilesystemIterator::SKIP_DOTS),
124            RecursiveIteratorIterator::LEAVES_ONLY
125        );
126
127        /** @var SplFileInfo $file */
128        foreach ($iterator as $file) {
129            if (!$file->isFile()) {
130                continue;
131            }
132
133            $realPath = $file->getRealPath();
134            if ($realPath === false) {
135                continue;
136            }
137
138            $canonicalReal = str_replace('\\', '/', $realPath);
139            if (!str_starts_with($canonicalReal, $canonicalRoot)) {
140                continue;
141            }
142
143            $normalizedRelative = ltrim(substr($canonicalReal, strlen($canonicalRoot)), '/');
144
145            // Skip output archive itself and excluded artifacts
146            if (
147                str_starts_with($normalizedRelative, 'build/')
148                || $this->filterService->shouldExclude($normalizedRelative)
149            ) {
150                continue;
151            }
152
153            $hash = hash_file('sha256', $realPath);
154            $filesManifest[] = [
155                'path' => $normalizedRelative,
156                'sha256' => $hash,
157                'size' => $file->getSize(),
158            ];
159
160            $zip->addFile($realPath, $normalizedRelative);
161            $filesCount++;
162        }
163
164        return [$filesManifest, $filesCount];
165    }
166}