Lines 12.12% 8 / 66
Methods 35.71% 5 / 14
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 0.00% 0 / 1 0.00% 0 / 1 2
 configure 0.00% 0 / 5 0.00% 0 / 1 2
 execute 0.00% 0 / 10 0.00% 0 / 1 30
 prepareDaemon 0.00% 0 / 14 0.00% 0 / 1 12
 noteMissingConfigFile 0.00% 0 / 2 0.00% 0 / 1 6
 warnAboutDiscovery 0.00% 0 / 19 0.00% 0 / 1 30
 configOverridesFromOptions 0.00% 0 / 2 0.00% 0 / 1 6
 selectAppsFromOptions 0.00% 0 / 4 0.00% 0 / 1 12
 logFormatFromOptions 0.00% 0 / 1 0.00% 0 / 1 6
 [Stewart\Runtime\Console\StewartCommand] findConfigOption 100.00% 1 / 1 100.00% 1 / 1 1
 [Stewart\Runtime\Console\StewartCommand] nonEmptyStringOption 100.00% 2 / 2 100.00% 1 / 1 3
 [Stewart\Runtime\Console\StewartCommand] writeMissingConfigFileNotice 100.00% 2 / 2 100.00% 1 / 1 2
 [Stewart\Runtime\Console\StewartCommand] writeNotice 100.00% 1 / 1 100.00% 1 / 1 2
 [Stewart\Runtime\Console\StewartCommand] readStringArgument 100.00% 2 / 2 100.00% 1 / 1 2
