Browse Source

Command Line Parser Concept (#6099)

* Adds logic for validation

* Adds validation to do-install

* Adds help to do-install

* Adds validation & help to reconfigure

* Adds validation to check.translation

* Adds validation to manipulate.translation

* Small fixes to help texts

* Refactors language option validation

* Adds default options to validation

* Fixes validation with regex

* Refactors readAs functions

* Updates to new regex validation format

* Fixes typing around default values

* Adds file extension validation

* Restandardises validation & parsing typing around array of strings

* Adds NotOneOf validation

* Adds ArrayOfString read as

* Refactors existing validation

* Adds validation throughout cli

* Removes unused file

* Adds new CL parser with goal of wrapping CLI behaviour

* Hides parsing and validation

* Rewites CL parser to make better use of classes

* Rolls out new parser across CL

* Fixes error during unknown option check

* Fixes misnamed property calls

* Seperates validations into more appropriate locations

* Adds common boolean forms to validation

* Moves CommandLineParser and Option classes into their own files

* Fixes error when validating Int type

* Rewrites appendTypedValues -> appendTypedValidValues now filters invalid values from output

* Renames  ->  for clarity

* Adds some docs clarifying option defaults and value taking behaviour

* Refactors getUsageMessage for readability

* Minor formatting changes

* Adds tests for CommandLineParser

* Adds more tests

* Adds minor fixs

* Reconfigure now correctly updates config

* More fixes to reconfigure

* Fixes required files for CommandLineParserTest

* Use .php extension for PHP file

* PHPStan ignore instead of wrong typing

* Refactors to support php 7.4

* Moves away from dynamic properties by adding 'Definintions' to all commands

* Renames target to definition for clarity

* Stops null from being returned as a valid value in a certain edge case

* Adds PHPStan ignore instead of incorrect typing

* Refactors tests to take account of new typing solution

* Marks file as executable

* Draft CLI rework

* Finish rewrite as object-oriented

* Fix PHPStan ignore and make more strongly typed

* Rename class Option to CliOption

* Light renaming + anonymous classes

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Kasimir Cash 2 years ago
parent
commit
4b29e666b0

+ 104 - 0
cli/CliOption.php

@@ -0,0 +1,104 @@
+<?php
+declare(strict_types=1);
+
+final class CliOption {
+	public const VALUE_NONE = 'none';
+	public const VALUE_REQUIRED = 'required';
+	public const VALUE_OPTIONAL = 'optional';
+
+	private string $longAlias;
+	private ?string $shortAlias;
+	private string $valueTaken = self::VALUE_REQUIRED;
+	/** @var array{type:string,isArray:bool} $types */
+	private array $types = ['type' => 'string', 'isArray' => false];
+	private string $optionalValueDefault = '';
+	private ?string $deprecatedAlias = null;
+
+	public function __construct(string $longAlias, ?string $shortAlias = null) {
+		$this->longAlias = $longAlias;
+		$this->shortAlias = $shortAlias;
+	}
+
+	/** Sets this option to be treated as a flag. */
+	public function withValueNone(): self {
+		$this->valueTaken = static::VALUE_NONE;
+		return $this;
+	}
+
+	/** Sets this option to always require a value when used. */
+	public function withValueRequired(): self {
+		$this->valueTaken = static::VALUE_REQUIRED;
+		return $this;
+	}
+
+	/**
+	 * Sets this option to accept both values and flag behavior.
+	 * @param string $optionalValueDefault When this option is used as a flag it receives this value as input.
+	 */
+	public function withValueOptional(string $optionalValueDefault = ''): self {
+		$this->valueTaken = static::VALUE_OPTIONAL;
+		$this->optionalValueDefault = $optionalValueDefault;
+		return $this;
+	}
+
+	public function typeOfString(): self {
+		$this->types = ['type' => 'string', 'isArray' => false];
+		return $this;
+	}
+
+	public function typeOfInt(): self {
+		$this->types = ['type' => 'int', 'isArray' => false];
+		return $this;
+	}
+
+	public function typeOfBool(): self {
+		$this->types = ['type' => 'bool', 'isArray' => false];
+		return $this;
+	}
+
+	public function typeOfArrayOfString(): self {
+		$this->types = ['type' => 'string', 'isArray' => true];
+		return $this;
+	}
+
+	public function deprecatedAs(string $deprecated): self {
+		$this->deprecatedAlias = $deprecated;
+		return $this;
+	}
+
+	public function getValueTaken(): string {
+		return $this->valueTaken;
+	}
+
+	public function getOptionalValueDefault(): string {
+		return $this->optionalValueDefault;
+	}
+
+	public function getDeprecatedAlias(): ?string {
+		return $this->deprecatedAlias;
+	}
+
+	public function getLongAlias(): string {
+		return $this->longAlias;
+	}
+
+	public function getShortAlias(): ?string {
+		return $this->shortAlias;
+	}
+
+	/** @return array{type:string,isArray:bool} */
+	public function getTypes(): array {
+		return $this->types;
+	}
+
+	/** @return string[] */
+	public function getAliases(): array {
+		$aliases = [
+			$this->longAlias,
+			$this->shortAlias,
+			$this->deprecatedAlias,
+		];
+
+		return array_filter($aliases);
+	}
+}

+ 247 - 0
cli/CliOptionsParser.php

@@ -0,0 +1,247 @@
+<?php
+declare(strict_types=1);
+
+abstract class CliOptionsParser {
+	/** @var array<string,CliOption> */
+	private array $options = [];
+	/** @var array<string,array{defaultInput:?string[],required:?bool,aliasUsed:?string,values:?string[]}> */
+	private array $inputs = [];
+	/** @var array<string,string> $errors */
+	public array $errors = [];
+	public string $usage = '';
+
+	public function __construct() {
+		global $argv;
+
+		$this->usage = $this->getUsageMessage($argv[0]);
+
+		$this->parseInput();
+		$this->appendUnknownAliases($argv);
+		$this->appendInvalidValues();
+		$this->appendTypedValidValues();
+	}
+
+	private function parseInput(): void {
+		$getoptInputs = $this->getGetoptInputs();
+		$this->getoptOutputTransformer(getopt($getoptInputs['short'], $getoptInputs['long']));
+		$this->checkForDeprecatedAliasUse();
+	}
+
+	/** Adds an option that produces an error message if not set. */
+	protected function addRequiredOption(string $name, CliOption $option): void {
+		$this->inputs[$name] = [
+			'defaultInput' => null,
+			'required' => true,
+			'aliasUsed' => null,
+			'values' => null,
+		];
+		$this->options[$name] = $option;
+	}
+
+	/**
+	 * Adds an optional option.
+	 * @param string $defaultInput If not null this value is received as input in all cases where no
+	 *  user input is present. e.g. set this if you want an option to always return a value.
+	 */
+	protected function addOption(string $name, CliOption $option, string $defaultInput = null): void {
+		$this->inputs[$name] = [
+			'defaultInput' => is_string($defaultInput) ? [$defaultInput] : $defaultInput,
+			'required' => null,
+			'aliasUsed' => null,
+			'values' => null,
+		];
+		$this->options[$name] = $option;
+	}
+
+	private function appendInvalidValues(): void {
+		foreach ($this->options as $name => $option) {
+			if ($this->inputs[$name]['required'] && $this->inputs[$name]['values'] === null) {
+				$this->errors[$name] = 'invalid input: ' . $option->getLongAlias() . ' cannot be empty';
+			}
+		}
+
+		foreach ($this->inputs as $name => $input) {
+			foreach ($input['values'] ?? $input['defaultInput'] ?? [] as $value) {
+				switch ($this->options[$name]->getTypes()['type']) {
+					case 'int':
+						if (!ctype_digit($value)) {
+							$this->errors[$name] = 'invalid input: ' . $input['aliasUsed'] . ' must be an integer';
+						}
+						break;
+					case 'bool':
+						if (filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) === null) {
+							$this->errors[$name] = 'invalid input: ' . $input['aliasUsed'] . ' must be a boolean';
+						}
+						break;
+				}
+			}
+		}
+	}
+
+	private function appendTypedValidValues(): void {
+		foreach ($this->inputs as $name => $input) {
+			$values = $input['values'] ?? $input['defaultInput'] ?? null;
+			$types = $this->options[$name]->getTypes();
+			if ($values) {
+				$validValues = [];
+				$typedValues = [];
+
+				switch ($types['type']) {
+					case 'string':
+						$typedValues = $values;
+						break;
+					case 'int':
+						$validValues = array_filter($values, static fn($value) => ctype_digit($value));
+						$typedValues = array_map(static fn($value) => (int) $value, $validValues);
+						break;
+					case 'bool':
+						$validValues = array_filter($values, static fn($value) => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null);
+						$typedValues = array_map(static fn($value) => (bool) filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE), $validValues);
+						break;
+				}
+
+				if (!empty($typedValues)) {
+					// @phpstan-ignore-next-line (change to `@phpstan-ignore property.dynamicName` when upgrading to PHPStan 1.11+)
+					$this->$name = $types['isArray'] ? $typedValues : array_pop($typedValues);
+				}
+			}
+		}
+	}
+
+	/** @param array<string,string|false>|false $getoptOutput */
+	private function getoptOutputTransformer($getoptOutput): void {
+		$getoptOutput = is_array($getoptOutput) ? $getoptOutput : [];
+
+		foreach ($getoptOutput as $alias => $value) {
+			foreach ($this->options as $name => $data) {
+				if (in_array($alias, $data->getAliases(), true)) {
+					$this->inputs[$name]['aliasUsed'] = $alias;
+					$this->inputs[$name]['values'] = $value === false
+						? [$data->getOptionalValueDefault()]
+						: (is_array($value)
+							? $value
+							: [$value]);
+				}
+			}
+		}
+	}
+
+	/**
+	 * @param array<string> $userInputs
+	 * @return array<string>
+	 */
+	private function getAliasesUsed(array $userInputs, string $regex): array {
+		$foundAliases = [];
+
+		foreach ($userInputs as $input) {
+			preg_match($regex, $input, $matches);
+
+			if(!empty($matches['short'])) {
+				$foundAliases = array_merge($foundAliases, str_split($matches['short']));
+			}
+			if(!empty($matches['long'])) {
+				$foundAliases[] = $matches['long'];
+			}
+		}
+
+		return $foundAliases;
+	}
+
+	/**
+	 * @param array<string> $input List of user command-line inputs.
+	 */
+	private function appendUnknownAliases(array $input): void {
+		$valid = [];
+		foreach ($this->options as $option) {
+			$valid = array_merge($valid, $option->getAliases());
+		}
+
+		$sanitizeInput = $this->getAliasesUsed($input, $this->makeInputRegex());
+		$unknownAliases = array_diff($sanitizeInput, $valid);
+		if (empty($unknownAliases)) {
+			return;
+		}
+
+		foreach ($unknownAliases as $unknownAlias) {
+			$this->errors[$unknownAlias] = 'unknown option: ' . $unknownAlias;
+		}
+	}
+
+	/**
+	 * Checks for presence of deprecated aliases.
+	 * @return bool Returns TRUE and generates a deprecation warning if deprecated aliases are present, FALSE otherwise.
+	 */
+	private function checkForDeprecatedAliasUse(): bool {
+		$deprecated = [];
+		$replacements = [];
+
+		foreach ($this->inputs as $name => $data) {
+			if ($data['aliasUsed'] !== null && $data['aliasUsed'] === $this->options[$name]->getDeprecatedAlias()) {
+				$deprecated[] = $this->options[$name]->getDeprecatedAlias();
+				$replacements[] = $this->options[$name]->getLongAlias();
+			}
+		}
+
+		if (empty($deprecated)) {
+			return false;
+		}
+
+		fwrite(STDERR, "FreshRSS deprecation warning: the CLI option(s): " . implode(', ', $deprecated) .
+			" are deprecated and will be removed in a future release. Use: " . implode(', ', $replacements) .
+			" instead\n");
+		return true;
+	}
+
+	/** @return array{long:array<string>,short:string}*/
+	private function getGetoptInputs(): array {
+		$getoptNotation = [
+			'none' => '',
+			'required' => ':',
+			'optional' => '::',
+		];
+
+		$long = [];
+		$short = '';
+
+		foreach ($this->options as $option) {
+			$long[] = $option->getLongAlias() . $getoptNotation[$option->getValueTaken()];
+			$long[] = $option->getDeprecatedAlias() ? $option->getDeprecatedAlias() . $getoptNotation[$option->getValueTaken()] : '';
+			$short .= $option->getShortAlias() ? $option->getShortAlias() . $getoptNotation[$option->getValueTaken()] : '';
+		}
+
+		return [
+			'long' => array_filter($long),
+			'short' => $short
+		];
+	}
+
+	private function getUsageMessage(string $command): string {
+		$required = ['Usage: ' . basename($command)];
+		$optional = [];
+
+		foreach ($this->options as $name => $option) {
+			$shortAlias = $option->getShortAlias() ? '-' . $option->getShortAlias() . ' ' : '';
+			$longAlias = '--' . $option->getLongAlias() . ($option->getValueTaken() === 'required' ? '=<' . strtolower($name) . '>' : '');
+			if ($this->inputs[$name]['required']) {
+				$required[] = $shortAlias . $longAlias;
+			} else {
+				$optional[] = '[' . $shortAlias . $longAlias . ']';
+			}
+		}
+
+		return implode(' ', $required) . ' ' . implode(' ', $optional);
+	}
+
+	private function makeInputRegex() : string {
+		$shortWithValues = '';
+		foreach ($this->options as $option) {
+			if (($option->getValueTaken() === 'required' || $option->getValueTaken() === 'optional') && $option->getShortAlias()) {
+				$shortWithValues .= $option->getShortAlias();
+			}
+		}
+
+		return $shortWithValues === ''
+			? "/^--(?'long'[^=]+)|^-(?<short>\w+)/"
+			: "/^--(?'long'[^=]+)|^-(?<short>(?(?=\w*[$shortWithValues])[^$shortWithValues]*[$shortWithValues]|\w+))/";
+	}
+}

