Lines 90.90% 50 / 55
Methods 66.66% 6 / 9
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 planChanges 100.00% 8 / 8 100.00% 1 / 1 3
 writeChanges 100.00% 14 / 14 100.00% 1 / 1 7
 outcomeFor 100.00% 7 / 7 100.00% 1 / 1 5
 refuseLargeShrink 100.00% 4 / 4 100.00% 1 / 1 2
 orphanedGeneratedFiles 100.00% 10 / 10 100.00% 1 / 1 9
 ensureDirectory 75.00% 3 / 4 0.00% 0 / 1 4.25
 writeFileAtomically 40.00% 2 / 5 0.00% 0 / 1 7.46
 removeFile 50.00% 1 / 2 0.00% 0 / 1 4.12
12final readonly class OutputDirectory
13{
14    private const string PHP_EXTENSION = '.php';
15
16    public function __construct(private string $path) {}
17
18    /**
19     * @throws CodegenException
20     * @param list<GeneratedFile> $files
21     */
22    public function planChanges(array $files): FileChangeCollection
23    {
24        $changes = [];
25        $rendered = [];
26
27        foreach ($files as $file) {
28            $rendered[$file->path] = true;
29            $changes[] = new FileChange($file->path, $this->outcomeFor($file));
30        }
31
32        foreach ($this->orphanedGeneratedFiles($rendered) as $path) {
33            $changes[] = new FileChange($path, FileOutcome::Deleted);
34        }
35
36        return FileChangeCollection::fromChangesSortedByPath($changes);
37    }
38
39    /**
40     * @throws CodegenException
41     * @param list<GeneratedFile> $files
42     */
43    public function writeChanges(array $files, ShrinkPolicy $shrinkPolicy): FileChangeCollection
44    {
45        $changes = $this->planChanges($files);
46
47        if ($shrinkPolicy === ShrinkPolicy::Refuse) {
48            $this->refuseLargeShrink($changes);
49        }
50
51        $written = [];
52
53        foreach ($changes->filterStale() as $change) {
54            $written[$change->path] = true;
55        }
56
57        $this->ensureDirectory();
58
59        foreach ($files as $file) {
60            if (isset($written[$file->path])) {
61                $this->writeFileAtomically($this->path . '/' . $file->path, $file->contents);
62            }
63        }
64
65        foreach ($changes as $change) {
66            if ($change->outcome === FileOutcome::Deleted) {
67                $this->removeFile($this->path . '/' . $change->path);
68            }
69        }
70
71        return $changes;
72    }
73
74    /** @throws CodegenException */
75    private function outcomeFor(GeneratedFile $file): FileOutcome
76    {
77        $target = $this->path . '/' . $file->path;
78
79        if (!is_file($target)) {
80            return FileOutcome::Created;
81        }
82
83        $current = file_get_contents($target);
84
85        if ($current !== false && !GeneratedCodePrinter::isGenerated($current)) {
86            throw CodegenException::createForReason(CodegenError::UnmarkedFileInTheWay, path: $target);
87        }
88
89        return $current === $file->contents ? FileOutcome::Unchanged : FileOutcome::Updated;
90    }
91
92    /** @throws CodegenException */
93    private function refuseLargeShrink(FileChangeCollection $changes): void
94    {
95        $deleted = $changes->countWithOutcome(FileOutcome::Deleted);
96        $existing = $changes->countExistingFiles();
97
98        if ($deleted * 2 > $existing) {
99            throw CodegenException::createForReason(CodegenError::ShrinkRefused, deleted: $deleted, existing: $existing);
100        }
101    }
102
103    /**
104     * @param array<string, true> $rendered
105     * @return list<string>
106     */
107    private function orphanedGeneratedFiles(array $rendered): array
108    {
109        if (!is_dir($this->path)) {
110            return [];
111        }
112
113        $orphaned = [];
114
115        foreach (scandir($this->path) ?: [] as $name) {
116            $file = $this->path . '/' . $name;
117
118            if (!str_ends_with($name, self::PHP_EXTENSION) || isset($rendered[$name]) || !is_file($file)) {
119                continue;
120            }
121
122            $contents = file_get_contents($file);
123
124            if ($contents !== false && GeneratedCodePrinter::isGenerated($contents)) {
125                $orphaned[] = $name;
126            }
127        }
128
129        return $orphaned;
130    }
131
132    private function ensureDirectory(): void
133    {
134        if (is_dir($this->path)) {
135            return;
136        }
137
138        if (!mkdir($this->path, 0o777, true) && !is_dir($this->path)) {
139            throw CodegenException::createForReason(CodegenError::OutputDirectoryNotCreatable, path: $this->path);
140        }
141    }
142
143    private function writeFileAtomically(string $target, string $contents): void
144    {
145        // Not `.php`, so the stale scan and the autoloader ignore leftovers of an interrupted run.
146        $temporary = \sprintf('%s.%s.tmp', $target, bin2hex(random_bytes(4)));
147
148        if (file_put_contents($temporary, $contents) === false || !rename($temporary, $target)) {
149            if (is_file($temporary)) {
150                unlink($temporary);
151            }
152
153            throw CodegenException::fileNotWritable($target);
154        }
155    }
156
157    private function removeFile(string $target): void
158    {
159        if (is_file($target) && !unlink($target)) {
160            throw CodegenException::createForReason(CodegenError::FileNotDeletable, path: $target);
161        }
162    }
163}