diff --git a/.gitignore b/.gitignore index 0951afe..129946f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ vendor/ \#*# .*.marks -/nbproject/private/ diff --git a/bin/cfg b/bin/cfg index a7f7b9d..3c7c0ef 100755 --- a/bin/cfg +++ b/bin/cfg @@ -22,7 +22,12 @@ foreach ($autoloadFiles as $autoloadFile) { } } -$version = '0.2'; +function verboseLog(bool $enabled, string $message): void +{ + 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; + } + + $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 + .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); -} catch (\Exception $ex) { +} +catch (\Exception $ex) +{ echo $usage; if (isset($argv[1])) { @@ -158,7 +301,7 @@ try { exit(0); } } - echo $ex->getMessage().PHP_EOL; + fwrite(STDERR, $ex->getMessage().PHP_EOL); exit(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. .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//.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) { - echo "Error: ".$e->getMessage()."\n"; +} +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; @@ -220,7 +362,7 @@ if ($settings['key']) $result = $result->{$setting}; } catch (\OutOfRangeException $e) { - echo $e->getMessage().PHP_EOL; + fwrite(STDERR, $e->getMessage().PHP_EOL); exit(1); } } @@ -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': + $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); - - $setting2write = $settings['value']; + verboseLog($verbose, 'write source: '.($inputPayload ? '--in' : 'SETTING')); + 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; diff --git a/composer.json b/composer.json index 121ab8b..5905b59 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "rabe/util-settings", "description": "Package for reading and writing configuration settings", + "bin": ["bin/cfg"], "license": "AGPL3", "authors": [ { @@ -14,8 +15,8 @@ } }, "require": { - "pointybeard/helpers-cli-input": "1.2.2.2", - "pointybeard/helpers-functions-cli": "v1.1.10" + "pointybeard/helpers-cli-input": "dev-master as 1.2.3", + "pointybeard/helpers-functions-cli": "dev-master as 1.2" }, "require-dev": { "phpunit/phpunit": "^9.5" diff --git a/composer.lock b/composer.lock index 671abee..5974213 100644 --- a/composer.lock +++ b/composer.lock @@ -498,30 +498,30 @@ "packages-dev": [ { "name": "doctrine/instantiator", - "version": "1.5.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^11", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { @@ -548,7 +548,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -564,20 +564,20 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:15:36+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.11.1", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", "shasum": "" }, "require": { @@ -585,11 +585,12 @@ }, "conflict": { "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", @@ -615,7 +616,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" }, "funding": [ { @@ -623,29 +624,31 @@ "type": "tidelift" } ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2024-11-08T17:47:46+00:00" }, { "name": "nikic/php-parser", - "version": "v4.15.4", + "version": "v5.4.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290" + "reference": "447a020a1f875a434d62f2a401f53b82a396e494" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6bb5176bc4af8bcb7d926f88718db9b96a2d4290", - "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", + "reference": "447a020a1f875a434d62f2a401f53b82a396e494", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" @@ -653,7 +656,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -677,26 +680,27 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.4" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" }, - "time": "2023-03-05T19:49:14+00:00" + "time": "2024-12-30T11:07:19+00:00" }, { "name": "phar-io/manifest", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { "ext-dom": "*", + "ext-libxml": "*", "ext-phar": "*", "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", @@ -737,9 +741,15 @@ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.3" + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-07-20T11:28:43+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { "name": "phar-io/version", @@ -794,35 +804,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.26", + "version": "9.2.32", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1" + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", - "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^4.19.1 || ^5.1.0", "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.3", - "phpunit/php-text-template": "^2.0.2", - "sebastian/code-unit-reverse-lookup": "^2.0.2", - "sebastian/complexity": "^2.0", - "sebastian/environment": "^5.1.2", - "sebastian/lines-of-code": "^1.0.3", - "sebastian/version": "^3.0.1", - "theseer/tokenizer": "^1.2.0" + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9.6" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -831,7 +841,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.2-dev" + "dev-main": "9.2.x-dev" } }, "autoload": { @@ -859,7 +869,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.26" + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, "funding": [ { @@ -867,7 +878,7 @@ "type": "github" } ], - "time": "2023-03-06T12:58:08+00:00" + "time": "2024-08-22T04:23:01+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1112,45 +1123,45 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.5", + "version": "9.6.22", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "86e761949019ae83f49240b2f2123fb5ab3b2fc5" + "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/86e761949019ae83f49240b2f2123fb5ab3b2fc5", - "reference": "86e761949019ae83f49240b2f2123fb5ab3b2fc5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1 || ^2", + "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.10.1", - "phar-io/manifest": "^2.0.3", - "phar-io/version": "^3.0.2", + "myclabs/deep-copy": "^1.12.1", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.13", - "phpunit/php-file-iterator": "^3.0.5", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.3", - "phpunit/php-timer": "^5.0.2", - "sebastian/cli-parser": "^1.0.1", - "sebastian/code-unit": "^1.0.6", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.3", - "sebastian/environment": "^5.1.3", - "sebastian/exporter": "^4.0.5", - "sebastian/global-state": "^5.0.1", - "sebastian/object-enumerator": "^4.0.3", - "sebastian/resource-operations": "^3.0.3", - "sebastian/type": "^3.2", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.6", + "sebastian/global-state": "^5.0.7", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", "sebastian/version": "^3.0.2" }, "suggest": { @@ -1194,7 +1205,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.5" + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" }, "funding": [ { @@ -1210,20 +1222,20 @@ "type": "tidelift" } ], - "time": "2023-03-09T06:34:10+00:00" + "time": "2024-12-05T13:48:26+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", "shasum": "" }, "require": { @@ -1258,7 +1270,7 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" }, "funding": [ { @@ -1266,7 +1278,7 @@ "type": "github" } ], - "time": "2020-09-28T06:08:49+00:00" + "time": "2024-03-02T06:27:43+00:00" }, { "name": "sebastian/code-unit", @@ -1455,20 +1467,20 @@ }, { "name": "sebastian/complexity", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.7", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -1500,7 +1512,7 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" }, "funding": [ { @@ -1508,20 +1520,20 @@ "type": "github" } ], - "time": "2020-10-26T15:52:27+00:00" + "time": "2023-12-22T06:19:30+00:00" }, { "name": "sebastian/diff", - "version": "4.0.4", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", "shasum": "" }, "require": { @@ -1566,7 +1578,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" }, "funding": [ { @@ -1574,7 +1586,7 @@ "type": "github" } ], - "time": "2020-10-26T13:10:38+00:00" + "time": "2024-03-02T06:30:58+00:00" }, { "name": "sebastian/environment", @@ -1641,16 +1653,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", "shasum": "" }, "require": { @@ -1706,7 +1718,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" }, "funding": [ { @@ -1714,20 +1726,20 @@ "type": "github" } ], - "time": "2022-09-14T06:03:37+00:00" + "time": "2024-03-02T06:33:00+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.5", + "version": "5.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", "shasum": "" }, "require": { @@ -1770,7 +1782,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" }, "funding": [ { @@ -1778,24 +1790,24 @@ "type": "github" } ], - "time": "2022-02-14T08:28:10+00:00" + "time": "2024-03-02T06:35:11+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.3", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", "shasum": "" }, "require": { - "nikic/php-parser": "^4.6", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -1827,7 +1839,7 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" }, "funding": [ { @@ -1835,7 +1847,7 @@ "type": "github" } ], - "time": "2020-11-28T06:42:11+00:00" + "time": "2023-12-22T06:20:34+00:00" }, { "name": "sebastian/object-enumerator", @@ -2014,16 +2026,16 @@ }, { "name": "sebastian/resource-operations", - "version": "3.0.3", + "version": "3.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", "shasum": "" }, "require": { @@ -2035,7 +2047,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -2056,8 +2068,7 @@ "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", "support": { - "issues": "https://github.com/sebastianbergmann/resource-operations/issues", - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" }, "funding": [ { @@ -2065,7 +2076,7 @@ "type": "github" } ], - "time": "2020-09-28T06:45:17+00:00" + "time": "2024-03-14T16:00:52+00:00" }, { "name": "sebastian/type", @@ -2178,16 +2189,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.1", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", "shasum": "" }, "require": { @@ -2216,7 +2227,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" }, "funding": [ { @@ -2224,7 +2235,7 @@ "type": "github" } ], - "time": "2021-07-28T10:34:58+00:00" + "time": "2024-03-03T12:36:25+00:00" } ], "aliases": [ diff --git a/src/Settings.php b/src/Settings.php index d248051..ebf7aeb 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -26,7 +26,7 @@ namespace rabe\Util; * Settings Class to read Configuration Files * @author Norbert.e.Wagner dev@norb.me */ -class Settings implements \Iterator +class Settings implements \Iterator, \Countable { private const SITE = 0x01; @@ -153,13 +153,24 @@ class Settings implements \Iterator // load local config without merging - we need it here for the mode $conf = $this->buildFileName(); - $localConf = false; + $localConf = []; if (file_exists($conf)) $localConf = require($conf); // 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; } @@ -169,7 +180,7 @@ class Settings implements \Iterator $siteConf = $this->buildFileName(self::SITE); if (file_exists($siteConf)) { - $localConf = array_replace_recursive($this->settings, require($siteConf)); + $localConf = array_replace_recursive($localConf, require($siteConf)); } } @@ -177,7 +188,7 @@ class Settings implements \Iterator if ( isset( $this->modes[ $this->mode ] ) ) { $modeConf = $this->buildFileName( $this->modes[ $this->mode ] ); - + if ( file_exists($modeConf) ) { $this->settings = array_replace_recursive( $this->settings, require($modeConf) ); @@ -270,7 +281,7 @@ class Settings implements \Iterator * Rewind the Iterator to the first element * @link https://www.php.net/iterator.rewind */ - public function rewind() + public function rewind(): void { reset( $this->settings); } @@ -279,7 +290,7 @@ class Settings implements \Iterator * Return the current element * @link https://www.php.net/iterator.current */ - public function current() + public function current(): mixed { return current( $this->settings ); } @@ -288,7 +299,7 @@ class Settings implements \Iterator * Return the key of the current element * @link https://www.php.net/iterator.key */ - public function key() + public function key(): mixed { return key( $this->settings ); } @@ -297,16 +308,16 @@ class Settings implements \Iterator * Move forward to next element * @link https://www.php.net/iterator.next */ - public function next() + public function next(): void { - return next( $this->settings ); + next( $this->settings ); } /** * Checks if current position is valid * @link https://www.php.net/iterator.valid */ - public function valid() + public function valid(): bool { $key = key( $this->settings ); return ($key !== null && $key !== false); @@ -314,6 +325,17 @@ class Settings implements \Iterator // }}} + // Countable {{{ + /** + * Count elements + * @link https://www.php.net/countable.count.php + */ + public function count(): int + { + return count($this->settings); + } + // }}} + // Magic getter/setter methods {{{ public function __set( $name, $value ) { @@ -347,4 +369,4 @@ class Settings implements \Iterator /* jEdit buffer local properties {{{ * :folding=explicit:collapseFolds=1: -}}}*/ \ No newline at end of file +}}}*/ diff --git a/src/SettingsWriter.php b/src/SettingsWriter.php index d6fc41c..6246a9f 100644 --- a/src/SettingsWriter.php +++ b/src/SettingsWriter.php @@ -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,13 +47,16 @@ 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' ) ) { throw new ErrorException( "Can not open File: $file" ); diff --git a/tests/CfgTest.php b/tests/CfgTest.php new file mode 100644 index 0000000..646bcb2 --- /dev/null +++ b/tests/CfgTest.php @@ -0,0 +1,457 @@ +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', + " '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', + " [\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); + } + /* }}} */ +} diff --git a/tests/SettingsTest.php b/tests/SettingsTest.php index dd78e34..09f311c 100644 --- a/tests/SettingsTest.php +++ b/tests/SettingsTest.php @@ -30,7 +30,7 @@ class SettingsTest extends TestCase public function testConstruct() { $cfg = new Settings(); - $this->assertNotEmpty($cfg); + $this->assertIsObject($cfg); } /** @@ -69,7 +69,7 @@ class SettingsTest extends TestCase { $cfg = new Settings(); $cfg = $this->appPath($cfg)->load(); - $this->assertNotEmpty($cfg); + $this->assertIsObject($cfg); return $cfg; } @@ -129,7 +129,23 @@ class SettingsTest extends TestCase $this->assertEquals('site', $cfg->testFile); $this->assertEquals(42, $cfg->answer); } - + + /** + */ + public function testLoadingOrder() + { + $cfg = new Settings(); + $cfg = $this->appPath($cfg, 'order')->site('site')->load(); + + $this->assertEquals('default', $cfg->testFiles); + $this->assertEquals('conf', $cfg->testFiles2); + $this->assertEquals('site', $cfg->testFile); + $this->assertEquals(42, $cfg->answer); + + return $cfg; + } + + /** */ public function testTestingOverride() @@ -176,6 +192,15 @@ class SettingsTest extends TestCase $cfg = $this->appPath($cfg, 'localOverride')->load(); $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 @@ -195,6 +220,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() { @@ -224,4 +265,4 @@ class SettingsTest extends TestCase /* jEdit buffer local properties {{{ * :folding=explicit:collapseFolds=1: -}}}*/ \ No newline at end of file +}}}*/ diff --git a/tests/cfg/noMode/config/default.conf.php b/tests/cfg/noMode/config/default.conf.php new file mode 100644 index 0000000..c0a0d9b --- /dev/null +++ b/tests/cfg/noMode/config/default.conf.php @@ -0,0 +1,3 @@ + 'default', +]; diff --git a/tests/cfg/order/config/conf.php b/tests/cfg/order/config/conf.php new file mode 100644 index 0000000..8ea09df --- /dev/null +++ b/tests/cfg/order/config/conf.php @@ -0,0 +1,5 @@ + 1, + 'testFile' => 'conf', + 'testFiles2' => 'conf' +]; diff --git a/tests/cfg/order/config/default.conf.php b/tests/cfg/order/config/default.conf.php new file mode 100644 index 0000000..90020ca --- /dev/null +++ b/tests/cfg/order/config/default.conf.php @@ -0,0 +1,7 @@ + 'prod', + 'answer' => 0, + 'testFile' => 'default', + 'testFiles' => 'default', + 'testFiles2' => 'default' +]; diff --git a/tests/cfg/order/config/site/conf.php b/tests/cfg/order/config/site/conf.php new file mode 100644 index 0000000..54ad2b8 --- /dev/null +++ b/tests/cfg/order/config/site/conf.php @@ -0,0 +1,4 @@ + 42, + 'testFile' => 'site' +];