+ 2 - 117
cli/_cli.php

@@ -6,11 +6,12 @@ if (php_sapi_name() !== 'cli') {
 }
 
 const EXIT_CODE_ALREADY_EXISTS = 3;
-const REGEX_INPUT_OPTIONS = '/^-{2}|^-{1}/';
 
 require(__DIR__ . '/../constants.php');
 require(LIB_PATH . '/lib_rss.php');	//Includes class autoloader
 require(LIB_PATH . '/lib_install.php');
+require_once(__DIR__ . '/CliOption.php');
+require_once(__DIR__ . '/CliOptionsParser.php');
 
 Minz_Session::init('FreshRSS', true);
 FreshRSS_Context::initSystem();
@@ -73,119 +74,3 @@ function performRequirementCheck(string $databaseType): void {
 		fail($message);
 	}
 }
-
-/**
- * Parses parameters used with FreshRSS' CLI commands.
- * @param array{'long':array<string,string>,'short':array<string,string>,'deprecated':array<string,string>} $parameters
- * Matrix of 'long': map of long option names as keys and their respective getopt() notations as values,
- * 'short': map of short option names as values and their equivalent long options as keys, 'deprecated': map of
- * replacement option names as keys and their respective deprecated option names as values.
- * @return array{'valid':array<string,string>,'invalid':array<string>} Matrix of 'valid': map of of all known
- * option names used and their respective values and 'invalid': list of all unknown options used.
- */
-function parseCliParams(array $parameters): array {
-	global $argv;
-	$longOptions = [];
-	$shortOptions = '';
-
-	foreach ($parameters['long'] as $name => $getopt_note) {
-		$longOptions[] = $name . $getopt_note;
-	}
-	foreach ($parameters['deprecated'] as $name => $deprecatedName) {
-		$longOptions[] = $deprecatedName . $parameters['long'][$name];
-	}
-	foreach ($parameters['short'] as $name => $shortName) {
-		$shortOptions .= $shortName . $parameters['long'][$name];
-	}
-
-	$options = getopt($shortOptions, $longOptions);
-
-	$valid = is_array($options) ? $options : [];
-
-	array_walk($valid, static fn(&$option) => $option = $option === false ? '' : $option);
-
-	/** @var array<string,string> $valid */
-	checkForDeprecatedOptions(array_keys($valid), $parameters['deprecated']);
-
-	$valid = replaceOptions($valid, $parameters['short']);
-	$valid = replaceOptions($valid, $parameters['deprecated']);
-
-	$invalid = findInvalidOptions(
-		$argv,
-		array_merge(array_keys($parameters['long']), array_values($parameters['short']), array_values($parameters['deprecated']))
-	);
-
-	return [
-		'valid' => $valid,
-		'invalid' => $invalid
-	];
-}
-
-/**
- * @param array<string> $options
- * @return array<string>
- */
-function getOptions(array $options, string $regex): array {
-	$longOptions = array_filter($options, static function (string $a) use ($regex) {
-		return preg_match($regex, $a) === 1;
-	});
-	return array_map(static function (string $a) use ($regex) {
-		return preg_replace($regex, '', $a) ?? '';
-	}, $longOptions);
-}
-
-/**
- * Checks for presence of unknown options.
- * @param array<string> $input List of command line arguments to check for validity.
- * @param array<string> $params List of valid options to check against.
- * @return array<string> Returns a list all unknown options found.
- */
-function findInvalidOptions(array $input, array $params): array {
-	$sanitizeInput = getOptions($input, REGEX_INPUT_OPTIONS);
-	$unknownOptions = array_diff($sanitizeInput, $params);
-
-	if (0 === count($unknownOptions)) {
-		return [];
-	}
-
-	fwrite(STDERR, sprintf("FreshRSS error: unknown options: %s\n", implode (', ', $unknownOptions)));
-	return $unknownOptions;
-}
-
-/**
- * Checks for presence of deprecated options.
- * @param array<string> $optionNames Command line option names to check for deprecation.
- * @param array<string,string> $params Map of replacement options as keys and their respective deprecated
- * options as values.
- * @return bool Returns TRUE and generates a deprecation warning if deprecated options are present, FALSE otherwise.
- */
-function checkForDeprecatedOptions(array $optionNames, array $params): bool {
-	$deprecatedOptions = array_intersect($optionNames, $params);
-	$replacements = array_map(static fn($option) => array_search($option, $params, true), $deprecatedOptions);
-
-	if (0 === count($deprecatedOptions)) {
-		return false;
-	}
-
-	fwrite(STDERR, "FreshRSS deprecation warning: the CLI option(s): " . implode(', ', $deprecatedOptions) .
-		" are deprecated and will be removed in a future release. Use: "
-		. implode(', ', $replacements) . " instead\n");
-	return true;
-}
-
-/**
- * Switches items in a list to their provided replacements.
- * @param array<string,string> $options Map with items to check for replacement as keys.
- * @param array<string,string> $replacements Map of replacement items as keys and the item they replace as their values.
- * @return array<string,string>  Returns $options with replacements.
- */
-function replaceOptions(array $options, array $replacements): array {
-	$updatedOptions = [];
-
-	foreach ($options as $name => $value) {
-		$replacement = array_search($name, $replacements, true);
-		$updatedOptions[$replacement ? $replacement : $name] = $value;
-	}
-
-	return $updatedOptions;
-}

+ 0 - 71
cli/_update-or-create-user.php

