Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ http-smoke <environment> [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.

---
Expand Down
2 changes: 1 addition & 1 deletion docs/claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions docs/claude/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\*`).
Expand Down
198 changes: 155 additions & 43 deletions src/Console/InputParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,64 +6,172 @@

final class InputParser
{
/** @var list<string> */
private const array FLAGS = [
'--help',
'-h',
'--verbose',
'-v',
'--insecure',
'-k',
'--no-console',
'--no-github-summary',
'--no-payloads',
];

/** @var list<string> */
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<string> $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 <<<HELP
Expand Down Expand Up @@ -95,6 +203,10 @@ public function helpText(string $binary = 'http-smoke'): string
--insecure, -k Disable TLS peer verification
--help, -h Show this help

Options take their value with "=" or as the next argument:
{$binary} prod --output-html=report.html
{$binary} prod --output-html report.html


HELP;
}
Expand Down
3 changes: 3 additions & 0 deletions src/Console/ParsedInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ final class ParsedInput

public bool $showHelp = false;

/** @var list<string> */
public array $errors = [];

public bool $verbose = false;

public bool $insecureTls = false;
Expand Down
9 changes: 9 additions & 0 deletions src/Console/RunCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ public function execute(array $argv, string $projectRoot): ExitCode
return ExitCode::Success;
}

if ($input->errors !== []) {
foreach ($input->errors as $error) {
fwrite(STDERR, "Error: {$error}\n");
}
fwrite(STDERR, $this->parser->helpText());

return ExitCode::UsageError;
}

if ($input->environment === null) {
fwrite(STDERR, $this->parser->helpText());

Expand Down
Loading
Loading