Task: #53687 Support instance-specific cfg write targets and add coverage V2 #4

Merged
norb merged 11 commits from 53687/task-support-instance-specific-cfg-write-target-v2 into master 2026-03-30 15:09:21 +00:00
6 changed files with 731 additions and 58 deletions

272
bin/cfg
View file

@ -22,7 +22,12 @@ foreach ($autoloadFiles as $autoloadFile) {
}
}
$version = '0.3';
function verboseLog(bool $enabled, string $message): void
alejandro.sosa marked this conversation as resolved Outdated

I don't really like this .. seems over complicating the code. It is just for beging able to have different order of arguments?

not that important to me .. and probably better put inside the input helper library or replace it altogether .. IIRC the one used is not maintained anymore

I don't really like this .. seems over complicating the code. It is just for beging able to have different order of arguments? not that important to me .. and probably better put inside the input helper library or replace it altogether .. IIRC the one used is not maintained anymore

@norb wrote in #4 (comment):

I don't really like this .. seems over complicating the code. It is just for beging able to have different order of arguments?

not that important to me .. and probably better put inside the input helper library or replace it altogether .. IIRC the one used is not maintained anymore

You're right. After reviewing it again with the alias cleanup in place, I realized we can keep this much simpler. The pre-parse block was added as a defensive workaround while multiple option variants were still in play, but with --siteDir now unified and a couple of small follow-up fixes, the standard parser flow is sufficient. I removed that extra layer to keep the code cleaner and easier to maintain.

@norb wrote in https://code.verua.online/rabe/Util-Settings/pulls/4#issuecomment-3732: > I don't really like this .. seems over complicating the code. It is just for beging able to have different order of arguments? > > not that important to me .. and probably better put inside the input helper library or replace it altogether .. IIRC the one used is not maintained anymore You're right. After reviewing it again with the alias cleanup in place, I realized we can keep this much simpler. The pre-parse block was added as a defensive workaround while multiple option variants were still in play, but with --siteDir now unified and a couple of small follow-up fixes, the standard parser flow is sufficient. I removed that extra layer to keep the code cleaner and easier to maintain.
alejandro.sosa marked this conversation as resolved Outdated

can you do this as validators? e.g.

->validator(new Input\Validator(

can you do this as validators? e.g. https://code.verua.online/rabe/helpers-cli-input/src/commit/c6fe64321ef06633c89bc055252f22d6ff676d52/example/example.php#L35
{
if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL);
}
$version = '0.4';
$actions = [ 'show', 'write', 'help' ];
$settings = ['key' => '', 'value' => ''];
@ -35,15 +40,55 @@ $collection = (new Input\InputCollection())
->description('Display help text')
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a json data file to to read')
->add( Input\InputTypeFactory::build('LongOption')->name('verbose')->short('v') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL)
->description('Print debug details to STDERR')
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('out')->short('o') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a json file to to write to')
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a JSON data file to read (for write)')
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$path = $context->find('in');
if ($path === null || $path === '') {
return null;
}
if (!is_readable($path))
{
throw new \Exception("Input file is not readable: $path");
}
$content = file_get_contents($path);
if ($content === false)
{
throw new \Exception("Can not read input file: $path");
}
try
{
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
}
catch (\JsonException $ex)
{
throw new \Exception(sprintf(
'Input JSON is invalid (%s): %s',
(string)$ex->getCode(),
$ex->getMessage()
));
}
if (!is_array($data))
{
throw new \Exception('Input JSON must decode to an object/array');
}
return $data;
}
))
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('mode')->short('m') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
@ -60,6 +105,60 @@ $collection = (new Input\InputCollection())
->description('Path where the config/ directory of the package conf files is located, defaults to the working dir')
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('siteDir')->short('s') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Site/instance directory below config/. Accepts owner_xyz or config/owner_xyz')
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$siteInput = $context->find('siteDir');
if ($siteInput === null || $siteInput === false) {
return null;
}
alejandro.sosa marked this conversation as resolved Outdated