@@ -1,71 +0,0 @@
-<?php
-declare(strict_types=1);
-require(__DIR__ . '/_cli.php');
-
-performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
-
-$parameters = [
-	'long' => [
-		'user' => ':',
-		'password' => ':',
-		'api-password' => ':',
-		'language' => ':',
-		'email' => ':',
-		'token' => ':',
-		'purge-after-months' => ':',
-		'feed-min-articles-default' => ':',
-		'feed-ttl-default' => ':',
-		'since-hours-posts-per-rss' => ':',
-		'max-posts-per-rss' => ':',
-	],
-	'short' => [],
-	'deprecated' => [
-		'api-password' => 'api_password',
-		'purge-after-months' => 'purge_after_months',
-		'feed-min-articles-default' => 'feed_min_articles_default',
-		'feed-ttl-default' => 'feed_ttl_default',
-		'since-hours-posts-per-rss' => 'since_hours_posts_per_rss',
-		'max-posts-per-rss' => 'max_posts_per_rss',
-	],
-];
-
-if (!isset($isUpdate)) {
-	$isUpdate = false;
-} elseif (!$isUpdate) {
-	$parameters['long']['no-default-feeds'] = '';	//Only for creating new users
-	$parameters['deprecated']['no-default-feeds'] = 'no_default_feeds';
-}
-
-$GLOBALS['options'] = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || empty($options['valid']['user'])) {
-	fail('Usage: ' . basename($_SERVER['SCRIPT_FILENAME']) .
-		" --user username ( --password 'password' --api-password 'api_password'" .
-		" --language en --email user@example.net --token 'longRandomString'" .
-		($isUpdate ? '' : ' --no-default-feeds') .
-		" --purge-after-months 3 --feed-min-articles-default 50 --feed-ttl-default 3600" .
-		" --since-hours-posts-per-rss 168 --max-posts-per-rss 400 )");
-}
-
-function strParam(string $name): ?string {
-	global $options;
-	return isset($options['valid'][$name]) ? strval($options['valid'][$name]) : null;
-}
-
-function intParam(string $name): ?int {
-	global $options;
-	return isset($options['valid'][$name]) && ctype_digit($options['valid'][$name]) ? intval($options['valid'][$name]) : null;
-}
-
-$values = array(
-		'language' => strParam('language'),
-		'mail_login' => strParam('email'),
-		'token' => strParam('token'),
-		'old_entries' => intParam('purge-after-months'),	//TODO: Update with new mechanism
-		'keep_history_default' => intParam('feed-min-articles-default'),	//TODO: Update with new mechanism
-		'ttl_default' => intParam('feed-ttl-default'),
-		'since_hours_posts_per_rss' => intParam('since-hours-posts-per-rss'),
-		'max_posts_per_rss' => intParam('max-posts-per-rss'),
-	);
-
-$values = array_filter($values);

+ 15 - 13
cli/actualize-user.php

@@ -5,21 +5,23 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':'
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) {
-	fail('Usage: ' . basename(__FILE__) . " --user username");
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
-$username = cliInitUser($options['valid']['user']);
+$username = cliInitUser($cliOptions->user);
+
+Minz_ExtensionManager::callHookVoid('freshrss_user_maintenance');
+
 fwrite(STDERR, 'FreshRSS actualizing user “' . $username . "”…\n");
 
 $databaseDAO = FreshRSS_Factory::createDatabaseDAO();

+ 27 - 26
cli/check.translation.php

@@ -8,38 +8,39 @@ require_once __DIR__ . '/i18n/I18nFile.php';
 require_once __DIR__ . '/i18n/I18nUsageValidator.php';
 require_once __DIR__ . '/../constants.php';
 
-$i18nFile = new I18nFile();
-$i18nData = new I18nData($i18nFile->load());
+$cliOptions = new class extends CliOptionsParser {
+	/** @var array<int,string> $language */
+	public array $language;
+	public string $displayResult;
+	public string $help;
+	public string $displayReport;
+
+	public function __construct() {
+		$this->addOption('language', (new CliOption('language', 'l'))->typeOfArrayOfString());
+		$this->addOption('displayResult', (new CliOption('display-result', 'd'))->withValueNone());
+		$this->addOption('help', (new CliOption('help', 'h'))->withValueNone());
+		$this->addOption('displayReport', (new CliOption('display-report', 'r'))->withValueNone());
+		parent::__construct();
+	}
+};
 
-$parameters = [
-	'long' => [
-		'display-result' => '',
-		'help' => '',
-		'language' => ':',
-		'display-report' => '',
-	],
-	'short' => [
-		'display-result' => 'd',
-		'help' => 'h',
-		'language' => 'l',
-		'display-report' => 'r',
-	],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || array_key_exists('help', $options['valid'])) {
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
+}
+if (isset($cliOptions->help)) {
 	checkHelp();
 }
 
-if (array_key_exists('language', $options['valid'])) {
-	$languages = [$options['valid']['language']];
+$i18nFile = new I18nFile();
+$i18nData = new I18nData($i18nFile->load());
+
+if (isset($cliOptions->language)) {
+	$languages = $cliOptions->language;
 } else {
 	$languages = $i18nData->getAvailableLanguages();
 }
-$displayResults = array_key_exists('display-result', $options['valid']);
-$displayReport = array_key_exists('display-report', $options['valid']);
+$displayResults = isset($cliOptions->displayResult);
+$displayReport = isset($cliOptions->displayReport);
 
 $isValidated = true;
 $result = [];
@@ -122,5 +123,5 @@ DESCRIPTION
 	-r, --display-report	display completion report.
 
 HELP;
-	exit;
+	exit();
 }

+ 71 - 16
cli/create-user.php

@@ -1,38 +1,93 @@
 #!/usr/bin/env php
 <?php
 declare(strict_types=1);
+require(__DIR__ . '/_cli.php');
 
-$isUpdate = false;
-require(__DIR__ . '/_update-or-create-user.php');
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+	public string $password;
+	public string $apiPassword;
+	public string $language;
+	public string $email;
+	public string $token;
+	public int $purgeAfterMonths;
+	public int $feedMinArticles;
+	public int $feedTtl;
+	public int $sinceHoursPostsPerRss;
+	public int $maxPostsPerRss;
+	public bool $noDefaultFeeds;
 
-$username = $GLOBALS['options']['valid']['user'];
-if (!FreshRSS_user_Controller::checkUsername($username)) {
-	fail('FreshRSS error: invalid username “' . $username .
-		'”! Must be matching ' . FreshRSS_user_Controller::USERNAME_PATTERN);
-}
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		$this->addOption('password', (new CliOption('password')));
+		$this->addOption('apiPassword', (new CliOption('api-password'))->deprecatedAs('api_password'));
+		$this->addOption('language', (new CliOption('language')));
+		$this->addOption('email', (new CliOption('email')));
+		$this->addOption('token', (new CliOption('token')));
+		$this->addOption(
+			'purgeAfterMonths',
+			(new CliOption('purge-after-months'))->typeOfInt()->deprecatedAs('purge_after_months')
+		);
+		$this->addOption(
+			'feedMinArticles',
+			(new CliOption('feed-min-articles-default'))->typeOfInt()->deprecatedAs('feed_min_articles_default')
+		);
+		$this->addOption(
+			'feedTtl',
+			(new CliOption('feed-ttl-default'))->typeOfInt()->deprecatedAs('feed_ttl_default')
+		);
+		$this->addOption(
+			'sinceHoursPostsPerRss',
+			(new CliOption('since-hours-posts-per-rss'))->typeOfInt()->deprecatedAs('since_hours_posts_per_rss')
+		);
+		$this->addOption(
+			'maxPostsPerRss',
+			(new CliOption('max-posts-per-rss'))->typeOfInt()->deprecatedAs('max_posts_per_rss')
+		);
+		$this->addOption(
+			'noDefaultFeeds',
+			(new CliOption('no-default-feeds'))->withValueNone()->deprecatedAs('no_default_feeds')
+		);
+		parent::__construct();
+	}
+};
 
