Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
43 changes: 42 additions & 1 deletion src/CLI/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Utopia\DI\Container;
use Utopia\Servers\Hook;
use Utopia\Validator;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;

class CLI
{
Expand Down Expand Up @@ -302,7 +304,7 @@ protected function getParams(Hook $hook): array

$this->validate($key, $param, $value);

$params[$this->camelCaseIt($key)] = $value;
$params[$this->camelCaseIt($key)] = $this->coerce($param['validator'], $value);
}

foreach ($hook->getDependencies() as $dependency) {
Expand Down Expand Up @@ -410,6 +412,45 @@ protected function validate(string $key, array $param, $value): void
}
}

/**
* Coerce string CLI inputs to native PHP types based on the param's validator.
*
* CLI args arrive as strings via getopt. When the validator is `Boolean`
* (loose mode), strings like "true"/"false"/"1"/"0" pass validation but
* remain strings. If the action callback declares a `bool` parameter, PHP's
* implicit string-to-bool cast turns any non-empty string except "0" into
* `true` -- so `--commit=false` silently becomes `true`. Validators in
* utopia-php are pure (validate only, never mutate), so the coercion has
* to happen here at the dispatch boundary.
*
* Only string inputs are coerced; bool defaults are passed through
* untouched.
*
* @param Validator|callable $validator
* @param mixed $value
* @return mixed
*/
protected function coerce(Validator|callable $validator, mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}

if (\is_callable($validator)) {
$validator = $validator();
}

while ($validator instanceof Nullable) {
$validator = $validator->getValidator();
}

if ($validator instanceof Boolean) {
return \filter_var($value, FILTER_VALIDATE_BOOLEAN);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

return $value;
}

public function setContainer(Container $container): self
{
$this->parentContainer = $container;
Expand Down
93 changes: 93 additions & 0 deletions tests/CLI/CLITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Utopia\CLI\CLI;
use Utopia\DI\Container;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;

class CLITest extends TestCase
Expand Down Expand Up @@ -289,6 +291,97 @@ public function testMatch()
$this->assertEquals(null, $cli->match());
}

/**
* @return iterable<string, array{0: string, 1: bool}>
*/
public static function looseBooleanValuesProvider(): iterable
{
yield '"false" string' => ['false', false];
yield '"true" string' => ['true', true];
yield '"0" string' => ['0', false];
yield '"1" string' => ['1', true];
}

/**
* Regression: --flag=false used to arrive as the literal string "false",
* which PHP's implicit string-to-bool cast turned into `true` at the
* `bool $flag` parameter boundary. The CLI dispatcher now coerces string
* inputs whose validator is `Boolean` to a real PHP bool.
*
* @dataProvider looseBooleanValuesProvider
*/
public function testBooleanParamCoercesStringInput(string $input, bool $expected): void
{
$captured = null;

$cli = new CLI(new Generic(), ['test.php', 'build', '--commit='.$input]);

$cli
->task('build')
->param('commit', false, new Boolean(true), 'Commit changes', true)
->action(function (bool $commit) use (&$captured) {
$captured = $commit;
});

$cli->run();

$this->assertSame($expected, $captured);
}

public function testBooleanParamUsesDefaultWhenOmitted(): void
{
$captured = null;

$cli = new CLI(new Generic(), ['test.php', 'build']);

$cli
->task('build')
->param('commit', false, new Boolean(true), 'Commit changes', true)
->action(function (bool $commit) use (&$captured) {
$captured = $commit;
});

$cli->run();

$this->assertFalse($captured);
}

public function testBooleanParamCoercionUnwrapsNullable(): void
{
$captured = 'untouched';

$cli = new CLI(new Generic(), ['test.php', 'build', '--commit=false']);

$cli
->task('build')
->param('commit', null, new Nullable(new Boolean(true)), 'Commit changes', true)
->action(function (bool $commit) use (&$captured) {
$captured = $commit;
});

$cli->run();

$this->assertFalse($captured);
}

public function testNonBooleanValidatorPassesValueThroughUnchanged(): void
{
$captured = null;

$cli = new CLI(new Generic(), ['test.php', 'build', '--name=false']);

$cli
->task('build')
->param('name', '', new Text(64), 'A name')
->action(function (string $name) use (&$captured) {
$captured = $name;
});

$cli->run();

$this->assertSame('false', $captured);
}

public function testEscaping()
{
ob_start();
Expand Down
Loading