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