diff --git a/src/Commands/Search.php b/src/Commands/Search.php index 9325a0e..5d4098c 100644 --- a/src/Commands/Search.php +++ b/src/Commands/Search.php @@ -3,6 +3,8 @@ namespace Winter\Packager\Commands; use Winter\Packager\Exceptions\CommandException; +use Winter\Packager\Package\Package; +use Winter\Packager\Package\Collection; /** * Search command. @@ -34,11 +36,6 @@ class Search extends BaseCommand */ public ?string $limitTo = null; - /** - * @var array The results returned from the query. - */ - public array $results = []; - /** * Command handler. */ @@ -69,9 +66,20 @@ public function execute() throw new CommandException(implode(PHP_EOL, $output['output'])); } - $this->results = json_decode(implode(PHP_EOL, $output['output']), true); + $results = json_decode(implode(PHP_EOL, $output['output']), true); + $packages = []; - return $this; + foreach ($results as $result) { + [$namespace, $name] = preg_split('/\//', $result['name'], 2); + + $packages[] = new Package( + $namespace, + $name, + $result['description'] ?? '' + ); + } + + return new Collection($packages); } /** @@ -90,24 +98,6 @@ public function requiresWorkDir(): bool return false; } - /** - * Returns the list of results found. - * - * @return array - */ - public function getResults(): array - { - return $this->results; - } - - /** - * Returns the number of results found. - */ - public function count(): int - { - return count($this->results); - } - /** * @inheritDoc */ diff --git a/src/Commands/Show.php b/src/Commands/Show.php index 1d08e7f..5e453b4 100644 --- a/src/Commands/Show.php +++ b/src/Commands/Show.php @@ -2,7 +2,12 @@ namespace Winter\Packager\Commands; +use Winter\Packager\Enums\VersionStatus; use Winter\Packager\Exceptions\CommandException; +use Winter\Packager\Package\Collection; +use Winter\Packager\Package\DetailedPackage; +use Winter\Packager\Package\Package; +use Winter\Packager\Package\VersionedPackage; /** * Show command. @@ -98,7 +103,91 @@ public function execute() } } - return json_decode(implode(PHP_EOL, $output['output']), true); + $results = json_decode(implode(PHP_EOL, $output['output']), true); + $packages = []; + + if (is_null($this->package) && in_array($this->mode, ['installed', 'locked', 'platform', 'path', 'outdated', 'direct'])) { + // Convert packages in simple lists to a package collection + $key = (!in_array($this->mode, ['locked', 'platform'])) ? 'installed' : $this->mode; + $results = $results[$key]; + + foreach ($results as $result) { + [$namespace, $name] = $this->nameSplit($result['name']); + + if (isset($result['version'])) { + $packages[] = new VersionedPackage( + $namespace, + $name, + $result['description'] ?? '', + $result['version'], + $result['latest'] ?? '', + VersionStatus::tryFrom($result['latest-status'] ?? '') ?? VersionStatus::UP_TO_DATE + ); + } else { + $packages[] = new Package( + $namespace, + $name, + $result['description'] ?? '', + ); + } + } + + return new Collection($packages); + } elseif (is_null($this->package) && $this->mode === 'available') { + // Convert entire available package list into a package collection + foreach ($results['available'] as $result) { + [$namespace, $name] = $this->nameSplit($result['name']); + + $packages[] = new Package( + $namespace, + $name, + $result['description'] ?? '', + ); + } + + return new Collection($packages); + } elseif ($this->mode === 'self') { + $result = $results; + [$namespace, $name] = $this->nameSplit($result['name']); + + // Return the current package + return new DetailedPackage( + $namespace, + $name, + $result['description'] ?? '', + $result['type'] ?? 'library', + $result['keywords'] ?? [], + $result['homepage'] ?? '', + $result['authors'] ?? [], + $result['licenses'] ?? [], + $result['support'] ?? [], + $result['funding'] ?? [], + $result['requires'] ?? [], + $result['devRequires'] ?? [], + $result['extras'] ?? [], + ); + } elseif (!is_null($this->package)) { + $result = $results; + [$namespace, $name] = $this->nameSplit($result['name']); + + return new DetailedPackage( + $namespace, + $name, + $result['description'] ?? '', + $result['type'] ?? 'library', + $result['keywords'] ?? [], + $result['homepage'] ?? '', + $result['authors'] ?? [], + $result['licenses'] ?? [], + $result['support'] ?? [], + $result['funding'] ?? [], + $result['requires'] ?? [], + $result['devRequires'] ?? [], + $result['extras'] ?? [], + ); + } + + return null; } /** @@ -140,4 +229,14 @@ public function arguments(): array return $arguments; } + + /** + * Split package name from namespace. + * + * @return string[] + */ + protected function nameSplit(string $name): array + { + return preg_split('/\//', $name, 2); + } } diff --git a/src/Enums/VersionStatus.php b/src/Enums/VersionStatus.php new file mode 100644 index 0000000..f237917 --- /dev/null +++ b/src/Enums/VersionStatus.php @@ -0,0 +1,10 @@ + + * @since 0.3.0 + * @implements \ArrayAccess + * @implements \Iterator + */ +class Collection implements \ArrayAccess, \Iterator, \Countable +{ + /** + * @var array The packages contained in the collection. + */ + protected array $items = []; + + /** + * Present position in the collection. + */ + protected int $position = 0; + + /** + * Constructor. + * + * @param Package[]|Package $items + */ + public function __construct(...$items) + { + foreach ($items as $item) { + if ($item instanceof Package) { + $this->items[] = $item; + } elseif (is_array($item)) { + foreach ($item as $subItem) { + if ($subItem instanceof Package) { + $this->items[] = $subItem; + } + } + } + } + } + + /** + * Adds a package to this collection. + */ + protected function add(Package $package): void + { + $this->items[] = $package; + + uasort($this->items, function (Package $a, Package $b) { + return $a->getPackageName() <=> $b->getPackageName(); + }); + } + + /** + * Gets the count of packages in this collection. + */ + public function count(): int + { + return count($this->items); + } + + public function rewind(): void + { + $this->position = 0; + } + + public function current(): mixed + { + return $this->items[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function valid(): bool + { + return isset($this->items[$this->position]); + } + + /** + * Gets a package at a given index. + * + * This does not reset the internal pointer of the collection. + * + * If no package is found at the given index, `null` is returned. + */ + public function get(int $index): ?Package + { + return $this->items[$index] ?? null; + } + + /** + * Finds a given package in the collection. + */ + public function find(string $namespace, string $name = '', ?string $version = null): ?Package + { + if (empty($name) && strpos($namespace, '/') !== false) { + [$namespace, $name] = explode('/', $namespace, 2); + } + + foreach ($this->items as $item) { + if ($item->getNamespace() === $namespace && $item->getName() === $name) { + if (is_null($version) || ($item instanceof VersionedPackage && $item->getVersion() === $version)) { + return $item; + } + } + } + + return null; + } + + /** + * Checks if a given offset exists. + * + * You may either provide an integer key to retrieve by index, or a string key in the format `namespace/name` to + * find a particular package. + */ + public function offsetExists(mixed $offset): bool + { + if (is_int($offset)) { + return isset($this->items[$offset]); + } + + if (is_string($offset)) { + [$namespace, $name] = explode('/', $offset, 2); + return !is_null($this->find($namespace, $name)); + } + } + + /** + * Gets a package at a given offset. + * + * You may either provide an integer key to retrieve by index, or a string key in the format `namespace/name` to + * find a particular package. + */ + public function offsetGet(mixed $offset): ?Package + { + if (is_int($offset)) { + return $this->get($offset); + } + + if (is_string($offset)) { + [$namespace, $name] = explode('/', $offset, 2); + return $this->find($namespace, $name); + } + } + + /** + * Sets a package at a given offset. + * + * This method is not supported. + */ + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \RuntimeException('You cannot set values in a package collection.'); + } + + /** + * Unsets a package at a given offset. + * + * This method is not supported. + */ + public function offsetUnset(mixed $offset): void + { + throw new \RuntimeException('You cannot unset values in a package collection.'); + } + + /** + * Retrieve the collection as an array. + * + * @return array + */ + public function toArray() + { + return $this->items; + } +} diff --git a/src/Package/DetailedPackage.php b/src/Package/DetailedPackage.php new file mode 100644 index 0000000..61b7dfc --- /dev/null +++ b/src/Package/DetailedPackage.php @@ -0,0 +1,136 @@ + $keywords + * @param array> $authors + * @param array> $licenses + * @param array $support + * @param array $funding + * @param array $requires + * @param array $devRequires + * @param array $extras + */ + public function __construct( + string $namespace, + string $name, + string $description = '', + protected string $type = 'library', + protected array $keywords = [], + protected string $homepage = '', + protected array $authors = [], + protected array $licenses = [], + protected array $support = [], + protected array $funding = [], + protected array $requires = [], + protected array $devRequires = [], + protected array $extras = [], + ) { + parent::__construct($namespace, $name, $description); + } + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): void + { + $this->type = $type; + } + + public function getKeywords(): array + { + return $this->keywords; + } + + public function setKeywords(array $keywords): void + { + $this->keywords = $keywords; + } + + public function getHomepage(): string + { + return $this->homepage; + } + + public function setHomepage(string $homepage): void + { + $this->homepage = $homepage; + } + + public function getAuthors(): array + { + return $this->authors; + } + + public function setAuthors(array $authors): void + { + $this->authors = $authors; + } + + public function getLicenses(): array + { + return $this->licenses; + } + + public function setLicenses(array $licenses): void + { + $this->licenses = $licenses; + } + + public function getSupport(): array + { + return $this->support; + } + + public function setSupport(array $support): void + { + $this->support = $support; + } + + public function getFunding(): array + { + return $this->funding; + } + + public function setFunding(array $funding): void + { + $this->funding = $funding; + } + + public function getRequires(): array + { + return $this->requires; + } + + public function setRequires(array $requires): void + { + $this->requires = $requires; + } + + public function getDevRequires(): array + { + return $this->devRequires; + } + + public function setDevRequires(array $devRequires): void + { + $this->devRequires = $devRequires; + } + + public function getExtras(): array + { + return $this->extras; + } + + public function setExtras(array $extras): void + { + $this->extras = $extras; + } +} diff --git a/src/Package/Package.php b/src/Package/Package.php new file mode 100644 index 0000000..c8c0c85 --- /dev/null +++ b/src/Package/Package.php @@ -0,0 +1,63 @@ + + * @since 0.3.0 + */ +class Package +{ + public function __construct( + protected string $namespace, + protected string $name, + protected string $description = '' + ) { + } + + public function setNamespace(string $namespace): void + { + $this->namespace = $namespace; + } + + public function getNamespace(): string + { + return $this->namespace; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function getPackageName(): string + { + return implode('/', [$this->namespace, $this->name]); + } + + public function setDescription(string $description): void + { + $this->description = $description; + } + + public function getDescription(): string + { + return $this->description; + } +} diff --git a/src/Package/VersionedPackage.php b/src/Package/VersionedPackage.php new file mode 100644 index 0000000..cdb411b --- /dev/null +++ b/src/Package/VersionedPackage.php @@ -0,0 +1,73 @@ +versionNormalized = $this->normalizeVersion($version); + } + + public function setVersion(string $version): void + { + $this->version = $version; + $this->versionNormalized = $this->normalizeVersion($version); + } + + public function getVersion(): string + { + return $this->version; + } + + public function getVersionNormalized(): string + { + return $this->versionNormalized; + } + + public function setLatestVersion(string $latestVersion): void + { + $this->latestVersion = $latestVersion; + $this->latestVersionNormalized = $this->normalizeVersion($latestVersion); + } + + public function getLatestVersion(): string + { + return $this->latestVersion; + } + + public function getLatestVersionNormalized(): string + { + return $this->latestVersionNormalized; + } + + public function setUpdateStatus(VersionStatus $updateStatus): void + { + $this->updateStatus = $updateStatus; + } + + public function getUpdateStatus(): VersionStatus + { + return $this->updateStatus; + } + + protected function normalizeVersion(string $version): string + { + $parser = new VersionParser; + return $parser->normalize($version); + } +} diff --git a/src/Storage/Storage.php b/src/Storage/Storage.php index 4ebf969..949812e 100644 --- a/src/Storage/Storage.php +++ b/src/Storage/Storage.php @@ -8,6 +8,8 @@ * A storage object contains metadata for packages, and can either be ephemeral or persistent. This * is used to avoid repeated retrievals of package metadata from Composer. * + * In general, you should only store "detailed" packages in storage. + * * @author Ben Thomson * @since 0.3.0 */ diff --git a/tests/Cases/Commands/SearchTest.php b/tests/Cases/Commands/SearchTest.php index d7249eb..5aead52 100644 --- a/tests/Cases/Commands/SearchTest.php +++ b/tests/Cases/Commands/SearchTest.php @@ -52,20 +52,23 @@ public function itCanRunAMockedSearch(): void ], JSON_PRETTY_PRINT) ); - $search = $this->composer->search('winter', 'winter-module'); + $results = $this->composer->search('winter', 'winter-module'); - $this->assertArraySubset([ - [ - 'name' => 'winter/wn-system-module', - ], - [ - 'name' => 'winter/wn-cms-module', - ], - [ - 'name' => 'winter/wn-backend-module', - ], - ], $search->getResults()); - $this->assertEquals(3, $search->count()); + $this->assertEquals(3, $results->count()); + $this->assertContainsOnlyInstancesOf(\Winter\Packager\Package\Package::class, $results); + + // Check packages + $this->assertEquals('winter', $results['winter/wn-system-module']->getNamespace()); + $this->assertEquals('wn-system-module', $results['winter/wn-system-module']->getName()); + $this->assertEquals('System module for Winter CMS', $results['winter/wn-system-module']->getDescription()); + + $this->assertEquals('winter', $results['winter/wn-cms-module']->getNamespace()); + $this->assertEquals('wn-cms-module', $results['winter/wn-cms-module']->getName()); + $this->assertEquals('CMS module for Winter CMS', $results['winter/wn-cms-module']->getDescription()); + + $this->assertEquals('winter', $results['winter/wn-backend-module']->getNamespace()); + $this->assertEquals('wn-backend-module', $results['winter/wn-backend-module']->getName()); + $this->assertEquals('Backend module for Winter CMS', $results['winter/wn-backend-module']->getDescription()); } /** @@ -78,19 +81,21 @@ public function itCanRunAMockedSearch(): void */ public function itCanRunRealSearch(): void { - $search = $this->composer->search('winter/', 'winter-module'); + $results = $this->composer->search('winter/', 'winter-module'); + + $this->assertContainsOnlyInstancesOf(\Winter\Packager\Package\Package::class, $results); + + // Check packages + $this->assertEquals('winter', $results['winter/wn-system-module']->getNamespace()); + $this->assertEquals('wn-system-module', $results['winter/wn-system-module']->getName()); + $this->assertEquals('winter/wn-system-module', $results['winter/wn-system-module']->getPackageName()); + + $this->assertEquals('winter', $results['winter/wn-cms-module']->getNamespace()); + $this->assertEquals('wn-cms-module', $results['winter/wn-cms-module']->getName()); + $this->assertEquals('winter/wn-cms-module', $results['winter/wn-cms-module']->getPackageName()); - $this->assertArraySubset([ - [ - 'name' => 'winter/wn-system-module', - ], - [ - 'name' => 'winter/wn-cms-module', - ], - [ - 'name' => 'winter/wn-backend-module', - ], - ], $search->getResults()); - $this->assertEquals(3, $search->count()); + $this->assertEquals('winter', $results['winter/wn-backend-module']->getNamespace()); + $this->assertEquals('wn-backend-module', $results['winter/wn-backend-module']->getName()); + $this->assertEquals('winter/wn-backend-module', $results['winter/wn-backend-module']->getPackageName()); } } diff --git a/tests/Cases/Commands/ShowTest.php b/tests/Cases/Commands/ShowTest.php index b6c1d5a..20e70cf 100644 --- a/tests/Cases/Commands/ShowTest.php +++ b/tests/Cases/Commands/ShowTest.php @@ -4,6 +4,7 @@ namespace Winter\Packager\Tests\Cases; +use Winter\Packager\Enums\VersionStatus; use Winter\Packager\Exceptions\CommandException; use Winter\Packager\Tests\ComposerTestCase; @@ -34,14 +35,19 @@ public function itCanShowInstalledPackages() $this->copyToWorkDir($this->testBasePath() . '/fixtures/valid/simple/composer.json'); $this->composer->update(); - $result = $this->composer->show(); - - $this->assertIsArray($result); - $this->assertCount(2, $result['installed']); - $this->assertEquals('composer/ca-bundle', $result['installed'][0]['name']); - $this->assertEquals('1.2.9', $result['installed'][0]['version']); - $this->assertEquals('composer/semver', $result['installed'][1]['name']); - $this->assertEquals('1.7.1', $result['installed'][1]['version']); + $results = $this->composer->show(); + + $this->assertEquals(2, $results->count()); + $this->assertContainsOnlyInstancesOf(\Winter\Packager\Package\Package::class, $results); + + // Check packages + $this->assertEquals('composer', $results['composer/semver']->getNamespace()); + $this->assertEquals('semver', $results['composer/semver']->getName()); + $this->assertEquals('1.7.1', $results['composer/semver']->getVersion()); + + $this->assertEquals('composer', $results['composer/ca-bundle']->getNamespace()); + $this->assertEquals('ca-bundle', $results['composer/ca-bundle']->getName()); + $this->assertEquals('1.2.9', $results['composer/ca-bundle']->getVersion()); } /** @@ -55,11 +61,15 @@ public function itCanShowOnePackage() $this->copyToWorkDir($this->testBasePath() . '/fixtures/valid/simple/composer.json'); $this->composer->update(); + + /** @var \Winter\Packager\Package\DetailedPackage */ $result = $this->composer->show(null, 'composer/ca-bundle'); - $this->assertIsArray($result); - $this->assertEquals('composer/ca-bundle', $result['name']); - $this->assertEquals('library', $result['type']); + $this->assertInstanceOf(\Winter\Packager\Package\Package::class, $result); + $this->assertEquals('composer', $result->getNamespace()); + $this->assertEquals('ca-bundle', $result->getName()); + $this->assertEquals('library', $result->getType()); + $this->assertContains('cabundle', $result->getKeywords()); } /** @@ -89,12 +99,26 @@ public function itCanShowOutdatedPackages() $this->copyToWorkDir($this->testBasePath() . '/fixtures/valid/simple/composer.json'); $this->composer->update(); - $result = $this->composer->show('outdated'); - - $this->assertIsArray($result); - $this->assertCount(2, $result['installed']); - - $this->assertArrayHasKey('latest', $result['installed'][0]); - $this->assertArrayHasKey('latest-status', $result['installed'][0]); + $results = $this->composer->show('outdated'); + + $this->assertCount(2, $results); + $this->assertContainsOnlyInstancesOf(\Winter\Packager\Package\Package::class, $results); + + // Check packages + /** @var \Winter\Packager\Package\VersionedPackage */ + $package = $results['composer/semver']; + $this->assertEquals('composer', $package->getNamespace()); + $this->assertEquals('semver', $package->getName()); + $this->assertEquals('1.7.1', $package->getVersion()); + $this->assertNotEmpty($package->getLatestVersion()); + $this->assertEquals(VersionStatus::MAJOR_UPDATE, $package->getUpdateStatus()); + + /** @var \Winter\Packager\Package\VersionedPackage */ + $package = $results['composer/ca-bundle']; + $this->assertEquals('composer', $package->getNamespace()); + $this->assertEquals('ca-bundle', $package->getName()); + $this->assertEquals('1.2.9', $package->getVersion()); + $this->assertNotEmpty($package->getLatestVersion()); + $this->assertEquals(VersionStatus::SEMVER_UPDATE, $package->getUpdateStatus()); } }