diff --git a/bin/cfg b/bin/cfg index 78e84c5..3c7c0ef 100755 --- a/bin/cfg +++ b/bin/cfg @@ -22,7 +22,12 @@ foreach ($autoloadFiles as $autoloadFile) { } } -$version = '0.3'; +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])) { @@ -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) { +} +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': + $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/src/Settings.php b/src/Settings.php index 56e1b0f..ebf7aeb 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -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; } @@ -358,4 +369,4 @@ class Settings implements \Iterator, \Countable /* 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 64ed411..09f311c 100644 --- a/tests/SettingsTest.php +++ b/tests/SettingsTest.php @@ -192,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 @@ -211,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() { 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', +];