30final class RunCommand extends StewartCommand
31{
32    public const string NAME = 'run';
33
34    // sysexits EX_TEMPFAIL: the container entrypoint starts the daemon again on the staged release.
35    public const int RESTART_EXIT_CODE = 75;
36
37    public function __construct(
38        private readonly ConfigLoader $config,
39        private readonly AppDiscovery $discovery,
40        private readonly AppCatalogResolver $catalogs,
41        private readonly BrokerKernel $brokers,
42        private readonly LoggerFactory $loggers,
43    ) {
44        parent::__construct();
45    }
46
47    protected function configure(): void
48    {
49        parent::configure();
50        $this
51            ->addOption('log-level', 'l', InputOption::VALUE_REQUIRED, 'debug|info|notice|warning|error')
52            ->addOption('json-logs', null, InputOption::VALUE_NONE, 'Emit logs as JSON')
53            ->addOption('only', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run only this automation, even if disabled; repeat for more');
54    }
55
56    protected function execute(InputInterface $input, OutputInterface $output): int
57    {
58        try {
59            $daemon = $this->prepareDaemon($input);
60        } catch (StewartException $e) {
61            $output->writeln('<error>' . $e->getMessage() . '</error>');
62
63            return Command::FAILURE;
64        }
65
66        try {
67            $outcome = $daemon->broker->run();
68        } catch (Throwable $e) {
69            $daemon->logger->error('Stewart stopped', ['exception' => $e]);
70
71            return Command::FAILURE;
72        }
73
74        return match ($outcome) {
75            BrokerStopOutcome::Stopped => Command::SUCCESS,
76            BrokerStopOutcome::RestartRequested => self::RESTART_EXIT_CODE,
77        };
78    }
79
80    /** @throws StewartException */
81    private function prepareDaemon(InputInterface $input): PreparedDaemon
82    {
83        $configOption = $this->findConfigOption($input);
84        $config = $this->config->loadConfig($configOption, $this->configOverridesFromOptions($input));
85        // Validated before the logger exists, so a missing home_assistant section fails as a config error.
86        $config->requireHomeAssistant();
87        $discovered = $this->discovery->discover();
88        $selection = $this->selectAppsFromOptions($input);
89        $apps = $this->catalogs->resolveCatalog($discovered, $config->apps, $config->workers, $selection);
90        $logger = $this->loggers->createStdoutLogger($config->logLevel, $this->logFormatFromOptions($input, $config->logFormat));
91
92        $this->noteMissingConfigFile($this->config->findMissingDefaultFile($configOption), $logger);
93        $this->warnAboutDiscovery($discovered, $apps, $this->config->locateConfigFile($configOption), $logger);
94
95        if ($config->serviceCalls->dryRun) {
96            $logger->warning('service_calls.dry_run is on: service calls are logged, not sent to Home Assistant.');
97        }
98
99        if ($selection->onlyIds !== null) {
100            $logger->notice('Running only the automations named by --only.', ['apps' => $selection->onlyIds->toStrings()]);
101        }
102
103        return new PreparedDaemon($this->brokers->createBroker($config, $apps, $logger, $configOption), $logger);
104    }
105
106    private function noteMissingConfigFile(?string $missingConfigFile, LoggerInterface $logger): void
107    {
108        if ($missingConfigFile !== null) {
109            $logger->info('No config file found; using the environment only.', ['config' => $missingConfigFile]);
110        }
111    }
112
113    private function warnAboutDiscovery(DiscoveryResult $discovered, AppCatalog $apps, string $configPath, LoggerInterface $logger): void
114    {
115        foreach ($discovered->unmarked as $unmarked) {
116            $logger->warning('A class in apps/ implements App but is not marked #[Automation], so it will not run.', [
117                'class' => $unmarked->class,
118            ]);
119        }
120
121        foreach ($discovered->unloadable as $file) {
122            $logger->error('A file in apps/ failed to load, so the automation it declares will not run.', [
123                'file' => $file->path,
124                'exception' => $file->cause,
125            ]);
126        }
127
128        if (!$apps->unmatchedOverrideIds->isEmpty()) {
129            $logger->warning('stewart.yaml configures automations that were not found, possibly because their file failed to load.', [
130                'apps' => $apps->unmatchedOverrideIds->toStrings(),
131                'files' => $discovered->unloadable->listPaths(),
132            ]);
133        }
134
135        if ($apps->enabled->isEmpty()) {
136            $logger->warning('No automations to run; Stewart will connect and do nothing.', [
137                'config' => $configPath,
138                'found' => $discovered->apps->count(),
139            ]);
140        }
141    }
142
143    /** @return array<string, mixed> */
144    private function configOverridesFromOptions(InputInterface $input): array
145    {
146        $level = $this->nonEmptyStringOption($input, 'log-level');
147
148        return $level === null ? [] : ['log_level' => $level];
149    }
150
151    /** @throws IdentifierException */
152    private function selectAppsFromOptions(InputInterface $input): AppSelection
153    {
154        $only = $input->getOption('only');
155
156        if (!\is_array($only) || $only === []) {
157            return new AppSelection();
158        }
159
160        return new AppSelection(AppIdCollection::fromList(array_map(static fn(string $id): AppId => new AppId($id), array_values(array_filter($only, \is_string(...))))));
161    }
162
163    private function logFormatFromOptions(InputInterface $input, LogFormat $configured): LogFormat
164    {
165        return $input->getOption('json-logs') === true ? LogFormat::Json : $configured;
166    }
167}

Inherited from Stewart\Runtime\Console\StewartCommand

20    protected function findConfigOption(InputInterface $input): ?string
21    {
22        return $this->nonEmptyStringOption($input, 'config');
23    }
25    protected function nonEmptyStringOption(InputInterface $input, string $name): ?string
26    {
27        $value = $input->getOption($name);
28
29        return \is_string($value) && $value !== '' ? $value : null;
30    }
32    protected function writeMissingConfigFileNotice(OutputInterface $output, ?string $missingConfigFile): void
33    {
34        if ($missingConfigFile !== null) {
35            $this->writeNotice($output, \sprintf('No config file at %s; using the environment only.', $missingConfigFile));
36        }
37    }
39    protected function writeNotice(OutputInterface $output, string $notice): void
40    {
41        ($output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output)->writeln('<comment>' . $notice . '</comment>');
42    }
44    protected function readStringArgument(InputInterface $input, string $name): string
45    {
46        $value = $input->getArgument($name);
47
48        return \is_string($value) ? $value : '';
49    }