Lines 100.00% 145 / 145
Methods 100.00% 17 / 17
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 buildAppServiceId 100.00% 1 / 1 100.00% 1 / 1 1
 buildPeerStoreServiceId 100.00% 1 / 1 100.00% 1 / 1 1
 buildAppContainer 100.00% 12 / 12 100.00% 1 / 1 5
 isolateUnbuildableApps 100.00% 11 / 11 100.00% 1 / 1 4
 compileOrFailWorker 100.00% 3 / 3 100.00% 1 / 1 2
 compileContainerFor 100.00% 20 / 20 100.00% 1 / 1 3
 explainAppFailure 100.00% 1 / 1 100.00% 1 / 1 2
 explainWorkerFailure 100.00% 5 / 5 100.00% 1 / 1 2
 loadUserServices 100.00% 4 / 4 100.00% 1 / 1 2
 registerFrameworkServices 100.00% 18 / 18 100.00% 1 / 1 2
 compileWithSynthetics 100.00% 5 / 5 100.00% 1 / 1 2
 rejectAppDefinitions 100.00% 4 / 4 100.00% 1 / 1 3
 registerSharedGeneratedRoots 100.00% 6 / 6 100.00% 1 / 1 3
 registerApp 100.00% 9 / 9 100.00% 1 / 1 1
 registerAppScopedServices 100.00% 29 / 29 100.00% 1 / 1 3
 buildAppBindings 100.00% 15 / 15 100.00% 1 / 1 2
