Lines 100.00% 68 / 68
Functions and Methods 100.00% 13 / 13
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
GenerateCommand 100.00% 68 / 68 100.00% 13 / 13 28 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 configure 100.00% 6 / 6 100.00% 1 / 1 1
 execute 100.00% 10 / 10 100.00% 1 / 1 3
 copySnapshotIfRequested 100.00% 3 / 3 100.00% 1 / 1 2
 generateFromSnapshot 100.00% 5 / 5 100.00% 1 / 1 2
 selectShrinkPolicy 100.00% 1 / 1 100.00% 1 / 1 2
 loadSnapshot 100.00% 5 / 5 100.00% 1 / 1 2
 describeAutoloadMismatch 100.00% 12 / 12 100.00% 1 / 1 3
 printGenerationSummary 100.00% 3 / 3 100.00% 1 / 1 1
 printCheckResult 100.00% 11 / 11 100.00% 1 / 1 3
 printWarnings 100.00% 2 / 2 100.00% 1 / 1 2
 printFailure 100.00% 2 / 2 100.00% 1 / 1 1
 normalizePath 100.00% 7 / 7 100.00% 1 / 1 5
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Codegen\Console;
6
7use Stewart\Codegen\GenerationTarget;
8use Stewart\Codegen\Generator;
9use Stewart\Codegen\Model\GenerationWarning;
10use Stewart\Codegen\Output\CheckResult;
11use Stewart\Codegen\Output\FileChange;
12use Stewart\Codegen\Output\GenerationReport;
13use Stewart\Codegen\Output\ShrinkPolicy;
14use Stewart\Codegen\Snapshot\Snapshot;
15use Stewart\Codegen\Snapshot\SnapshotFetcher;
16use Stewart\Codegen\Snapshot\SnapshotFileReader;
17use Stewart\Codegen\Snapshot\SnapshotFileWriter;
18use Stewart\Contracts\Exception\StewartException;
19use Stewart\Runtime\Config\CodegenConfig;
20use Stewart\Runtime\Config\ConfigLoader;
21use Stewart\Runtime\Config\ProjectRoot;
22use Stewart\Runtime\Config\Psr4Map;
23use Stewart\Runtime\Config\StewartConfig;
24use Stewart\Runtime\Console\StewartCommand;
25use Stewart\Runtime\Logging\LoggerFactory;
26use Symfony\Component\Console\Attribute\AsCommand;
27use Symfony\Component\Console\Command\Command;
28use Symfony\Component\Console\Formatter\OutputFormatter;
29use Symfony\Component\Console\Helper\Table;
30use Symfony\Component\Console\Input\InputInterface;
31use Symfony\Component\Console\Input\InputOption;
32use Symfony\Component\Console\Output\OutputInterface;
33
34#[AsCommand(name: self::NAME, description: 'Write typed entity and service classes from your Home Assistant')]
35final class GenerateCommand extends StewartCommand
36{
37    public const string NAME = 'generate';
38
39    public function __construct(
40        private readonly ConfigLoader $config,
41        private readonly Psr4Map $psr4Map,
42        private readonly ProjectRoot $projectRoot,
43        private readonly LoggerFactory $loggers,
44        private readonly SnapshotFileReader $snapshotReader,
45        private readonly SnapshotFileWriter $snapshotWriter,
46        private readonly SnapshotFetcher $snapshotFetcher,
47        private readonly Generator $generator,
48        private readonly GenerationOptionsFactory $generationOptionsFactory,
49    ) {
50        parent::__construct();
51    }
52
53    protected function configure(): void
54    {
55        parent::configure();
56        $this
57            ->addOption('snapshot-in', null, InputOption::VALUE_REQUIRED, 'Generate from this file instead of connecting')
58            ->addOption('snapshot-out', null, InputOption::VALUE_REQUIRED, 'Write what was read to this file')
59            ->addOption('check', null, InputOption::VALUE_NONE, 'Report what would change and fail if anything would')
60            ->addOption('allow-shrink', null, InputOption::VALUE_NONE, 'Write even if that deletes more than half of the generated files');
61    }
62
63    protected function execute(InputInterface $input, OutputInterface $output): int
64    {
65        try {
66            $config = $this->config->loadConfig($this->findConfigOption($input));
67            $mismatch = $this->describeAutoloadMismatch($config->codegen);
68
69            if ($mismatch !== null) {
70                $output->writeln($mismatch);
71
72                return Command::FAILURE;
73            }
74
75            $snapshot = $this->loadSnapshot($input, $config);
76            $this->copySnapshotIfRequested($input, $snapshot);
77
78            return $this->generateFromSnapshot($input, $output, $config->codegen, $snapshot);
79        } catch (StewartException $e) {
80            return $this->printFailure($output, $e->getMessage());
81        }
82    }
83
84    /** @throws StewartException */
85    private function copySnapshotIfRequested(InputInterface $input, Snapshot $snapshot): void
86    {
87        $snapshotOut = $this->nonEmptyStringOption($input, 'snapshot-out');
88
89        if ($snapshotOut !== null) {
90            $this->snapshotWriter->writeSnapshot($snapshotOut, $snapshot);
91        }
92    }
93
94    /** @throws StewartException */
95    private function generateFromSnapshot(InputInterface $input, OutputInterface $output, CodegenConfig $codegen, Snapshot $snapshot): int
96    {
97        $target = new GenerationTarget($codegen->namespace->value, $codegen->outputDir->resolveWithin($this->projectRoot->path));
98        $options = $this->generationOptionsFactory->createFromCodegenConfig($codegen);
99
100        return $input->getOption('check') === true
101            ? $this->printCheckResult($this->generator->checkAgainstSnapshot($snapshot, $target, $options), $output)
102            : $this->printGenerationSummary($this->generator->writeFiles($snapshot, $target, $options, $this->selectShrinkPolicy($input)), $output);
103    }
104
105    private function selectShrinkPolicy(InputInterface $input): ShrinkPolicy
106    {
107        return $input->getOption('allow-shrink') === true ? ShrinkPolicy::Allow : ShrinkPolicy::Refuse;
108    }
109
110    private function loadSnapshot(InputInterface $input, StewartConfig $config): Snapshot
111    {
112        $snapshotIn = $this->nonEmptyStringOption($input, 'snapshot-in');
113        $logger = $this->loggers->createStdoutLogger($config->logLevel, $config->logFormat);
114
115        if ($snapshotIn !== null) {
116            return $this->snapshotReader->readSnapshot($snapshotIn, $logger);
117        }
118
119        return $this->snapshotFetcher->fetchSnapshot($config->requireHomeAssistant(), $logger);
120    }
121
122    // Compared as strings: realpath fails while the directory does not exist yet.
123    private function describeAutoloadMismatch(CodegenConfig $codegen): ?string
124    {
125        $expected = $this->normalizePath($codegen->outputDir->resolveWithin($this->projectRoot->path));
126
127        foreach ($this->psr4Map->listDirectoriesFor($codegen->namespace->value . '\\') as $directory) {
128            if ($this->normalizePath($directory) === $expected) {
129                return null;
130            }
131        }
132
133        return \sprintf(
134            "<error>Composer does not map %s\\ to %s/.</error>\nAdd this to composer.json, then run `make install`:\n\n"
135            . "    \"autoload\": {\n        \"psr-4\": {\n            \"%s\\\\\": \"%s/\"\n        }\n    }\n",
136            $codegen->namespace->value,
137            $codegen->outputDir->value,
138            str_replace('\\', '\\\\', $codegen->namespace->value),
139            $codegen->outputDir->value,
140        );
141    }
142
143    private function printGenerationSummary(GenerationReport $report, OutputInterface $output): int
144    {
145        $this->printWarnings($output, $report->warnings);
146        $output->writeln($report->summary());
147
148        return Command::SUCCESS;
149    }
150
151    private function printCheckResult(CheckResult $result, OutputInterface $output): int
152    {
153        $this->printWarnings($output, $result->warnings);
154
155        if ($result->isClean()) {
156            $output->writeln('<info>The generated classes match Home Assistant.</info>');
157
158            return Command::SUCCESS;
159        }
160
161        $rows = $result->listStaleFiles()->mapToList(
162            static fn(FileChange $change): array => [$change->path, $change->outcome->value],
163        );
164
165        if ($rows !== []) {
166            new Table($output)->setStyle('compact')->setHeaders(['file', 'would be'])->setRows($rows)->render();
167        }
168
169        $output->writeln('<comment>Run `make generate`.</comment>');
170
171        return Command::FAILURE;
172    }
173
174    /** @param list<GenerationWarning> $warnings */
175    private function printWarnings(OutputInterface $output, array $warnings): void
176    {
177        foreach ($warnings as $warning) {
178            $output->writeln('<comment>' . OutputFormatter::escape($warning->describeWarning()) . '</comment>');
179        }
180    }
181
182    private function printFailure(OutputInterface $output, string $message): int
183    {
184        $output->writeln('<error>' . $message . '</error>');
185
186        return Command::FAILURE;
187    }
188
189    // Composer's base directory contains `..`, and realpath fails before the first run.
190    private function normalizePath(string $path): string
191    {
192        $segments = [];
193
194        foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
195            match ($segment) {
196                '', '.' => null,
197                '..' => array_pop($segments),
198                default => $segments[] = $segment,
199            };
200        }
201
202        return '/' . implode('/', $segments);
203    }
204}