as agreed in chat, again to keep the code simple, use --siteDir without alias

same argument, if we think we will need arguments, implement in used lib or use different lib

as agreed in chat, again to keep the code simple, use --siteDir without alias same argument, if we think we will need arguments, implement in used lib or use different lib

@norb wrote in #4 (comment):

as agreed in chat, again to keep the code simple, use --siteDir without alias

same argument, if we think we will need arguments, implement in used lib or use different lib

👍🏼 Done, agreed. I removed aliases and kept only --siteDir to keep the CLI simple

@norb wrote in https://code.verua.online/rabe/Util-Settings/pulls/4#issuecomment-3733: > as agreed in chat, again to keep the code simple, use --siteDir without alias > > same argument, if we think we will need arguments, implement in used lib or use different lib 👍🏼 Done, agreed. I removed aliases and kept only --siteDir to keep the CLI simple
$siteInput = trim((string)$siteInput);
$appPath = $context->find('appPath');
if (!$appPath) {
$appPath = getcwd().'/';
}
$appPath = rtrim($appPath, '/').'/';
// Accept both "owner_xyz" and "config/owner_xyz" and normalize to site key.
if (str_starts_with($siteInput, $appPath.'config/'))
{
$siteInput = substr($siteInput, strlen($appPath.'config/'));
}
elseif (str_starts_with($siteInput, 'config/'))
{
$siteInput = substr($siteInput, strlen('config/'));
}
$siteInput = trim($siteInput, '/');
if ($siteInput === '')
{
throw new \Exception('Option --siteDir is empty.');
}
// Block directory traversal and hidden-dot segments.
if (str_contains($siteInput, '..') || preg_match('~(^|/)\.(?:/|$)~', $siteInput))
{
throw new \Exception("Invalid directory in --siteDir: '$siteInput'.");
}
// Allow only predictable path segments for instance directories.
foreach (explode('/', $siteInput) as $part)
{
if ($part === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $part))
{
throw new \Exception("Invalid directory name segment '$part' in --siteDir.");
}
}
return $siteInput;
}
))
) // }}}
->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{
->flags(AbstractInputType::FLAG_REQUIRED)
->description(
@ -102,18 +201,30 @@ $collection = (new Input\InputCollection())
->description(
'the settings you want to work on. With action "write" you can pass the value to set as JSON after an equal sign.'
)
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$setting = $context->find('setting');
$action = $context->find('action');
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$setting = $context->find('setting');
$action = $context->find('action');
$inputPayload = $context->find('in');
if ($action === 'write')
{
if (($setting !== null && $setting !== '') && $inputPayload) {
throw new \Exception('Please use either SETTING or --in, not both.');
}
}
if ($setting === null || $setting === '') {
return ['key' => '', 'value' => null];
}
$setting = explode('=', $setting);
$settings['key'] = $setting[0];
if ($action === 'write')
{
$value = $setting[1];
if (! (isset($value) || $context->find('data'))) {
$value = $setting[1] ?? null;
if (! (isset($value) || $context->find('in'))) {
throw new \Exception('You need a value to write');
}
$specialValues = [ 'true', 'false', 'null' ];
@ -139,18 +250,50 @@ $collection = (new Input\InputCollection())
$usage = Cli\manpage( basename(__FILE__), $version,
'read write settings',
$collection, Colour::FG_GREEN, Colour::FG_WHITE,
[
'Examples' =>
'cfg show VeruA db:host'.PHP_EOL.
'cfg write VeruA \'db:host="newHost"\''.PHP_EOL
]
[
'Examples' =>
'Basic usage:'
.PHP_EOL
.'cfg show VeruA db:host'
.PHP_EOL
.'cfg write VeruA \'db:host="newHost"\''
.PHP_EOL
.PHP_EOL
.'Advance usage:'
.PHP_EOL
.'cfg write prefix \'module:enabled=true\''
.PHP_EOL
.PHP_EOL
.'#Site specific write:'
.PHP_EOL
.'cfg write prefix \'module:enabled=true\' --siteDir=owner_xyz'
.PHP_EOL
.PHP_EOL
.'#Batch write from JSON file:'
.PHP_EOL
.'cfg write prefix --siteDir=owner_xyz -i /tmp/extension.json'
.PHP_EOL
.PHP_EOL
.'#Batch write from JSON string:'
.PHP_EOL
.'cfg write prefix \'module={"enabled":true,"timeout":30,"label":"example"}\' --siteDir=owner_xyz'
.PHP_EOL
alejandro.sosa marked this conversation as resolved Outdated

our coding style would always put the opening curly bracket on a newline. I retain it specially important if the following codeblock is longer than a couple of lines

our coding style would always put the opening curly bracket on a newline. I retain it specially important if the following codeblock is longer than a couple of lines
.PHP_EOL
.'#Read merged value for a site:'
.PHP_EOL
.'cfg show prefix module:enabled --siteDir=owner_xyz'
.PHP_EOL
]
).PHP_EOL;
// Get the supplied input. Passing the collection will make the handler bind values
// and validate the input according to our collection
try {
try
{
$argv = Input\InputHandlerFactory::build('Argv', $collection);
alejandro.sosa marked this conversation as resolved Outdated

probably most of this should be done in the validator as well

probably most of this should be done in the validator as well
} catch (\Exception $ex) {
}
catch (\Exception $ex)
{
echo $usage;
if (isset($argv[1])) {
@ -172,42 +315,41 @@ if ($argv->find( 'help' ) || $argv->find('action') == 'help')
$prefix = $argv->find('prefix');
/*
echo $argv->find('action').PHP_EOL;
echo ($prefix).PHP_EOL;
echo $argv->find('setting')['key'].PHP_EOL;
echo $argv->find('setting')['value'].PHP_EOL;
*/
// var_dump($cfg);
$verbose = (bool)$argv->find('verbose');
$appPath = $argv->find('appPath');
if (!$appPath) $appPath = getcwd().'/';
$appPath = rtrim($appPath, '/').'/';
/* $it = new RecursiveDirectoryIterator($appPath);
foreach(new RecursiveIteratorIterator($it) as $file)
{
$configDir = $file->getPath();
if ($file->isDir() && $file->getFilename() == '.' && basename($configDir) == 'config') {
echo "found config dir: $configDir\n";
}
}
*/
$mode = ($argv->find('mode') == '') ? null : $argv->find('mode');
$cfg = (new Settings([], $mode))->appPath($appPath)->prefix($prefix);
if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath($pkgPath);
// pkgPath points to package defaults (e.g. <prefix>.default.conf.php)
if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/');
try {
$site = null;
$siteFlag = 0x01;
$site = $argv->find('siteDir');
if ($site !== null && $site !== false)
{
// Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
$cfg->site($site);
verboseLog($verbose, "siteDir resolved to '$site'");
}
try
{
if (is_readable($cfg->buildFileName('default'))) {
$cfg->load();
}
elseif (is_readable($cfgFile = $cfg->buildFileName())) {
$cfg->load(require($cfgFile));
}
} catch (Exception $e) {
}
catch (\Throwable $e)
{
fwrite(STDERR, "Error: ".$e->getMessage().PHP_EOL);
exit(1);
}
//var_dump($cfg);
verboseLog($verbose, 'config bootstrap loaded');
$result = $cfg;
$settings = $argv->find('setting') ?? $settings;
@ -232,33 +374,63 @@ if ($result instanceof Settings) $result = $result->toArray();
switch ($argv->find('action'))
{
case 'show':
verboseLog($verbose, "show action for prefix '$prefix'");
$out = (is_string($result)) ? $result : json_encode($result, JSON_PRETTY_PRINT);
echo $out.PHP_EOL;
break;
case 'write':
alejandro.sosa marked this conversation as resolved Outdated

probably this one too?

probably this one too?
$inputPayload = $argv->find('in');
if (!$inputPayload && $settings['key'] === '')
{
fwrite(STDERR, 'Nothing to write: provide SETTING or --in.'.PHP_EOL);
exit(1);
}
$path = ($settings['key'] !== '') ? explode(':', $settings['key']) : [];
var_dump($path);
norb marked this conversation as resolved Outdated

perhaps we could have a verbose mode?

perhaps we could have a verbose mode?

@norb wrote in #4 (comment):

perhaps we could have a verbose mode?

Hi Norber, for this round I kept the verbose implementation minimal on purpose. The current CLI helper library is not fully compatible with PHP 8 in the verbosity path (especially around incrementing flags), so going further right now would either require patching vendor code or introducing larger structural changes. I agree we should improve this longer term; a good replacement would be Symfony Console (https://symfony.com/doc/current/components/console.html), which I know well and that already provides robust argument parsing, verbosity levels, and many related features. For this version, I think it is safer to keep it simple and stable, and handle a full CLI migration in a dedicated follow-up.

@norb wrote in https://code.verua.online/rabe/Util-Settings/pulls/4#issuecomment-3735: > perhaps we could have a verbose mode? Hi Norber, for this round I kept the verbose implementation minimal on purpose. The current CLI helper library is not fully compatible with PHP 8 in the verbosity path (especially around incrementing flags), so going further right now would either require patching vendor code or introducing larger structural changes. I agree we should improve this longer term; a good replacement would be Symfony Console (https://symfony.com/doc/current/components/console.html), which I know well and that already provides robust argument parsing, verbosity levels, and many related features. For this version, I think it is safer to keep it simple and stable, and handle a full CLI migration in a dedicated follow-up.
verboseLog($verbose, 'write source: '.($inputPayload ? '--in' : 'SETTING'));
$setting2write = $settings['value'];
if ($inputPayload)
{
$setting2write = $inputPayload;
}
else
{
$setting2write = $settings['value'];
}
verboseLog($verbose, 'write path: '.json_encode($path));
while ( ! empty($path))
{
$setting2write = [array_pop($path) => $setting2write];
}
if (is_readable($file = $cfg->buildFileName()))
$writeType = ($site !== null && $site !== false) ? $siteFlag : null;
$file = $cfg->buildFileName($writeType);
verboseLog($verbose, "write target: $file");
if (is_readable($file))
{
$setting2write = array_replace_recursive(require($file), $setting2write);
copy($file, "$file.bak");
verboseLog($verbose, "existing config merged from: $file");
}
$targetDir = dirname($file);
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir))
{
fwrite(STDERR, "Can not create directory: $targetDir".PHP_EOL);
exit(1);
}
$writeCfg = $cfg->create($setting2write);
// var_dump($writeCfg->toArray());
try {
(new SettingsWriter($writeCfg))->write();
verboseLog($verbose, 'payload prepared for writer');
try
{
(new SettingsWriter($writeCfg, '', $writeType))->write();
echo "Written modified settings to: $file".PHP_EOL;
}
catch (\Exception $e) {
echo $e->getMessage().PHP_EOL;
fwrite(STDERR, $e->getMessage().PHP_EOL);
exit(1);
}
break;

View file

@ -159,7 +159,18 @@ class Settings implements \Iterator, \Countable
// if a mode was set in the constructor do not overwrite it
if (! isset($this->mode)) {
// if a localConf Mode is set use it, or take the default conf mode
$this->mode = (isset($localConf['mode'])) ? $localConf['mode'] : $this->settings['mode'];
// if local/default config has no mode, fall back to the first known mode (normally "prod")
if (isset($localConf['mode']) && is_string($localConf['mode']) && $localConf['mode'] !== '') {
$this->mode = $localConf['mode'];
}
elseif (isset($this->settings['mode']) && is_string($this->settings['mode']) && $this->settings['mode'] !== '') {
$this->mode = $this->settings['mode'];
}
else {
// Backward-compatible fallback for configs without explicit mode.
$this->mode = (string)array_key_first($this->modes);
$this->settings['mode'] = $this->mode;
}
} else {
$this->settings['mode'] = $this->mode;
}

View file

@ -22,6 +22,8 @@
declare(strict_types=1);
namespace rabe\Util;
use ErrorException;
/**
* Write Settings into a File
* @author Norbert.e.Wagner dev@norb.me
@ -45,12 +47,15 @@ class SettingsWriter
*
* @param Settings $settings an object of type settings
* @param String $name The name midfix for the settings File
*/
public function __construct( Settings $settings, $name='' )
* @param String|int|null $type Optional type for Settings::buildFileName(), e.g. site flag
*/
public function __construct( Settings $settings, $name='', $type=null )
{
$this->settings = $settings;
$file = $settings->buildFileName( $name );
$file = ( $type === null )
? $settings->buildFileName( $name )
: $settings->buildFileName( $type );
if ( ! $this->handle = fopen( $file, 'w' ) )
{

457
tests/CfgTest.php Normal file
View file

@ -0,0 +1,457 @@
<?php
declare(strict_types=1);
namespace rabe\Util\tests;
use PHPUnit\Framework\TestCase;
class CfgTest extends TestCase
{
private string $tmpDir;
/* Lifecycle {{{ */
protected function setUp(): void
{
$this->tmpDir = rtrim(sys_get_temp_dir(), '/').'/util-settings-cfg-'.bin2hex(random_bytes(8));
mkdir($this->tmpDir, 0775, true);
mkdir($this->tmpDir.'/config', 0775, true);
file_put_contents(
$this->tmpDir.'/config/Extension.default.conf.php',
"<?php return [\n\t'mode' => 'prod',\n\t'module' => [\n\t\t'code' => '',\n\t\t'label' => '',\n\t\t'flags' => [\n\t\t\t'enabled' => false,\n\t\t],\n\t],\n\t'feature' => [\n\t\t'endpoint' => '',\n\t],\n];\n"
);
}
protected function tearDown(): void
{
$this->removeDir($this->tmpDir);
}
/* }}} */
/* Write Flow {{{ */
public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void
{
$result = $this->runWrite([
'module:code="X100"',
]);
$this->assertCfgSuccess($result);
$localFile = $this->tmpDir.'/config/Extension.conf.php';
$this->assertFileExists($localFile);
$cfg = require $localFile;
$this->assertSame('X100', $cfg['module']['code']);
}
public function testWriteWithoutModeFallsBackToDefaultMode(): void
{
file_put_contents(
$this->tmpDir.'/config/Extension.default.conf.php',
"<?php return [\n\t'module' => [\n\t\t'code' => '',\n\t],\n];\n"
);
$result = $this->runWrite([
'module:code="X100"',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/Extension.conf.php');
}
public function testWriteWithInputFileWritesMultipleSettings(): void
{
$inFile = $this->createJsonInputFile([
'module' => [
'code' => 'X100',
'label' => 'demo-module',
],
'feature' => [
'endpoint' => 'https://example.invalid/v1/resource',
],
]);
$result = $this->runWrite([
'--siteDir=owner_xyz',
'-i',
$inFile,
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
$this->assertSame('https://example.invalid/v1/resource', $cfg['feature']['endpoint']);
}
public function testWriteAcceptsJsonStringValueInSetting(): void
{
$result = $this->runWrite([
'module={"code":"X100","label":"demo-module"}',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
}
public function testWriteSupportsNestedPathWithColonNotation(): void
{
$result = $this->runWrite([
'module:flags:enabled=true',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertTrue($cfg['module']['flags']['enabled']);
}
public function testWriteCoercesSimpleStringValueToJsonString(): void
{
$result = $this->runWrite([
'module:code=X100',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
}
public function testWriteRejectsInvalidJsonStringValue(): void
{
$result = $this->runWrite([
'module={"code":',
'--siteDir=owner_xyz',
]);
$this->assertCfgFailure($result, 'The value does not appear to be a valid JSON string.');
}
public function testWriteWithSiteDirCreatesAndWritesSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$this->assertFileExists($siteFile);
$this->assertFileDoesNotExist($this->tmpDir.'/config/Extension.conf.php');
$cfg = require $siteFile;
$this->assertSame('X100', $cfg['module']['code']);
}
public function testWriteWithSiteDirMergesIntoExistingSiteConfig(): void
{
$firstWrite = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($firstWrite);
$secondWrite = $this->runWrite([
'module:label="demo-module"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($secondWrite);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
}
public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=config/owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
$this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/Extension.conf.php');
}
public function testWriteWithSiteDirUsingAbsoluteConfigPrefixIsNormalized(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir='.$this->tmpDir.'/config/owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
}
public function testWriteWithNestedSiteDirWritesToNestedSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz/sub_a',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/sub_a/Extension.conf.php');
}
public function testWriteWithSiteDirWritesToSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
}
public function testWriteToExistingSiteConfigCreatesBackup(): void
{
$firstWrite = $this->runWrite([
'module:code="first"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($firstWrite);
$secondWrite = $this->runWrite([
'module:code="second"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($secondWrite);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$backupFile = $siteFile.'.bak';
$this->assertFileExists($backupFile);
$current = require $siteFile;
$backup = require $backupFile;
$this->assertSame('second', $current['module']['code']);
$this->assertSame('first', $backup['module']['code']);
}
/* }}} */
/* Show Flow {{{ */
public function testShowWithSiteReturnsSiteSpecificValue(): void
{
$this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$show = $this->runShow([
'module:code',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($show);
$this->assertStringContainsString('X100', $show['output']);
}
/* }}} */
/* Validation {{{ */
public function testInvalidActionReturnsError(): void
{
$result = $this->runCfg([
'-a',
$this->tmpDir,
'delete',
'Extension',
]);
$this->assertCfgFailure($result, "'delete' not found");
}
public function testSiteDirRejectsEmptyValue(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=',
]);
$this->assertCfgFailure($result, 'a value is required for --siteDir');
}
/**
* @dataProvider siteDirValidationDataProvider
*/
public function testSiteDirValidation(string $siteDir, string $expectedMessage): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir='.$siteDir,
]);
$this->assertCfgFailure($result, $expectedMessage);
}
/**
* @dataProvider inputFileValidationDataProvider
*/
public function testInputFileValidation(?string $fileContents, string $pathSuffix, string $expectedMessage): void
{
$inFile = $this->tmpDir.'/'.$pathSuffix;
if ($fileContents !== null) {
file_put_contents($inFile, $fileContents);
}
$result = $this->runWrite([
'-i',
$inFile,
]);
$this->assertCfgFailure($result, $expectedMessage);
}
/**
* @dataProvider writePayloadSourceValidationDataProvider
*/
public function testWritePayloadSourceValidation(array $extraArgs, string $expectedMessage): void
{
$inFile = $this->createJsonInputFile(['module' => ['code' => 'X100']]);
$args = array_merge(
[
'-a',
$this->tmpDir,
'write',
'Extension',
],
array_map(
fn (string $arg): string => $arg === '__INPUT_FILE__' ? $inFile : $arg,
$extraArgs
)
);
$result = $this->runCfg($args);
$this->assertCfgFailure($result, $expectedMessage);
}
public function siteDirValidationDataProvider(): array
{
return [
'traversal' => ['../owner_xyz', 'Invalid directory in --siteDir'],
'invalid segment' => ['owner xyz', "Invalid directory name segment 'owner xyz' in --siteDir."],
'hidden dot segment' => ['owner_xyz/./secret', "Invalid directory in --siteDir: 'owner_xyz/./secret'."],
'config root only' => ['config/', 'Option --siteDir is empty.'],
];
}
public function inputFileValidationDataProvider(): array
{
return [
'missing file' => [null, 'missing.json', 'Input file is not readable'],
'invalid json' => ['{"module":', 'invalid.json', 'Input JSON is invalid'],
'scalar json' => ['true', 'scalar.json', 'Input JSON must decode to an object/array'],
];
}
public function writePayloadSourceValidationDataProvider(): array
{
return [
'missing payload source' => [[], 'Nothing to write: provide SETTING or --in.'],
'both payload sources' => [['module:code="x"', '-i', '__INPUT_FILE__'], 'Please use either SETTING or --in, not both.'],
];
}
/* }}} */
/* Helpers {{{ */
private function runWrite(array $args): array
{
return $this->runCfg(array_merge([
'-a',
$this->tmpDir,
'write',
'Extension',
], $args));
}
private function runShow(array $args): array
{
return $this->runCfg(array_merge([
'-a',
$this->tmpDir,
'show',
'Extension',
], $args));
}
private function createJsonInputFile(array $contents, string $name = 'extension-in.json'): string
{
$path = $this->tmpDir.'/'.$name;
file_put_contents($path, json_encode($contents, JSON_PRETTY_PRINT));
return $path;
}
private function loadWrittenConfig(?string $siteDir = null): array
{
$path = $siteDir === null
? $this->tmpDir.'/config/Extension.conf.php'
: $this->tmpDir.'/config/'.$siteDir.'/Extension.conf.php';
return require $path;
}
private function assertCfgSuccess(array $result): void
{
$this->assertSame(0, $result['code'], $result['output']);
}
private function assertCfgFailure(array $result, string $message): void
{
$this->assertSame(1, $result['code'], $result['output']);
$this->assertStringContainsString($message, $result['output']);
}
private function runCfg(array $args): array
{
$script = realpath(__DIR__.'/../bin/cfg');
$this->assertNotFalse($script);
$command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($script);
foreach ($args as $arg) {
$command .= ' '.escapeshellarg($arg);
}
$command .= ' 2>&1';
$outputLines = [];
$exitCode = 0;
exec($command, $outputLines, $exitCode);
return [
'code' => $exitCode,
'output' => implode(PHP_EOL, $outputLines),
];
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir.'/'.$entry;
if (is_dir($path)) {
$this->removeDir($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/* }}} */
}

View file

@ -193,6 +193,15 @@ class SettingsTest extends TestCase
$this->assertEquals(42, $cfg->answer);
}
public function testLoadWithoutModeFallsBackToFirstKnownMode(): void
{
$cfg = new Settings();
$cfg = $this->appPath($cfg, 'noMode')->load();
$this->assertEquals('prod', $cfg->mode);
$this->assertEquals('default', $cfg->testFiles);
}
/**
* @dataProvider fileNameData
*/
@ -212,6 +221,22 @@ class SettingsTest extends TestCase
$this->assertEquals($path.$expected, $cfg->buildFileName($type));
}
public function testBuildFileNameWithPrefixAndSite(): void
{
$cfg = new Settings();
$cfg->appPath('./')->prefix('Extension')->site('owner_xyz');
$this->assertEquals('./config/owner_xyz/Extension.conf.php', $cfg->buildFileName(0x01));
}
public function testBuildFileNameWithPrefixAndNestedSite(): void
{
$cfg = new Settings();
$cfg->appPath('./')->prefix('Extension')->site('owner_xyz/sub_a');
$this->assertEquals('./config/owner_xyz/sub_a/Extension.conf.php', $cfg->buildFileName(0x01));
}
public function fileNameData()
{
return [

View file

@ -0,0 +1,3 @@
<?php return [
'testFiles' => 'default',
];