packages/sql-catalog/tests/Unit/Cli/CommandLineParserTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Cli;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Cli\CommandLine;
11use SqlCatalog\Cli\CommandLineParser;
12use SqlCatalog\Cli\InvalidCommandLineException;
13use SqlCatalog\Core\Catalog\Severity;
14use SqlCatalog\Core\Filter\CatalogFilter;
15use SqlCatalog\Core\Sql\StatementKind;
16
17#[CoversClass(CommandLineParser::class)]
18#[UsesClass(CommandLine::class)]
19#[UsesClass(CatalogFilter::class)]
20#[UsesClass(InvalidCommandLineException::class)]
21#[UsesClass(\SqlCatalog\Facade\Configuration::class)]
22#[UsesClass(\SqlCatalog\Facade\ConfigurationSchema::class)]
23#[UsesClass(\SqlCatalog\Facade\InvalidConfigurationException::class)]
24final class CommandLineParserTest extends TestCase
25{
26    public function testParseReadsPathsAndOptions(): void
27    {
28        $command = (new CommandLineParser())->parse(['--output', 'catalog', 'src', 'lib']);
29        self::assertSame(['src', 'lib'], $command->paths);
30        self::assertSame('catalog', $command->output);
31    }
32
33    public function testParseReadsAnOptionWrittenWithAnEqualsSign(): void
34    {
35        self::assertSame('catalog', (new CommandLineParser())->parse(['--output=catalog', 'src'])->output);
36    }
37
38    public function testParseReadsShortOptions(): void
39    {
40        $command = (new CommandLineParser())->parse(['-o', 'catalog', '-r', 'html', '-e', 'laravel', 'src']);
41        self::assertSame('html', $command->reporter);
42        self::assertSame(['laravel'], $command->extensions);
43    }
44
45    public function testParseSplitsARepeatableOptionOnCommas(): void
46    {
47        self::assertSame(['pdo', 'laravel'], (new CommandLineParser())->parse(['-e', 'pdo,laravel', 'src'])->extensions);
48    }
49
50    public function testParseAccumulatesARepeatedOption(): void
51    {
52        self::assertSame(['pdo', 'laravel'], (new CommandLineParser())->parse(['-e', 'pdo', '-e', 'laravel', 'src'])->extensions);
53    }
54
55    public function testParseReadsFlags(): void
56    {
57        self::assertTrue((new CommandLineParser())->parse(['--help'])->help);
58        self::assertTrue((new CommandLineParser())->parse(['-h'])->help);
59        self::assertTrue((new CommandLineParser())->parse(['--list-extensions'])->listExtensions);
60        self::assertTrue((new CommandLineParser())->parse(['--list-reporters'])->listReporters);
61    }
62
63    public function testParseDefaultsToJsonWhenWritingIntoADirectory(): void
64    {
65        self::assertSame('json', (new CommandLineParser())->parse(['-o', 'catalog', 'src'])->reporter);
66        self::assertSame('text', (new CommandLineParser())->parse(['src'])->reporter);
67    }
68
69    public function testParseBuildsTheFilterFromTheFilteringOptions(): void
70    {
71        $command = (new CommandLineParser())->parse([
72            '--namespace=App', '--method=find', '--path=src/*', '--kind=select',
73            '--table=users', '--sink=pdo.query', '--severity=high', 'src',
74        ]);
75        self::assertSame(['App'], $command->filter->namespaces);
76        self::assertSame(['find'], $command->filter->functions);
77        self::assertSame([StatementKind::Select], $command->filter->kinds);
78        self::assertSame(Severity::High, $command->filter->minimumSeverity);
79    }
80
81    public function testParseTreatsADoubleDashAsAPath(): void
82    {
83        self::assertSame(['--'], (new CommandLineParser())->parse(['--'])->paths);
84    }
85
86    public function testParseRefusesAnUnknownOption(): void
87    {
88        $this->expectException(InvalidCommandLineException::class);
89        $this->expectExceptionMessage('Unknown option "--nope".');
90        (new CommandLineParser())->parse(['--nope', 'src']);
91    }
92
93    public function testParseRefusesAnOptionWithoutItsValue(): void
94    {
95        $this->expectException(InvalidCommandLineException::class);
96        $this->expectExceptionMessage('Option "--output" needs a value.');
97        (new CommandLineParser())->parse(['--output']);
98    }
99
100    public function testReadOptionConsumesTheArgumentThatFollows(): void
101    {
102        $values = [];
103        $flags = [];
104        $index = (new CommandLineParser())->readOption('--output', ['--output', 'catalog'], 0, $values, $flags);
105        self::assertSame(1, $index);
106        self::assertSame(['output' => ['catalog']], $values);
107    }
108
109    public function testBuildAppliesEveryDocumentedDefault(): void
110    {
111        $command = (new CommandLineParser())->build([], [], ['src']);
112
113        self::assertSame(['src'], $command->paths);
114        self::assertNull($command->output);
115        self::assertSame('text', $command->reporter);
116        self::assertSame(['pdo', 'mysqli'], $command->extensions);
117        self::assertSame([], $command->excluded);
118        self::assertSame('.', $command->root);
119        self::assertNull($command->failOn);
120        self::assertFalse($command->help);
121        self::assertFalse($command->listExtensions);
122        self::assertFalse($command->listReporters);
123        self::assertTrue($command->filter->isEmpty());
124    }
125
126    public function testBuildTakesEveryOptionThatWasGiven(): void
127    {
128        $command = (new CommandLineParser())->build(
129            [
130                'output' => ['catalog'],
131                'reporter' => ['html'],
132                'extension' => ['laravel'],
133                'exclude' => ['vendor'],
134                'root' => ['/app'],
135                'fail-on' => ['high'],
136                'namespace' => ['App'],
137                'method' => ['find'],
138                'path' => ['src/*'],
139                'kind' => ['select'],
140                'table' => ['users'],
141                'sink' => ['pdo.query'],
142                'severity' => ['medium'],
143            ],
144            ['help' => true, 'list-extensions' => true, 'list-reporters' => true],
145            ['src'],
146        );
147
148        self::assertSame('catalog', $command->output);
149        self::assertSame('html', $command->reporter);
150        self::assertSame(['laravel'], $command->extensions);
151        self::assertSame(['vendor'], $command->excluded);
152        self::assertSame('/app', $command->root);
153        self::assertSame(Severity::High, $command->failOn);
154        self::assertTrue($command->help);
155        self::assertTrue($command->listExtensions);
156        self::assertTrue($command->listReporters);
157        self::assertSame(['App'], $command->filter->namespaces);
158        self::assertSame(['find'], $command->filter->functions);
159        self::assertSame(['src/*'], $command->filter->paths);
160        self::assertSame(['users'], $command->filter->tables);
161        self::assertSame(['pdo.query'], $command->filter->sinks);
162        self::assertSame(Severity::Medium, $command->filter->minimumSeverity);
163    }
164
165    public function testBuildFallsBackToTheDefaults(): void
166    {
167        $command = (new CommandLineParser())->build([], [], ['src']);
168        self::assertSame(['pdo', 'mysqli'], $command->extensions);
169        self::assertSame('.', $command->root);
170    }
171
172    public function testFilterIsEmptyWithoutFilteringOptions(): void
173    {
174        self::assertTrue((new CommandLineParser())->filter([])->isEmpty());
175    }
176
177    public function testKindsReadsTheWrittenNames(): void
178    {
179        self::assertSame([StatementKind::Insert], (new CommandLineParser())->kinds(['INSERT']));
180        self::assertSame([], (new CommandLineParser())->kinds([]));
181    }
182
183    public function testKindsRefusesAnUnknownName(): void
184    {
185        $this->expectException(InvalidCommandLineException::class);
186        $this->expectExceptionMessage('Unknown statement kind "nope".');
187        (new CommandLineParser())->kinds(['nope']);
188    }
189
190    public function testSeverityReadsTheWrittenName(): void
191    {
192        self::assertSame(Severity::Low, (new CommandLineParser())->severity(['fail-on' => ['LOW']], 'fail-on'));
193        self::assertNull((new CommandLineParser())->severity([], 'fail-on'));
194    }
195
196    public function testSeverityRefusesAnUnknownName(): void
197    {
198        $this->expectException(InvalidCommandLineException::class);
199        $this->expectExceptionMessage('Unknown severity "nope".');
200        (new CommandLineParser())->severity(['severity' => ['nope']], 'severity');
201    }
202
203    public function testLastTakesTheValueGivenLast(): void
204    {
205        $parser = new CommandLineParser();
206        self::assertSame('b', $parser->last(['output' => ['a', 'b']], 'output'));
207        self::assertNull($parser->last([], 'output'));
208    }
209    public function testParseReadsAConfigurationPathWithoutSplittingCommas(): void
210    {
211        $path = sys_get_temp_dir() . '/catalog,local-' . bin2hex(random_bytes(6)) . '.yaml';
212        file_put_contents($path, '{}');
213        try {
214            $parser = new CommandLineParser();
215            self::assertSame($path, $parser->parse(['--config=' . $path, 'src'])->config);
216            self::assertSame($path, $parser->parse(['-c', $path, 'src'])->config);
217            self::assertNull($parser->parse(['src'])->config);
218        } finally {
219            unlink($path);
220        }
221    }
222
223    public function testConfigurationDiscoversTheWorkingDirectoryFile(): void
224    {
225        $directory = sys_get_temp_dir() . '/catalog-' . bin2hex(random_bytes(6));
226        mkdir($directory);
227        file_put_contents($directory . '/.catalog.yaml', "paths: [src]\nexclude: [tests]\nreporter: html\noutput: catalog\n");
228        $original = getcwd();
229        self::assertNotFalse($original);
230        chdir($directory);
231        $absolute = getcwd();
232        self::assertNotFalse($absolute);
233        try {
234            $parser = new CommandLineParser();
235            $command = $parser->parse([]);
236            self::assertSame([$absolute . '/src'], $command->paths);
237            self::assertSame($absolute . '/catalog', $command->output);
238            self::assertSame($absolute, $command->root);
239            self::assertSame('html', $command->reporter);
240            self::assertSame(['tests'], $command->excluded);
241            $overridden = $parser->parse(['other', '--reporter=text', '--exclude=vendor', '--output=cli-output']);
242            self::assertSame(['other'], $overridden->paths);
243            self::assertSame('text', $overridden->reporter);
244            self::assertSame(['vendor'], $overridden->excluded);
245            self::assertSame('cli-output', $overridden->output);
246        } finally {
247            chdir($original);
248            unlink($directory . '/.catalog.yaml');
249            rmdir($directory);
250        }
251    }
252
253    public function testConfigurationIsOptionalAndExplicitFilesMustExist(): void
254    {
255        self::assertNull((new CommandLineParser())->configuration(null)->file);
256        $this->expectException(\SqlCatalog\Facade\InvalidConfigurationException::class);
257        (new CommandLineParser())->configuration('/missing/.catalog.yaml');
258    }
259
260    public function testParseReadsTheLaravelDialectInBothOptionForms(): void
261    {
262        self::assertSame('sqlite', (new CommandLineParser())->parse(['--dialect=sqlite'])->dialect);
263        self::assertSame('mysql', (new CommandLineParser())->parse(['--dialect', 'mysql'])->dialect);
264    }
265
266    public function testParseRejectsAnUnknownDialect(): void
267    {
268        $this->expectException(InvalidCommandLineException::class);
269        (new CommandLineParser())->parse(['--dialect=unknown']);
270    }
271}
272