-$usernames = listUsers();
-if (preg_grep("/^$username$/i", $usernames)) {
-	fail('FreshRSS warning: username already exists “' . $username . '”', EXIT_CODE_ALREADY_EXISTS);
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
+$username = $cliOptions->user;
+
 echo 'FreshRSS creating user “', $username, "”…\n";
 
+$values = [
+	'language' => $cliOptions->language ?? null,
+	'mail_login' => $cliOptions->email ?? null,
+	'token' => $cliOptions->token ?? null,
+	'old_entries' => $cliOptions->purgeAfterMonths ?? null,
+	'keep_history_default' => $cliOptions->feedMinArticles ?? null,
+	'ttl_default' => $cliOptions->feedTtl ?? null,
+	'since_hours_posts_per_rss' => $cliOptions->sinceHoursPostsPerRss ?? null,
+	'max_posts_per_rss' => $cliOptions->maxPostsPerRss ?? null,
+];
+
+$values = array_filter($values);
+
 $ok = FreshRSS_user_Controller::createUser(
 	$username,
-	empty($options['valid']['email']) ? '' : $options['valid']['email'],
-	empty($options['valid']['password']) ? '' : $options['valid']['password'],
-	$GLOBALS['values'],
-	!isset($options['valid']['no-default-feeds'])
+	isset($cliOptions->email) ? $cliOptions->email : null,
+	$cliOptions->password ?? '',
+	$values,
+	!isset($cliOptions->noDefaultFeeds)
 );
 
 if (!$ok) {
 	fail('FreshRSS could not create user!');
 }
 
-if (!empty($options['valid']['api-password'])) {
+if (isset($cliOptions->apiPassword)) {
 	$username = cliInitUser($username);
-	$error = FreshRSS_api_Controller::updatePassword($options['valid']['api-password']);
+	$error = FreshRSS_api_Controller::updatePassword($cliOptions->apiPassword);
 	if ($error !== false) {
 		fail($error);
 	}

+ 12 - 13
cli/db-optimize.php

@@ -5,21 +5,20 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) {
-	fail('Usage: ' . basename(__FILE__) . " --user username");
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
-$username = cliInitUser($options['valid']['user']);
+$username = cliInitUser($cliOptions->user);
 
 echo 'FreshRSS optimizing database for user “', $username, "”…\n";
 

+ 17 - 19
cli/delete-user.php

@@ -5,29 +5,27 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) {
-	fail('Usage: ' . basename(__FILE__) . " --user username");
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
-$username = $options['valid']['user'];
+
+$username = $cliOptions->user;
+
 if (!FreshRSS_user_Controller::checkUsername($username)) {
-	fail('FreshRSS error: invalid username “' . $username . '”');
+	fail('FreshRSS error: invalid username: ' . $username . "\n");
 }
-
-$usernames = listUsers();
-if (!preg_grep("/^$username$/i", $usernames)) {
-	fail('FreshRSS error: username not found “' . $username . '”');
+if (!FreshRSS_user_Controller::userExists($username)) {
+	fail('FreshRSS error: user not found: ' . $username . "\n");
 }
-
 if (strcasecmp($username, FreshRSS_Context::systemConf()->default_user) === 0) {
 	fail('FreshRSS error: default user must not be deleted: “' . $username . '”');
 }

+ 101 - 81
cli/do-install.php

@@ -7,74 +7,91 @@ if (file_exists(DATA_PATH . '/applied_migrations.txt')) {
 	fail('FreshRSS seems to be already installed!' . "\n" . 'Please use `./cli/reconfigure.php` instead.', EXIT_CODE_ALREADY_EXISTS);
 }
 
-$parameters = [
-	'long' => [
-		'environment' => ':',
-		'base-url' => ':',
-		'language' => ':',
-		'title' => ':',
-		'default-user' => ':',
-		'allow-anonymous' => '',
-		'allow-anonymous-refresh' => '',
-		'auth-type' => ':',
-		'api-enabled' => '',
-		'allow-robots' => '',
-		'disable-update' => '',
-		'db-type' => ':',
-		'db-host' => ':',
-		'db-user' => ':',
-		'db-password' => ':',
-		'db-base' => ':',
-		'db-prefix' => '::',
-	],
-	'short' => [],
-	'deprecated' => [
-		'base-url' => 'base_url',
-		'default-user' => 'default_user',
-		'allow-anonymous' => 'allow_anonymous',
-		'allow-anonymous-refresh' => 'allow_anonymous_refresh',
-		'auth-type' => 'auth_type',
-		'api-enabled' => 'api_enabled',
-		'allow-robots' => 'allow_robots',
-		'disable-update' => 'disable_update',
-	],
-];
-
-$configParams = [
-	'environment' => 'environment',
-	'base-url' => 'base_url',
-	'language' => 'language',
-	'title' => 'title',
-	'default-user' => 'default_user',
-	'allow-anonymous' => 'allow_anonymous',
-	'allow-anonymous-refresh' => 'allow_anonymous_refresh',
-	'auth-type' => 'auth_type',
-	'api-enabled' => 'api_enabled',
-	'allow-robots' => 'allow_robots',
-	'disable-update' => 'disable_update',
-];
-
-$dBconfigParams = [
-	'db-type' => 'type',
-	'db-host' => 'host',
-	'db-user' => 'user',
-	'db-password' => 'password',
-	'db-base' => 'base',
-	'db-prefix' => 'prefix',
-];
-
-$options = parseCliParams($parameters);
+$cliOptions = new class extends CliOptionsParser {
+	public string $defaultUser;
+	public string $environment;
+	public string $baseUrl;
+	public string $language;
+	public string $title;
+	public bool $allowAnonymous;
+	public bool $allowAnonymousRefresh;
+	public string $authType;
+	public bool $apiEnabled;
+	public bool $allowRobots;
+	public bool $disableUpdate;
+	public string $dbType;
+	public string $dbHost;
+	public string $dbUser;
+	public string $dbPassword;
+	public string $dbBase;
+	public string $dbPrefix;
+
+	public function __construct() {
+		$this->addRequiredOption('defaultUser', (new CliOption('default-user'))->deprecatedAs('default_user'));
+		$this->addOption('environment', (new CliOption('environment')));
+		$this->addOption('baseUrl', (new CliOption('base-url'))->deprecatedAs('base_url'));
+		$this->addOption('language', (new CliOption('language')));
+		$this->addOption('title', (new CliOption('title')));
+		$this->addOption(
+			'allowAnonymous',
+			(new CliOption('allow-anonymous'))->withValueOptional('true')->deprecatedAs('allow_anonymous')->typeOfBool()
+		);
+		$this->addOption(
+			'allowAnonymousRefresh',
+			(new CliOption('allow-anonymous-refresh'))->withValueOptional('true')->deprecatedAs('allow_anonymous_refresh')->typeOfBool()
+		);
+		$this->addOption('authType', (new CliOption('auth-type'))->deprecatedAs('auth_type'));
+		$this->addOption(
+			'apiEnabled',
+			(new CliOption('api-enabled'))->withValueOptional('true')->deprecatedAs('api_enabled')->typeOfBool()
+		);
+		$this->addOption(
+			'allowRobots',
+			(new CliOption('allow-robots'))->withValueOptional('true')->deprecatedAs('allow_robots')->typeOfBool()
+		);
+		$this->addOption(
+			'disableUpdate',
+			(new CliOption('disable-update'))->withValueOptional('true')->deprecatedAs('disable_update')->typeOfBool()
+		);
+		$this->addOption('dbType', (new CliOption('db-type')));
+		$this->addOption('dbHost', (new CliOption('db-host')));
+		$this->addOption('dbUser', (new CliOption('db-user')));
+		$this->addOption('dbPassword', (new CliOption('db-password')));
+		$this->addOption('dbBase', (new CliOption('db-base')));
+		$this->addOption('dbPrefix', (new CliOption('db-prefix'))->withValueOptional());
+		parent::__construct();
+	}
+};
 
-if (!empty($options['invalid']) || empty($options['valid']['default-user']) || !is_string($options['valid']['default-user'])) {
-	fail('Usage: ' . basename(__FILE__) . " --default-user admin ( --auth-type form" .
-		" --environment production --base-url https://rss.example.net --allow-robots" .
-		" --language en --title FreshRSS --allow-anonymous --allow-anonymous-refresh --api-enabled" .
-		" --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" .
-		" --db-base freshrss --db-prefix freshrss_ --disable-update )");
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
 fwrite(STDERR, 'FreshRSS install…' . "\n");
 
+$values = [
+	'default_user' => $cliOptions->defaultUser ?? null,
+	'environment' => $cliOptions->environment ?? null,
+	'base_url' => $cliOptions->baseUrl ?? null,
+	'language' => $cliOptions->language ?? null,
+	'title' => $cliOptions->title ?? null,
+	'allow_anonymous' => $cliOptions->allowAnonymous ?? null,
+	'allow_anonymous_refresh' => $cliOptions->allowAnonymousRefresh ?? null,
+	'auth_type' => $cliOptions->authType ?? null,
+	'api_enabled' => $cliOptions->apiEnabled ?? null,
+	'allow_robots' => $cliOptions->allowRobots ?? null,
+	'disable_update' => $cliOptions->disableUpdate ?? null,
+];
+
+$dbValues = [
+	'type' => $cliOptions->dbType ?? null,
+	'host' => $cliOptions->dbHost ?? null,
+	'user' => $cliOptions->dbUser ?? null,
+	'password' => $cliOptions->dbPassword ?? null,
+	'base' => $cliOptions->dbBase ?? null,
+	'prefix' => $cliOptions->dbPrefix ?? null,
+];
+
 $config = array(
 		'salt' => generateSalt(),
 		'db' => FreshRSS_Context::systemConf()->db,
@@ -88,10 +105,26 @@ if (file_exists($customConfigPath)) {
 	}
 }
 
-foreach ($configParams as $param => $configParam) {
-	if (isset($options['valid'][$param])) {
-		$isFlag = $parameters['long'][$param] === '';
-		$config[$configParam] = $isFlag ? true : $options['valid'][$param];
+foreach ($values as $name => $value) {
+	if ($value !== null) {
+		switch ($name) {
+			case 'default_user':
+				if (!FreshRSS_user_Controller::checkUsername($value)) {
+					fail('FreshRSS invalid default username! default_user must be ASCII alphanumeric');
+				}
+				break;
+			case 'environment':
+				if (!in_array($value, ['development', 'production', 'silent'], true)) {
+					fail('FreshRSS invalid environment! environment must be one of { development, production, silent }');
+				}
+				break;
+			case 'auth_type':
+				if (!in_array($value, ['form', 'http_auth', 'none'], true)) {
+					fail('FreshRSS invalid authentication method! auth_type must be one of { form, http_auth, none }');
+				}
+				break;
+		}
+		$config[$name] = $value;
 	}
 }
 
@@ -99,23 +132,10 @@ if ((!empty($config['base_url'])) && is_string($config['base_url']) && Minz_Requ
 	$config['pubsubhubbub_enabled'] = true;
 }
 
-foreach ($dBconfigParams as $dBparam => $configDbParam) {
-	if (isset($options['valid'][$dBparam])) {
-		$config['db'][$configDbParam] = $options['valid'][$dBparam];
-	}
-}
+$config['db'] = array_merge($config['db'], array_filter($dbValues));
 
 performRequirementCheck($config['db']['type']);
 
-if (!FreshRSS_user_Controller::checkUsername($options['valid']['default-user'])) {
-	fail('FreshRSS error: invalid default username “' . $options['valid']['default-user']
-		. '”! Must be matching ' . FreshRSS_user_Controller::USERNAME_PATTERN);
-}
-
-if (isset($options['valid']['auth-type']) && !in_array($options['valid']['auth-type'], ['form', 'http_auth', 'none'], true)) {
-	fail('FreshRSS invalid authentication method (auth-type must be one of { form, http_auth, none })');
-}
-
 if (file_put_contents(join_path(DATA_PATH, 'config.php'),
 	"<?php\n return " . var_export($config, true) . ";\n") === false) {
 	fail('FreshRSS could not write configuration file!: ' . join_path(DATA_PATH, 'config.php'));

+ 12 - 13
cli/export-opml-for-user.php

@@ -5,21 +5,20 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) {
-	fail('Usage: ' . basename(__FILE__) . " --user username > /path/to/file.opml.xml");
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
-$username = cliInitUser($options['valid']['user']);
+$username = cliInitUser($cliOptions->user);
 
 fwrite(STDERR, 'FreshRSS exporting OPML for user “' . $username . "”…\n");
 

+ 15 - 18
cli/export-sqlite-for-user.php

@@ -5,26 +5,23 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-		'filename' => ':',
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid'])
-	|| empty($options['valid']['user']) || empty($options['valid']['filename'])
-	|| !is_string($options['valid']['user']) || !is_string($options['valid']['filename'])
-) {
-	fail('Usage: ' . basename(__FILE__) . ' --user username --filename /path/to/db.sqlite');
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+	public string $filename;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		$this->addRequiredOption('filename', (new CliOption('filename')));
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
-$username = cliInitUser($options['valid']['user']);
-$filename = $options['valid']['filename'];
+$username = cliInitUser($cliOptions->user);
+$filename = $cliOptions->filename;
 
 if (pathinfo($filename, PATHINFO_EXTENSION) !== 'sqlite') {
 	fail('Only *.sqlite files are supported!');

+ 15 - 15
cli/export-zip-for-user.php

@@ -5,31 +5,31 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-		'max-feed-entries' => ':',
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) {
-	fail('Usage: ' . basename(__FILE__) . " --user username ( --max-feed-entries 100 ) > /path/to/file.zip");
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+	public int $maxFeedEntries;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		$this->addOption('maxFeedEntries', (new CliOption('max-feed-entries'))->typeOfInt(), '100');
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
 if (!extension_loaded('zip')) {
 	fail('FreshRSS error: Lacking php-zip extension!');
 }
 
-$username = cliInitUser($options['valid']['user']);
+$username = cliInitUser($cliOptions->user);
 
 fwrite(STDERR, 'FreshRSS exporting ZIP for user “' . $username . "”…\n");
 
 $export_service = new FreshRSS_Export_Service($username);
-$number_entries = empty($options['valid']['max-feed-entries']) ? 100 : intval($options['valid']['max-feed-entries']);
+$number_entries = $cliOptions->maxFeedEntries;
 $exported_files = [];
 
 // First, we generate the OPML file

+ 15 - 18
cli/import-for-user.php

@@ -5,27 +5,24 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-		'filename' => ':',
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid'])
-	|| empty($options['valid']['user']) || empty($options['valid']['filename'])
-	|| !is_string($options['valid']['user']) || !is_string($options['valid']['filename'])
-) {
-	fail('Usage: ' . basename(__FILE__) . " --user username --filename /path/to/file.ext");
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+	public string $filename;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		$this->addRequiredOption('filename', (new CliOption('filename')));
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
-$username = cliInitUser($options['valid']['user']);
+$username = cliInitUser($cliOptions->user);
+$filename = $cliOptions->filename;
 
-$filename = $options['valid']['filename'];
 if (!is_readable($filename)) {
 	fail('FreshRSS error: file is not readable “' . $filename . '”');
 }

+ 18 - 20
cli/import-sqlite-for-user.php

@@ -5,27 +5,25 @@ require(__DIR__ . '/_cli.php');
 
 performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? '');
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-		'filename' => ':',
-		'force-overwrite' => '',
-	],
-	'short' => [],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid'])
-	|| empty($options['valid']['user']) || empty($options['valid']['filename'])
-	|| !is_string($options['valid']['user']) || !is_string($options['valid']['filename'])
-) {
-	fail('Usage: ' . basename(__FILE__) . ' --user username --force-overwrite --filename /path/to/db.sqlite');
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+	public string $filename;
+	public string $forceOverwrite;
+
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		$this->addRequiredOption('filename', (new CliOption('filename')));
+		$this->addOption('forceOverwrite', (new CliOption('force-overwrite'))->withValueNone());
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
-$username = cliInitUser($options['valid']['user']);
-$filename = $options['valid']['filename'];
+$username = cliInitUser($cliOptions->user);
+$filename = $cliOptions->filename;
 
 if (pathinfo($filename, PATHINFO_EXTENSION) !== 'sqlite') {
 	fail('Only *.sqlite files are supported!');
@@ -34,7 +32,7 @@ if (pathinfo($filename, PATHINFO_EXTENSION) !== 'sqlite') {
 echo 'FreshRSS importing database from SQLite for user “', $username, "”…\n";
 
 $databaseDAO = FreshRSS_Factory::createDatabaseDAO($username);
-$clearFirst = array_key_exists('force-overwrite', $options['valid']);
+$clearFirst = isset($cliOptions->forceOverwrite);
 $ok = $databaseDAO->dbCopy($filename, FreshRSS_DatabaseDAO::SQLITE_IMPORT, $clearFirst);
 if (!$ok) {
 	echo 'If you would like to clear the user database first, use the option --force-overwrite', "\n";

+ 50 - 53
cli/manipulate.translation.php

@@ -6,70 +6,65 @@ require_once __DIR__ . '/i18n/I18nData.php';
 require_once __DIR__ . '/i18n/I18nFile.php';
 require_once __DIR__ . '/../constants.php';
 
-$parameters = [
-	'long' => [
-		'action' => ':',
-		'help' => '',
-		'key' => ':',
-		'language' => ':',
-		'origin-language' => ':',
-		'revert' => '',
-		'value' => ':',
-	],
-	'short' => [
-		'action' => 'a',
-		'help' => 'h',
-		'key' => 'k',
-		'language' => 'l',
-		'origin-language' => 'o',
-		'revert' => 'r',
-		'value' => 'v',
-	],
-	'deprecated' => [],
-];
-
-$options = parseCliParams($parameters);
-
-if (!empty($options['invalid']) || array_key_exists('help', $options['valid'])) {
-	manipulateHelp();
-	exit();
+$cliOptions = new class extends CliOptionsParser {
+	public string $action;
+	public string $key;
+	public string $value;
+	public string $language;
+	public string $originLanguage;
+	public string $revert;
+	public string $help;
+
+	public function __construct() {
+		$this->addRequiredOption('action', (new CliOption('action', 'a')));
+		$this->addOption('key', (new CliOption('key', 'k')));
+		$this->addOption('value', (new CliOption('value', 'v')));
+		$this->addOption('language', (new CliOption('language', 'l')));
+		$this->addOption('originLanguage', (new CliOption('origin-language', 'o')));
+		$this->addOption('revert', (new CliOption('revert', 'r'))->withValueNone());
+		$this->addOption('help', (new CliOption('help', 'h'))->withValueNone());
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
-
-if (!array_key_exists('action', $options['valid'])) {
-	error('You need to specify the action to perform.');
+if (isset($cliOptions->help)) {
+	manipulateHelp();
 }
 
 $data = new I18nFile();
 $i18nData = new I18nData($data->load());
 
-switch ($options['valid']['action']) {
+switch ($cliOptions->action) {
 	case 'add' :
-		if (array_key_exists('key', $options['valid']) && array_key_exists('value', $options['valid']) && array_key_exists('language', $options['valid'])) {
-			$i18nData->addValue($options['valid']['key'], $options['valid']['value'], $options['valid']['language']);
-		} elseif (array_key_exists('key', $options['valid']) && array_key_exists('value', $options['valid'])) {
-			$i18nData->addKey($options['valid']['key'], $options['valid']['value']);
-		} elseif (array_key_exists('language', $options['valid'])) {
+		if (isset($cliOptions->key) && isset($cliOptions->value) && isset($cliOptions->language)) {
+			$i18nData->addValue($cliOptions->key, $cliOptions->value, $cliOptions->language);
+		} elseif (isset($cliOptions->key) && isset($cliOptions->value)) {
+			$i18nData->addKey($cliOptions->key, $cliOptions->value);
+		} elseif (isset($cliOptions->language)) {
 			$reference = null;
-			if (array_key_exists('origin-language', $options['valid'])) {
-				$reference = $options['valid']['origin-language'];
+			if (isset($cliOptions->originLanguage)) {
+				$reference = $cliOptions->originLanguage;
 			}
-			$i18nData->addLanguage($options['valid']['language'], $reference);
+			$i18nData->addLanguage($cliOptions->language, $reference);
 		} else {
 			error('You need to specify a valid set of options.');
 			exit;
 		}
 		break;
 	case 'delete' :
-		if (array_key_exists('key', $options['valid'])) {
-			$i18nData->removeKey($options['valid']['key']);
+		if (isset($cliOptions->key)) {
+			$i18nData->removeKey($cliOptions->key);
 		} else {
 			error('You need to specify the key to delete.');
 			exit;
 		}
 		break;
 	case 'exist':
-		if (array_key_exists('key', $options['valid'])) {
-			$key = $options['valid']['key'];
+		if (isset($cliOptions->key)) {
+			$key = $cliOptions->key;
 			if ($i18nData->isKnown($key)) {
 				echo "The '{$key}' key is known.\n\n";
 			} else {
@@ -83,16 +78,16 @@ switch ($options['valid']['action']) {
 	case 'format' :
 		break;
 	case 'ignore' :
-		if (array_key_exists('language', $options['valid']) && array_key_exists('key', $options['valid'])) {
-			$i18nData->ignore($options['valid']['key'], $options['valid']['language'], array_key_exists('revert', $options['valid']));
+		if (isset($cliOptions->language) && isset($cliOptions->key)) {
+			$i18nData->ignore($cliOptions->key, $cliOptions->language, isset($cliOptions->revert));
 		} else {
 			error('You need to specify a valid set of options.');
 			exit;
 		}
 		break;
 	case 'ignore_unmodified' :
-		if (array_key_exists('language', $options['valid'])) {
-			$i18nData->ignore_unmodified($options['valid']['language'], array_key_exists('revert', $options['valid']));
+		if (isset($cliOptions->language)) {
+			$i18nData->ignore_unmodified($cliOptions->language, isset($cliOptions->revert));
 		} else {
 			error('You need to specify a valid set of options.');
 			exit;
@@ -122,6 +117,7 @@ ERROR;
  */
 function manipulateHelp(): void {
 	$file = str_replace(__DIR__ . '/', '', __FILE__);
+
 	echo <<<HELP
 NAME
 	$file
@@ -144,17 +140,17 @@ DESCRIPTION
 				select the origin language (only for add language action)
 
 EXAMPLES
-Example 1:	add a language. It adds a new language by duplicating the referential.
+Example 1:	add a language. Adds a new language by duplicating the reference language.
 	php $file -a add -l my_lang
 	php $file -a add -l my_lang -o ref_lang
 
-Example 2:	add a new key. It adds the key for all supported languages.
+Example 2:	add a new key. Adds a key to all supported languages.
 	php $file -a add -k my_key -v my_value
 
-Example 3:	add a new value. It adds a new value for the selected key in the selected language.
+Example 3:	add a new value. Sets a new value for the selected key in the selected language.
 	php $file -a add -k my_key -v my_value -l my_lang
 
-Example 4:	delete a key. It deletes the selected key from all supported languages.
+Example 4:	delete a key. Deletes the selected key from all supported languages.
 	php $file -a delete -k my_key
 
 Example 5:	format i18n files.
@@ -170,11 +166,12 @@ Example 8:	ignore all unmodified keys. Adds IGNORE comments to all unmodified ke
 	php $file -a ignore_unmodified -l my_lang
 
 Example 9:	revert ignore on all unmodified keys. Removes IGNORE comments from all unmodified keys in the selected language.
-		Warning: will also revert individually added unmodified keys.
+		Warning: will also revert individually added IGNOREs on unmodified keys.
 	php $file -a ignore_unmodified -r -l my_lang
 
 Example 10:	check if a key exist.
-	php $file -a exist -k my_key\n\n
+	php $file -a exist -k my_key
 
 HELP;
+	exit();
 }

+ 97 - 110
cli/reconfigure.php

@@ -3,133 +3,120 @@
 declare(strict_types=1);
 require(__DIR__ . '/_cli.php');
 
-$parameters = [
-	'long' => [
-		'environment' => ':',
-		'base-url' => ':',
-		'language' => ':',
-		'title' => ':',
-		'default-user' => ':',
-		'allow-anonymous' => '',
-		'allow-anonymous-refresh' => '',
-		'auth-type' => ':',
-		'api-enabled' => '',
-		'allow-robots' => '',
-		'disable-update' => '',
-		'db-type' => ':',
-		'db-host' => ':',
-		'db-user' => ':',
-		'db-password' => ':',
-		'db-base' => ':',
-		'db-prefix' => '::',
-	],
-	'short' => [],
-	'deprecated' => [
-		'base-url' => 'base_url',
-		'default-user' => 'default_user',
-		'allow-anonymous' => 'allow_anonymous',
-		'allow-anonymous-refresh' => 'allow_anonymous_refresh',
-		'auth-type' => 'auth_type',
-		'api-enabled' => 'api_enabled',
-		'allow-robots' => 'allow_robots',
-		'disable-update' => 'disable_update',
-	],
-];
-
-$configParams = [
-	'environment',
-	'base-url',
-	'language',
-	'title',
-	'default-user',
-	'allow-anonymous',
-	'allow-anonymous-refresh',
-	'auth-type',
-	'api-enabled',
-	'allow-robots',
-	'disable-update',
-];
+$cliOptions = new class extends CliOptionsParser {
+	public string $defaultUser;
+	public string $environment;
+	public string $baseUrl;
+	public string $language;
+	public string $title;
+	public bool $allowAnonymous;
+	public bool $allowAnonymousRefresh;
+	public string $authType;
+	public bool $apiEnabled;
+	public bool $allowRobots;
+	public bool $disableUpdate;
+	public string $dbType;
+	public string $dbHost;
+	public string $dbUser;
+	public string $dbPassword;
+	public string $dbBase;
+	public string $dbPrefix;
 
-$dBconfigParams = [
-	'db-type' => 'type',
-	'db-host' => 'host',
-	'db-user' => 'user',
-	'db-password' => 'password',
-	'db-base' => 'base',
-	'db-prefix' => 'prefix',
-];
-
-$options = parseCliParams($parameters);
+	public function __construct() {
+		$this->addOption('defaultUser', (new CliOption('default-user'))->deprecatedAs('default_user'));
+		$this->addOption('environment', (new CliOption('environment')));
+		$this->addOption('baseUrl', (new CliOption('base-url'))->deprecatedAs('base_url'));
+		$this->addOption('language', (new CliOption('language')));
+		$this->addOption('title', (new CliOption('title')));
+		$this->addOption(
+			'allowAnonymous',
+			(new CliOption('allow-anonymous'))->withValueOptional('true')->deprecatedAs('allow_anonymous')->typeOfBool()
+		);
+		$this->addOption(
+			'allowAnonymousRefresh',
+			(new CliOption('allow-anonymous-refresh'))->withValueOptional('true')->deprecatedAs('allow_anonymous_refresh')->typeOfBool()
+		);
+		$this->addOption('authType', (new CliOption('auth-type'))->deprecatedAs('auth_type'));
+		$this->addOption(
+			'apiEnabled',
+			(new CliOption('api-enabled'))->withValueOptional('true')->deprecatedAs('api_enabled')->typeOfBool()
+		);
+		$this->addOption(
+			'allowRobots',
+			(new CliOption('allow-robots'))->withValueOptional('true')->deprecatedAs('allow_robots')->typeOfBool()
+		);
+		$this->addOption(
+			'disableUpdate',
+			(new CliOption('disable-update'))->withValueOptional('true')->deprecatedAs('disable_update')->typeOfBool()
+		);
+		$this->addOption('dbType', (new CliOption('db-type')));
+		$this->addOption('dbHost', (new CliOption('db-host')));
+		$this->addOption('dbUser', (new CliOption('db-user')));
+		$this->addOption('dbPassword', (new CliOption('db-password')));
+		$this->addOption('dbBase', (new CliOption('db-base')));
+		$this->addOption('dbPrefix', (new CliOption('db-prefix'))->withValueOptional());
+		parent::__construct();
+	}
+};
 
-if (!empty($options['invalid'])) {
-	fail('Usage: ' . basename(__FILE__) . " --default-user admin ( --auth-type form" .
-		" --environment production --base-url https://rss.example.net --allow-robots" .
-		" --language en --title FreshRSS --allow-anonymous --allow-anonymous-refresh --api-enabled" .
-		" --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" .
-		" --db-base freshrss --db-prefix freshrss_ --disable-update )");
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
 fwrite(STDERR, 'Reconfiguring FreshRSS…' . "\n");
 
-foreach ($configParams as $param) {
-	if (isset($options['valid'][$param])) {
-		switch ($param) {
-			case 'allow-anonymous-refresh':
-				FreshRSS_Context::systemConf()->allow_anonymous_refresh = true;
-				break;
-			case 'allow-anonymous':
-				FreshRSS_Context::systemConf()->allow_anonymous = true;
-				break;
-			case 'allow-robots':
-				FreshRSS_Context::systemConf()->allow_robots = true;
-				break;
-			case 'api-enabled':
-				FreshRSS_Context::systemConf()->api_enabled = true;
-				break;
-			case 'auth-type':
-				if (in_array($options['valid'][$param], ['form', 'http_auth', 'none'], true)) {
-					FreshRSS_Context::systemConf()->auth_type = $options['valid'][$param];
-				} else {
-					fail('FreshRSS invalid authentication method! auth_type must be one of { form, http_auth, none }');
-				}
-				break;
-			case 'base-url':
-				FreshRSS_Context::systemConf()->base_url = (string) $options['valid'][$param];
-				break;
-			case 'default-user':
-				if (FreshRSS_user_Controller::checkUsername((string) $options['valid'][$param])) {
-					FreshRSS_Context::systemConf()->default_user = (string) $options['valid'][$param];
-				} else {
+$values = [
+	'default_user' => $cliOptions->defaultUser ?? null,
+	'environment' => $cliOptions->environment ?? null,
+	'base_url' => $cliOptions->baseUrl ?? null,
+	'language' => $cliOptions->language ?? null,
+	'title' => $cliOptions->title ?? null,
+	'allow_anonymous' => $cliOptions->allowAnonymous ?? null,
+	'allow_anonymous_refresh' => $cliOptions->allowAnonymousRefresh ?? null,
+	'auth_type' => $cliOptions->authType ?? null,
+	'api_enabled' => $cliOptions->apiEnabled ?? null,
+	'allow_robots' => $cliOptions->allowRobots ?? null,
+	'disable_update' => $cliOptions->disableUpdate ?? null,
+];
+
+$dbValues = [
+	'type' => $cliOptions->dbType ?? null,
+	'host' => $cliOptions->dbHost ?? null,
+	'user' => $cliOptions->dbUser ?? null,
+	'password' => $cliOptions->dbPassword ?? null,
+	'base' => $cliOptions->dbBase ?? null,
+	'prefix' => $cliOptions->dbPrefix ?? null,
+];
+
+$systemConf = FreshRSS_Context::systemConf();
+foreach ($values as $name => $value) {
+	if ($value !== null) {
+		switch ($name) {
+			case 'default_user':
+				if (!FreshRSS_user_Controller::checkUsername($value)) {
 					fail('FreshRSS invalid default username! default_user must be ASCII alphanumeric');
 				}
 				break;
-			case 'disable-update':
-				FreshRSS_Context::systemConf()->disable_update = true;
-				break;
 			case 'environment':
-				if (in_array($options['valid'][$param], ['development', 'production', 'silent'], true)) {
-					FreshRSS_Context::systemConf()->environment = $options['valid'][$param];
-				} else {
+				if (!in_array($value, ['development', 'production', 'silent'], true)) {
 					fail('FreshRSS invalid environment! environment must be one of { development, production, silent }');
 				}
 				break;
-			case 'language':
-				FreshRSS_Context::systemConf()->language = (string) $options['valid'][$param];
-				break;
-			case 'title':
-				FreshRSS_Context::systemConf()->title = (string) $options['valid'][$param];
+			case 'auth_type':
+				if (!in_array($value, ['form', 'http_auth', 'none'], true)) {
+					fail('FreshRSS invalid authentication method! auth_type must be one of { form, http_auth, none }');
+				}
 				break;
 		}
+		// @phpstan-ignore-next-line (change to `@phpstan-ignore property.dynamicName` when upgrading to PHPStan 1.11+)
+		$systemConf->$name = $value;
 	}
 }
-$db = FreshRSS_Context::systemConf()->db;
-foreach ($dBconfigParams as $dBparam => $configDbParam) {
-	if (isset($options['valid'][$dBparam])) {
-		$db[$configDbParam] = $options['valid'][$dBparam];
-	}
-}
-/** @var array{'type':string,'host':string,'user':string,'password':string,'base':string,'prefix':string,
- *  'connection_uri_params':string,'pdo_options':array<int,int|string|bool>} $db */
+
+$db = array_merge(FreshRSS_Context::systemConf()->db, array_filter($dbValues));
+
+performRequirementCheck($db['type']);
+
 FreshRSS_Context::systemConf()->db = $db;
 
 FreshRSS_Context::systemConf()->save();

+ 67 - 8
cli/update-user.php

@@ -1,26 +1,85 @@
 #!/usr/bin/env php
 <?php
 declare(strict_types=1);
+require(__DIR__ . '/_cli.php');
 
-$isUpdate = true;
-require(__DIR__ . '/_update-or-create-user.php');
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+	public string $password;
+	public string $apiPassword;
+	public string $language;
+	public string $email;
+	public string $token;
+	public int $purgeAfterMonths;
+	public int $feedMinArticles;
+	public int $feedTtl;
+	public int $sinceHoursPostsPerRss;
+	public int $maxPostsPerRss;
 
-$username = cliInitUser($GLOBALS['options']['valid']['user']);
+	public function __construct() {
+		$this->addRequiredOption('user', (new CliOption('user')));
+		$this->addOption('password', (new CliOption('password')));
+		$this->addOption('apiPassword', (new CliOption('api-password'))->deprecatedAs('api_password'));
+		$this->addOption('language', (new CliOption('language')));
+		$this->addOption('email', (new CliOption('email')));
+		$this->addOption('token', (new CliOption('token')));
+		$this->addOption(
+			'purgeAfterMonths',
+			(new CliOption('purge-after-months'))->typeOfInt()->deprecatedAs('purge_after_months')
+		);
+		$this->addOption(
+			'feedMinArticles',
+			(new CliOption('feed-min-articles-default'))->typeOfInt()->deprecatedAs('feed_min_articles_default')
+		);
+		$this->addOption(
+			'feedTtl',
+			(new CliOption('feed-ttl-default'))->typeOfInt()->deprecatedAs('feed_ttl_default')
+		);
+		$this->addOption(
+			'sinceHoursPostsPerRss',
+			(new CliOption('since-hours-posts-per-rss'))->typeOfInt()->deprecatedAs('since_hours_posts_per_rss')
+		);
+		$this->addOption(
+			'maxPostsPerRss',
+			(new CliOption('max-posts-per-rss'))->typeOfInt()->deprecatedAs('max_posts_per_rss')
+		);
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
+}
+
+$username = cliInitUser($cliOptions->user);
 
 echo 'FreshRSS updating user “', $username, "”…\n";
 
+$values = [
+	'language' => $cliOptions->language ?? null,
+	'mail_login' => $cliOptions->email ?? null,
+	'token' => $cliOptions->token ?? null,
+	'old_entries' => $cliOptions->purgeAfterMonths ?? null,
+	'keep_history_default' => $cliOptions->feedMinArticles ?? null,
+	'ttl_default' => $cliOptions->feedTtl ?? null,
+	'since_hours_posts_per_rss' => $cliOptions->sinceHoursPostsPerRss ?? null,
+	'max_posts_per_rss' => $cliOptions->maxPostsPerRss ?? null,
+];
+
+$values = array_filter($values);
+
 $ok = FreshRSS_user_Controller::updateUser(
 	$username,
-	empty($options['valid']['email']) ? null : $options['valid']['email'],
-	empty($options['valid']['password']) ? '' : $options['valid']['password'],
-	$GLOBALS['values']);
+	isset($cliOptions->email) ? $cliOptions->email : null,
+	$cliOptions->password ?? '',
+	$values);
 
 if (!$ok) {
 	fail('FreshRSS could not update user!');
 }
 
-if (!empty($options['valid']['api_password'])) {
-	$error = FreshRSS_api_Controller::updatePassword($options['valid']['api_password']);
+if (isset($cliOptions->apiPassword)) {
+	$error = FreshRSS_api_Controller::updatePassword($cliOptions->apiPassword);
 	if ($error) {
 		fail($error);
 	}

+ 22 - 29
cli/user-info.php

@@ -5,45 +5,38 @@ require(__DIR__ . '/_cli.php');
 
 const DATA_FORMAT = "%-7s | %-20s | %-5s | %-7s | %-25s | %-15s | %-10s | %-10s | %-10s | %-10s | %-10s | %-10s | %-5s | %-10s\n";
 
-$parameters = [
-	'long' => [
-		'user' => ':',
-		'header' => '',
-		'json' => '',
-		'human-readable' => '',
-	],
-	'short' => [
-		'human-readable' => 'h',
-	],
-	'deprecated' => [],
-];
+$cliOptions = new class extends CliOptionsParser {
+	/** @var array<int,string> $user */
+	public array $user;
+	public string $header;
+	public string $json;
+	public string $humanReadable;
 
-$options = parseCliParams($parameters);
+	public function __construct() {
+		$this->addOption('user', (new CliOption('user'))->typeOfArrayOfString());
+		$this->addOption('header', (new CliOption('header'))->withValueNone());
+		$this->addOption('json', (new CliOption('json'))->withValueNone());
+		$this->addOption('humanReadable', (new CliOption('human-readable', 'h'))->withValueNone());
+		parent::__construct();
+	}
+};
 
-if (!empty($options['invalid'])) {
-	fail('Usage: ' . basename(__FILE__) . ' (--human-readable --header --json --user username --user username …)');
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
 }
 
-if (empty($options['valid']['user'])) {
-	$users = listUsers();
-} elseif (is_array($options['valid']['user'])) {
-	/** @var array<string> $users */
-	$users = $options['valid']['user'];
-} else {
-	/** @var array<string> $users */
-	$users = [$options['valid']['user']];
-}
+$users = $cliOptions->user ?? listUsers();
 
 sort($users);
 
-$formatJson = isset($options['valid']['json']);
+$formatJson = isset($cliOptions->json);
 $jsonOutput = [];
 if ($formatJson) {
-	unset($options['valid']['header']);
-	unset($options['valid']['human-readable']);
+	unset($cliOptions->header);
+	unset($cliOptions->humanReadable);
 }
 
-if (array_key_exists('header', $options['valid'])) {
+if (isset($cliOptions->header)) {
 	printf(
 		DATA_FORMAT,
 		'default',
@@ -92,7 +85,7 @@ foreach ($users as $username) {
 		'lang' => FreshRSS_Context::userConf()->language,
 		'mail_login' => FreshRSS_Context::userConf()->mail_login,
 	);
-	if (isset($options['valid']['human-readable'])) {	//Human format
+	if (isset($cliOptions->humanReadable)) {	//Human format
 		$data['last_user_activity'] = date('c', $data['last_user_activity']);
 		$data['database_size'] = format_bytes($data['database_size']);
 	}

+ 242 - 0
tests/cli/CliOptionsParserTest.php

@@ -0,0 +1,242 @@
+<?php
+declare(strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+require_once __DIR__ . '/../../cli/CliOption.php';
+require_once __DIR__ . '/../../cli/CliOptionsParser.php';
+
+final class CliOptionsOptionalTest extends CliOptionsParser {
+	public string $string = '';
+	public int $int = 0;
+	public bool $bool = false;
+	/** @var array<int,string> $arrayOfString */
+	public array $arrayOfString = [];
+	public string $defaultInput = '';
+	public string $optionalValue = '';
+	public bool $optionalValueWithDefault = false;
+	public string $defaultInputAndOptionalValueWithDefault = '';
+
+	public function __construct() {
+		$this->addOption('string', (new CliOption('string', 's'))->deprecatedAs('deprecated-string'));
+		$this->addOption('int', (new CliOption('int', 'i'))->typeOfInt());
+		$this->addOption('bool', (new CliOption('bool', 'b'))->typeOfBool());
+		$this->addOption('arrayOfString', (new CliOption('array-of-string', 'a'))->typeOfArrayOfString());
+		$this->addOption('defaultInput', (new CliOption('default-input', 'i')), 'default');
+		$this->addOption('optionalValue', (new CliOption('optional-value', 'o'))->withValueOptional());
+		$this->addOption('optionalValueWithDefault', (new CliOption('optional-value-with-default', 'd'))->withValueOptional('true')->typeOfBool());
+		$this->addOption('defaultInputAndOptionalValueWithDefault',
+			(new CliOption('default-input-and-optional-value-with-default', 'e'))->withValueOptional('optional'),
+			'default'
+		);
+		$this->addOption('flag', (new CliOption('flag', 'f'))->withValueNone());
+		parent::__construct();
+	}
+}
+
+final class CliOptionsOptionalAndRequiredTest extends CliOptionsParser {
+	public string $required = '';
+	public string $string = '';
+	public int $int = 0;
+	public bool $bool = false;
+	public string $flag = '';
+
+	public function __construct() {
+		$this->addRequiredOption('required', new CliOption('required'));
+		$this->addOption('string', new CliOption('string', 's'));
+		$this->addOption('int', (new CliOption('int', 'i'))->typeOfInt());
+		$this->addOption('bool', (new CliOption('bool', 'b'))->typeOfBool());
+		$this->addOption('flag', (new CliOption('flag', 'f'))->withValueNone());
+		parent::__construct();
+	}
+}
+
+class CliOptionsParserTest extends TestCase {
+
+	public function testInvalidOptionSetWithValueReturnsError(): void {
+		$result = $this->runOptionalOptions('--invalid=invalid');
+
+		self::assertEquals(['invalid' => 'unknown option: invalid'], $result->errors);
+	}
+
+	public function testInvalidOptionSetWithoutValueReturnsError(): void {
+		$result = $this->runOptionalOptions('--invalid');
+
+		self::assertEquals(['invalid' => 'unknown option: invalid'], $result->errors);
+	}
+
+	public function testValidOptionSetWithValidValueAndInvalidOptionSetWithValueReturnsValueForValidOptionAndErrorForInvalidOption(): void {
+		$result = $this->runOptionalOptions('--string=string --invalid=invalid');
+
+		self::assertEquals('string', $result->string);
+		self::assertEquals(['invalid' => 'unknown option: invalid'], $result->errors);
+	}
+
+	public function testOptionWithValueTypeOfStringSetOnceWithValidValueReturnsValueAsString(): void {
+		$result = $this->runOptionalOptions('--string=string');
+
+		self::assertEquals('string', $result->string);
+	}
+
+	public function testOptionWithRequiredValueTypeOfIntSetOnceWithValidValueReturnsValueAsInt(): void {
+		$result = $this->runOptionalOptions('--int=111');
+
+		self::assertEquals(111, $result->int);
+	}
+
+	public function testOptionWithRequiredValueTypeOfBoolSetOnceWithValidValueReturnsValueAsBool(): void {
+		$result = $this->runOptionalOptions('--bool=on');
+
+		self::assertEquals(true, $result->bool);
+	}
+
+	public function testOptionWithValueTypeOfArrayOfStringSetOnceWithValidValueReturnsValueAsArrayOfString(): void {
+		$result = $this->runOptionalOptions('--array-of-string=string');
+
+		self::assertEquals(['string'], $result->arrayOfString);
+	}
+
+	public function testOptionWithValueTypeOfStringSetMultipleTimesWithValidValueReturnsLastValueSetAsString(): void {
+		$result = $this->runOptionalOptions('--string=first --string=second');
+
+		self::assertEquals('second', $result->string);
+	}
+
+	public function testOptionWithValueTypeOfIntSetMultipleTimesWithValidValueReturnsLastValueSetAsInt(): void {
+		$result = $this->runOptionalOptions('--int=111 --int=222');
+
+		self::assertEquals(222, $result->int);
+	}
+
+	public function testOptionWithValueTypeOfBoolSetMultipleTimesWithValidValueReturnsLastValueSetAsBool(): void {
+		$result = $this->runOptionalOptions('--bool=on --bool=off');
+
+		self::assertEquals(false, $result->bool);
+	}
+
+	public function testOptionWithValueTypeOfArrayOfStringSetMultipleTimesWithValidValueReturnsAllSetValuesAsArrayOfString(): void {
+		$result = $this->runOptionalOptions('--array-of-string=first --array-of-string=second');
+
+		self::assertEquals(['first', 'second'], $result->arrayOfString);
+	}
+
+	public function testOptionWithValueTypeOfIntSetWithInvalidValueReturnsAnError(): void {
+		$result = $this->runOptionalOptions('--int=one');
+
+		self::assertEquals(['int' => 'invalid input: int must be an integer'], $result->errors);
+	}
+
+	public function testOptionWithValueTypeOfBoolSetWithInvalidValuesReturnsAnError(): void {
+		$result = $this->runOptionalOptions('--bool=bad');
+
+		self::assertEquals(['bool' => 'invalid input: bool must be a boolean'], $result->errors);
+	}
+
+	public function testOptionWithValueTypeOfIntSetMultipleTimesWithValidAndInvalidValuesReturnsLastValidValueSetAsIntAndError(): void {
+		$result = $this->runOptionalOptions('--int=111 --int=one --int=222 --int=two');
+
+		self::assertEquals(222, $result->int);
+		self::assertEquals(['int' => 'invalid input: int must be an integer'], $result->errors);
+	}
+
+	public function testOptionWithValueTypeOfBoolSetMultipleTimesWithWithValidAndInvalidValuesReturnsLastValidValueSetAsBoolAndError(): void {
+		$result = $this->runOptionalOptions('--bool=on --bool=good --bool=off --bool=bad');
+
+		self::assertEquals(false, $result->bool);
+		self::assertEquals(['bool' => 'invalid input: bool must be a boolean'], $result->errors);
+	}
+
+	public function testNotSetOptionWithDefaultInputReturnsDefaultInput(): void {
+		$result = $this->runOptionalOptions('');
+
+		self::assertEquals('default', $result->defaultInput);
+	}
+
+	public function testOptionWithDefaultInputSetWithValidValueReturnsCorrectlyTypedValue(): void {
+		$result = $this->runOptionalOptions('--default-input=input');
+
+		self::assertEquals('input', $result->defaultInput);
+	}
+
+	public function testOptionWithOptionalValueSetWithoutValueReturnsEmptyString(): void {
+		$result = $this->runOptionalOptions('--optional-value');
+
+		self::assertEquals('', $result->optionalValue);
+	}
+
+	public function testOptionWithOptionalValueDefaultSetWithoutValueReturnsOptionalValueDefault(): void {
+		$result = $this->runOptionalOptions('--optional-value-with-default');
+
+		self::assertEquals(true, $result->optionalValueWithDefault);
+	}
+
+	public function testNotSetOptionWithOptionalValueDefaultAndDefaultInputReturnsDefaultInput(): void {
+		$result = $this->runOptionalOptions('');
+
+		self::assertEquals('default', $result->defaultInputAndOptionalValueWithDefault);
+	}
+
+	public function testOptionWithOptionalValueDefaultAndDefaultInputSetWithoutValueReturnsOptionalValueDefault(): void {
+		$result = $this->runOptionalOptions('--default-input-and-optional-value-with-default');
+
+		self::assertEquals('optional', $result->defaultInputAndOptionalValueWithDefault);
+	}
+
+	public function testRequiredOptionNotSetReturnsError(): void {
+		$result = $this->runOptionalAndRequiredOptions('');
+
+		self::assertEquals(['required' => 'invalid input: required cannot be empty'], $result->errors);
+	}
+
+	public function testOptionSetWithDeprecatedAliasGeneratesDeprecationWarningAndReturnsValue(): void {
+		$result = $this->runCommandReadingStandardError('--deprecated-string=string');
+
+		self::assertEquals('FreshRSS deprecation warning: the CLI option(s): deprecated-string are deprecated ' .
+				'and will be removed in a future release. Use: string instead',
+			$result
+		);
+
+		$result = $this->runOptionalOptions('--deprecated-string=string');
+
+		self::assertEquals('string', $result->string);
+	}
+
+	public function testAlwaysReturnUsageMessageWithUsageInfoForAllOptions(): void {
+		$result = $this->runOptionalAndRequiredOptions('');
+
+		self::assertEquals('Usage: cli-parser-test.php --required=<required> [-s --string=<string>] [-i --int=<int>] [-b --bool=<bool>] [-f --flag]',
+			$result->usage,
+		);
+	}
+
+	private function runOptionalOptions(string $cliOptions = ''): CliOptionsOptionalTest {
+		$command = __DIR__ . '/cli-parser-test.php';
+		$className = CliOptionsOptionalTest::class;
+
+		$result = shell_exec("CLI_PARSER_TEST_OPTIONS_CLASS='$className' $command $cliOptions 2>/dev/null");
+		$result = is_string($result) ? unserialize($result) : new CliOptionsOptionalTest();
+
+		/** @var CliOptionsOptionalTest $result */
+		return $result;
+	}
+
+	private function runOptionalAndRequiredOptions(string $cliOptions = ''): CliOptionsOptionalAndRequiredTest {
+		$command = __DIR__ . '/cli-parser-test.php';
+		$className = CliOptionsOptionalAndRequiredTest::class;
+
+		$result = shell_exec("CLI_PARSER_TEST_OPTIONS_CLASS='$className' $command $cliOptions 2>/dev/null");
+		$result = is_string($result) ? unserialize($result) : new CliOptionsOptionalAndRequiredTest();
+
+		/** @var CliOptionsOptionalAndRequiredTest $result */
+		return $result;
+	}
+
+	private function runCommandReadingStandardError(string $cliOptions = ''): string {
+		$command = __DIR__ . '/cli-parser-test.php';
+		$className = CliOptionsOptionalTest::class;
+
+		$result = shell_exec("CLI_PARSER_TEST_OPTIONS_CLASS='$className' $command $cliOptions 2>&1");
+		$result = is_string($result) ? explode("\n", $result) : '';
+
+		return is_array($result) ? $result[0] : '';
+	}
+}

+ 24 - 0
tests/cli/cli-parser-test.php

@@ -0,0 +1,24 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+require(__DIR__ . '/../../vendor/autoload.php');
+require(__DIR__ . '/CliOptionsParserTest.php');
+
+$optionsClass = getenv('CLI_PARSER_TEST_OPTIONS_CLASS');
+if (!is_string($optionsClass) || !class_exists($optionsClass)) {
+	die('Invalid test static method!');
+}
+
+switch ($optionsClass) {
+	case CliOptionsOptionalTest::class:
+		$options = new CliOptionsOptionalTest();
+		break;
+	case CliOptionsOptionalAndRequiredTest::class:
+		$options = new CliOptionsOptionalAndRequiredTest();
+		break;
+	default:
+		die('Unknown test static method!');
+}
+
+echo serialize($options);