45final readonly class AppContainerBuilder
46{
47    // A leading dot hides an id from autowiring, so user wiring sees only the contract types.
48    public const string LOGGER = '.stewart.logger';
49
50    public const string CONTEXT = '.stewart.ha';
51
52    public const string SCHEDULER = '.stewart.scheduler';
53
54    public const string STORES = '.stewart.stores';
55
56    public const string MQTT = '.stewart.mqtt';
57
58    public const string EXPOSURE = '.stewart.exposure';
59
60    public const string REGISTRY_EDITOR = '.stewart.registry_editor';
61
62    public const string GLOBAL_STORE = '.stewart.store.global';
63
64    public const string READ_ONLY_GLOBAL_STORE = '.stewart.store.global.read_only';
65
66    private const string TIMERS = '.stewart.timers';
67
68    private const string LOOP_TIMERS = '.stewart.loop_timers';
69
70    private const string LOGGER_SUFFIX = '.logger';
71
72    private const string CONTEXT_SUFFIX = '.context';
73
74    private const string SCHEDULER_SUFFIX = '.scheduler';
75
76    private const string TIMERS_SUFFIX = '.timers';
77
78    private const string STORE_SUFFIX = '.store';
79
80    private const string READ_ONLY_STORE_SUFFIX = '.store.read_only';
81
82    private const string MQTT_SUFFIX = '.mqtt';
83
84    private const string EXPOSURE_SUFFIX = '.exposure';
85
86    private const string REGISTRY_EDITOR_SUFFIX = '.registry_editor';
87
88    private const string ENTITIES_SUFFIX = '.entities';
89
90    private const string SERVICES_SUFFIX = '.services';
91
92    public function __construct(private AppOptionResolver $optionResolver) {}
93
94    public static function buildAppServiceId(AppId $appId): string
95    {
96        return '.stewart.app.' . $appId->value;
97    }
98
99    public static function buildPeerStoreServiceId(AppId $appId): string
100    {
101        return '.stewart.store.peer.' . $appId->value;
102    }
103
104    /** @throws StewartException */
105    public function buildAppContainer(WorkerAppCollection $apps, AppRuntimeServices $runtime, ?string $servicesFile, WorkerId $workerId): AppContainerBuild
106    {
107        $registrations = [];
108        $failures = [];
109
110        foreach ($apps as $app) {
111            try {
112                $registrations[] = new AppRegistration($app, $this->optionResolver->resolveOptionArguments($app));
113            } catch (Throwable $e) {
114                $failures[] = new AppBuildFailure($app->id, $this->explainAppFailure($app->id, $e));
115            }
116        }
117
118        try {
119            $container = $this->compileContainerFor($registrations, $runtime, $servicesFile, $workerId);
120        } catch (Throwable $e) {
121            if ($registrations === []) {
122                throw $this->explainWorkerFailure($workerId, $e);
123            }
124
125            return $this->isolateUnbuildableApps($registrations, $failures, $runtime, $servicesFile, $workerId);
126        }
127
128        return new AppContainerBuild($container, AppBuildFailureCollection::keyedByAppId($failures));
129    }
130
131    /**
132     * @param list<AppRegistration> $registrations
133     * @param list<AppBuildFailure> $failures
134     * @throws StewartException
135     */
136    private function isolateUnbuildableApps(
137        array $registrations,
138        array $failures,
139        AppRuntimeServices $runtime,
140        ?string $servicesFile,
141        WorkerId $workerId,
142    ): AppContainerBuild {
143        $this->compileOrFailWorker([], $runtime, $servicesFile, $workerId);
144        $isolated = [];
145
146        foreach ($registrations as $registration) {
147            try {
148                $this->compileContainerFor([$registration], $runtime, $servicesFile, $workerId);
149            } catch (Throwable $e) {
150                $failures[] = new AppBuildFailure($registration->app->id, $this->explainAppFailure($registration->app->id, $e));
151                $isolated[] = $registration->app->id->value;
152            }
153        }
154
155        if ($isolated !== []) {
156            $runtime->logger->warning('The app container failed to compile; apps that cannot be built were left out', ['apps' => $isolated]);
157        }
158
159        $survivors = array_values(array_filter($registrations, static fn(AppRegistration $registration): bool => !\in_array($registration->app->id->value, $isolated, true)));
160
161        return new AppContainerBuild($this->compileOrFailWorker($survivors, $runtime, $servicesFile, $workerId), AppBuildFailureCollection::keyedByAppId($failures));
162    }
163
164    /**
165     * @param list<AppRegistration> $registrations
166     * @throws StewartException
167     */
168    private function compileOrFailWorker(array $registrations, AppRuntimeServices $runtime, ?string $servicesFile, WorkerId $workerId): TypedContainer
169    {
170        try {
171            return $this->compileContainerFor($registrations, $runtime, $servicesFile, $workerId);
172        } catch (Throwable $e) {
173            throw $this->explainWorkerFailure($workerId, $e);
174        }
175    }
176
177    /**
178     * @param list<AppRegistration> $registrations
179     * @throws Throwable
180     */
181    private function compileContainerFor(array $registrations, AppRuntimeServices $runtime, ?string $servicesFile, WorkerId $workerId): TypedContainer
182    {
183        $container = new ContainerBuilder();
184        $container->setParameter('stewart.worker_id', $workerId->value);
185
186        if ($servicesFile !== null) {
187            $this->loadUserServices($container, $servicesFile);
188        }
189
190        $synthetics = [
191            Clock::class => $runtime->clock,
192            SunCalendar::class => $runtime->sunCalendar,
193            self::LOGGER => $runtime->logger,
194            self::CONTEXT => $runtime->context,
195            self::SCHEDULER => $runtime->scheduler,
196            self::LOOP_TIMERS => $runtime->timers,
197            self::STORES => $runtime->stores,
198            self::MQTT => $runtime->mqtt,
199            self::EXPOSURE => $runtime->exposure,
200            self::REGISTRY_EDITOR => $runtime->registryEditor,
201        ];
202
203        $this->registerFrameworkServices($container, array_keys($synthetics), $runtime->generated);
204
205        foreach ($registrations as $registration) {
206            $this->registerApp($container, $registration, $runtime->generated);
207        }
208
209        return $this->compileWithSynthetics($container, $synthetics);
210    }
211
212    /** @return StewartException<ExceptionReason> */
213    private function explainAppFailure(AppId $appId, Throwable $error): StewartException
214    {
215        return $error instanceof StewartException ? $error : ContainerException::createForReason(ContainerError::AppBuildFailed, $error, appId: $appId->value);
216    }
217
218    /** @return StewartException<ExceptionReason> */
219    private function explainWorkerFailure(WorkerId $workerId, Throwable $error): StewartException
220    {
221        return $error instanceof StewartException ? $error : ContainerException::createForReason(
222            ContainerError::BuildFailed,
223            $error,
224            workerId: $workerId->value,
225        );
226    }
227
228    /** @throws ContainerException */
229    private function loadUserServices(ContainerBuilder $container, string $servicesFile): void
230    {
231        if (!is_file($servicesFile)) {
232            throw ContainerException::createForReason(ContainerError::ServicesFileMissing, path: $servicesFile);
233        }
234
235        new PhpFileLoader($container, new FileLocator(\dirname($servicesFile)))->load(basename($servicesFile));
236        $this->rejectAppDefinitions($container);
237    }
238
239    /** @param list<string> $syntheticIds */
240    private function registerFrameworkServices(ContainerBuilder $container, array $syntheticIds, ?GeneratedRoots $generated): void
241    {
242        foreach ($syntheticIds as $id) {
243            // Public so unused definitions survive compilation and can still be set.
244            $container->register($id)->setSynthetic(true)->setPublic(true);
245        }
246
247        // A user service belongs to no automation, so its Store is the global scope.
248        $container->register(self::GLOBAL_STORE, ScopedStore::class)
249            ->setFactory([new Reference(self::STORES), 'openGlobalStore']);
250        $container->register(self::READ_ONLY_GLOBAL_STORE, ReadOnlyStore::class)
251            ->setArguments([new Reference(self::GLOBAL_STORE)]);
252        $container->register(self::TIMERS, WorkerTimers::class)
253            ->setArguments([new Reference(self::SCHEDULER), new Reference(self::LOOP_TIMERS)]);
254
255        $container->setAlias(LoggerInterface::class, self::LOGGER)->setPublic(true);
256        $container->setAlias(HaContext::class, self::CONTEXT)->setPublic(true);
257        $container->setAlias(Scheduler::class, self::SCHEDULER)->setPublic(true);
258        $container->setAlias(Timers::class, self::TIMERS)->setPublic(true);
259        $container->setAlias(Store::class, self::GLOBAL_STORE)->setPublic(true);
260        $container->setAlias(ReadableStore::class, self::READ_ONLY_GLOBAL_STORE)->setPublic(true);
261        $container->setAlias(Mqtt::class, self::MQTT)->setPublic(true);
262        $container->setAlias(EntityExposure::class, self::EXPOSURE)->setPublic(true);
263        $container->setAlias(RegistryEditor::class, self::REGISTRY_EDITOR)->setPublic(true);
264
265        $this->registerSharedGeneratedRoots($container, $generated);
266    }
267
268    /** @param array<string, object> $synthetics */
269    private function compileWithSynthetics(ContainerBuilder $container, array $synthetics): TypedContainer
270    {
271        $container->addCompilerPass(new StoreAttributesPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION);
272        $container->compile();
273
274        foreach ($synthetics as $id => $service) {
275            $container->set($id, $service);
276        }
277
278        return new TypedContainer($container);
279    }
280
281    /** @throws ContainerException */
282    private function rejectAppDefinitions(ContainerBuilder $container): void
283    {
284        foreach ($container->getDefinitions() as $id => $definition) {
285            $class = $definition->getClass() ?? $id;
286
287            if (is_a($class, App::class, true)) {
288                throw ContainerException::createForReason(ContainerError::AppInServicesFile, serviceId: $id, class: $class);
289            }
290        }
291    }
292
293    private function registerSharedGeneratedRoots(ContainerBuilder $container, ?GeneratedRoots $generated): void
294    {
295        if ($generated === null) {
296            return;
297        }
298
299        foreach ([$generated->entitiesClass, $generated->servicesClass] as $class) {
300            $container->register($class, $class)
301                ->setArguments([new Reference(self::CONTEXT)])
302                ->setPublic(true);
303        }
304    }
305
306    private function registerApp(ContainerBuilder $container, AppRegistration $registration, ?GeneratedRoots $generated): void
307    {
308        $appId = $registration->app->id;
309        $serviceId = self::buildAppServiceId($appId);
310        $this->registerAppScopedServices($container, $appId, $serviceId, $generated);
311
312        $container->register($serviceId, $registration->app->class)
313            ->setPublic(true)
314            ->setAutowired(true)
315            ->setAutoconfigured(true)
316            ->setBindings($this->buildAppBindings($serviceId, $generated))
317            ->setArguments($registration->optionArguments);
318    }
319
320    private function registerAppScopedServices(ContainerBuilder $container, AppId $appId, string $serviceId, ?GeneratedRoots $generated): void
321    {
322        $container->register($serviceId . self::LOGGER_SUFFIX, WorkerLogger::class)
323            ->setFactory([new Reference(self::LOGGER), 'forApp'])
324            ->addArgument($appId);
325
326        $container->register($serviceId . self::CONTEXT_SUFFIX, WorkerHaContext::class)
327            ->setFactory([new Reference(self::CONTEXT), 'forApp'])
328            ->addArgument($appId);
329
330        $container->register($serviceId . self::SCHEDULER_SUFFIX, WorkerScheduler::class)
331            ->setFactory([new Reference(self::SCHEDULER), 'forApp'])
332            ->setArguments([$appId, new Reference($serviceId . self::LOGGER_SUFFIX)]);
333
334        $container->register($serviceId . self::TIMERS_SUFFIX, WorkerTimers::class)
335            ->setArguments([new Reference($serviceId . self::SCHEDULER_SUFFIX), new Reference(self::LOOP_TIMERS)]);
336
337        $container->register($serviceId . self::STORE_SUFFIX, ScopedStore::class)
338            ->setFactory([new Reference(self::STORES), 'openAppStore'])
339            ->addArgument($appId);
340
341        $container->register($serviceId . self::READ_ONLY_STORE_SUFFIX, ReadOnlyStore::class)
342            ->addArgument(new Reference($serviceId . self::STORE_SUFFIX));
343
344        $container->register($serviceId . self::MQTT_SUFFIX, WorkerMqtt::class)
345            ->setFactory([new Reference(self::MQTT), 'forApp'])
346            ->addArgument($appId);
347
348        $container->register($serviceId . self::EXPOSURE_SUFFIX, WorkerEntityExposure::class)
349            ->setFactory([new Reference(self::EXPOSURE), 'forApp'])
350            ->addArgument($appId);
351
352        $container->register($serviceId . self::REGISTRY_EDITOR_SUFFIX, WorkerRegistryEditor::class)
353            ->setFactory([new Reference(self::REGISTRY_EDITOR), 'forApp'])
354            ->addArgument($appId);
355
356        if ($generated === null) {
357            return;
358        }
359
360        foreach ([self::ENTITIES_SUFFIX => $generated->entitiesClass, self::SERVICES_SUFFIX => $generated->servicesClass] as $suffix => $class) {
361            $container->register($serviceId . $suffix, $class)->setArguments([new Reference($serviceId . self::CONTEXT_SUFFIX)]);
362        }
363    }
364
365    // Untracked, like the framework bindings, so an app that uses none of them still compiles.
366    /** @return array<string, BoundArgument> */
367    private function buildAppBindings(string $serviceId, ?GeneratedRoots $generated): array
368    {
369        $serviceIdsByType = [
370            HaContext::class => $serviceId . self::CONTEXT_SUFFIX,
371            LoggerInterface::class => $serviceId . self::LOGGER_SUFFIX,
372            Scheduler::class => $serviceId . self::SCHEDULER_SUFFIX,
373            Timers::class => $serviceId . self::TIMERS_SUFFIX,
374            Store::class => $serviceId . self::STORE_SUFFIX,
375            ReadableStore::class => $serviceId . self::READ_ONLY_STORE_SUFFIX,
376            Mqtt::class => $serviceId . self::MQTT_SUFFIX,
377            EntityExposure::class => $serviceId . self::EXPOSURE_SUFFIX,
378            RegistryEditor::class => $serviceId . self::REGISTRY_EDITOR_SUFFIX,
379        ];
380
381        if ($generated !== null) {
382            $serviceIdsByType[$generated->entitiesClass] = $serviceId . self::ENTITIES_SUFFIX;
383            $serviceIdsByType[$generated->servicesClass] = $serviceId . self::SERVICES_SUFFIX;
384        }
385
386        return array_map(static fn(string $id): BoundArgument => new BoundArgument(new Reference($id), false), $serviceIdsByType);
387    }
388}