Lines 97.72% 43 / 44
Methods 80.00% 4 / 5
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 discover 100.00% 33 / 33 100.00% 1 / 1 10
 findClassForFile 100.00% 3 / 3 100.00% 1 / 1 4
 isRunnableApp 100.00% 1 / 1 100.00% 1 / 1 2
 validateAppId 83.33% 5 / 6 0.00% 0 / 1 3.04
22final readonly class AppDiscovery
23{
24    public function __construct(
25        private Psr4Map $psr4Map,
26        private string $appsDirectory,
27    ) {}
28
29    /** @throws AppException */
30    public function discover(): DiscoveryResult
31    {
32        $directory = realpath($this->appsDirectory);
33
34        if ($directory === false || !is_dir($directory)) {
35            return DiscoveryResult::createEmpty();
36        }
37
38        $root = $this->psr4Map->findAutoloadRootContaining($directory) ?? throw AppException::createForReason(
39            AppError::DirectoryNotAutoloadable,
40            directory: $directory,
41        );
42        $apps = [];
43        $unmarked = [];
44        $unloadable = [];
45        $classesByKey = [];
46
47        foreach (Finder::create()->files()->in($directory)->name('*.php')->sortByName() as $file) {
48            try {
49                $reflection = $this->findClassForFile($root, $file);
50            } catch (Throwable $e) {
51                $unloadable[] = new UnloadableAppFile($file->getRealPath() ?: $file->getPathname(), $e);
52
53                continue;
54            }
55
56            if ($reflection === null) {
57                continue;
58            }
59
60            $automation = $reflection->getAttributes(Automation::class)[0] ?? null;
61
62            if ($automation === null) {
63                if ($this->isRunnableApp($reflection)) {
64                    $unmarked[] = new UnmarkedApp($reflection->getName());
65                }
66
67                continue;
68            }
69
70            $app = new DiscoveredApp($reflection->getName(), $this->validateAppId($reflection, $automation->newInstance()));
71            $key = $app->id->toEnvironmentKey();
72
73            if (isset($classesByKey[$key])) {
74                throw AppException::createForReason(AppError::DuplicateId, appId: $app->id->value, firstClass: $classesByKey[$key], secondClass: $app->class);
75            }
76
77            $classesByKey[$key] = $app->class;
78            $apps[] = $app;
79        }
80
81        return new DiscoveryResult(
82            DiscoveredAppCollection::fromApps($apps),
83            UnmarkedAppCollection::fromList($unmarked),
84            UnloadableAppFileCollection::fromList($unloadable),
85        );
86    }
87
88    /** @return ReflectionClass<object>|null */
89    private function findClassForFile(AutoloadRoot $root, SplFileInfo $file): ?ReflectionClass
90    {
91        $path = $file->getRealPath();
92        $class = $path === false ? null : $root->findClassNameForFile($path);
93
94        return $class === null || !class_exists($class) ? null : new ReflectionClass($class);
95    }
96
97    /** @param ReflectionClass<object> $reflection */
98    private function isRunnableApp(ReflectionClass $reflection): bool
99    {
100        return $reflection->isInstantiable() && $reflection->implementsInterface(App::class);
101    }
102
103    /**
104     * @param ReflectionClass<object> $reflection
105     * @throws AppException
106     */
107    private function validateAppId(ReflectionClass $reflection, Automation $automation): AppId
108    {
109        $class = $reflection->getName();
110
111        if (!$reflection->implementsInterface(App::class)) {
112            throw AppException::createForReason(AppError::NotAnApp, className: $class, appInterface: App::class);
113        }
114
115        if (!$reflection->isInstantiable()) {
116            throw AppException::createForReason(AppError::NotInstantiable, className: $class);
117        }
118
119        return AppId::tryFromString($automation->id) ?? throw AppException::createForReason(AppError::IdInvalid, className: $class, appId: $automation->id);
120    }
121}