diff --git a/README.md b/README.md index fda28a2..c39f3ab 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,18 @@ http-smoke [options] --help, -h ``` +Options take their value either with `=` or as the next argument: + +```bash +vendor/bin/http-smoke prod --output-html=report.html +vendor/bin/http-smoke prod --output-html report.html +``` + +Unknown options, missing values, non-numeric numbers and stray positional +arguments are hard errors (exit code `3`) — the runner never silently ignores a +flag it does not know, so a typo like `--output-htlm=` cannot quietly skip your +report. + Exit codes: `0` success · `1` tests failed · `2` config error · `3` usage error. --- diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 5787b35..7913b54 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -49,7 +49,7 @@ CLI input → Console → Config → Discovery → Definition | `Discovery/` | `ConfigDiscovery` — recursive `*.php` walk + filename filter. Each definition file returns `Closure(Suite): void`. | | `Config/` | `SmokeConfig` (root config DTO) + `SmokeConfigLoader` (loads `smoke.config.php`). | | `Container/` | Lightweight PSR-11 container + `ServiceFactory::build()` wires everything. `getTyped(class)` for type-narrowed retrieval. | -| `Console/` | `InputParser`, `ParsedInput`, `RunCommand` (the orchestrator called from `bin/http-smoke`), `ExitCode` enum. | +| `Console/` | `InputParser` (declarative `FLAGS` / `VALUE_OPTIONS` tables, accepts `--opt=value` and `--opt value`, collects `ParsedInput::$errors` for unknown options / missing values / bad numbers / stray positionals with a levenshtein "did you mean" hint), `ParsedInput`, `RunCommand` (prints the errors + help and returns `ExitCode::UsageError`), `ExitCode` enum. No `symfony/console` — the package keeps `psr/container` as its only runtime dependency. | | `Exception/`, `Support/` | `SmokeException` base + specifics; `JsonDotPath` helper used by JSON assertions/captures. | ## Key extension points (interfaces) diff --git a/docs/claude/conventions.md b/docs/claude/conventions.md index 7bd682d..e2a4edc 100644 --- a/docs/claude/conventions.md +++ b/docs/claude/conventions.md @@ -37,6 +37,17 @@ All three must pass before considering work done: PHPStan is configured to **forbid** suppression: no `@phpstan-ignore`, no baseline, no `assert()` to silence, no inline `@var`. Fix root causes. +## Dependencies + +Runtime dependencies are deliberately minimal: `psr/container` plus the `curl` / +`json` / `mbstring` extensions. This is a `--dev` tool that must drop into any +project (including old ones) without dragging a console framework and its version +constraints along, so **do not add `symfony/console` or similar** — the CLI +surface is one command and a handful of options, hand-parsed in +`Console\InputParser`. In exchange the parser must stay strict: every unknown +option, missing value or stray argument is a hard `ExitCode::UsageError`, never a +silent no-op. + ## Architecture conventions - One interface per extension point. Default impls live in subfolders (e.g. `Variable\Source\*`, `Http\Curl\*`). diff --git a/src/Console/InputParser.php b/src/Console/InputParser.php index 28b0c82..fcad00d 100644 --- a/src/Console/InputParser.php +++ b/src/Console/InputParser.php @@ -6,64 +6,172 @@ final class InputParser { + /** @var list */ + private const array FLAGS = [ + '--help', + '-h', + '--verbose', + '-v', + '--insecure', + '-k', + '--no-console', + '--no-github-summary', + '--no-payloads', + ]; + + /** @var list */ + private const array VALUE_OPTIONS = [ + '--concurrency', + '--payload-limit', + '--config-dir', + '--config', + '--smoke-json', + '--env-file', + '--base-url', + '--var', + '--filter', + '--group', + '--output', + '--output-json', + '--output-html', + ]; + /** * @param list $argv Already shifted past the script name. */ public function parse(array $argv): ParsedInput { $input = new ParsedInput(); + $count = count($argv); + + for ($i = 0; $i < $count; $i++) { + $arg = $argv[$i]; - foreach ($argv as $arg) { - if ($arg === '--help' || $arg === '-h') { - $input->showHelp = true; - } elseif ($arg === '--verbose' || $arg === '-v') { - $input->verbose = true; - } elseif ($arg === '--insecure' || $arg === '-k') { - $input->insecureTls = true; - } elseif ($arg === '--no-console') { - $input->noConsole = true; - } elseif ($arg === '--no-github-summary') { - $input->noGithubSummary = true; - } elseif ($arg === '--no-payloads') { - $input->noPayloads = true; - } elseif (str_starts_with($arg, '--payload-limit=')) { - $input->payloadMaxBytes = (int) substr($arg, 16); - } elseif (str_starts_with($arg, '--concurrency=')) { - $input->concurrency = (int) substr($arg, 14); - } elseif (str_starts_with($arg, '--output-json=')) { - $input->jsonOutputPath = substr($arg, 14); - } elseif (str_starts_with($arg, '--output-html=')) { - $input->htmlOutputPath = substr($arg, 14); - } elseif (str_starts_with($arg, '--output=')) { - $input->markdownOutputPath = substr($arg, 9); - } elseif (str_starts_with($arg, '--base-url=')) { - $input->cliVariables['APP_BASE_URL'] = substr($arg, 11); - } elseif (str_starts_with($arg, '--config-dir=')) { - $input->configDir = substr($arg, 13); - } elseif (str_starts_with($arg, '--config=')) { - $input->configFile = substr($arg, 9); - } elseif (str_starts_with($arg, '--smoke-json=')) { - $input->smokeJsonPath = substr($arg, 13); - } elseif (str_starts_with($arg, '--env-file=')) { - $input->envFilePath = substr($arg, 11); - } elseif (str_starts_with($arg, '--filter=')) { - $input->filter = substr($arg, 9); - } elseif (str_starts_with($arg, '--group=')) { - $input->groupFilter = substr($arg, 8); - } elseif (str_starts_with($arg, '--var=')) { - $value = substr($arg, 6); - $eq = strpos($value, '='); - if ($eq !== false) { - $input->cliVariables[substr($value, 0, $eq)] = substr($value, $eq + 1); + if (in_array($arg, self::FLAGS, true)) { + $this->applyFlag($input, $arg); + continue; + } + + if (!str_starts_with($arg, '-')) { + if ($input->environment !== null) { + $input->errors[] = "Unexpected argument \"{$arg}\" — environment is already set to \"{$input->environment}\"."; + continue; } - } elseif (!str_starts_with($arg, '-')) { $input->environment = $arg; + continue; + } + + $eq = strpos($arg, '='); + $name = $eq === false ? $arg : substr($arg, 0, $eq); + + if (!in_array($name, self::VALUE_OPTIONS, true)) { + $input->errors[] = self::unknownOptionMessage($name); + continue; + } + + if ($eq !== false) { + $this->applyValue($input, $name, substr($arg, $eq + 1)); + continue; + } + + $next = $argv[$i + 1] ?? null; + if ($next === null || str_starts_with($next, '-')) { + $input->errors[] = "Option {$name} requires a value: {$name}=VALUE"; + continue; } + $i++; + $this->applyValue($input, $name, $next); } return $input; } + private function applyFlag(ParsedInput $input, string $flag): void + { + match ($flag) { + '--help', '-h' => $input->showHelp = true, + '--verbose', '-v' => $input->verbose = true, + '--insecure', '-k' => $input->insecureTls = true, + '--no-console' => $input->noConsole = true, + '--no-github-summary' => $input->noGithubSummary = true, + '--no-payloads' => $input->noPayloads = true, + default => $input->errors[] = self::unknownOptionMessage($flag), + }; + } + + private function applyValue(ParsedInput $input, string $name, string $value): void + { + if ($value === '') { + $input->errors[] = "Option {$name} requires a non-empty value."; + + return; + } + + match ($name) { + '--concurrency' => $input->concurrency = $this->intValue($input, $name, $value), + '--payload-limit' => $input->payloadMaxBytes = $this->intValue($input, $name, $value), + '--config-dir' => $input->configDir = $value, + '--config' => $input->configFile = $value, + '--smoke-json' => $input->smokeJsonPath = $value, + '--env-file' => $input->envFilePath = $value, + '--base-url' => $input->cliVariables['APP_BASE_URL'] = $value, + '--var' => $this->applyVariable($input, $value), + '--filter' => $input->filter = $value, + '--group' => $input->groupFilter = $value, + '--output' => $input->markdownOutputPath = $value, + '--output-json' => $input->jsonOutputPath = $value, + '--output-html' => $input->htmlOutputPath = $value, + default => $input->errors[] = self::unknownOptionMessage($name), + }; + } + + private function applyVariable(ParsedInput $input, string $value): void + { + $eq = strpos($value, '='); + if ($eq === false || $eq === 0) { + $input->errors[] = "Option --var expects KEY=VALUE, got \"{$value}\"."; + + return; + } + + $input->cliVariables[substr($value, 0, $eq)] = substr($value, $eq + 1); + } + + private function intValue(ParsedInput $input, string $name, string $value): ?int + { + if (!ctype_digit($value)) { + $input->errors[] = "Option {$name} expects a whole number, got \"{$value}\"."; + + return null; + } + + return (int) $value; + } + + private static function unknownOptionMessage(string $name): string + { + $message = "Unknown option: {$name}"; + $suggestion = self::closestOption($name); + + return $suggestion === null ? $message : "{$message} — did you mean {$suggestion}?"; + } + + private static function closestOption(string $name): ?string + { + $best = null; + $bestDistance = PHP_INT_MAX; + + foreach ([...self::FLAGS, ...self::VALUE_OPTIONS] as $candidate) { + $distance = levenshtein($name, $candidate); + if ($distance < $bestDistance) { + $bestDistance = $distance; + $best = $candidate; + } + } + + return $bestDistance <= 3 ? $best : null; + } + public function helpText(string $binary = 'http-smoke'): string { return <<parser->helpText()); + + return ExitCode::UsageError; + } + if ($input->environment === null) { fwrite(STDERR, $this->parser->helpText()); diff --git a/tests/Unit/Console/InputParserTest.php b/tests/Unit/Console/InputParserTest.php new file mode 100644 index 0000000..cfc2dd8 --- /dev/null +++ b/tests/Unit/Console/InputParserTest.php @@ -0,0 +1,167 @@ + $argv + */ + #[Test] + #[TestWith([['prod', '--output-html=report.html']])] + #[TestWith([['prod', '--output-html', 'report.html']])] + public function value_options_accept_both_equals_and_separate_argument(array $argv): void + { + $input = new InputParser()->parse($argv); + + self::assertSame([], $input->errors); + self::assertSame('prod', $input->environment); + self::assertSame('report.html', $input->htmlOutputPath); + } + + #[Test] + public function all_options_are_parsed(): void + { + $input = new InputParser()->parse([ + 'staging', + '--concurrency=20', + '--payload-limit=4096', + '--config-dir=tests/Smoke', + '--config=smoke.php', + '--smoke-json=vars.json', + '--env-file=.env.staging', + '--base-url=https://example.test', + '--var=TOKEN=abc=def', + '--filter=02-thread', + '--group=api.*', + '--output=report.md', + '--output-json=report.json', + '--output-html=report.html', + '--no-payloads', + '--no-console', + '--no-github-summary', + '--verbose', + '--insecure', + ]); + + self::assertSame([], $input->errors); + self::assertSame('staging', $input->environment); + self::assertSame(20, $input->concurrency); + self::assertSame(4096, $input->payloadMaxBytes); + self::assertSame('tests/Smoke', $input->configDir); + self::assertSame('smoke.php', $input->configFile); + self::assertSame('vars.json', $input->smokeJsonPath); + self::assertSame('.env.staging', $input->envFilePath); + self::assertSame('report.md', $input->markdownOutputPath); + self::assertSame('report.json', $input->jsonOutputPath); + self::assertSame('report.html', $input->htmlOutputPath); + self::assertSame('02-thread', $input->filter); + self::assertSame('api.*', $input->groupFilter); + self::assertTrue($input->noPayloads); + self::assertTrue($input->noConsole); + self::assertTrue($input->noGithubSummary); + self::assertTrue($input->verbose); + self::assertTrue($input->insecureTls); + self::assertSame( + ['APP_BASE_URL' => 'https://example.test', 'TOKEN' => 'abc=def'], + $input->cliVariables, + ); + } + + #[Test] + public function unknown_option_is_reported_with_a_suggestion(): void + { + $input = new InputParser()->parse(['prod', '--output-htlm=report.html']); + + self::assertNull($input->htmlOutputPath); + self::assertCount(1, $input->errors); + self::assertStringContainsString('Unknown option: --output-htlm', $input->errors[0]); + self::assertStringContainsString('did you mean --output-html?', $input->errors[0]); + } + + #[Test] + public function unknown_option_without_a_close_match_is_reported_plainly(): void + { + $input = new InputParser()->parse(['prod', '--totally-different-thing']); + + self::assertSame(['Unknown option: --totally-different-thing'], $input->errors); + } + + /** + * @param list $argv + */ + #[Test] + #[TestWith([['prod', '--output-html'], 'requires a value'])] + #[TestWith([['prod', '--output-html='], 'requires a non-empty value'])] + #[TestWith([['prod', '--output-html', '--verbose'], 'requires a value'])] + public function missing_option_value_is_reported(array $argv, string $expected): void + { + $input = new InputParser()->parse($argv); + + self::assertNull($input->htmlOutputPath); + self::assertCount(1, $input->errors); + self::assertStringContainsString($expected, $input->errors[0]); + } + + #[Test] + public function a_second_positional_argument_is_reported_instead_of_silently_replacing_the_environment(): void + { + $input = new InputParser()->parse(['prod', 'report.html']); + + self::assertSame('prod', $input->environment); + self::assertCount(1, $input->errors); + self::assertStringContainsString('Unexpected argument "report.html"', $input->errors[0]); + } + + #[Test] + #[TestWith(['--concurrency=many'])] + #[TestWith(['--payload-limit=-5'])] + public function non_numeric_values_are_reported(string $arg): void + { + $input = new InputParser()->parse(['prod', $arg]); + + self::assertCount(1, $input->errors); + self::assertStringContainsString('expects a whole number', $input->errors[0]); + } + + #[Test] + public function var_without_a_key_value_pair_is_reported(): void + { + $input = new InputParser()->parse(['prod', '--var=TOKEN']); + + self::assertSame([], $input->cliVariables); + self::assertCount(1, $input->errors); + self::assertStringContainsString('--var expects KEY=VALUE', $input->errors[0]); + } + + #[Test] + #[TestWith(['--help'])] + #[TestWith(['-h'])] + public function help_is_requested_without_an_environment(string $arg): void + { + $input = new InputParser()->parse([$arg]); + + self::assertTrue($input->showHelp); + self::assertSame([], $input->errors); + self::assertNull($input->environment); + } + + #[Test] + public function help_text_documents_every_option(): void + { + $help = new InputParser()->helpText(); + + foreach (['--output-html=FILE', '--no-payloads', '--payload-limit=BYTES', '--var=KEY=VALUE'] as $option) { + self::assertStringContainsString($option, $help); + } + } +}