diff --git a/composer.json b/composer.json
index 69cb0f6..f62bbe3 100644
--- a/composer.json
+++ b/composer.json
@@ -12,6 +12,7 @@
],
"require": {
"php": ">= 7.3",
+ "ext-json": "*",
"doctrine/annotations": "^1.12|^2.0",
"symfony/framework-bundle": "~4.4|~5.0|~6.0",
"symfony/yaml": "~4.4|~5.0|~6.0"
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 2381268..ed3a0b6 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -5,4 +5,5 @@ parameters:
level: max
paths:
- src
- checkGenericClassInNonGenericObjectType: false
\ No newline at end of file
+ checkGenericClassInNonGenericObjectType: false
+ checkMissingIterableValueType: false
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index bee66d4..60da0cf 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -21,4 +21,4 @@
src
-
\ No newline at end of file
+
diff --git a/src/Command/ListFeatureCommand.php b/src/Command/ListFeatureCommand.php
new file mode 100644
index 0000000..9eec880
--- /dev/null
+++ b/src/Command/ListFeatureCommand.php
@@ -0,0 +1,126 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Novaway\Bundle\FeatureFlagBundle\Command;
+
+use Novaway\Bundle\FeatureFlagBundle\Model\Feature;
+use Novaway\Bundle\FeatureFlagBundle\Storage\StorageInterface;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+final class ListFeatureCommand extends Command
+{
+ private const FORMAT_CSV = 'csv';
+ private const FORMAT_JSON = 'json';
+ private const FORMAT_TABLE = 'table';
+
+ /** @var StorageInterface */
+ private $storage;
+
+ public function __construct(StorageInterface $storage)
+ {
+ parent::__construct('novaway:feature-flag:list');
+
+ $this->storage = $storage;
+ }
+
+ protected function configure(): void
+ {
+ $this->setDescription('List all features with their state');
+ $this->addOption('format', 'f', InputOption::VALUE_OPTIONAL, 'Output format', self::FORMAT_TABLE);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $features = $this->storage->all();
+
+ switch ($input->getOption('format')) {
+ case self::FORMAT_CSV:
+ $this->renderCsv($output, $features);
+ break;
+
+ case self::FORMAT_JSON:
+ $this->renderJson($output, $features);
+ break;
+
+ case self::FORMAT_TABLE:
+ $this->renderTable($output, $features);
+ break;
+
+ default:
+ /* @phpstan-ignore-next-line */
+ $output->writeln("Invalid format: {$input->getOption('format')}");
+
+ return 1;
+ }
+
+ return 0;
+ }
+
+ private function renderTable(OutputInterface $output, array $features): void
+ {
+ $table = new Table($output);
+ $table->setHeaders(['Name', 'Enabled', 'Description']);
+ foreach ($features as $feature) {
+ $table->addRow([
+ $feature->getKey(),
+ $feature->isEnabled() ? 'Yes' : 'No',
+ $feature->getDescription(),
+ ]);
+ }
+
+ $table->render();
+ }
+
+ private function renderJson(OutputInterface $output, array $features): void
+ {
+ $json = json_encode(
+ array_map(static function (Feature $feature): array {
+ return $feature->toArray();
+ }, $features),
+ JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR
+ );
+
+ $output->writeln($json);
+ }
+
+ private function renderCsv(OutputInterface $output, array $features): void
+ {
+ $output->writeln($this->getCsvLine(['Name', 'Enabled', 'Description']));
+
+ foreach ($features as $feature) {
+ $output->writeln($this->getCsvLine($feature->toArray()));
+ }
+ }
+
+ private function getCsvLine(array $columns): string
+ {
+ $fp = fopen('php://temp', 'w+');
+ if (false === $fp) {
+ throw new \RuntimeException('Unable to open temporary file');
+ }
+
+ fputcsv($fp, $columns);
+
+ rewind($fp);
+ $data = fread($fp, 1048576); // 1MB
+ if (false === $data) {
+ throw new \RuntimeException('Unable to read temporary file');
+ }
+
+ fclose($fp);
+
+ return rtrim($data, PHP_EOL);
+ }
+}
diff --git a/src/Model/Feature.php b/src/Model/Feature.php
index fdada24..43b0197 100644
--- a/src/Model/Feature.php
+++ b/src/Model/Feature.php
@@ -56,4 +56,13 @@ public function isEnabled(): bool
{
return $this->enabled;
}
+
+ public function toArray(): array
+ {
+ return [
+ 'key' => $this->key,
+ 'enabled' => $this->enabled,
+ 'description' => $this->description,
+ ];
+ }
}
diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml
index 0ed84ce..50d129e 100644
--- a/src/Resources/config/services.yml
+++ b/src/Resources/config/services.yml
@@ -1,4 +1,8 @@
services:
+ Novaway\Bundle\FeatureFlagBundle\Command\ListFeatureCommand:
+ arguments: ['@novaway_feature_flag.storage']
+ tags: ['console.command']
+
Novaway\Bundle\FeatureFlagBundle\Manager\DefaultFeatureManager:
arguments: ['@Novaway\Bundle\FeatureFlagBundle\Storage\ArrayStorage']
Novaway\Bundle\FeatureFlagBundle\Manager\LegacyFeatureManager:
diff --git a/src/Storage/ArrayStorage.php b/src/Storage/ArrayStorage.php
index 2a31c31..acfc4bd 100644
--- a/src/Storage/ArrayStorage.php
+++ b/src/Storage/ArrayStorage.php
@@ -24,6 +24,8 @@ class ArrayStorage extends AbstractStorage
*/
public function __construct(array $features = [])
{
+ ksort($features);
+
$this->features = [];
foreach ($features as $key => $feature) {
$this->features[$key] = new Feature($key, $feature['enabled'], $feature['description'] ?? '');
diff --git a/tests/Functional/Command/ListFeatureCommandTest.php b/tests/Functional/Command/ListFeatureCommandTest.php
new file mode 100644
index 0000000..296783e
--- /dev/null
+++ b/tests/Functional/Command/ListFeatureCommandTest.php
@@ -0,0 +1,27 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Novaway\Bundle\FeatureFlagBundle\Tests\Functional\Command;
+
+use Novaway\Bundle\FeatureFlagBundle\Tests\Functional\WebTestCase;
+use Symfony\Bundle\FrameworkBundle\Console\Application;
+use Symfony\Component\Console\Command\Command;
+
+final class ListFeatureCommandTest extends WebTestCase
+{
+ public function testCommandIsAvailable(): void
+ {
+ $application = new Application(static::bootKernel());
+ $command = $application->find('novaway:feature-flag:list');
+
+ static::assertInstanceOf(Command::class, $command);
+ }
+}
diff --git a/tests/Unit/Command/ListFeatureCommandTest.php b/tests/Unit/Command/ListFeatureCommandTest.php
new file mode 100644
index 0000000..2588551
--- /dev/null
+++ b/tests/Unit/Command/ListFeatureCommandTest.php
@@ -0,0 +1,138 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Novaway\Bundle\FeatureFlagBundle\Tests\Unit\Command;
+
+use Novaway\Bundle\FeatureFlagBundle\Command\ListFeatureCommand;
+use Novaway\Bundle\FeatureFlagBundle\Storage\ArrayStorage;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+final class ListFeatureCommandTest extends TestCase
+{
+ private const TEST_DATA = [
+ 'empty-features' => [
+ 'features' => [],
+ 'output' => [
+ 'table' => <<